Files
buzz/desktop/src/features/projects/ui/ProjectsCreateMenu.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

122 lines
3.3 KiB
TypeScript

import { CircleDot, FolderGit2, GitPullRequest, Plus } from "lucide-react";
import * as React from "react";
import { Button } from "@/shared/ui/button";
import {
POPOVER_SHADOW_STYLE,
POPOVER_SURFACE_CLASS,
} from "@/shared/ui/popoverSurface";
const MENU_ITEM_CLASS =
"flex min-h-9 w-full items-center gap-2 rounded-lg py-2 pl-2 pr-4 text-left text-sm outline-hidden transition-colors hover:bg-muted/50 focus:bg-muted/50 focus:text-foreground focus-visible:ring-1 focus-visible:ring-ring [&_svg]:size-4 [&_svg]:shrink-0";
export function ProjectsCreateMenu({
onCreateIssue,
onCreateProject,
onCreatePullRequest,
}: {
onCreateIssue: () => void;
onCreateProject: () => void;
onCreatePullRequest: () => void;
}) {
const [open, setOpen] = React.useState(false);
const containerRef = React.useRef<HTMLElement>(null);
React.useEffect(() => {
if (!open) return;
function handlePointerDown(event: PointerEvent) {
if (!containerRef.current?.contains(event.target as Node)) {
setOpen(false);
}
}
globalThis.document.addEventListener(
"pointerdown",
handlePointerDown,
true,
);
return () =>
globalThis.document.removeEventListener(
"pointerdown",
handlePointerDown,
true,
);
}, [open]);
function select(action: () => void) {
setOpen(false);
action();
}
return (
<nav
aria-label="Create project item"
className="relative shrink-0"
onBlurCapture={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) {
setOpen(false);
}
}}
onKeyDown={(event) => {
if (event.key === "Escape") {
setOpen(false);
containerRef.current?.querySelector<HTMLElement>("button")?.focus();
}
}}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
ref={containerRef}
>
<Button
aria-expanded={open}
aria-haspopup="menu"
aria-label="Create"
className="h-8 w-8 rounded-full"
data-testid="projects-create-menu"
onClick={() => setOpen(true)}
size="icon"
type="button"
variant="default"
>
<Plus className="h-4 w-4" />
</Button>
{open ? (
<div className="absolute right-0 top-full z-50 min-w-48 pt-1">
<div
className={`rounded-xl p-1 ${POPOVER_SURFACE_CLASS}`}
role="menu"
style={POPOVER_SHADOW_STYLE}
>
<button
className={MENU_ITEM_CLASS}
onClick={() => select(onCreateProject)}
role="menuitem"
type="button"
>
<FolderGit2 />
Project
</button>
<button
className={MENU_ITEM_CLASS}
onClick={() => select(onCreateIssue)}
role="menuitem"
type="button"
>
<CircleDot />
Issue
</button>
<button
className={MENU_ITEM_CLASS}
onClick={() => select(onCreatePullRequest)}
role="menuitem"
type="button"
>
<GitPullRequest />
Pull Request
</button>
</div>
</div>
) : null}
</nav>
);
}