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

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

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

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

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

## Test plan

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

---------

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

114 lines
3.2 KiB
TypeScript

import { effectiveCloneUrls } from "./projectCloneUrl";
export type ProjectRepoHost =
| { kind: "buzz" }
| { kind: "external"; host: string }
| { kind: "unresolved" };
/**
* Classifies the canonical git remote using the same origin and path boundary
* enforced by the Tauri git commands. This is presentation/query gating only;
* Rust remains the security boundary for clone operations.
*/
export function projectRepoHost(
cloneUrl: string | null | undefined,
relayOrigin: string | null | undefined,
): ProjectRepoHost {
if (!cloneUrl || !relayOrigin) return { kind: "unresolved" };
try {
const clone = new URL(cloneUrl);
const relay = new URL(relayOrigin);
const isBuzzPath = /^\/git\/[0-9a-f]{64}\/[^/]+\/?$/i.test(clone.pathname);
if (clone.origin === relay.origin && isBuzzPath) {
return { kind: "buzz" };
}
return { kind: "external", host: clone.host };
} catch {
return { kind: "unresolved" };
}
}
type RepositoryHostInput = {
cloneUrls: string[];
dtag: string;
owner: string;
repoAddress?: string;
};
export function projectRepoHostForRepository(
repository: RepositoryHostInput | null | undefined,
relayOrigin: string | null | undefined,
): ProjectRepoHost {
if (!repository) return { kind: "unresolved" };
const cloneUrl = effectiveCloneUrls(
repository.cloneUrls,
relayOrigin,
repository.owner,
repository.dtag,
)[0];
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
| {
primaryRepositoryAddress: string | null;
repositories: RepositoryHostInput[];
}
| null
| undefined,
relayOrigin: string | null | undefined,
): ProjectRepoHost {
if (!project) return { kind: "unresolved" };
if (!("repositories" in project)) {
return projectRepoHostForRepository(project, relayOrigin);
}
const repository =
project.repositories.find(
(candidate) => candidate.repoAddress === project.primaryRepositoryAddress,
) ??
project.repositories[0] ??
null;
return projectRepoHostForRepository(repository, relayOrigin);
}