mirror of
https://github.com/block/buzz.git
synced 2026-10-30 05:51:46 +00:00
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>
This commit is contained in:
@@ -788,7 +788,7 @@ export function AppShell() {
|
||||
/>
|
||||
) : null}
|
||||
<SidebarProvider
|
||||
className="relative z-10 min-h-0 flex-1 flex-col overflow-visible"
|
||||
className="relative z-10 min-h-0 min-w-0 flex-1 flex-col overflow-visible"
|
||||
data-testid="app-sidebar-layer"
|
||||
>
|
||||
<AppProfilePanelProvider>
|
||||
|
||||
@@ -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<RelayEvent[]> = fetchProjectEventsExhaustively,
|
||||
fetchExhaustively: FetchProjectEventsExhaustively = fetchProjectEventsExhaustively,
|
||||
): Promise<Project[]> {
|
||||
// Delegates to `buildProjectsFromFetcher` in `projectEnumeration.ts`, which
|
||||
// is the pure, Tauri-free core of this operation. That helper's javadoc
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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<string>;
|
||||
/** `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[],
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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<RelayEvent[]>;
|
||||
|
||||
type FetchProjectEventPage = (
|
||||
filter: ProjectEventFilter,
|
||||
) => Promise<RelayEvent[]>;
|
||||
@@ -29,6 +43,7 @@ export async function enumerateProjectEvents(
|
||||
fetchPage: FetchProjectEventPage,
|
||||
kinds: number[],
|
||||
pageSize: number,
|
||||
extraFilter?: ProjectEventExtraFilter,
|
||||
): Promise<RelayEvent[]> {
|
||||
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,15 +92,64 @@ export async function enumerateProjectEvents(
|
||||
|
||||
export function fetchProjectEventsExhaustively(
|
||||
kinds: number[],
|
||||
extraFilter?: ProjectEventExtraFilter,
|
||||
pageSize = PROJECT_ENUMERATION_PAGE_SIZE,
|
||||
): Promise<RelayEvent[]> {
|
||||
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<RelayEvent[]> {
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Core fetch-and-build logic for `fetchProjects`, extracted for testability.
|
||||
*
|
||||
@@ -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<RelayEvent[]>,
|
||||
fetchExhaustively: FetchProjectEventsExhaustively,
|
||||
options: {
|
||||
relayOrigin?: string | null;
|
||||
hiddenAddresses?: ReadonlySet<string>;
|
||||
} = {},
|
||||
): Promise<Project[]> {
|
||||
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.`,
|
||||
|
||||
@@ -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 (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
aria-label={`Repository ${status.label.toLowerCase()}`}
|
||||
aria-label={`Repository ${label.toLowerCase()}`}
|
||||
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-amber-600 hover:bg-amber-500/10 dark:text-amber-300"
|
||||
role="img"
|
||||
>
|
||||
@@ -306,8 +320,8 @@ function RepositoryUnavailableIndicator({
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-64">
|
||||
<p className="font-medium">{status.label}</p>
|
||||
<p className="text-muted-foreground">{status.description}</p>
|
||||
<p className="font-medium">{label}</p>
|
||||
<p className="text-muted-foreground">{description}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -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. */}
|
||||
<ReadmePanel
|
||||
accessChannelId={accessChannelId}
|
||||
externalHost={externalHost}
|
||||
externalUrl={externalUrl}
|
||||
file={readmeFile}
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
|
||||
import { useChannelsQuery } from "@/features/channels/hooks";
|
||||
import type { ProjectRepoFile } from "@/features/projects/hooks";
|
||||
import type { ProjectRepoUnavailableReason } from "@/features/projects/lib/projectRepoAvailability";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
@@ -90,7 +92,50 @@ function normalizeReadmeMarkdown(content: string) {
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Description for the access-restricted state. Links to the bound channel
|
||||
* when it is visible to the viewer (public channels appear in the channel
|
||||
* list even before joining); private channels fall back to generic copy.
|
||||
*/
|
||||
function AccessRestrictedDescription({
|
||||
accessChannelId,
|
||||
}: {
|
||||
accessChannelId: string;
|
||||
}) {
|
||||
const { goChannel } = useAppNavigation();
|
||||
const channelsQuery = useChannelsQuery();
|
||||
const channel = channelsQuery.data?.find(
|
||||
(candidate) => 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{" "}
|
||||
<button
|
||||
aria-label={`Open repository access channel #${channel.name}`}
|
||||
className="font-medium text-foreground underline-offset-2 hover:underline"
|
||||
onClick={() => void goChannel(channel.id)}
|
||||
type="button"
|
||||
>
|
||||
#{channel.name}
|
||||
</button>
|
||||
, 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}
|
||||
</h3>
|
||||
<p className="mt-1 max-w-lg text-sm text-muted-foreground">
|
||||
{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 ? (
|
||||
<AccessRestrictedDescription accessChannelId={accessChannelId} />
|
||||
) : (
|
||||
unavailable.description
|
||||
)}
|
||||
</p>
|
||||
{externalUrl ? (
|
||||
<a
|
||||
|
||||
@@ -23,7 +23,11 @@ import {
|
||||
type ViewerGitIdentity,
|
||||
} from "@/features/projects/lib/projectContributorMatching";
|
||||
import type { ProjectRepoHost } from "@/features/projects/lib/projectRepoHost";
|
||||
import { projectRepoUnavailableReason } from "@/features/projects/lib/projectRepoAvailability";
|
||||
import {
|
||||
projectRepoUnavailableReason,
|
||||
refineRepoUnavailableReason,
|
||||
} from "@/features/projects/lib/projectRepoAvailability";
|
||||
import { useMemberChannelIds } from "@/features/projects/useRepositoryAccess";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Tabs, TabsContent } from "@/shared/ui/tabs";
|
||||
@@ -216,9 +220,17 @@ export function WorkspaceTabs({
|
||||
: files.length === 0
|
||||
? "empty"
|
||||
: "available";
|
||||
// The relay masks channel-ACL denials as 404 (anti-enumeration), so a
|
||||
// "missing" git result is re-classified with the repository's channel
|
||||
// binding and the viewer's memberships before it reaches the UI copy.
|
||||
const memberChannelIds = useMemberChannelIds();
|
||||
const unavailableReason =
|
||||
gitDataState === "unavailable" && !externalHost
|
||||
? projectRepoUnavailableReason(displayedSnapshotError)
|
||||
? refineRepoUnavailableReason({
|
||||
reason: projectRepoUnavailableReason(displayedSnapshotError),
|
||||
repositoryChannelId: project.channelId,
|
||||
memberChannelIds,
|
||||
})
|
||||
: undefined;
|
||||
const repositoryLoaded =
|
||||
gitDataState === "available" || gitDataState === "empty";
|
||||
@@ -411,6 +423,7 @@ export function WorkspaceTabs({
|
||||
|
||||
<TabsContent className="m-0" value="overview">
|
||||
<ProjectOverviewPanel
|
||||
accessChannelId={project.channelId}
|
||||
contributors={displayedContributors}
|
||||
externalHost={externalHost}
|
||||
externalUrl={externalHost ? sourceControls?.externalUrl : null}
|
||||
|
||||
@@ -252,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;
|
||||
@@ -274,42 +279,71 @@ function ActivityCard({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative block w-full rounded-xl border border-border/60 bg-transparent text-left transition-colors hover:bg-muted/20",
|
||||
compact ? "p-3" : "p-4",
|
||||
"relative block w-full rounded-xl bg-transparent text-left transition-colors hover:bg-muted/20",
|
||||
compact ? "py-3 pr-3" : "py-4 pr-4",
|
||||
)}
|
||||
data-testid="projects-activity-card"
|
||||
>
|
||||
<button
|
||||
aria-label={`Open ${item.title} in ${item.target.project.name}`}
|
||||
className="absolute inset-0 rounded-xl focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="absolute inset-0 rounded-xl focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
onClick={onOpen}
|
||||
type="button"
|
||||
/>
|
||||
<div className="pointer-events-none relative flex min-w-0 items-start gap-3">
|
||||
{item.actorPubkey ? (
|
||||
<UserProfilePopover pubkey={item.actorPubkey} triggerElement="span">
|
||||
<button
|
||||
aria-label={`View ${actorLabel}'s profile`}
|
||||
className="pointer-events-auto relative z-10 shrink-0 rounded-full focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
||||
type="button"
|
||||
>
|
||||
<UserAvatar
|
||||
accent={profile?.isAgent === true}
|
||||
avatarUrl={profile?.avatarUrl ?? null}
|
||||
displayName={actorLabel}
|
||||
size={compact ? "xs" : "md"}
|
||||
/>
|
||||
</button>
|
||||
</UserProfilePopover>
|
||||
) : (
|
||||
<UserAvatar
|
||||
accent={profile?.isAgent === true}
|
||||
avatarUrl={profile?.avatarUrl ?? null}
|
||||
className="shrink-0"
|
||||
displayName={actorLabel}
|
||||
size={compact ? "xs" : "md"}
|
||||
/>
|
||||
)}
|
||||
{/* 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. */}
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex shrink-0 items-start justify-center self-stretch",
|
||||
compact ? "w-5" : "w-9",
|
||||
)}
|
||||
>
|
||||
{isFirst ? null : (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"absolute left-1/2 w-px -translate-x-1/2 bg-border/80",
|
||||
compact ? "-top-3 h-3" : "-top-4 h-4",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{isLast ? null : (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"absolute left-1/2 w-px -translate-x-1/2 bg-border/80",
|
||||
compact ? "-bottom-3 top-5" : "-bottom-4 top-9",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{item.actorPubkey ? (
|
||||
<UserProfilePopover pubkey={item.actorPubkey} triggerElement="span">
|
||||
<button
|
||||
aria-label={`View ${actorLabel}'s profile`}
|
||||
className="pointer-events-auto relative z-10 shrink-0 rounded-full focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
||||
type="button"
|
||||
>
|
||||
<UserAvatar
|
||||
accent={profile?.isAgent === true}
|
||||
avatarUrl={profile?.avatarUrl ?? null}
|
||||
displayName={actorLabel}
|
||||
size={compact ? "xs" : "md"}
|
||||
/>
|
||||
</button>
|
||||
</UserProfilePopover>
|
||||
) : (
|
||||
<UserAvatar
|
||||
accent={profile?.isAgent === true}
|
||||
avatarUrl={profile?.avatarUrl ?? null}
|
||||
className="relative z-10 shrink-0"
|
||||
displayName={actorLabel}
|
||||
size={compact ? "xs" : "md"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<div className="min-w-0 flex-1 text-xs text-muted-foreground/70">
|
||||
@@ -320,7 +354,7 @@ function ActivityCard({
|
||||
triggerElement="span"
|
||||
>
|
||||
<button
|
||||
className="pointer-events-auto relative z-10 rounded-sm hover:underline focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
|
||||
className="pointer-events-auto relative z-10 rounded-sm font-semibold text-foreground hover:underline focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
|
||||
type="button"
|
||||
>
|
||||
{actorLabel}
|
||||
@@ -331,7 +365,7 @@ function ActivityCard({
|
||||
)}{" "}
|
||||
{item.action}{" "}
|
||||
<button
|
||||
className="pointer-events-auto relative z-10 inline-block max-w-48 truncate rounded-sm align-bottom hover:underline focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring sm:max-w-64 2xl:max-w-none"
|
||||
className="pointer-events-auto relative z-10 inline-block max-w-48 truncate rounded-sm align-bottom font-semibold text-foreground hover:underline focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring sm:max-w-64 2xl:max-w-none"
|
||||
onClick={onOpenProject}
|
||||
type="button"
|
||||
>
|
||||
@@ -365,9 +399,16 @@ function ActivityCard({
|
||||
) : null}
|
||||
</div>
|
||||
<div className={compact ? "mt-2" : "mt-3"}>
|
||||
<p className="min-w-0 truncate text-sm font-semibold leading-5 text-foreground">
|
||||
{item.title}
|
||||
</p>
|
||||
{/* Bare event-type glyph beside the headline (no badge circle). */}
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<TypeIcon
|
||||
aria-hidden="true"
|
||||
className={cn("mt-0.5 h-4 w-4 shrink-0", visual.iconClassName)}
|
||||
/>
|
||||
<p className="min-w-0 flex-1 truncate text-sm font-semibold leading-5 text-foreground">
|
||||
{item.title}
|
||||
</p>
|
||||
</div>
|
||||
{item.body ? (
|
||||
<p
|
||||
className={cn(
|
||||
@@ -420,13 +461,16 @@ export function ProjectsActivityFeed(props: ProjectsActivityFeedProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("relative", props.compact ? "space-y-2.5" : "space-y-3")}
|
||||
className="relative bg-transparent"
|
||||
data-testid="projects-activity-timeline"
|
||||
>
|
||||
{items.map((item) => {
|
||||
{items.map((item, index) => {
|
||||
return (
|
||||
<div className="relative" key={item.id}>
|
||||
<ActivityCard
|
||||
compact={props.compact === true}
|
||||
isFirst={index === 0}
|
||||
isLast={index === items.length - 1}
|
||||
item={item}
|
||||
onOpen={() => {
|
||||
if (item.target.type === "project") {
|
||||
|
||||
@@ -50,7 +50,7 @@ export function ProjectsCreateMenu({
|
||||
return (
|
||||
<nav
|
||||
aria-label="Create project item"
|
||||
className="relative shrink-0 pl-4"
|
||||
className="relative shrink-0"
|
||||
onBlurCapture={(event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget)) {
|
||||
setOpen(false);
|
||||
|
||||
@@ -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({
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<p className={PROJECT_LIST_ROW_TITLE_CLASS}>{issue.title}</p>
|
||||
</div>
|
||||
<p className={`truncate ${PROJECT_LIST_ROW_SUBTEXT_CLASS}`}>
|
||||
{project.name}
|
||||
{includeDate ? ` · created ${relativeTime(issue.createdAt)}` : null} ·
|
||||
by{" "}
|
||||
<ProjectAuthorIdentity
|
||||
label={authorLabel}
|
||||
profiles={profiles}
|
||||
pubkey={issue.author}
|
||||
testId={authorTestId}
|
||||
/>
|
||||
{/* Flex (not inline flow) so the 20px author avatar cannot grow the
|
||||
line box — keeps row heights identical to the PR list. */}
|
||||
<div
|
||||
className={`flex min-w-0 items-center gap-x-1.5 overflow-hidden whitespace-nowrap ${PROJECT_LIST_ROW_SUBTEXT_CLASS}`}
|
||||
>
|
||||
<span>{project.name}</span>
|
||||
{includeDate ? (
|
||||
` · ${issue.status}`
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>created {relativeTime(issue.createdAt)}</span>
|
||||
</>
|
||||
) : null}
|
||||
<span>·</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span>by</span>
|
||||
<ProjectAuthorIdentity
|
||||
label={authorLabel}
|
||||
profiles={profiles}
|
||||
pubkey={issue.author}
|
||||
testId={authorTestId}
|
||||
/>
|
||||
</span>
|
||||
{includeDate ? (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>{issue.status}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="md:hidden"> · </span>
|
||||
<span className="md:hidden">·</span>
|
||||
<span className="md:hidden">{issue.status}</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -223,6 +241,7 @@ function IssueListRow({
|
||||
}
|
||||
|
||||
export function ProjectsIssuesList({
|
||||
embedded,
|
||||
error,
|
||||
failedSections,
|
||||
isLoading,
|
||||
@@ -235,7 +254,12 @@ export function ProjectsIssuesList({
|
||||
}: ProjectsIssuesListProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="border border-border/60 px-4 py-12 text-center text-sm text-muted-foreground">
|
||||
<div
|
||||
className={cn(
|
||||
"px-4 py-12 text-center text-sm text-muted-foreground",
|
||||
!embedded && "border border-border/60",
|
||||
)}
|
||||
>
|
||||
Loading issues...
|
||||
</div>
|
||||
);
|
||||
@@ -259,7 +283,12 @@ export function ProjectsIssuesList({
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{loadNotice}
|
||||
<div className="border border-dashed border-border/60 px-4 py-12 text-center text-sm text-muted-foreground">
|
||||
<div
|
||||
className={cn(
|
||||
"px-4 py-12 text-center text-sm text-muted-foreground",
|
||||
!embedded && "border border-dashed border-border/60",
|
||||
)}
|
||||
>
|
||||
No issues yet.
|
||||
</div>
|
||||
</div>
|
||||
@@ -291,7 +320,9 @@ export function ProjectsIssuesList({
|
||||
<div className="space-y-3">
|
||||
{loadNotice}
|
||||
<div
|
||||
className={PROJECT_LIST_CONTAINER_CLASS}
|
||||
className={
|
||||
embedded ? "divide-y divide-border/60" : PROJECT_LIST_CONTAINER_CLASS
|
||||
}
|
||||
data-testid="projects-list-container"
|
||||
>
|
||||
{issues.map(({ project, issue, repository }) => (
|
||||
|
||||
149
desktop/src/features/projects/ui/ProjectsListHeaderBar.tsx
Normal file
149
desktop/src/features/projects/ui/ProjectsListHeaderBar.tsx
Normal file
@@ -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" ? (
|
||||
<ProjectsListScopeDropdown
|
||||
label="Filter pull requests"
|
||||
onChange={onPullRequestScopeChange}
|
||||
options={PULL_REQUEST_SCOPE_OPTIONS}
|
||||
value={pullRequestScope}
|
||||
/>
|
||||
) : filter === "issues" ? (
|
||||
<ProjectsListScopeDropdown
|
||||
label="Filter issues"
|
||||
onChange={onIssueScopeChange}
|
||||
options={ISSUE_SCOPE_OPTIONS}
|
||||
value={issueScope}
|
||||
/>
|
||||
) : filter === "projects" ? (
|
||||
<ProjectsListScopeDropdown
|
||||
label="Filter projects"
|
||||
onChange={onRepositoryScopeChange}
|
||||
options={PROJECT_SCOPE_OPTIONS}
|
||||
value={repositoryScope}
|
||||
/>
|
||||
) : (
|
||||
<ProjectsListScopeDropdown
|
||||
label="Filter repositories"
|
||||
onChange={onRepositoryScopeChange}
|
||||
options={REPOSITORY_SCOPE_OPTIONS}
|
||||
value={repositoryScope}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-wrap items-center justify-between gap-2 bg-muted/20 px-3 py-1.5",
|
||||
variant === "bar"
|
||||
? "rounded-xl border border-border/60"
|
||||
: "border-b border-border/60",
|
||||
)}
|
||||
data-testid="projects-list-header"
|
||||
>
|
||||
{scopeDropdown}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="sr-only">Sort projects</span>
|
||||
<select
|
||||
className="h-8 rounded-md bg-transparent px-2 text-xs text-foreground outline-hidden hover:bg-muted/50 focus:ring-1 focus:ring-ring"
|
||||
onChange={(event) =>
|
||||
onSortChange(event.target.value as ProjectsSort)
|
||||
}
|
||||
value={sort}
|
||||
>
|
||||
<option value="updated">Recent activity</option>
|
||||
<option value="created">Created date</option>
|
||||
<option value="name">Name</option>
|
||||
</select>
|
||||
</label>
|
||||
<ProjectsViewModeToggle
|
||||
onViewModeChange={onViewModeChange}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -60,7 +60,7 @@ function StatPill({
|
||||
</span>
|
||||
<Icon className="h-3.5 w-3.5 text-muted-foreground/70" />
|
||||
</span>
|
||||
<span className="mt-auto pt-4 text-4xl font-semibold leading-none tracking-tight text-foreground">
|
||||
<span className="mt-auto pt-3 text-2xl font-semibold leading-none tracking-tight text-foreground">
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
@@ -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 (
|
||||
<section className="-mx-4 mb-4" data-testid="projects-overview-panel">
|
||||
<section
|
||||
className="-mr-[calc(1rem+10px)] mb-4"
|
||||
data-testid="projects-overview-panel"
|
||||
>
|
||||
<div className="grid xl:grid-cols-[minmax(0,1fr)_18rem] 2xl:grid-cols-[minmax(0,1fr)_24rem]">
|
||||
<div className="order-1 grid grid-cols-2 gap-2 p-4 pt-0 sm:gap-3 xl:order-none xl:col-start-1 xl:row-start-1 xl:grid-cols-4">
|
||||
<StatPill
|
||||
count={projects.length}
|
||||
icon={Folders}
|
||||
label="Projects"
|
||||
onClick={() => onSelectSection("projects")}
|
||||
/>
|
||||
<StatPill
|
||||
count={projects.reduce(
|
||||
(count, project) => count + project.repositories.length,
|
||||
0,
|
||||
)}
|
||||
icon={FolderGit2}
|
||||
label="Repositories"
|
||||
onClick={() => onSelectSection("repositories")}
|
||||
/>
|
||||
<StatPill
|
||||
count={stats.prs}
|
||||
icon={GitPullRequest}
|
||||
label="Pull requests"
|
||||
onClick={() => onSelectSection("prs")}
|
||||
/>
|
||||
<StatPill
|
||||
count={stats.issues}
|
||||
icon={CircleDot}
|
||||
label="Issues"
|
||||
onClick={() => onSelectSection("issues")}
|
||||
/>
|
||||
</div>
|
||||
{metadata}
|
||||
<div className="order-2 min-w-0 p-4 pt-2 xl:order-none xl:col-start-1 xl:row-start-2">
|
||||
<div className="order-1 min-w-0 pb-4 pr-4 pt-2 xl:order-none xl:col-start-1 xl:row-start-1">
|
||||
{children}
|
||||
</div>
|
||||
<div className="order-2 flex min-w-0 flex-col gap-3 px-4 pb-4 pt-2 xl:order-none xl:col-start-2 xl:row-start-1">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<StatPill
|
||||
count={projects.length}
|
||||
icon={Folders}
|
||||
label="Projects"
|
||||
onClick={() => onSelectSection("projects")}
|
||||
/>
|
||||
<StatPill
|
||||
count={projects.reduce(
|
||||
(count, project) => count + project.repositories.length,
|
||||
0,
|
||||
)}
|
||||
icon={FolderGit2}
|
||||
label="Repositories"
|
||||
onClick={() => onSelectSection("repositories")}
|
||||
/>
|
||||
<StatPill
|
||||
count={stats.prs}
|
||||
icon={GitPullRequest}
|
||||
label="Pull requests"
|
||||
onClick={() => onSelectSection("prs")}
|
||||
/>
|
||||
<StatPill
|
||||
count={stats.issues}
|
||||
icon={CircleDot}
|
||||
label="Issues"
|
||||
onClick={() => onSelectSection("issues")}
|
||||
/>
|
||||
</div>
|
||||
{metadata}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<div className="order-4 min-w-0 border-t border-border/40 px-4 py-4 xl:order-none xl:col-start-2 xl:row-start-1 xl:border-t-0 xl:pt-0">
|
||||
<div className="rounded-lg border border-border/60 p-4">
|
||||
<OverviewRailSection title="People" titleClassName="text-base">
|
||||
{people.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
@@ -94,13 +96,13 @@ export function ProjectsOverviewRail({
|
||||
</OverviewRailSection>
|
||||
</div>
|
||||
|
||||
<div className="order-5 min-w-0 border-t border-border/40 px-4 pb-4 pt-3 xl:order-none xl:col-start-2 xl:row-start-2 xl:border-t-0">
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
Contribution Activity
|
||||
</h3>
|
||||
<div className="min-w-0 rounded-lg border border-border/60 p-4">
|
||||
<OverviewRailSection
|
||||
title="Contribution Activity"
|
||||
titleClassName="text-base"
|
||||
>
|
||||
<ProjectsContributionGraph activityByDay={activityByDay} compact />
|
||||
</section>
|
||||
</OverviewRailSection>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<div className="border border-border/60 px-4 py-12 text-center text-sm text-muted-foreground">
|
||||
<div
|
||||
className={cn(
|
||||
"px-4 py-12 text-center text-sm text-muted-foreground",
|
||||
!embedded && "border border-border/60",
|
||||
)}
|
||||
>
|
||||
Loading pull requests...
|
||||
</div>
|
||||
);
|
||||
@@ -267,7 +276,12 @@ export function ProjectsPullRequestsList({
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{loadNotice}
|
||||
<div className="border border-dashed border-border/60 px-4 py-12 text-center text-sm text-muted-foreground">
|
||||
<div
|
||||
className={cn(
|
||||
"px-4 py-12 text-center text-sm text-muted-foreground",
|
||||
!embedded && "border border-dashed border-border/60",
|
||||
)}
|
||||
>
|
||||
No pull requests yet.
|
||||
</div>
|
||||
</div>
|
||||
@@ -299,7 +313,9 @@ export function ProjectsPullRequestsList({
|
||||
<div className="space-y-3">
|
||||
{loadNotice}
|
||||
<div
|
||||
className={PROJECT_LIST_CONTAINER_CLASS}
|
||||
className={
|
||||
embedded ? "divide-y divide-border/60" : PROJECT_LIST_CONTAINER_CLASS
|
||||
}
|
||||
data-testid="projects-list-container"
|
||||
>
|
||||
{pullRequests.map(({ project, pullRequest, repository }) => (
|
||||
|
||||
@@ -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<HTMLElement | null>) {
|
||||
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<HTMLFieldSetElement>(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<HTMLElement>(`[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
|
||||
>
|
||||
<div className="flex h-full min-w-0 flex-1 items-center gap-0.5 overflow-hidden">
|
||||
<fieldset className="flex h-full min-w-0 flex-1 flex-nowrap items-stretch gap-1 overflow-x-auto scrollbar-none [&::-webkit-scrollbar]:hidden">
|
||||
<fieldset
|
||||
className={cn(
|
||||
"flex h-full min-w-0 flex-1 flex-nowrap items-stretch gap-1 overflow-x-auto scrollbar-none [&::-webkit-scrollbar]:hidden",
|
||||
overflow.left && overflow.right
|
||||
? MASK_BOTH
|
||||
: overflow.left
|
||||
? MASK_LEFT
|
||||
: overflow.right
|
||||
? MASK_RIGHT
|
||||
: undefined,
|
||||
)}
|
||||
ref={scrollRef}
|
||||
>
|
||||
<legend className="sr-only">Project owner filter</legend>
|
||||
{filterOptions.map((option) => (
|
||||
<Button
|
||||
|
||||
@@ -18,6 +18,10 @@ import { useRepositoryActivitySummariesQuery } from "@/features/projects/reposit
|
||||
import { useCreateProjectMutation } from "@/features/projects/useCreateProject";
|
||||
import { selectProjectRepository } from "@/features/projects/projectModels";
|
||||
import { useProjectsRepoSnapshotsQuery } from "@/features/projects/useProjectsRepoSnapshots";
|
||||
import {
|
||||
useMemberChannelIds,
|
||||
useRepositoryUnavailableReasonFor,
|
||||
} from "@/features/projects/useRepositoryAccess";
|
||||
import {
|
||||
projectRepoHostForProject,
|
||||
projectRepoHostForRepository,
|
||||
@@ -38,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,
|
||||
@@ -54,8 +54,10 @@ import {
|
||||
} from "@/features/projects/ui/RepositoryCards";
|
||||
import {
|
||||
getProjectUpdatedAt,
|
||||
isProjectAccessibleToViewer,
|
||||
isProjectMine,
|
||||
isProjectOwnedByCurrentUser,
|
||||
isRepositoryAccessibleToViewer,
|
||||
projectHasAgent,
|
||||
projectOwnerIsUser,
|
||||
projectPeople,
|
||||
@@ -89,38 +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: "My Projects", value: "mine" },
|
||||
{ label: "Local", value: "local" },
|
||||
];
|
||||
const REPOSITORY_SCOPE_OPTIONS: Array<{
|
||||
label: string;
|
||||
value: ProjectsRepositoryScope;
|
||||
}> = [
|
||||
{ label: "All", value: "all" },
|
||||
{ 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();
|
||||
@@ -217,6 +187,11 @@ export function ProjectsView() {
|
||||
snapshotProjects,
|
||||
activeCommunity?.reposDir,
|
||||
);
|
||||
const memberChannelIds = useMemberChannelIds();
|
||||
const repositoryUnavailableReasonFor = useRepositoryUnavailableReasonFor(
|
||||
repoSnapshotsQuery.data?.unavailable,
|
||||
memberChannelIds,
|
||||
);
|
||||
const [createProjectOpen, setCreateProjectOpen] = React.useState(false);
|
||||
const [createIssueOpen, setCreateIssueOpen] = React.useState(false);
|
||||
const [createPullRequestOpen, setCreatePullRequestOpen] =
|
||||
@@ -325,6 +300,16 @@ export function ProjectsView() {
|
||||
[localRepositoriesQuery.data],
|
||||
);
|
||||
|
||||
const repositoryAccessInput = React.useMemo(
|
||||
() => ({
|
||||
currentPubkey,
|
||||
localRepoNames,
|
||||
memberChannelIds,
|
||||
relayOrigin,
|
||||
}),
|
||||
[currentPubkey, localRepoNames, memberChannelIds, relayOrigin],
|
||||
);
|
||||
|
||||
const visibleProjects = React.useMemo(() => {
|
||||
if (filter !== "projects" && filter !== "agents" && filter !== "users") {
|
||||
return [];
|
||||
@@ -334,6 +319,8 @@ export function ProjectsView() {
|
||||
.filter((project) => {
|
||||
const summary = activitySummariesQuery.data?.[project.id];
|
||||
const people = projectPeople(project, summary);
|
||||
if (repositoryScope === "accessible")
|
||||
return isProjectAccessibleToViewer(project, repositoryAccessInput);
|
||||
if (repositoryScope === "mine")
|
||||
return isProjectMine(project, currentPubkey);
|
||||
if (repositoryScope === "local")
|
||||
@@ -376,6 +363,7 @@ export function ProjectsView() {
|
||||
profiles,
|
||||
projects,
|
||||
relayOrigin,
|
||||
repositoryAccessInput,
|
||||
repositoryScope,
|
||||
sort,
|
||||
]);
|
||||
@@ -396,6 +384,12 @@ export function ProjectsView() {
|
||||
];
|
||||
return repositories
|
||||
.filter(({ repository }) => {
|
||||
if (repositoryScope === "accessible") {
|
||||
return isRepositoryAccessibleToViewer(
|
||||
repository,
|
||||
repositoryAccessInput,
|
||||
);
|
||||
}
|
||||
if (repositoryScope === "mine") {
|
||||
if (!currentPubkey) return false;
|
||||
const normalizedCurrentPubkey = normalizePubkey(currentPubkey);
|
||||
@@ -444,6 +438,7 @@ export function ProjectsView() {
|
||||
localRepoNames,
|
||||
projects,
|
||||
relayOrigin,
|
||||
repositoryAccessInput,
|
||||
repositoryActivitySummariesQuery.data,
|
||||
repositoryScope,
|
||||
sort,
|
||||
@@ -625,9 +620,9 @@ export function ProjectsView() {
|
||||
people={projectPeople(project, summary)}
|
||||
profiles={profiles}
|
||||
project={project}
|
||||
repositoryUnavailableReason={
|
||||
repoSnapshotsQuery.data?.unavailable[project.id]
|
||||
}
|
||||
repositoryUnavailableReason={repositoryUnavailableReasonFor(
|
||||
project,
|
||||
)}
|
||||
summary={summary}
|
||||
/>
|
||||
);
|
||||
@@ -635,7 +630,7 @@ export function ProjectsView() {
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={PROJECT_LIST_CONTAINER_CLASS}
|
||||
className="divide-y divide-border/60"
|
||||
data-testid="projects-list-container"
|
||||
>
|
||||
{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() {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className={PROJECT_LIST_CONTAINER_CLASS}>
|
||||
<div className="divide-y divide-border/60">
|
||||
{visibleRepositories.map(({ project, repository }) => (
|
||||
<RepositoryListRow
|
||||
hasLocal={hasLocalRepositoryCheckout(repository, localRepoNames)}
|
||||
@@ -701,27 +696,21 @@ export function ProjectsView() {
|
||||
</div>
|
||||
);
|
||||
|
||||
const listControls = (
|
||||
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="sr-only">Sort projects</span>
|
||||
<select
|
||||
className="h-8 rounded-md bg-transparent px-2 text-xs text-foreground outline-hidden hover:bg-muted/50 focus:ring-1 focus:ring-ring"
|
||||
onChange={(event) =>
|
||||
handleSortChange(event.target.value as ProjectsSort)
|
||||
}
|
||||
value={sort}
|
||||
>
|
||||
<option value="updated">Recent activity</option>
|
||||
<option value="created">Created date</option>
|
||||
<option value="name">Name</option>
|
||||
</select>
|
||||
</label>
|
||||
<ProjectsViewModeToggle
|
||||
onViewModeChange={handleViewModeChange}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
</div>
|
||||
const listHeaderBar = (
|
||||
<ProjectsListHeaderBar
|
||||
filter={filter}
|
||||
variant={viewMode === "list" ? "row" : "bar"}
|
||||
issueScope={issueScope}
|
||||
onIssueScopeChange={handleIssueScopeChange}
|
||||
onPullRequestScopeChange={handlePullRequestScopeChange}
|
||||
onRepositoryScopeChange={handleRepositoryScopeChange}
|
||||
onSortChange={handleSortChange}
|
||||
onViewModeChange={handleViewModeChange}
|
||||
pullRequestScope={pullRequestScope}
|
||||
repositoryScope={repositoryScope}
|
||||
sort={sort}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
);
|
||||
|
||||
const workItemFailedSections = [
|
||||
@@ -776,10 +765,9 @@ export function ProjectsView() {
|
||||
|
||||
const projectsNavigation = (
|
||||
<div className="flex h-[3.25rem] min-w-0 items-center">
|
||||
<div className="h-full min-w-0 flex-1">
|
||||
<div className="h-full min-w-0 flex-1 overflow-hidden">
|
||||
<ProjectsToolbar filter={filter} onFilterChange={handleFilterChange} />
|
||||
</div>
|
||||
{createMenu}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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. */}
|
||||
<div className="absolute right-4 top-4 z-40">{createMenu}</div>
|
||||
<CreateProjectDialog
|
||||
isCreating={createProjectMutation.isPending}
|
||||
onCreate={async (input) => {
|
||||
@@ -877,79 +868,62 @@ export function ProjectsView() {
|
||||
<section className="space-y-3">{activityFeed}</section>
|
||||
</ProjectsOverviewPanel>
|
||||
) : (
|
||||
<section className="space-y-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<section>
|
||||
{/* 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. */}
|
||||
<div
|
||||
className={
|
||||
viewMode === "list"
|
||||
? "overflow-hidden rounded-xl border border-border/60"
|
||||
: "space-y-3"
|
||||
}
|
||||
>
|
||||
{listHeaderBar}
|
||||
{filter === "prs" ? (
|
||||
<ProjectsListScopeDropdown
|
||||
label="Filter pull requests"
|
||||
onChange={handlePullRequestScopeChange}
|
||||
options={PULL_REQUEST_SCOPE_OPTIONS}
|
||||
value={pullRequestScope}
|
||||
<ProjectsPullRequestsList
|
||||
embedded={viewMode === "list"}
|
||||
error={projectsWorkItemsQuery.error}
|
||||
failedSections={
|
||||
projectsWorkItemsQuery.data?.pullRequests
|
||||
.failedSections ?? []
|
||||
}
|
||||
isLoading={projectsWorkItemsQuery.isLoading}
|
||||
isRetrying={
|
||||
projectsWorkItemsQuery.isFetching &&
|
||||
!projectsWorkItemsQuery.isLoading
|
||||
}
|
||||
onOpen={handleOpenPullRequest}
|
||||
onRetry={() => void projectsWorkItemsQuery.refetch()}
|
||||
profiles={profiles}
|
||||
pullRequests={visiblePullRequests}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
) : filter === "issues" ? (
|
||||
<ProjectsListScopeDropdown
|
||||
label="Filter issues"
|
||||
onChange={handleIssueScopeChange}
|
||||
options={ISSUE_SCOPE_OPTIONS}
|
||||
value={issueScope}
|
||||
<ProjectsIssuesList
|
||||
embedded={viewMode === "list"}
|
||||
error={projectsWorkItemsQuery.error}
|
||||
failedSections={
|
||||
projectsWorkItemsQuery.data?.issues.failedSections ??
|
||||
[]
|
||||
}
|
||||
isLoading={projectsWorkItemsQuery.isLoading}
|
||||
isRetrying={
|
||||
projectsWorkItemsQuery.isFetching &&
|
||||
!projectsWorkItemsQuery.isLoading
|
||||
}
|
||||
issues={visibleIssues}
|
||||
onOpen={handleOpenIssue}
|
||||
onRetry={() => void projectsWorkItemsQuery.refetch()}
|
||||
profiles={profiles}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
) : filter === "projects" ? (
|
||||
<ProjectsListScopeDropdown
|
||||
label="Filter projects"
|
||||
onChange={handleRepositoryScopeChange}
|
||||
options={PROJECT_SCOPE_OPTIONS}
|
||||
value={repositoryScope}
|
||||
/>
|
||||
projectItems
|
||||
) : (
|
||||
<ProjectsListScopeDropdown
|
||||
label="Filter repositories"
|
||||
onChange={handleRepositoryScopeChange}
|
||||
options={REPOSITORY_SCOPE_OPTIONS}
|
||||
value={repositoryScope}
|
||||
/>
|
||||
repositoryItems
|
||||
)}
|
||||
{listControls}
|
||||
</div>
|
||||
{filter === "prs" ? (
|
||||
<ProjectsPullRequestsList
|
||||
error={projectsWorkItemsQuery.error}
|
||||
failedSections={
|
||||
projectsWorkItemsQuery.data?.pullRequests
|
||||
.failedSections ?? []
|
||||
}
|
||||
isLoading={projectsWorkItemsQuery.isLoading}
|
||||
isRetrying={
|
||||
projectsWorkItemsQuery.isFetching &&
|
||||
!projectsWorkItemsQuery.isLoading
|
||||
}
|
||||
onOpen={handleOpenPullRequest}
|
||||
onRetry={() => void projectsWorkItemsQuery.refetch()}
|
||||
profiles={profiles}
|
||||
pullRequests={visiblePullRequests}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
) : filter === "issues" ? (
|
||||
<ProjectsIssuesList
|
||||
error={projectsWorkItemsQuery.error}
|
||||
failedSections={
|
||||
projectsWorkItemsQuery.data?.issues.failedSections ?? []
|
||||
}
|
||||
isLoading={projectsWorkItemsQuery.isLoading}
|
||||
isRetrying={
|
||||
projectsWorkItemsQuery.isFetching &&
|
||||
!projectsWorkItemsQuery.isLoading
|
||||
}
|
||||
issues={visibleIssues}
|
||||
onOpen={handleOpenIssue}
|
||||
onRetry={() => void projectsWorkItemsQuery.refetch()}
|
||||
profiles={profiles}
|
||||
viewMode={viewMode}
|
||||
/>
|
||||
) : filter === "projects" ? (
|
||||
projectItems
|
||||
) : (
|
||||
repositoryItems
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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<RepositoryItemProps, "project" | "repository"> & {
|
||||
}: Pick<RepositoryItemProps, "profiles" | "project" | "repository"> & {
|
||||
inlineBranch?: boolean;
|
||||
}) {
|
||||
// Where the git data lives beats repeating the (often identical) project
|
||||
// name — "github.com/block/buzz" for external repos, "owner/repo" for
|
||||
// Buzz-hosted ones.
|
||||
const displayPath = repositoryDisplayPath(
|
||||
repository,
|
||||
useRelayOrigin(),
|
||||
resolveUserLabel({ pubkey: repository.owner, profiles }),
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<RepositoryHostIcon repository={repository} />
|
||||
@@ -114,7 +129,7 @@ function RepositoryIdentity({
|
||||
</span>
|
||||
</div>
|
||||
<p className={PROJECT_LIST_ROW_PREVIEW_CLASS}>
|
||||
{project.name}
|
||||
{displayPath ?? project.name}
|
||||
{inlineBranch ? ` · ${repository.defaultBranch}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
@@ -203,6 +218,7 @@ export function RepositoryGridCard(props: RepositoryItemProps) {
|
||||
<div className="flex min-w-0 items-start gap-2.5">
|
||||
<RepositoryIdentity
|
||||
inlineBranch
|
||||
profiles={profiles}
|
||||
project={project}
|
||||
repository={repository}
|
||||
/>
|
||||
@@ -258,7 +274,11 @@ export function RepositoryListRow(props: RepositoryItemProps) {
|
||||
/>
|
||||
<div className="pointer-events-none relative z-10 flex min-w-0 flex-1 items-center gap-2.5">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2.5">
|
||||
<RepositoryIdentity project={project} repository={repository} />
|
||||
<RepositoryIdentity
|
||||
profiles={profiles}
|
||||
project={project}
|
||||
repository={repository}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn(PROJECT_LIST_ROW_TRAILING_CLASS, "pointer-events-auto")}
|
||||
|
||||
47
desktop/src/features/projects/useRepositoryAccess.ts
Normal file
47
desktop/src/features/projects/useRepositoryAccess.ts
Normal file
@@ -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<string, ProjectRepoUnavailableReason> | 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],
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user