Files
buzz/desktop/src/features/projects/ui/RepositoryCards.tsx
Thomas Petersen 1bc2105f3e feat(desktop): share Projects entities by copying a direct link
Projects, repositories, issues, and pull requests had no shareable
reference — the only way to point someone at one was to describe it.
Each now offers "Copy link" in its row menu (plus a copy button in the
project, issue, and pull request detail headers), yielding a `buzz://`
deep link that renders as a preview card in chat and opens the entity
in-app when clicked or opened from the OS.

Completes the entity-link format for this: adds the `buzz://project`
scheme on both sides (TS builders/parser and the buzz-cli mirror, with
`projects create` emitting `link`), resolves repository and project card
titles from their announcement events, and registers the four entity
hosts with the Tauri deep-link handler.

Coordinates whose d-tag falls outside the link charset simply have no
share affordance, rather than producing a link the recipient cannot
parse.

Signed-off-by: Thomas Petersen <thomasp@squareup.com>
2026-08-10 08:01:16 +02:00

334 lines
10 KiB
TypeScript

import { FolderGit2, GitBranch, Globe, SquareTerminal } from "lucide-react";
import type {
Project,
ProjectActivitySummary,
Repository,
} from "@/features/projects/hooks";
import {
resolveUserLabel,
type UserProfileLookup,
} from "@/features/profile/lib/identity";
import {
projectRepoHostForRepository,
repositoryDisplayPath,
} from "@/features/projects/lib/projectRepoHost";
import { repositoryShareLink } from "@/features/projects/lib/projectShareLinks";
import {
formatExactTimestamp,
relativeTime,
} from "@/features/projects/lib/projectsViewHelpers";
import { cn } from "@/shared/lib/cn";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { useRelayOrigin } from "@/shared/lib/useRelayOrigin";
import { BuzzMark } from "@/shared/ui/buzz-logo/BuzzMark";
import { Card } from "@/shared/ui/card";
import { DropdownMenuItem } from "@/shared/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import {
PROJECT_LIST_ROW_CLASS,
PROJECT_LIST_ROW_DATE_CLASS,
PROJECT_LIST_ROW_PREVIEW_CLASS,
PROJECT_LIST_ROW_TITLE_CLASS,
PROJECT_LIST_ROW_TRAILING_CLASS,
} from "./projectListRowStyles";
import {
ProjectActivityBar,
ProjectPeopleStack,
ProjectStatsRow,
} from "./ProjectCards";
import { CopyShareLinkMenuItem } from "./CopyShareLinkMenuItem";
import { GitHubMark } from "./GitHubMark";
import { ProjectListRowMenu } from "./ProjectListRowMenu";
import { projectTerminalLabel } from "./useOpenProjectTerminal";
export type RepositoryListItem = {
project: Project;
repository: Repository;
};
type RepositoryItemProps = RepositoryListItem & {
hasLocal: boolean;
onOpen: (project: Project, repository: Repository) => void;
onOpenTerminal: (repository: Repository) => void;
profiles?: UserProfileLookup;
summary?: ProjectActivitySummary;
};
function RepositoryHostIcon({ repository }: { repository: Repository }) {
const host = projectRepoHostForRepository(repository, useRelayOrigin());
const label =
host.kind === "buzz"
? "Buzz-hosted repository"
: host.kind === "external"
? `Git data hosted on ${host.host}`
: "Repository host";
return (
<Tooltip>
<TooltipTrigger asChild>
<span
aria-label={label}
className="pointer-events-auto flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-border/60 bg-muted/40 text-muted-foreground"
data-testid="repository-host-icon"
role="img"
>
{host.kind === "buzz" ? (
<BuzzMark className="h-4.5 w-5" />
) : host.kind === "external" && host.host === "github.com" ? (
<GitHubMark className="h-4.5 w-4.5" />
) : host.kind === "external" ? (
<Globe className="h-4.5 w-4.5" />
) : (
<FolderGit2 className="h-4.5 w-4.5" />
)}
</span>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
}
function RepositoryOpenButton({
onOpen,
project,
repository,
}: Pick<RepositoryItemProps, "onOpen" | "project" | "repository">) {
return (
<button
className="absolute inset-0 z-0 cursor-pointer"
onClick={() => onOpen(project, repository)}
type="button"
>
<span className="sr-only">View {repository.name}</span>
</button>
);
}
function RepositoryIdentity({
inlineBranch = false,
profiles,
project,
repository,
}: Pick<RepositoryItemProps, "profiles" | "project" | "repository"> & {
inlineBranch?: boolean;
}) {
// Where the git data lives beats repeating the (often identical) project
// name — "github.com/block/buzz" for external repos, "owner/repo" for
// Buzz-hosted ones.
const displayPath = repositoryDisplayPath(
repository,
useRelayOrigin(),
resolveUserLabel({ pubkey: repository.owner, profiles }),
);
return (
<>
<RepositoryHostIcon repository={repository} />
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<span className={PROJECT_LIST_ROW_TITLE_CLASS}>
{repository.name}
</span>
</div>
<p className={PROJECT_LIST_ROW_PREVIEW_CLASS}>
{displayPath ?? project.name}
{inlineBranch ? ` · ${repository.defaultBranch}` : ""}
</p>
</div>
</>
);
}
function RepositoryUpdatedLabel({
repository,
summary,
}: Pick<RepositoryItemProps, "repository" | "summary">) {
const updatedAt = summary?.updatedAt || repository.createdAt;
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="whitespace-nowrap text-xs leading-4 text-muted-foreground/70">
{relativeTime(updatedAt)}
</span>
</TooltipTrigger>
<TooltipContent>
{summary?.latestCommit
? `${summary.latestCommit.title || summary.latestCommit.commit.slice(0, 7)} · ${formatExactTimestamp(summary.latestCommit.createdAt)}`
: `Created ${formatExactTimestamp(repository.createdAt)}`}
</TooltipContent>
</Tooltip>
);
}
function repositoryPeople(
repository: Repository,
summary: ProjectActivitySummary | undefined,
) {
return [
...new Set(
[
repository.owner,
...repository.contributors,
...(summary?.participantPubkeys ?? []),
].map(normalizePubkey),
),
];
}
function RepositoryActionsMenu({
hasLocal,
onOpenTerminal,
repository,
}: Pick<RepositoryItemProps, "hasLocal" | "onOpenTerminal" | "repository">) {
return (
<ProjectListRowMenu label={`More options for ${repository.name}`}>
<CopyShareLinkMenuItem
link={repositoryShareLink(repository)}
testId={`repository-copy-link-${repository.dtag}`}
/>
<DropdownMenuItem
onSelect={(event) => {
event.preventDefault();
event.stopPropagation();
onOpenTerminal(repository);
}}
>
<SquareTerminal className="h-4 w-4" />
{projectTerminalLabel(hasLocal)}
</DropdownMenuItem>
</ProjectListRowMenu>
);
}
export function RepositoryGridCard(props: RepositoryItemProps) {
const {
hasLocal,
onOpen,
onOpenTerminal,
profiles,
project,
repository,
summary,
} = props;
return (
<Card
className="group relative flex min-h-40 flex-col overflow-hidden border-border/60 bg-transparent shadow-none transition-colors duration-150 hover:bg-muted/20"
data-testid={`repository-card-${repository.dtag}`}
>
<RepositoryOpenButton
onOpen={onOpen}
project={project}
repository={repository}
/>
<div className="pointer-events-none relative z-10 flex min-h-0 flex-1 flex-col p-4">
<div className="flex min-w-0 items-start gap-2.5">
<RepositoryIdentity
inlineBranch
profiles={profiles}
project={project}
repository={repository}
/>
<div className="pointer-events-auto ml-auto">
<RepositoryActionsMenu
hasLocal={hasLocal}
onOpenTerminal={onOpenTerminal}
repository={repository}
/>
</div>
</div>
<p className="line-clamp-2 min-h-10 py-3 text-sm text-muted-foreground">
{repository.description || "A repository in this project."}
</p>
<div className="pointer-events-auto mt-auto flex items-center justify-between gap-3">
<ProjectPeopleStack
profiles={profiles}
pubkeys={repositoryPeople(repository, summary)}
workOwnerPubkey={repository.owner}
/>
<RepositoryUpdatedLabel repository={repository} summary={summary} />
</div>
<div className="mt-2">
<ProjectStatsRow summary={summary} />
<div className="mt-2">
<ProjectActivityBar summary={summary} />
</div>
</div>
</div>
</Card>
);
}
export function RepositoryListRow(props: RepositoryItemProps) {
const {
hasLocal,
onOpen,
onOpenTerminal,
profiles,
project,
repository,
summary,
} = props;
return (
<div
className={cn(PROJECT_LIST_ROW_CLASS, "py-3")}
data-testid={`repository-row-${repository.dtag}`}
>
<RepositoryOpenButton
onOpen={onOpen}
project={project}
repository={repository}
/>
<div className="pointer-events-none relative z-10 flex min-w-0 flex-1 items-center gap-2.5">
<div className="flex min-w-0 flex-1 items-center gap-2.5">
<RepositoryIdentity
profiles={profiles}
project={project}
repository={repository}
/>
</div>
<div
className={cn(PROJECT_LIST_ROW_TRAILING_CLASS, "pointer-events-auto")}
>
<div
className="hidden w-24 shrink-0 items-center gap-1.5 text-xs text-muted-foreground md:flex"
data-testid="repositories-row-branch"
>
<GitBranch className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{repository.defaultBranch}</span>
</div>
<div
className="hidden items-center gap-3 xl:flex"
data-testid="repositories-row-summary"
>
<ProjectStatsRow fixedColumns summary={summary} />
<div className="w-20 shrink-0">
<ProjectActivityBar summary={summary} />
</div>
</div>
<div
className="hidden w-24 shrink-0 justify-end lg:flex"
data-testid="repositories-row-people"
>
<ProjectPeopleStack
profiles={profiles}
pubkeys={repositoryPeople(repository, summary)}
workOwnerPubkey={repository.owner}
/>
</div>
<div
className={PROJECT_LIST_ROW_DATE_CLASS}
data-testid="repositories-row-date"
>
<RepositoryUpdatedLabel repository={repository} summary={summary} />
</div>
<RepositoryActionsMenu
hasLocal={hasLocal}
onOpenTerminal={onOpenTerminal}
repository={repository}
/>
</div>
</div>
</div>
);
}