Files
buzz/desktop/src/features/projects/ui/RepositoryCards.tsx
thomaspblock fb73561e64 feat(desktop): Projects follow-ups — access restrictions, fast loading, activity feed polish (#5073)
## Summary

Follow-up batch on the Projects overview (continues merged #1677):

- **Repository access restrictions** — repositories the viewer can't
reach are surfaced with a reason instead of failing silently.
Channel-ACL denials (which arrive as the same 404 as a missing repo, for
anti-enumeration) are re-classified using the repository's channel
binding and the viewer's memberships (`useRepositoryAccess.ts`,
`projectRepoAvailability.ts`).
- **Projects loads in seconds instead of minutes** — enumeration no
longer crawls every kind:5 deletion event on the relay. It fetches
project/repo announcements first, then queries deletions scoped to those
coordinates via chunked `#a` filters (3 queries instead of hundreds on
staging).
- **Activity feed layout polish** — bare event-type glyph beside the
headline (no badge circle), timeline spine runs through the avatars
connecting consecutive cards, linkable actor/project names are bold in
theme foreground, rounded hover state, alignment fixes.
- **Create button pinned** — the "+" create menu is pinned to the pane's
top-right corner (equal 16px insets) and no longer scrolls away with the
page header.
- **List controls as a table header** — the scope selector (left) and
sort + layout toggle (right) render as the first row of the list
container on the Projects/Repositories/PRs/Issues tabs; in card view the
identical bar stands alone with the cards below
(`ProjectsListHeaderBar.tsx`).
- **Repository rows show the git location** — subtitle is
`github.com/org/repo` for external repos or `owner/repo` (resolved
profile name) for Buzz-hosted ones, instead of repeating the project
name (`repositoryDisplayPath`).
- **Uniform work-item row heights** — issue rows previously ran the
author chip in inline flow, letting the 20px avatar grow the line box
~3px taller than PR rows; both lists now share the same flex subtitle.

📸 Screenshots: [feed layout / pinned
button](https://github.com/block/buzz/pull/5073#issuecomment-5214114069)
· [list header / repo subtitles / row
heights](https://github.com/block/buzz/pull/5073#issuecomment-5217480712).

Note: two empty `chore: retrigger CI` commits exist on the branch from
working around the Aug 6 GitHub Actions incident; happy to drop them
with a signoff rebase before undrafting if preferred. Latest `main` is
merged in (`a0cc35220`).

## Test plan

- [x] Desktop unit tests (4,493 pass after merging main), Biome, tsc
- [x] New unit tests for scoped deletion enumeration and repo
availability re-classification
- [x] New unit tests for `repositoryDisplayPath` (external, Buzz-hosted,
unresolvable)
- [x] Screenshot verification of feed layout, connector spine, and
pinned button (top + scrolled states) — posted to the PR
- [x] Screenshot verification of the list header row (list + card), repo
subtitles, and matching PR/issue row heights — posted to the PR
- [ ] Manual pass against staging (projects list load time,
restricted-repo states)

---------

Signed-off-by: Thomas Petersen <thomasp@squareup.com>
2026-08-07 19:23:35 +02:00

328 lines
9.8 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 {
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 { 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}`}>
<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>
);
}