From b4f6a4acbcc664c19c893117926cf571582c42f0 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Thu, 6 Aug 2026 09:24:50 +0200 Subject: [PATCH 1/7] feat(desktop): surface repository access restrictions in Projects The relay answers channel-ACL denials with the same 404 as a missing repository (anti-enumeration), so the git error alone can't tell "never initialized" from "no access". Re-classify missing results client-side using the repository's buzz-channel binding and the viewer's memberships: new access/unbound unavailable reasons with dedicated card indicator and README-panel copy (linking to the bound channel when visible), viewer accessibility helpers with an "accessible" repository scope filter, and a timeline connector treatment for the activity feed. Also adds min-w-0 to the sidebar layer in AppShell: without it, min-width:auto keeps the layer at full window width next to the community rail and pushes the main pane past the right edge. Signed-off-by: Thomas Petersen --- desktop/src/app/AppShell.tsx | 2 +- .../lib/projectRepoAvailability.test.mjs | 60 +++++++- .../projects/lib/projectRepoAvailability.ts | 32 +++++ .../projects/lib/projectsViewHelpers.test.mjs | 133 +++++++++++++++++- .../projects/lib/projectsViewHelpers.ts | 62 ++++++++ .../src/features/projects/ui/ProjectCards.tsx | 22 ++- .../projects/ui/ProjectOverviewPanel.tsx | 4 + .../projects/ui/ProjectReadmePanel.tsx | 69 ++++++++- .../projects/ui/ProjectWorkspaceTabs.tsx | 17 ++- .../projects/ui/ProjectsActivityFeed.tsx | 27 +++- .../projects/ui/ProjectsCreateMenu.tsx | 2 +- .../projects/ui/ProjectsOverviewPanel.tsx | 74 +++++----- .../projects/ui/ProjectsOverviewRail.tsx | 16 ++- .../features/projects/ui/ProjectsToolbar.tsx | 74 +++++++++- .../src/features/projects/ui/ProjectsView.tsx | 49 +++++-- .../features/projects/useRepositoryAccess.ts | 47 +++++++ 16 files changed, 625 insertions(+), 65 deletions(-) create mode 100644 desktop/src/features/projects/useRepositoryAccess.ts diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index fdb49071805..3cb367a7221 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -787,7 +787,7 @@ export function AppShell() { /> ) : null} diff --git a/desktop/src/features/projects/lib/projectRepoAvailability.test.mjs b/desktop/src/features/projects/lib/projectRepoAvailability.test.mjs index b1a40ca9ba1..7c9e6834bc4 100644 --- a/desktop/src/features/projects/lib/projectRepoAvailability.test.mjs +++ b/desktop/src/features/projects/lib/projectRepoAvailability.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { projectRepoUnavailableReason } from "./projectRepoAvailability.ts"; +import { + projectRepoUnavailableReason, + refineRepoUnavailableReason, +} from "./projectRepoAvailability.ts"; test("classifies a missing repository", () => { assert.equal( @@ -47,3 +50,58 @@ test("keeps unmatched failures generic", () => { "unknown", ); }); + +test("refines a masked 404 into an unbound-repository reason", () => { + assert.equal( + refineRepoUnavailableReason({ + reason: "missing", + repositoryChannelId: null, + memberChannelIds: ["11111111-1111-4111-8111-111111111111"], + }), + "unbound", + ); +}); + +test("refines a masked 404 into an access-denied reason for non-members", () => { + assert.equal( + refineRepoUnavailableReason({ + reason: "missing", + repositoryChannelId: "22222222-2222-4222-8222-222222222222", + memberChannelIds: ["11111111-1111-4111-8111-111111111111"], + }), + "access", + ); +}); + +test("keeps missing when the viewer is a member of the bound channel", () => { + assert.equal( + refineRepoUnavailableReason({ + reason: "missing", + repositoryChannelId: "11111111-1111-4111-8111-111111111111", + memberChannelIds: ["11111111-1111-4111-8111-111111111111"], + }), + "missing", + ); +}); + +test("does not guess while memberships are still loading", () => { + assert.equal( + refineRepoUnavailableReason({ + reason: "missing", + repositoryChannelId: "22222222-2222-4222-8222-222222222222", + memberChannelIds: null, + }), + "missing", + ); +}); + +test("never rewrites non-missing reasons", () => { + assert.equal( + refineRepoUnavailableReason({ + reason: "network", + repositoryChannelId: null, + memberChannelIds: [], + }), + "network", + ); +}); diff --git a/desktop/src/features/projects/lib/projectRepoAvailability.ts b/desktop/src/features/projects/lib/projectRepoAvailability.ts index 803548d3dda..ce1b520cf03 100644 --- a/desktop/src/features/projects/lib/projectRepoAvailability.ts +++ b/desktop/src/features/projects/lib/projectRepoAvailability.ts @@ -1,5 +1,7 @@ export type ProjectRepoUnavailableReason = | "missing" + | "access" + | "unbound" | "authentication" | "network" | "ref" @@ -46,3 +48,33 @@ export function projectRepoUnavailableReason( } return "unknown"; } + +/** + * The relay deliberately answers channel-ACL denials with the same 404 as a + * genuinely absent repository (SEC-005 anti-enumeration), so the git error + * alone cannot distinguish "never initialized" from "you have no access". + * The announcement events ARE visible to every relay member though, so the + * client can re-classify a `missing` result using the repository's + * `buzz-channel` binding and the viewer's own channel memberships: + * + * - no binding at all → `unbound` (the relay refuses access for everyone + * until the owner binds a channel) + * - bound to a channel the viewer is not a member of → `access` + * - bound to a channel the viewer IS a member of → keep `missing` (the + * repository truly has no git data pointer on the relay) + * + * `memberChannelIds === null` means memberships are still loading — the + * reason is left untouched rather than guessed. + */ +export function refineRepoUnavailableReason(input: { + reason: ProjectRepoUnavailableReason; + repositoryChannelId: string | null | undefined; + memberChannelIds: readonly string[] | null; +}): ProjectRepoUnavailableReason { + if (input.reason !== "missing") return input.reason; + if (!input.repositoryChannelId) return "unbound"; + if (input.memberChannelIds === null) return input.reason; + return input.memberChannelIds.includes(input.repositoryChannelId) + ? input.reason + : "access"; +} diff --git a/desktop/src/features/projects/lib/projectsViewHelpers.test.mjs b/desktop/src/features/projects/lib/projectsViewHelpers.test.mjs index cd58f06a516..b296ea3ece4 100644 --- a/desktop/src/features/projects/lib/projectsViewHelpers.test.mjs +++ b/desktop/src/features/projects/lib/projectsViewHelpers.test.mjs @@ -1,7 +1,11 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { relativeTime } from "./projectsViewHelpers.ts"; +import { + isProjectAccessibleToViewer, + isRepositoryAccessibleToViewer, + relativeTime, +} from "./projectsViewHelpers.ts"; const DAY_SECONDS = 24 * 60 * 60; @@ -9,6 +13,133 @@ function localSeconds(year, month, day) { return Math.floor(new Date(year, month, day, 12).getTime() / 1_000); } +const REPO_OWNER = "a".repeat(64); +const VIEWER = "b".repeat(64); +const BOUND_CHANNEL = "11111111-1111-4111-8111-111111111111"; +const RELAY_ORIGIN = "https://relay.example"; + +function makeRepository(overrides = {}) { + return { + channelId: BOUND_CHANNEL, + cloneUrls: [`${RELAY_ORIGIN}/git/${REPO_OWNER}/repo-a`], + contributors: [], + createdAt: 0, + defaultBranch: "main", + dtag: "repo-a", + id: `${REPO_OWNER}:repo-a`, + name: "repo-a", + owner: REPO_OWNER, + repoAddress: `30617:${REPO_OWNER}:repo-a`, + ...overrides, + }; +} + +function makeAccessInput(overrides = {}) { + return { + currentPubkey: VIEWER, + localRepoNames: new Set(), + memberChannelIds: [], + relayOrigin: RELAY_ORIGIN, + ...overrides, + }; +} + +test("a channel-bound repository is accessible only to channel members", () => { + const repository = makeRepository(); + + assert.equal( + isRepositoryAccessibleToViewer( + repository, + makeAccessInput({ memberChannelIds: [BOUND_CHANNEL] }), + ), + true, + ); + assert.equal( + isRepositoryAccessibleToViewer( + repository, + makeAccessInput({ memberChannelIds: [] }), + ), + false, + ); +}); + +test("an unbound repository stays accessible to its owner", () => { + const repository = makeRepository({ channelId: null }); + + assert.equal( + isRepositoryAccessibleToViewer(repository, makeAccessInput()), + false, + ); + assert.equal( + isRepositoryAccessibleToViewer( + repository, + makeAccessInput({ currentPubkey: REPO_OWNER }), + ), + true, + ); +}); + +test("external hosting and local checkouts bypass the channel gate", () => { + assert.equal( + isRepositoryAccessibleToViewer( + makeRepository({ cloneUrls: ["https://github.com/acme/site.git"] }), + makeAccessInput(), + ), + true, + ); + assert.equal( + isRepositoryAccessibleToViewer( + makeRepository(), + makeAccessInput({ localRepoNames: new Set(["repo-a"]) }), + ), + true, + ); +}); + +test("channel-bound repositories stay visible while memberships load", () => { + assert.equal( + isRepositoryAccessibleToViewer( + makeRepository(), + makeAccessInput({ memberChannelIds: null }), + ), + true, + ); +}); + +test("a project is accessible when any repository is, or when owned", () => { + const accessible = makeRepository({ channelId: BOUND_CHANNEL }); + const restricted = makeRepository({ + channelId: "22222222-2222-4222-8222-222222222222", + dtag: "repo-b", + id: `${REPO_OWNER}:repo-b`, + name: "repo-b", + repoAddress: `30617:${REPO_OWNER}:repo-b`, + }); + const input = makeAccessInput({ memberChannelIds: [BOUND_CHANNEL] }); + + assert.equal( + isProjectAccessibleToViewer( + { owner: REPO_OWNER, repositories: [restricted, accessible] }, + input, + ), + true, + ); + assert.equal( + isProjectAccessibleToViewer( + { owner: REPO_OWNER, repositories: [restricted] }, + input, + ), + false, + ); + assert.equal( + isProjectAccessibleToViewer( + { owner: VIEWER, repositories: [restricted] }, + input, + ), + true, + ); +}); + test("relativeTime switches to an absolute date at seven days", () => { const now = localSeconds(2025, 5, 15); diff --git a/desktop/src/features/projects/lib/projectsViewHelpers.ts b/desktop/src/features/projects/lib/projectsViewHelpers.ts index 6084c7275f3..45bb3e3254d 100644 --- a/desktop/src/features/projects/lib/projectsViewHelpers.ts +++ b/desktop/src/features/projects/lib/projectsViewHelpers.ts @@ -1,7 +1,10 @@ import type { Project, ProjectActivitySummary, + Repository, } from "@/features/projects/hooks"; +import { hasLocalRepositoryCheckout } from "@/features/projects/lib/projectLocalRepos"; +import { projectRepoHostForRepository } from "@/features/projects/lib/projectRepoHost"; import { selectProjectRepository } from "@/features/projects/projectModels"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { normalizePubkey } from "@/shared/lib/pubkey"; @@ -9,6 +12,7 @@ import { normalizePubkey } from "@/shared/lib/pubkey"; export type ProjectsViewMode = "grid" | "list"; export type ProjectsRepositoryScope = | "all" + | "accessible" | "mine" | "local" | "buzz" @@ -85,6 +89,7 @@ export function readStoredRepositoryScope(): ProjectsRepositoryScope { PROJECTS_REPOSITORY_SCOPE_STORAGE_KEY, ); if ( + value === "accessible" || value === "mine" || value === "local" || value === "buzz" || @@ -360,6 +365,63 @@ export function isProjectOwnedByCurrentUser( : false; } +export type RepositoryAccessInput = { + currentPubkey: string | undefined; + localRepoNames: Set; + /** `null` while channel memberships are still loading. */ + memberChannelIds: readonly string[] | null; + relayOrigin: string | null | undefined; +}; + +/** + * Whether the viewer can actually read a repository's git data. The relay + * gates git reads on membership in the repository's bound `buzz-channel`, + * so a repository is considered accessible when the viewer owns it (owners + * can repair a missing binding), has a local checkout, the code is hosted + * externally (no relay ACL applies), or the viewer is a member of the bound + * channel. While memberships are still loading (`memberChannelIds === null`) + * channel-bound repositories are kept visible rather than flashing out. + */ +export function isRepositoryAccessibleToViewer( + repository: Repository, + input: RepositoryAccessInput, +) { + if ( + input.currentPubkey && + normalizePubkey(repository.owner) === normalizePubkey(input.currentPubkey) + ) { + return true; + } + if (hasLocalRepositoryCheckout(repository, input.localRepoNames)) { + return true; + } + if ( + projectRepoHostForRepository(repository, input.relayOrigin).kind === + "external" + ) { + return true; + } + if (!repository.channelId) return false; + if (input.memberChannelIds === null) return true; + return input.memberChannelIds.includes(repository.channelId); +} + +/** + * A project is accessible when the viewer owns it or can read at least one + * of its repositories. + */ +export function isProjectAccessibleToViewer( + project: Project, + input: RepositoryAccessInput, +) { + return ( + isProjectOwnedByCurrentUser(project, input.currentPubkey) || + project.repositories.some((repository) => + isRepositoryAccessibleToViewer(repository, input), + ) + ); +} + export function projectHasAgent( project: Project, people: string[], diff --git a/desktop/src/features/projects/ui/ProjectCards.tsx b/desktop/src/features/projects/ui/ProjectCards.tsx index ed28ddd5834..09535fdfc77 100644 --- a/desktop/src/features/projects/ui/ProjectCards.tsx +++ b/desktop/src/features/projects/ui/ProjectCards.tsx @@ -280,6 +280,16 @@ function RepositoryUnavailableIndicator({ description: "No git repository was found on the Buzz relay.", label: "Uninitialized", }, + access: { + description: + "You’re not a member of the channel that grants access to this repository.", + label: "No access", + }, + unbound: { + description: + "The repository has no access channel binding, so the relay cannot authorize reads.", + label: "No access channel", + }, network: { description: "The Buzz git service could not be reached.", label: "Unreachable", @@ -292,13 +302,17 @@ function RepositoryUnavailableIndicator({ description: "Buzz could not load this repository.", label: "Unavailable", }, - }[reason]; + } satisfies Record< + ProjectRepoUnavailableReason, + { description: string; label: string } + >; + const { description, label } = status[reason]; return ( @@ -306,8 +320,8 @@ function RepositoryUnavailableIndicator({ -

{status.label}

-

{status.description}

+

{label}

+

{description}

); diff --git a/desktop/src/features/projects/ui/ProjectOverviewPanel.tsx b/desktop/src/features/projects/ui/ProjectOverviewPanel.tsx index de9044e1a50..0cb06b3e99a 100644 --- a/desktop/src/features/projects/ui/ProjectOverviewPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectOverviewPanel.tsx @@ -29,6 +29,8 @@ import { ReadmePanel } from "./ProjectReadmePanel"; import type { RepoSourceHeaderControls } from "./ProjectRepositorySource"; type ProjectOverviewPanelProps = { + /** `buzz-channel` binding of the repository, for access-restricted copy. */ + accessChannelId?: string | null; contributors: ProjectRepoContributor[]; externalHost?: string; externalUrl?: string | null; @@ -141,6 +143,7 @@ export function OverviewRailSection({ } export function ProjectOverviewPanel({ + accessChannelId, contributors, externalHost, externalUrl, @@ -174,6 +177,7 @@ export function ProjectOverviewPanel({ {/* ReadmePanel renders its own "no README" fallback while keeping the branch + source controls reachable. */} candidate.id === accessChannelId, + ); + + if (!channel) { + return ( + <> + Repository access is granted through a channel you can’t see. Ask the + repository owner for an invite. + + ); + } + + return ( + <> + Repository access is granted through{" "} + + , and you’re not a member. Join the channel or ask the repository owner + for an invite. + + ); +} + export function ReadmePanel({ + accessChannelId, file, gitDataState, externalHost, @@ -98,6 +143,8 @@ export function ReadmePanel({ sourceControls, unavailableReason, }: { + /** `buzz-channel` binding of the repository, for access-restricted copy. */ + accessChannelId?: string | null; file: ProjectRepoFile | null; gitDataState: "checking" | "available" | "empty" | "unavailable"; externalHost?: string; @@ -174,6 +221,18 @@ export function ReadmePanel({ icon: CircleAlert, title: "Repository not initialized", }, + access: { + description: + "Repository access is granted through its channel, and you’re not a member of the channel bound to this repository. Ask the repository owner for an invite.", + icon: LockKeyhole, + title: "Repository access restricted", + }, + unbound: { + description: + "This repository has no access channel binding, so the relay cannot authorize anyone to read it. The repository owner can bind a channel from the Access menu.", + icon: LockKeyhole, + title: "No access channel bound", + }, network: { description: "The Buzz git service could not be reached. Check your connection and try again.", @@ -221,9 +280,13 @@ export function ReadmePanel({ : unavailable.title}

- {externalHost - ? "Clone this repository locally to explore its files, commits, and contributors in Buzz." - : unavailable.description} + {externalHost ? ( + "Clone this repository locally to explore its files, commits, and contributors in Buzz." + ) : reason === "access" && accessChannelId ? ( + + ) : ( + unavailable.description + )}

{externalUrl ? ( @@ -76,42 +76,50 @@ export function ProjectsOverviewPanel({ }: ProjectsOverviewPanelProps) { const stats = overviewStats(projects, summaries); + // The feed owns the full left column; the stat counters live at the top + // of the side rail as compact cards, above People and Contribution + // Activity, instead of a full-width row over the feed. return ( -
+
-
- onSelectSection("projects")} - /> - count + project.repositories.length, - 0, - )} - icon={FolderGit2} - label="Repositories" - onClick={() => onSelectSection("repositories")} - /> - onSelectSection("prs")} - /> - onSelectSection("issues")} - /> -
- {metadata} -
+
{children}
+
+
+ onSelectSection("projects")} + /> + count + project.repositories.length, + 0, + )} + icon={FolderGit2} + label="Repositories" + onClick={() => onSelectSection("repositories")} + /> + onSelectSection("prs")} + /> + onSelectSection("issues")} + /> +
+ {metadata} +
); diff --git a/desktop/src/features/projects/ui/ProjectsOverviewRail.tsx b/desktop/src/features/projects/ui/ProjectsOverviewRail.tsx index 284c53365fe..38660b3a049 100644 --- a/desktop/src/features/projects/ui/ProjectsOverviewRail.tsx +++ b/desktop/src/features/projects/ui/ProjectsOverviewRail.tsx @@ -62,9 +62,11 @@ export function ProjectsOverviewRail({ const people = overviewPeople(projects, summaries); const activityByDay = overviewActivityByDay(projects, summaries); + // Plain stacked cards — the overview panel's rail column owns placement + // and spacing, so the sections can never drift apart or collide. return ( <> -
+
{people.length > 0 ? (
@@ -94,13 +96,13 @@ export function ProjectsOverviewRail({
-
-
-

- Contribution Activity -

+
+ -
+
); diff --git a/desktop/src/features/projects/ui/ProjectsToolbar.tsx b/desktop/src/features/projects/ui/ProjectsToolbar.tsx index 925e708c3ba..f4955fcb36b 100644 --- a/desktop/src/features/projects/ui/ProjectsToolbar.tsx +++ b/desktop/src/features/projects/ui/ProjectsToolbar.tsx @@ -1,4 +1,5 @@ import { LayoutGrid, List } from "lucide-react"; +import * as React from "react"; import type { ProjectsFilter, @@ -10,6 +11,17 @@ import { Button } from "@/shared/ui/button"; const SELECTED_MENU_ITEM_CLASSES = "font-semibold text-foreground after:opacity-100 hover:text-foreground"; +// Fade the clipped edge(s) of the scrollable tab row so a cut-off label +// reads as "scroll for more" instead of a rendering bug. Masking the row +// itself (rather than overlaying a gradient) keeps the effect correct over +// the translucent sticky header backdrop. +const MASK_BOTH = + "[mask-image:linear-gradient(to_right,transparent,black_1.5rem,black_calc(100%-1.5rem),transparent)]"; +const MASK_LEFT = + "[mask-image:linear-gradient(to_right,transparent,black_1.5rem)]"; +const MASK_RIGHT = + "[mask-image:linear-gradient(to_left,transparent,black_1.5rem)]"; + type ProjectsToolbarProps = { filter: ProjectsFilter; onFilterChange: (filter: ProjectsFilter) => void; @@ -51,10 +63,58 @@ export function ProjectsViewModeToggle({ ); } +/** Tracks which edges of a horizontal scroller are currently clipped. */ +function useHorizontalOverflow(ref: React.RefObject) { + const [overflow, setOverflow] = React.useState({ + left: false, + right: false, + }); + + React.useEffect(() => { + const element = ref.current; + if (!element) return; + + const update = () => { + const maxScrollLeft = element.scrollWidth - element.clientWidth; + setOverflow((previous) => { + const next = { + left: element.scrollLeft > 1, + right: element.scrollLeft < maxScrollLeft - 1, + }; + return previous.left === next.left && previous.right === next.right + ? previous + : next; + }); + }; + + update(); + element.addEventListener("scroll", update, { passive: true }); + const observer = new ResizeObserver(update); + observer.observe(element); + return () => { + element.removeEventListener("scroll", update); + observer.disconnect(); + }; + }, [ref]); + + return overflow; +} + export function ProjectsToolbar({ filter, onFilterChange, }: ProjectsToolbarProps) { + const scrollRef = React.useRef(null); + const overflow = useHorizontalOverflow(scrollRef); + + // Keep the active tab visible when it changes (e.g. selected while + // partially scrolled out of view, or restored from storage on mount). + React.useEffect(() => { + scrollRef.current + ?.querySelector(`[data-testid="projects-section-${filter}"]`) + ?.scrollIntoView({ block: "nearest", inline: "nearest" }); + }, [filter]); + const filterOptions: Array<{ label: string; value: ProjectsFilter; @@ -72,7 +132,19 @@ export function ProjectsToolbar({ data-tauri-drag-region >
-
+
Project owner filter {filterOptions.map((option) => (
} className="pointer-events-auto mb-8" description="Set up and manage your projects." title="Projects" @@ -776,10 +810,9 @@ export function ProjectsView() { const projectsNavigation = (
-
+
- {createMenu}
); diff --git a/desktop/src/features/projects/useRepositoryAccess.ts b/desktop/src/features/projects/useRepositoryAccess.ts new file mode 100644 index 00000000000..16ba7085cb2 --- /dev/null +++ b/desktop/src/features/projects/useRepositoryAccess.ts @@ -0,0 +1,47 @@ +import * as React from "react"; + +import { useChannelsQuery } from "@/features/channels/hooks"; +import type { Project } from "@/features/projects/hooks"; +import { + type ProjectRepoUnavailableReason, + refineRepoUnavailableReason, +} from "@/features/projects/lib/projectRepoAvailability"; +import { selectProjectRepository } from "@/features/projects/projectModels"; + +/** Channel ids the viewer is a member of, or `null` while they load. */ +export function useMemberChannelIds(): readonly string[] | null { + const channelsQuery = useChannelsQuery(); + return React.useMemo( + () => + channelsQuery.data + ? channelsQuery.data + .filter((channel) => channel.isMember) + .map((channel) => channel.id) + : null, + [channelsQuery.data], + ); +} + +/** + * Channel-ACL denials arrive as the same 404 as a missing repository + * (anti-enumeration), so a project's raw snapshot reason is re-classified + * with the repository's channel binding and the viewer's memberships + * before it reaches the status indicator. + */ +export function useRepositoryUnavailableReasonFor( + unavailable: Record | undefined, + memberChannelIds: readonly string[] | null, +): (project: Project) => ProjectRepoUnavailableReason | undefined { + return React.useCallback( + (project: Project) => { + const reason = unavailable?.[project.id]; + if (!reason) return undefined; + return refineRepoUnavailableReason({ + reason, + repositoryChannelId: selectProjectRepository(project, null)?.channelId, + memberChannelIds, + }); + }, + [memberChannelIds, unavailable], + ); +} From 55fa1d1e6b27e17ed303b86ad314682a183b8c27 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Thu, 6 Aug 2026 17:43:41 +0200 Subject: [PATCH 2/7] perf(desktop): scope project deletion lookups to announcement coordinates Projects previously enumerated every kind:5 deletion event on the relay to find tombstones, which took minutes on staging. Fetch announcements first, then query kind:5 with #a filters chunked over their coordinates. Signed-off-by: Thomas Petersen --- desktop/src/features/projects/hooks.ts | 5 +- .../projects/projectEnumeration.test.mjs | 103 ++++++++++++++++++ .../features/projects/projectEnumeration.ts | 93 ++++++++++++++-- 3 files changed, 188 insertions(+), 13 deletions(-) diff --git a/desktop/src/features/projects/hooks.ts b/desktop/src/features/projects/hooks.ts index f6e5d2f1b0a..8591430cc22 100644 --- a/desktop/src/features/projects/hooks.ts +++ b/desktop/src/features/projects/hooks.ts @@ -62,6 +62,7 @@ import { } from "./projectModels"; import { buildProjectsFromFetcher, + type FetchProjectEventsExhaustively, fetchProjectEventsExhaustively, } from "./projectEnumeration"; import { projectMatchesRouteId } from "./projectRoutes"; @@ -163,9 +164,7 @@ export function eventToProject( } export async function fetchProjects( - fetchExhaustively: ( - kinds: number[], - ) => Promise = fetchProjectEventsExhaustively, + fetchExhaustively: FetchProjectEventsExhaustively = fetchProjectEventsExhaustively, ): Promise { // Delegates to `buildProjectsFromFetcher` in `projectEnumeration.ts`, which // is the pure, Tauri-free core of this operation. That helper's javadoc diff --git a/desktop/src/features/projects/projectEnumeration.test.mjs b/desktop/src/features/projects/projectEnumeration.test.mjs index 5d60bd47661..240546b8a4b 100644 --- a/desktop/src/features/projects/projectEnumeration.test.mjs +++ b/desktop/src/features/projects/projectEnumeration.test.mjs @@ -142,3 +142,106 @@ test("buildProjectsFromFetcher does not throw when tombstone enumeration succeed "unclaimed repo must appear as legacy project", ); }); + +// ── Tombstone scoping ──────────────────────────────────────────────────────── +// +// Kind:5 is the app-wide NIP-09 deletion kind (every deleted chat message is +// one), so the tombstone fetch must be scoped server-side with `#a` filters +// on the announcement coordinates rather than crawling the relay's entire +// deletion history. + +test("buildProjectsFromFetcher scopes the tombstone fetch to announcement coordinates", async () => { + const OWNER = "a".repeat(64); + + const projectEvent = { + id: "p".repeat(64), + kind: 30621, + pubkey: OWNER, + created_at: 200, + content: "", + tags: [["d", "proj"]], + }; + const repoEvent = { + id: "r".repeat(64), + kind: 30617, + pubkey: OWNER, + created_at: 100, + content: "", + tags: [ + ["d", "repo"], + ["name", "repo"], + ], + }; + + const deletionCalls = []; + const fetchExhaustively = async (kinds, extraFilter) => { + if (kinds.includes(5)) { + deletionCalls.push(extraFilter); + return []; + } + if (kinds.includes(30621)) return [projectEvent]; + if (kinds.includes(30617)) return [repoEvent]; + return []; + }; + + await buildProjectsFromFetcher(fetchExhaustively); + + assert.equal(deletionCalls.length, 1, "one scoped tombstone query"); + assert.deepEqual(deletionCalls[0], { + "#a": [`30621:${OWNER}:proj`, `30617:${OWNER}:repo`], + }); +}); + +test("buildProjectsFromFetcher skips the tombstone fetch when there are no announcements", async () => { + let deletionQueried = false; + const fetchExhaustively = async (kinds) => { + if (kinds.includes(5)) deletionQueried = true; + return []; + }; + + const projects = await buildProjectsFromFetcher(fetchExhaustively); + + assert.deepEqual(projects, []); + assert.equal( + deletionQueried, + false, + "no coordinates means no tombstone query at all", + ); +}); + +test("buildProjectsFromFetcher still suppresses deleted heads via the scoped fetch", async () => { + const OWNER = "a".repeat(64); + + const repoEvent = { + id: "r".repeat(64), + kind: 30617, + pubkey: OWNER, + created_at: 100, + content: "", + tags: [ + ["d", "repo"], + ["name", "repo"], + ], + }; + const deletionEvent = { + id: "5".repeat(64), + kind: 5, + pubkey: OWNER, + created_at: 150, + content: "", + tags: [["a", `30617:${OWNER}:repo`]], + }; + + const fetchExhaustively = async (kinds, extraFilter) => { + if (kinds.includes(5)) { + return extraFilter?.["#a"]?.includes(`30617:${OWNER}:repo`) + ? [deletionEvent] + : []; + } + if (kinds.includes(30617)) return [repoEvent]; + return []; + }; + + const projects = await buildProjectsFromFetcher(fetchExhaustively); + assert.deepEqual(projects, [], "deleted repo must not surface as a project"); +}); diff --git a/desktop/src/features/projects/projectEnumeration.ts b/desktop/src/features/projects/projectEnumeration.ts index 072b417a5b4..ab1502c7577 100644 --- a/desktop/src/features/projects/projectEnumeration.ts +++ b/desktop/src/features/projects/projectEnumeration.ts @@ -9,13 +9,27 @@ import { buildProjectReadModels, type Project } from "./projectModels"; const PROJECT_ENUMERATION_PAGE_SIZE = 500; -type ProjectEventFilter = { +// Relays commonly cap filter tag-value lists; chunk `#a` scoping well below +// any such cap. +const TOMBSTONE_COORDINATE_CHUNK_SIZE = 100; + +/** Additional server-side scoping merged into every enumeration page. */ +export type ProjectEventExtraFilter = { + "#a"?: string[]; +}; + +type ProjectEventFilter = ProjectEventExtraFilter & { kinds: number[]; limit: number; since?: number; until?: number; }; +export type FetchProjectEventsExhaustively = ( + kinds: number[], + extraFilter?: ProjectEventExtraFilter, +) => Promise; + type FetchProjectEventPage = ( filter: ProjectEventFilter, ) => Promise; @@ -29,6 +43,7 @@ export async function enumerateProjectEvents( fetchPage: FetchProjectEventPage, kinds: number[], pageSize: number, + extraFilter?: ProjectEventExtraFilter, ): Promise { if (!Number.isSafeInteger(pageSize) || pageSize <= 0) { throw new Error( @@ -41,6 +56,7 @@ export async function enumerateProjectEvents( for (;;) { const page = await fetchPage({ + ...extraFilter, kinds, limit: pageSize, ...(until === undefined ? {} : { until }), @@ -50,6 +66,7 @@ export async function enumerateProjectEvents( const oldest = Math.min(...page.map((event) => event.created_at)); const boundary = await fetchPage({ + ...extraFilter, kinds, limit: pageSize, since: oldest, @@ -75,13 +92,62 @@ export async function enumerateProjectEvents( export function fetchProjectEventsExhaustively( kinds: number[], + extraFilter?: ProjectEventExtraFilter, pageSize = PROJECT_ENUMERATION_PAGE_SIZE, ): Promise { return enumerateProjectEvents( (filter) => relayClient.fetchEvents(filter), kinds, pageSize, + extraFilter, + ); +} + +/** `kind:owner:dtag` coordinate for an addressable announcement event. */ +function eventCoordinate(event: RelayEvent): string | null { + const dtag = event.tags.find((tag) => tag[0] === "d")?.[1]; + if (typeof dtag !== "string" || dtag.length === 0) return null; + return `${event.kind}:${event.pubkey.toLowerCase()}:${dtag}`; +} + +/** + * Fetches the NIP-09 kind:5 tombstones relevant to the given announcement + * events, scoped server-side with `#a` filters on the announcements' own + * coordinates. Kind:5 is the app-wide deletion kind (every deleted chat + * message is one), so enumerating it unscoped crawls the entire community's + * deletion history — minutes on a large relay. Only tombstones addressing a + * currently visible project/repo coordinate can affect the read models, so + * scoping is semantically equivalent (see `buildDeletionThresholds`). + */ +async function fetchScopedDeletionEvents( + fetchExhaustively: FetchProjectEventsExhaustively, + announcementEvents: RelayEvent[], +): Promise { + const coordinates = [ + ...new Set( + announcementEvents.flatMap((event) => { + const coordinate = eventCoordinate(event); + return coordinate ? [coordinate] : []; + }), + ), + ]; + if (coordinates.length === 0) return []; + + const chunks: string[][] = []; + for ( + let index = 0; + index < coordinates.length; + index += TOMBSTONE_COORDINATE_CHUNK_SIZE + ) { + chunks.push( + coordinates.slice(index, index + TOMBSTONE_COORDINATE_CHUNK_SIZE), + ); + } + + const pages = await Promise.all( + chunks.map((chunk) => fetchExhaustively([KIND_DELETION], { "#a": chunk })), ); + return pages.flat(); } /** @@ -95,24 +161,31 @@ export function fetchProjectEventsExhaustively( * returning an empty deletion set that would resurrect every deleted head. */ export async function buildProjectsFromFetcher( - fetchExhaustively: (kinds: number[]) => Promise, + fetchExhaustively: FetchProjectEventsExhaustively, options: { relayOrigin?: string | null; hiddenAddresses?: ReadonlySet; } = {}, ): Promise { - const [projectEvents, repositoryEvents, tombstoneResult] = await Promise.all([ + const [projectEvents, repositoryEvents] = await Promise.all([ fetchExhaustively([KIND_PROJECT_ANNOUNCEMENT]), fetchExhaustively([KIND_REPO_ANNOUNCEMENT]), - fetchExhaustively([KIND_DELETION]).then( - (events) => ({ ok: true as const, events }), - (error: unknown) => ({ - ok: false as const, - message: error instanceof Error ? error.message : "Unknown error", - }), - ), ]); + // Tombstones are fetched second (not in parallel) because the `#a` scoping + // needs the announcement coordinates; both announcement kinds are small, + // so this costs one extra round trip, not a full crawl. + const tombstoneResult = await fetchScopedDeletionEvents(fetchExhaustively, [ + ...projectEvents, + ...repositoryEvents, + ]).then( + (events) => ({ ok: true as const, events }), + (error: unknown) => ({ + ok: false as const, + message: error instanceof Error ? error.message : "Unknown error", + }), + ); + if (!tombstoneResult.ok) { throw new Error( `Could not fetch project deletion records: ${tombstoneResult.message} — refresh to retry.`, From b1b4b5c3483f537e4bf489717167abe1a2342fcb Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Thu, 6 Aug 2026 17:43:46 +0200 Subject: [PATCH 3/7] feat(desktop): polish Projects activity feed layout and pin create button Activity cards show the bare event-type glyph beside the headline, run the timeline spine through the avatars, and bold the linkable actor and project names in theme foreground. The create menu is pinned to the pane's top-right corner instead of scrolling with the header. Signed-off-by: Thomas Petersen --- .../projects/ui/ProjectsActivityFeed.tsx | 127 +++++++++++------- .../projects/ui/ProjectsOverviewPanel.tsx | 7 +- .../src/features/projects/ui/ProjectsView.tsx | 4 +- 3 files changed, 82 insertions(+), 56 deletions(-) diff --git a/desktop/src/features/projects/ui/ProjectsActivityFeed.tsx b/desktop/src/features/projects/ui/ProjectsActivityFeed.tsx index c1208f78e6b..dc317ab03c3 100644 --- a/desktop/src/features/projects/ui/ProjectsActivityFeed.tsx +++ b/desktop/src/features/projects/ui/ProjectsActivityFeed.tsx @@ -27,7 +27,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import { PROJECT_EVENT_VISUALS, - ProjectEventTypeIcon, type ProjectEventKind, } from "./ProjectEventTypeIcon"; @@ -253,18 +252,23 @@ function buildActivityItems({ function ActivityCard({ compact, + isFirst, + isLast, item, onOpen, onOpenProject, profiles, }: { compact: boolean; + isFirst: boolean; + isLast: boolean; item: ProjectActivityItem; onOpen: () => void; onOpenProject: () => void; profiles?: UserProfileLookup; }) { const visual = PROJECT_EVENT_VISUALS[item.kind]; + const TypeIcon = visual.icon; const profile = item.actorPubkey ? profiles?.[normalizePubkey(item.actorPubkey)] : undefined; @@ -275,61 +279,71 @@ function ActivityCard({ return (
+ + ) : ( + + )}
- {item.actorPubkey ? ( - - - - ) : ( - - )}
@@ -340,7 +354,7 @@ function ActivityCard({ triggerElement="span" >
-

- {item.title} -

+ {/* Bare event-type glyph beside the headline (no badge circle). */} +
+
{item.body ? (

- {items.map((item) => { + {items.map((item, index) => { return (

{ if (item.target.type === "project") { diff --git a/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx b/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx index 0c9ab509ff7..f93b953a225 100644 --- a/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx @@ -78,14 +78,15 @@ export function ProjectsOverviewPanel({ // The feed owns the full left column; the stat counters live at the top // of the side rail as compact cards, above People and Contribution - // Activity, instead of a full-width row over the feed. + // Activity, instead of a full-width row over the feed. The feed column + // has no left inset so the timeline spine lines up with the section tabs. return (
-
+
{children}
diff --git a/desktop/src/features/projects/ui/ProjectsView.tsx b/desktop/src/features/projects/ui/ProjectsView.tsx index e4ea2b42e50..5c7037e353b 100644 --- a/desktop/src/features/projects/ui/ProjectsView.tsx +++ b/desktop/src/features/projects/ui/ProjectsView.tsx @@ -801,7 +801,6 @@ export function ProjectsView() { const projectsHeader = ( {createMenu}
} className="pointer-events-auto mb-8" description="Set up and manage your projects." title="Projects" @@ -830,6 +829,9 @@ export function ProjectsView() { className="pointer-events-none absolute right-[3px] top-0 z-50 w-1 rounded-full bg-border/80 opacity-0 transition-opacity duration-200" ref={scrollIndicatorRef} /> + {/* Create button pinned to the pane's top-right corner: it never + scrolls with the page, it just stays put. */} +
{createMenu}
{ From 56465f05ab8be901b7876f4657092403faaa9f29 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Thu, 6 Aug 2026 18:02:07 +0200 Subject: [PATCH 4/7] fix(desktop): equalize create button padding in Projects corner Signed-off-by: Thomas Petersen --- desktop/src/features/projects/ui/ProjectsView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/src/features/projects/ui/ProjectsView.tsx b/desktop/src/features/projects/ui/ProjectsView.tsx index 5c7037e353b..d0f76b35a15 100644 --- a/desktop/src/features/projects/ui/ProjectsView.tsx +++ b/desktop/src/features/projects/ui/ProjectsView.tsx @@ -831,7 +831,7 @@ export function ProjectsView() { /> {/* Create button pinned to the pane's top-right corner: it never scrolls with the page, it just stays put. */} -
{createMenu}
+
{createMenu}
{ From 43ea79fd48436005b6adb8fc00a361ca1f1c74f2 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Thu, 6 Aug 2026 22:11:42 +0200 Subject: [PATCH 5/7] chore: retrigger CI after GitHub Actions incident Signed-off-by: Thomas Petersen From a4076af59f06cffdd954bd907fc19bea3f2193a7 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Fri, 7 Aug 2026 00:09:54 +0200 Subject: [PATCH 6/7] chore: retrigger CI (previous run stuck in incident-era queue) Signed-off-by: Thomas Petersen From dd6d0567a06e7d2dec57fbf318916d548b65997b Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Fri, 7 Aug 2026 13:53:44 +0200 Subject: [PATCH 7/7] feat(desktop): fold Projects list controls into a table header row The scope selector and sort/view controls now render as the first row of the list container (a standalone bar with the same proportions in card view). Repository rows show where the git data lives (github.com/org/repo or owner/repo for Buzz-hosted) instead of repeating the project name, and issue rows use the same flex subtitle as PR rows so both lists share an identical row height. Signed-off-by: Thomas Petersen --- .../projects/lib/projectCloneUrl.test.mjs | 50 +++++ .../features/projects/lib/projectRepoHost.ts | 35 ++++ .../projects/ui/ProjectsIssuesList.tsx | 63 ++++-- .../projects/ui/ProjectsListHeaderBar.tsx | 149 +++++++++++++ .../projects/ui/ProjectsPullRequestsList.tsx | 22 +- .../src/features/projects/ui/ProjectsView.tsx | 195 ++++++------------ .../features/projects/ui/RepositoryCards.tsx | 30 ++- .../tests/e2e/project-commit-detail.spec.ts | 3 +- 8 files changed, 394 insertions(+), 153 deletions(-) create mode 100644 desktop/src/features/projects/ui/ProjectsListHeaderBar.tsx diff --git a/desktop/src/features/projects/lib/projectCloneUrl.test.mjs b/desktop/src/features/projects/lib/projectCloneUrl.test.mjs index cc00d5f6311..e9e13f56f50 100644 --- a/desktop/src/features/projects/lib/projectCloneUrl.test.mjs +++ b/desktop/src/features/projects/lib/projectCloneUrl.test.mjs @@ -5,6 +5,7 @@ import { deriveRelayCloneUrl, effectiveCloneUrls } from "./projectCloneUrl.ts"; import { projectRepoHost, projectRepoHostForProject, + repositoryDisplayPath, } from "./projectRepoHost.ts"; const OWNER = "a".repeat(64); @@ -104,3 +105,52 @@ test("projectRepoHostForProject recognizes an implicit relay repository", () => { kind: "buzz" }, ); }); + +test("repositoryDisplayPath renders an external repo as host/path without .git", () => { + assert.equal( + repositoryDisplayPath( + { + cloneUrls: ["https://github.com/block/buzz.git"], + dtag: "buzz", + owner: OWNER, + }, + ORIGIN, + ), + "github.com/block/buzz", + ); +}); + +test("repositoryDisplayPath renders a relay-hosted repo as owner/repo", () => { + assert.equal( + repositoryDisplayPath( + { cloneUrls: [], dtag: "buzz", owner: OWNER }, + ORIGIN, + "thomas", + ), + "thomas/buzz", + ); +}); + +test("repositoryDisplayPath falls back to a shortened pubkey owner", () => { + assert.equal( + repositoryDisplayPath( + { cloneUrls: [], dtag: "buzz", owner: OWNER }, + ORIGIN, + ), + `${"a".repeat(8)}…/buzz`, + ); +}); + +test("repositoryDisplayPath fails closed without a resolvable clone URL", () => { + assert.equal( + repositoryDisplayPath({ cloneUrls: [], dtag: "buzz", owner: OWNER }, null), + null, + ); + assert.equal( + repositoryDisplayPath( + { cloneUrls: ["not a URL"], dtag: "buzz", owner: OWNER }, + ORIGIN, + ), + null, + ); +}); diff --git a/desktop/src/features/projects/lib/projectRepoHost.ts b/desktop/src/features/projects/lib/projectRepoHost.ts index 07e27f922c9..681a8753bdf 100644 --- a/desktop/src/features/projects/lib/projectRepoHost.ts +++ b/desktop/src/features/projects/lib/projectRepoHost.ts @@ -52,6 +52,41 @@ export function projectRepoHostForRepository( return projectRepoHost(cloneUrl, relayOrigin); } +/** + * Human-readable location of a repository's git data — "github.com/block/buzz" + * for external repos (host + path, `.git` stripped), or "owner/repo" for + * Buzz-hosted ones (the relay host and full owner pubkey carry no signal; + * `ownerLabel` should be the resolved profile name, falling back to a + * shortened pubkey). Returns `null` when no clone URL can be resolved. + */ +export function repositoryDisplayPath( + repository: RepositoryHostInput | null | undefined, + relayOrigin: string | null | undefined, + ownerLabel?: string | null, +): string | null { + if (!repository) return null; + const cloneUrl = effectiveCloneUrls( + repository.cloneUrls, + relayOrigin, + repository.owner, + repository.dtag, + )[0]; + if (!cloneUrl) return null; + + if (projectRepoHost(cloneUrl, relayOrigin).kind === "buzz") { + const owner = ownerLabel?.trim() || `${repository.owner.slice(0, 8)}…`; + return `${owner}/${repository.dtag}`; + } + + try { + const url = new URL(cloneUrl); + const path = url.pathname.replace(/\.git$/, "").replace(/\/+$/, ""); + return `${url.host}${path}`; + } catch { + return null; + } +} + export function projectRepoHostForProject( project: | RepositoryHostInput diff --git a/desktop/src/features/projects/ui/ProjectsIssuesList.tsx b/desktop/src/features/projects/ui/ProjectsIssuesList.tsx index ea0fdf5d8ad..f3f5f5aa414 100644 --- a/desktop/src/features/projects/ui/ProjectsIssuesList.tsx +++ b/desktop/src/features/projects/ui/ProjectsIssuesList.tsx @@ -12,6 +12,7 @@ import { resolveUserLabel, type UserProfileLookup, } from "@/features/profile/lib/identity"; +import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { Card } from "@/shared/ui/card"; import { DropdownMenuItem } from "@/shared/ui/dropdown-menu"; @@ -31,6 +32,8 @@ import { } from "./projectListRowStyles"; type ProjectsIssuesListProps = { + /** Render without container chrome — a parent table container provides border and rounding. */ + embedded?: boolean; error: unknown; failedSections: ProjectWorkItemSection[]; isLoading: boolean; @@ -73,25 +76,40 @@ function IssueHeader({

{issue.title}

-

- {project.name} - {includeDate ? ` · created ${relativeTime(issue.createdAt)}` : null} · - by{" "} - + {/* Flex (not inline flow) so the 20px author avatar cannot grow the + line box — keeps row heights identical to the PR list. */} +

+ {project.name} + {includeDate ? ( + <> + · + created {relativeTime(issue.createdAt)} + + ) : null} + · + + by + + {includeDate ? ( - ` · ${issue.status}` + <> + · + {issue.status} + ) : ( <> - · + · {issue.status} )} -

+
); } @@ -223,6 +241,7 @@ function IssueListRow({ } export function ProjectsIssuesList({ + embedded, error, failedSections, isLoading, @@ -235,7 +254,12 @@ export function ProjectsIssuesList({ }: ProjectsIssuesListProps) { if (isLoading) { return ( -
+
Loading issues...
); @@ -259,7 +283,12 @@ export function ProjectsIssuesList({ return (
{loadNotice} -
+
No issues yet.
@@ -291,7 +320,9 @@ export function ProjectsIssuesList({
{loadNotice}
{issues.map(({ project, issue, repository }) => ( diff --git a/desktop/src/features/projects/ui/ProjectsListHeaderBar.tsx b/desktop/src/features/projects/ui/ProjectsListHeaderBar.tsx new file mode 100644 index 00000000000..3d3fdb9383e --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectsListHeaderBar.tsx @@ -0,0 +1,149 @@ +import type { + ProjectsFilter, + ProjectsRepositoryScope, + ProjectsSort, + ProjectsViewMode, + ProjectsWorkItemScope, +} from "@/features/projects/lib/projectsViewHelpers"; +import { ProjectsListScopeDropdown } from "@/features/projects/ui/ProjectsListScopeDropdown"; +import { ProjectsViewModeToggle } from "@/features/projects/ui/ProjectsToolbar"; +import { cn } from "@/shared/lib/cn"; + +const PROJECT_SCOPE_OPTIONS: Array<{ + label: string; + value: ProjectsRepositoryScope; +}> = [ + { label: "All", value: "all" }, + { label: "Accessible", value: "accessible" }, + { label: "My Projects", value: "mine" }, + { label: "Local", value: "local" }, +]; +const REPOSITORY_SCOPE_OPTIONS: Array<{ + label: string; + value: ProjectsRepositoryScope; +}> = [ + { label: "All", value: "all" }, + { label: "Accessible", value: "accessible" }, + { label: "My Repositories", value: "mine" }, + { label: "Local", value: "local" }, + { label: "Buzz-hosted", value: "buzz" }, + { label: "Linked", value: "linked" }, +]; +const PULL_REQUEST_SCOPE_OPTIONS: Array<{ + label: string; + value: ProjectsWorkItemScope; +}> = [ + { label: "All", value: "all" }, + { label: "My Pull Requests", value: "mine" }, +]; +const ISSUE_SCOPE_OPTIONS: Array<{ + label: string; + value: ProjectsWorkItemScope; +}> = [ + { label: "All", value: "all" }, + { label: "My Issues", value: "mine" }, +]; + +type ProjectsListHeaderBarProps = { + filter: ProjectsFilter; + issueScope: ProjectsWorkItemScope; + onIssueScopeChange: (scope: ProjectsWorkItemScope) => void; + onPullRequestScopeChange: (scope: ProjectsWorkItemScope) => void; + onRepositoryScopeChange: (scope: ProjectsRepositoryScope) => void; + onSortChange: (sort: ProjectsSort) => void; + onViewModeChange: (viewMode: ProjectsViewMode) => void; + pullRequestScope: ProjectsWorkItemScope; + repositoryScope: ProjectsRepositoryScope; + sort: ProjectsSort; + /** + * "row" renders as the first row of the list table (no chrome of its own — + * the surrounding container provides border and rounding); "bar" renders as + * a standalone rounded bar with identical proportions for the card grid. + */ + variant: "bar" | "row"; + viewMode: ProjectsViewMode; +}; + +/** + * Header for the Projects lists: scope selector on the left, sort + view + * toggle on the right. + */ +export function ProjectsListHeaderBar({ + filter, + issueScope, + onIssueScopeChange, + onPullRequestScopeChange, + onRepositoryScopeChange, + onSortChange, + onViewModeChange, + pullRequestScope, + repositoryScope, + sort, + variant, + viewMode, +}: ProjectsListHeaderBarProps) { + const scopeDropdown = + filter === "prs" ? ( + + ) : filter === "issues" ? ( + + ) : filter === "projects" ? ( + + ) : ( + + ); + + return ( +
+ {scopeDropdown} +
+ + +
+
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectsPullRequestsList.tsx b/desktop/src/features/projects/ui/ProjectsPullRequestsList.tsx index f04acb5b828..7ebfcf685ac 100644 --- a/desktop/src/features/projects/ui/ProjectsPullRequestsList.tsx +++ b/desktop/src/features/projects/ui/ProjectsPullRequestsList.tsx @@ -8,6 +8,7 @@ import type { } from "@/features/projects/hooks"; import { relativeTime } from "@/features/projects/lib/projectsViewHelpers"; import type { ProjectWorkItemSection } from "@/features/projects/projectWorkItems"; +import { cn } from "@/shared/lib/cn"; import { resolveUserLabel, type UserProfileLookup, @@ -31,6 +32,8 @@ import { } from "./projectListRowStyles"; type ProjectsPullRequestsListProps = { + /** Render without container chrome — a parent table container provides border and rounding. */ + embedded?: boolean; error: unknown; failedSections: ProjectWorkItemSection[]; isLoading: boolean; @@ -231,6 +234,7 @@ function PullRequestListRow({ } export function ProjectsPullRequestsList({ + embedded, error, failedSections, isLoading, @@ -243,7 +247,12 @@ export function ProjectsPullRequestsList({ }: ProjectsPullRequestsListProps) { if (isLoading) { return ( -
+
Loading pull requests...
); @@ -267,7 +276,12 @@ export function ProjectsPullRequestsList({ return (
{loadNotice} -
+
No pull requests yet.
@@ -299,7 +313,9 @@ export function ProjectsPullRequestsList({
{loadNotice}
{pullRequests.map(({ project, pullRequest, repository }) => ( diff --git a/desktop/src/features/projects/ui/ProjectsView.tsx b/desktop/src/features/projects/ui/ProjectsView.tsx index d0f76b35a15..78ba09934de 100644 --- a/desktop/src/features/projects/ui/ProjectsView.tsx +++ b/desktop/src/features/projects/ui/ProjectsView.tsx @@ -42,12 +42,8 @@ import { ProjectsOverviewPanel } from "@/features/projects/ui/ProjectsOverviewPa import { ProjectsOverviewRail } from "@/features/projects/ui/ProjectsOverviewRail"; import { ProjectsPullRequestsList } from "@/features/projects/ui/ProjectsPullRequestsList"; import { ProjectsWorkItemsLoadNotice } from "@/features/projects/ui/ProjectsWorkItemsLoadNotice"; -import { ProjectsListScopeDropdown } from "@/features/projects/ui/ProjectsListScopeDropdown"; -import { PROJECT_LIST_CONTAINER_CLASS } from "@/features/projects/ui/projectListRowStyles"; -import { - ProjectsToolbar, - ProjectsViewModeToggle, -} from "@/features/projects/ui/ProjectsToolbar"; +import { ProjectsListHeaderBar } from "@/features/projects/ui/ProjectsListHeaderBar"; +import { ProjectsToolbar } from "@/features/projects/ui/ProjectsToolbar"; import { hasLocalCheckout, hasLocalRepositoryCheckout, @@ -95,40 +91,6 @@ import { Button } from "@/shared/ui/button"; import { PageHeader } from "@/shared/ui/PageHeader"; const MANY_PROJECTS_THRESHOLD = 12; -const PROJECT_SCOPE_OPTIONS: Array<{ - label: string; - value: ProjectsRepositoryScope; -}> = [ - { label: "All", value: "all" }, - { label: "Accessible", value: "accessible" }, - { label: "My Projects", value: "mine" }, - { label: "Local", value: "local" }, -]; -const REPOSITORY_SCOPE_OPTIONS: Array<{ - label: string; - value: ProjectsRepositoryScope; -}> = [ - { label: "All", value: "all" }, - { label: "Accessible", value: "accessible" }, - { label: "My Repositories", value: "mine" }, - { label: "Local", value: "local" }, - { label: "Buzz-hosted", value: "buzz" }, - { label: "Linked", value: "linked" }, -]; -const PULL_REQUEST_SCOPE_OPTIONS: Array<{ - label: string; - value: ProjectsWorkItemScope; -}> = [ - { label: "All", value: "all" }, - { label: "My Pull Requests", value: "mine" }, -]; -const ISSUE_SCOPE_OPTIONS: Array<{ - label: string; - value: ProjectsWorkItemScope; -}> = [ - { label: "All", value: "all" }, - { label: "My Issues", value: "mine" }, -]; export function ProjectsView() { const { goProject } = useAppNavigation(); @@ -668,7 +630,7 @@ export function ProjectsView() {
) : (
{visibleProjects.map((project) => { @@ -716,7 +678,7 @@ export function ProjectsView() { ))}
) : ( -
+
{visibleRepositories.map(({ project, repository }) => ( ); - const listControls = ( -
- - -
+ const listHeaderBar = ( + ); const workItemFailedSections = [ @@ -912,79 +868,62 @@ export function ProjectsView() {
{activityFeed}
) : ( -
-
+
+ {/* In list view the header is the table's first row inside + the bordered container; in card view it is a standalone + bar with the cards flowing below. */} +
+ {listHeaderBar} {filter === "prs" ? ( - void projectsWorkItemsQuery.refetch()} + profiles={profiles} + pullRequests={visiblePullRequests} + viewMode={viewMode} /> ) : filter === "issues" ? ( - void projectsWorkItemsQuery.refetch()} + profiles={profiles} + viewMode={viewMode} /> ) : filter === "projects" ? ( - + projectItems ) : ( - + repositoryItems )} - {listControls}
- {filter === "prs" ? ( - void projectsWorkItemsQuery.refetch()} - profiles={profiles} - pullRequests={visiblePullRequests} - viewMode={viewMode} - /> - ) : filter === "issues" ? ( - void projectsWorkItemsQuery.refetch()} - profiles={profiles} - viewMode={viewMode} - /> - ) : filter === "projects" ? ( - projectItems - ) : ( - repositoryItems - )}
)}
diff --git a/desktop/src/features/projects/ui/RepositoryCards.tsx b/desktop/src/features/projects/ui/RepositoryCards.tsx index b412b439474..575e4f35507 100644 --- a/desktop/src/features/projects/ui/RepositoryCards.tsx +++ b/desktop/src/features/projects/ui/RepositoryCards.tsx @@ -5,8 +5,14 @@ import type { ProjectActivitySummary, Repository, } from "@/features/projects/hooks"; -import type { UserProfileLookup } from "@/features/profile/lib/identity"; -import { projectRepoHostForRepository } from "@/features/projects/lib/projectRepoHost"; +import { + resolveUserLabel, + type UserProfileLookup, +} from "@/features/profile/lib/identity"; +import { + projectRepoHostForRepository, + repositoryDisplayPath, +} from "@/features/projects/lib/projectRepoHost"; import { formatExactTimestamp, relativeTime, @@ -99,11 +105,20 @@ function RepositoryOpenButton({ function RepositoryIdentity({ inlineBranch = false, + profiles, project, repository, -}: Pick & { +}: Pick & { 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 ( <> @@ -114,7 +129,7 @@ function RepositoryIdentity({

- {project.name} + {displayPath ?? project.name} {inlineBranch ? ` · ${repository.defaultBranch}` : ""}

@@ -203,6 +218,7 @@ export function RepositoryGridCard(props: RepositoryItemProps) {
@@ -258,7 +274,11 @@ export function RepositoryListRow(props: RepositoryItemProps) { />
- +