diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 147ab573817..e4233a8fae6 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -788,7 +788,7 @@ export function AppShell() { /> ) : null} 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/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/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/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/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/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.`, 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 ? ( 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; @@ -274,42 +279,71 @@ function ActivityCard({ return (
- - ) : ( - - )} + {/* Avatar gutter: a vertical spine runs through the avatar centers + to connect consecutive cards. Segments extend into the card's + vertical padding so they meet the neighbouring card's segments; + the first card has no incoming line and the last no outgoing. */} +
+ {isFirst ? null : ( +
@@ -320,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/ProjectsCreateMenu.tsx b/desktop/src/features/projects/ui/ProjectsCreateMenu.tsx index b1e64002a5f..07888230f10 100644 --- a/desktop/src/features/projects/ui/ProjectsCreateMenu.tsx +++ b/desktop/src/features/projects/ui/ProjectsCreateMenu.tsx @@ -50,7 +50,7 @@ export function ProjectsCreateMenu({ return (
); } @@ -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/ProjectsOverviewPanel.tsx b/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx index b31e120abc1..f93b953a225 100644 --- a/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx @@ -60,7 +60,7 @@ function StatPill({ - + {count} @@ -76,42 +76,51 @@ 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. The feed column + // has no left inset so the timeline spine lines up with the section tabs. 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/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/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) => (
) : (
{visibleProjects.map((project) => { @@ -652,9 +647,9 @@ export function ProjectsView() { people={projectPeople(project, summary)} profiles={profiles} project={project} - repositoryUnavailableReason={ - repoSnapshotsQuery.data?.unavailable[project.id] - } + repositoryUnavailableReason={repositoryUnavailableReasonFor( + project, + )} summary={summary} /> ); @@ -683,7 +678,7 @@ export function ProjectsView() { ))}
) : ( -
+
{visibleRepositories.map(({ project, repository }) => ( ); - const listControls = ( -
- - -
+ const listHeaderBar = ( + ); const workItemFailedSections = [ @@ -776,10 +765,9 @@ export function ProjectsView() { const projectsNavigation = (
-
+
- {createMenu}
); @@ -797,6 +785,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}
{ @@ -877,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) { />
- +
+ 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], + ); +} diff --git a/desktop/tests/e2e/project-commit-detail.spec.ts b/desktop/tests/e2e/project-commit-detail.spec.ts index 9b516473352..66324c3aef7 100644 --- a/desktop/tests/e2e/project-commit-detail.spec.ts +++ b/desktop/tests/e2e/project-commit-detail.spec.ts @@ -93,7 +93,8 @@ test("top-level project lists align dates and overflow actions", async ({ await expect( repositoryRow.getByTestId("repositories-row-branch"), ).toContainText("main"); - await expect(repositoryRow.locator("p")).toHaveText("buzz"); + // Subtitle is the repository location (owner/repo for Buzz-hosted repos). + await expect(repositoryRow.locator("p")).toHaveText(/\/buzz$/); const repositoryPositions = await trailingPositions(repositoryRow, { actionName: /More options for/, dateTestId: "repositories-row-date",