mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
feat(desktop): make the Projects overview follow the selected section (#6335)
## Summary Part 1 of 4 stacked PRs continuing the Projects work from #6003. - The Projects overview now follows the selected section: the right-hand context pod stays visible across the Projects / Repositories / Reviews / Tasks / Channels tabs and shows section-specific people, stats, and contribution graphs, so it reads as live context rather than a detached summary. - Entity list rows are consolidated onto one compact line (title first, icons after, aligned dates/counts), shared across projects, repositories, PRs, issues, and channel lists via `ProjectEntityListRow`. - New `projectRelatedChannels` helper resolves the channels a project is discussed in for the overview. Follow-ups in this stack: part 2 (selectable workspaces), part 3 (context-aware collaboration), part 4 (navigation & detail-page polish). ## Test plan - [x] Desktop unit tests (`pnpm test`) — pass - [x] TypeScript (`tsc --noEmit`), Biome, clippy — clean via pre-push gate - [x] Playwright specs updated for the new overview behavior (`projects-v3-screenshots.spec.ts`, `sidebar.spec.ts`) — pass locally in the smoke project --------- Signed-off-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import {
|
||||
collectProjectRelatedChannelRows,
|
||||
projectRelatedChannelRowKey,
|
||||
uniqueProjectRelatedChannelCount,
|
||||
} from "./projectRelatedChannels.ts";
|
||||
|
||||
const CHANNEL_A = "11111111-1111-4111-8111-111111111111";
|
||||
const CHANNEL_B = "22222222-2222-4222-8222-222222222222";
|
||||
|
||||
function makeProject(overrides = {}) {
|
||||
return {
|
||||
id: "project-buzz",
|
||||
name: "buzz",
|
||||
projectChannelId: null,
|
||||
repositories: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeRepository(overrides = {}) {
|
||||
return {
|
||||
id: "repo-buzz",
|
||||
name: "buzz",
|
||||
channelId: CHANNEL_A,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("collects one row per repository channel binding", () => {
|
||||
const rows = collectProjectRelatedChannelRows([
|
||||
makeProject({
|
||||
repositories: [
|
||||
makeRepository(),
|
||||
makeRepository({
|
||||
id: "repo-relay",
|
||||
name: "relay-tools",
|
||||
channelId: CHANNEL_A,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
makeProject({
|
||||
id: "project-design",
|
||||
name: "design-system",
|
||||
repositories: [
|
||||
makeRepository({
|
||||
id: "repo-design",
|
||||
name: "design-system",
|
||||
channelId: CHANNEL_A,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
assert.deepEqual(rows, [
|
||||
{
|
||||
channelId: CHANNEL_A,
|
||||
projectId: "project-buzz",
|
||||
projectName: "buzz",
|
||||
repositoryId: "repo-buzz",
|
||||
repositoryName: "buzz",
|
||||
},
|
||||
{
|
||||
channelId: CHANNEL_A,
|
||||
projectId: "project-buzz",
|
||||
projectName: "buzz",
|
||||
repositoryId: "repo-relay",
|
||||
repositoryName: "relay-tools",
|
||||
},
|
||||
{
|
||||
channelId: CHANNEL_A,
|
||||
projectId: "project-design",
|
||||
projectName: "design-system",
|
||||
repositoryId: "repo-design",
|
||||
repositoryName: "design-system",
|
||||
},
|
||||
]);
|
||||
assert.equal(uniqueProjectRelatedChannelCount([]), 0);
|
||||
assert.equal(
|
||||
uniqueProjectRelatedChannelCount([
|
||||
makeProject({
|
||||
repositories: [
|
||||
makeRepository(),
|
||||
makeRepository({ id: "repo-relay", channelId: CHANNEL_A }),
|
||||
],
|
||||
}),
|
||||
makeProject({
|
||||
id: "project-design",
|
||||
repositories: [makeRepository({ id: "repo-design" })],
|
||||
}),
|
||||
]),
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
uniqueProjectRelatedChannelCount([
|
||||
makeProject({
|
||||
projectChannelId: CHANNEL_B,
|
||||
repositories: [makeRepository()],
|
||||
}),
|
||||
]),
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps a project channel only when no repository in that project shares it", () => {
|
||||
assert.deepEqual(
|
||||
collectProjectRelatedChannelRows([
|
||||
makeProject({
|
||||
projectChannelId: CHANNEL_A,
|
||||
repositories: [makeRepository({ channelId: CHANNEL_A })],
|
||||
}),
|
||||
]),
|
||||
[
|
||||
{
|
||||
channelId: CHANNEL_A,
|
||||
projectId: "project-buzz",
|
||||
projectName: "buzz",
|
||||
repositoryId: "repo-buzz",
|
||||
repositoryName: "buzz",
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
collectProjectRelatedChannelRows([
|
||||
makeProject({
|
||||
projectChannelId: CHANNEL_B,
|
||||
repositories: [makeRepository({ channelId: CHANNEL_A })],
|
||||
}),
|
||||
]),
|
||||
[
|
||||
{
|
||||
channelId: CHANNEL_A,
|
||||
projectId: "project-buzz",
|
||||
projectName: "buzz",
|
||||
repositoryId: "repo-buzz",
|
||||
repositoryName: "buzz",
|
||||
},
|
||||
{
|
||||
channelId: CHANNEL_B,
|
||||
projectId: "project-buzz",
|
||||
projectName: "buzz",
|
||||
repositoryId: null,
|
||||
repositoryName: null,
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("skips blank channel ids", () => {
|
||||
assert.deepEqual(
|
||||
collectProjectRelatedChannelRows([
|
||||
makeProject({
|
||||
projectChannelId: " ",
|
||||
repositories: [makeRepository({ channelId: "" })],
|
||||
}),
|
||||
]),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("row keys distinguish project-level bindings from repository bindings", () => {
|
||||
assert.equal(
|
||||
projectRelatedChannelRowKey({
|
||||
channelId: CHANNEL_A,
|
||||
projectId: "project-buzz",
|
||||
projectName: "buzz",
|
||||
repositoryId: null,
|
||||
repositoryName: null,
|
||||
}),
|
||||
`${CHANNEL_A}:project-buzz:project`,
|
||||
);
|
||||
assert.equal(
|
||||
projectRelatedChannelRowKey({
|
||||
channelId: CHANNEL_A,
|
||||
projectId: "project-buzz",
|
||||
projectName: "buzz",
|
||||
repositoryId: "repo-buzz",
|
||||
repositoryName: "buzz",
|
||||
}),
|
||||
`${CHANNEL_A}:project-buzz:repo-buzz`,
|
||||
);
|
||||
});
|
||||
75
desktop/src/features/projects/lib/projectRelatedChannels.ts
Normal file
75
desktop/src/features/projects/lib/projectRelatedChannels.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/** Bound project and repository channels shown on the Projects overview. */
|
||||
|
||||
export type ProjectRelatedChannelSource = {
|
||||
id: string;
|
||||
name: string;
|
||||
projectChannelId: string | null;
|
||||
repositories: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
channelId?: string | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type ProjectRelatedChannelRow = {
|
||||
channelId: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
repositoryId: string | null;
|
||||
repositoryName: string | null;
|
||||
};
|
||||
|
||||
function trimmedChannelId(value: string | null | undefined) {
|
||||
const channelId = value?.trim() ?? "";
|
||||
return channelId.length > 0 ? channelId : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One row per project or repository binding so the overview list can show
|
||||
* which project and repository a channel belongs to. When a project channel
|
||||
* is also bound to a repository in that project, only the repository row is
|
||||
* kept.
|
||||
*/
|
||||
export function collectProjectRelatedChannelRows(
|
||||
projects: readonly ProjectRelatedChannelSource[],
|
||||
): ProjectRelatedChannelRow[] {
|
||||
const rows: ProjectRelatedChannelRow[] = [];
|
||||
for (const project of projects) {
|
||||
const repositoryChannelIds = new Set<string>();
|
||||
for (const repository of project.repositories) {
|
||||
const channelId = trimmedChannelId(repository.channelId);
|
||||
if (!channelId) continue;
|
||||
repositoryChannelIds.add(channelId);
|
||||
rows.push({
|
||||
channelId,
|
||||
projectId: project.id,
|
||||
projectName: project.name,
|
||||
repositoryId: repository.id,
|
||||
repositoryName: repository.name,
|
||||
});
|
||||
}
|
||||
const projectChannelId = trimmedChannelId(project.projectChannelId);
|
||||
if (projectChannelId && !repositoryChannelIds.has(projectChannelId)) {
|
||||
rows.push({
|
||||
channelId: projectChannelId,
|
||||
projectId: project.id,
|
||||
projectName: project.name,
|
||||
repositoryId: null,
|
||||
repositoryName: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function uniqueProjectRelatedChannelCount(
|
||||
projects: readonly ProjectRelatedChannelSource[],
|
||||
) {
|
||||
return new Set(
|
||||
collectProjectRelatedChannelRows(projects).map((row) => row.channelId),
|
||||
).size;
|
||||
}
|
||||
|
||||
export function projectRelatedChannelRowKey(row: ProjectRelatedChannelRow) {
|
||||
return `${row.channelId}:${row.projectId}:${row.repositoryId ?? "project"}`;
|
||||
}
|
||||
@@ -1,30 +1,37 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { beforeEach, test } from "node:test";
|
||||
import test, { beforeEach } from "node:test";
|
||||
|
||||
import {
|
||||
__resetProjectSidebarMembershipForTests,
|
||||
addProjectToSidebar,
|
||||
mergeProjectSidebarMembershipStores,
|
||||
parseProjectSidebarMembershipPayload,
|
||||
PROJECT_SIDEBAR_MEMBERSHIP_EVENT,
|
||||
readProjectSidebarMembership,
|
||||
removeProjectFromSidebar,
|
||||
selectedProjectAddressesFromStore,
|
||||
} from "./projectSidebarMembership.ts";
|
||||
|
||||
function store(projects = {}) {
|
||||
return { version: 1, projects };
|
||||
}
|
||||
|
||||
const RELAY = "wss://relay.example.com";
|
||||
const PUBKEY = "a".repeat(64);
|
||||
|
||||
const store = new Map();
|
||||
const backingStore = new Map();
|
||||
let failWrites = false;
|
||||
let failReads = false;
|
||||
globalThis.localStorage = {
|
||||
getItem: (key) => {
|
||||
if (failReads) throw new Error("storage read unavailable");
|
||||
return store.get(key) ?? null;
|
||||
return backingStore.get(key) ?? null;
|
||||
},
|
||||
setItem: (key, value) => {
|
||||
if (failWrites) throw new Error("storage write unavailable");
|
||||
store.set(key, String(value));
|
||||
backingStore.set(key, String(value));
|
||||
},
|
||||
removeItem: (key) => store.delete(key),
|
||||
removeItem: (key) => backingStore.delete(key),
|
||||
};
|
||||
|
||||
/** Captures the membership dispatched with each mutation. */
|
||||
@@ -46,12 +53,91 @@ function captureDispatches() {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
store.clear();
|
||||
backingStore.clear();
|
||||
failWrites = false;
|
||||
failReads = false;
|
||||
__resetProjectSidebarMembershipForTests();
|
||||
});
|
||||
|
||||
test("migrates the legacy selected-address array", () => {
|
||||
const parsed = parseProjectSidebarMembershipPayload([
|
||||
"30621:alice:buzz",
|
||||
"30621:alice:buzz",
|
||||
"",
|
||||
42,
|
||||
]);
|
||||
|
||||
assert.deepEqual(parsed, {
|
||||
version: 1,
|
||||
projects: {
|
||||
"30621:alice:buzz": { selected: true, updatedAt: 0 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("merge preserves independent selections from two clients", () => {
|
||||
const merged = mergeProjectSidebarMembershipStores(
|
||||
store({
|
||||
"30621:alice:one": { selected: true, updatedAt: 100 },
|
||||
}),
|
||||
store({
|
||||
"30621:alice:two": { selected: true, updatedAt: 200 },
|
||||
}),
|
||||
);
|
||||
|
||||
assert.deepEqual(selectedProjectAddressesFromStore(merged).sort(), [
|
||||
"30621:alice:one",
|
||||
"30621:alice:two",
|
||||
]);
|
||||
});
|
||||
|
||||
test("newer removal wins over an older selection", () => {
|
||||
const merged = mergeProjectSidebarMembershipStores(
|
||||
store({
|
||||
"30621:alice:buzz": { selected: true, updatedAt: 100 },
|
||||
}),
|
||||
store({
|
||||
"30621:alice:buzz": { selected: false, updatedAt: 200 },
|
||||
}),
|
||||
);
|
||||
|
||||
assert.deepEqual(selectedProjectAddressesFromStore(merged), []);
|
||||
});
|
||||
|
||||
test("equal-timestamp conflicts converge with removal winning", () => {
|
||||
const selected = store({
|
||||
"30621:alice:buzz": { selected: true, updatedAt: 100 },
|
||||
});
|
||||
const removed = store({
|
||||
"30621:alice:buzz": { selected: false, updatedAt: 100 },
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
mergeProjectSidebarMembershipStores(selected, removed),
|
||||
mergeProjectSidebarMembershipStores(removed, selected),
|
||||
);
|
||||
assert.deepEqual(
|
||||
selectedProjectAddressesFromStore(
|
||||
mergeProjectSidebarMembershipStores(selected, removed),
|
||||
),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("drops malformed entries and rejects unknown versions", () => {
|
||||
assert.deepEqual(
|
||||
parseProjectSidebarMembershipPayload({
|
||||
version: 1,
|
||||
projects: { project: { selected: "yes", updatedAt: 1 } },
|
||||
}),
|
||||
store(),
|
||||
);
|
||||
assert.equal(
|
||||
parseProjectSidebarMembershipPayload({ version: 2, projects: {} }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("membership round-trips through storage", () => {
|
||||
addProjectToSidebar("30617:owner:alpha", RELAY, PUBKEY);
|
||||
addProjectToSidebar("30617:owner:beta", RELAY, PUBKEY);
|
||||
|
||||
@@ -3,90 +3,244 @@ export const PROJECT_SIDEBAR_MEMBERSHIP_EVENT =
|
||||
"buzz:project-sidebar-membership-change";
|
||||
|
||||
/** Detail carried by {@link PROJECT_SIDEBAR_MEMBERSHIP_EVENT}: the computed
|
||||
* membership for one relay/pubkey scope. Listeners must consume this rather
|
||||
* than re-reading localStorage — when persistence fails the write never
|
||||
* lands, and re-reading would silently revert the user's add/remove. */
|
||||
* membership for one relay/pubkey scope. Listeners may consume this instead of
|
||||
* re-reading storage — when persistence fails the write never lands, but the
|
||||
* in-session scope below stays authoritative either way. */
|
||||
export type ProjectSidebarMembershipChange = {
|
||||
relayOrigin: string;
|
||||
pubkey: string;
|
||||
addresses: string[];
|
||||
};
|
||||
|
||||
function membershipKey(relayOrigin: string, pubkey: string) {
|
||||
export type ProjectSidebarMembershipEntry = {
|
||||
selected: boolean;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
export type ProjectSidebarMembershipStore = {
|
||||
version: 1;
|
||||
projects: Record<string, ProjectSidebarMembershipEntry>;
|
||||
};
|
||||
|
||||
export const EMPTY_PROJECT_SIDEBAR_MEMBERSHIP_STORE: ProjectSidebarMembershipStore =
|
||||
Object.freeze({
|
||||
version: 1,
|
||||
projects: {},
|
||||
});
|
||||
|
||||
export function projectSidebarMembershipStorageKey(
|
||||
relayOrigin: string,
|
||||
pubkey: string,
|
||||
) {
|
||||
return `${PROJECT_SIDEBAR_MEMBERSHIP_PREFIX}.${encodeURIComponent(relayOrigin)}.${pubkey.toLowerCase()}`;
|
||||
}
|
||||
|
||||
export function parseProjectSidebarMembershipPayload(
|
||||
value: unknown,
|
||||
): ProjectSidebarMembershipStore | null {
|
||||
if (Array.isArray(value)) {
|
||||
return {
|
||||
version: 1,
|
||||
projects: Object.fromEntries(
|
||||
value
|
||||
.filter(
|
||||
(address): address is string =>
|
||||
typeof address === "string" && address.length > 0,
|
||||
)
|
||||
.map((address) => [
|
||||
address,
|
||||
{
|
||||
selected: true,
|
||||
updatedAt: 0,
|
||||
} satisfies ProjectSidebarMembershipEntry,
|
||||
]),
|
||||
),
|
||||
};
|
||||
}
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if (candidate.version !== 1) return null;
|
||||
if (
|
||||
!candidate.projects ||
|
||||
typeof candidate.projects !== "object" ||
|
||||
Array.isArray(candidate.projects)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const projects = Object.fromEntries(
|
||||
Object.entries(candidate.projects).filter(
|
||||
(entry): entry is [string, ProjectSidebarMembershipEntry] => {
|
||||
const membership = entry[1];
|
||||
return (
|
||||
entry[0].length > 0 &&
|
||||
typeof membership === "object" &&
|
||||
membership !== null &&
|
||||
typeof (membership as Record<string, unknown>).selected ===
|
||||
"boolean" &&
|
||||
typeof (membership as Record<string, unknown>).updatedAt ===
|
||||
"number" &&
|
||||
Number.isFinite(
|
||||
(membership as Record<string, unknown>).updatedAt as number,
|
||||
) &&
|
||||
((membership as Record<string, unknown>).updatedAt as number) >= 0
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
return { version: 1, projects };
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope-keyed authoritative membership. localStorage is only the durable
|
||||
* mirror: once a scope is seeded here, every read and mutation goes through
|
||||
* this map, so `add(A) → add(B) → remove(A)` accumulates correctly even when
|
||||
* every storage write fails — recomputing each mutation from storage would
|
||||
* silently drop all but the latest unpersisted change.
|
||||
* silently drop all but the latest unpersisted change. Scoping by
|
||||
* relay origin and pubkey keeps entries from crossing tenant boundaries.
|
||||
*/
|
||||
const membershipByScope = new Map<string, string[]>();
|
||||
const membershipStoreByScope = new Map<string, ProjectSidebarMembershipStore>();
|
||||
|
||||
/** Clears the in-memory authoritative scopes between test cases. */
|
||||
export function __resetProjectSidebarMembershipForTests(): void {
|
||||
membershipByScope.clear();
|
||||
membershipStoreByScope.clear();
|
||||
}
|
||||
|
||||
function dedupe(addresses: readonly unknown[]): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
addresses.filter(
|
||||
(address): address is string =>
|
||||
typeof address === "string" && address.length > 0,
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function readStoredMembership(key: string): string[] {
|
||||
function readStoredMembershipStore(key: string): ProjectSidebarMembershipStore {
|
||||
try {
|
||||
const parsed = JSON.parse(globalThis.localStorage?.getItem(key) ?? "[]");
|
||||
return Array.isArray(parsed) ? dedupe(parsed) : [];
|
||||
const raw = globalThis.localStorage?.getItem(key);
|
||||
if (!raw) return EMPTY_PROJECT_SIDEBAR_MEMBERSHIP_STORE;
|
||||
return (
|
||||
parseProjectSidebarMembershipPayload(JSON.parse(raw)) ??
|
||||
EMPTY_PROJECT_SIDEBAR_MEMBERSHIP_STORE
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
return EMPTY_PROJECT_SIDEBAR_MEMBERSHIP_STORE;
|
||||
}
|
||||
}
|
||||
|
||||
export function readProjectSidebarMembershipStore(
|
||||
relayOrigin: string | null | undefined,
|
||||
pubkey: string | null | undefined,
|
||||
): ProjectSidebarMembershipStore {
|
||||
if (!relayOrigin || !pubkey) return EMPTY_PROJECT_SIDEBAR_MEMBERSHIP_STORE;
|
||||
const key = projectSidebarMembershipStorageKey(relayOrigin, pubkey);
|
||||
const cached = membershipStoreByScope.get(key);
|
||||
const stored = readStoredMembershipStore(key);
|
||||
if (!cached) {
|
||||
membershipStoreByScope.set(key, stored);
|
||||
return stored;
|
||||
}
|
||||
// Merge instead of preferring either side: the cache holds mutations whose
|
||||
// persistence failed, while storage may hold newer cross-tab writes. The
|
||||
// per-entry LWW merge keeps both.
|
||||
if (projectSidebarMembershipStoresEqual(cached, stored)) return cached;
|
||||
const merged = mergeProjectSidebarMembershipStores(cached, stored);
|
||||
membershipStoreByScope.set(key, merged);
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function selectedProjectAddressesFromStore(
|
||||
store: ProjectSidebarMembershipStore,
|
||||
): string[] {
|
||||
return Object.entries(store.projects)
|
||||
.filter(([, membership]) => membership.selected)
|
||||
.map(([address]) => address);
|
||||
}
|
||||
|
||||
export function readProjectSidebarMembership(
|
||||
relayOrigin: string | null | undefined,
|
||||
pubkey: string | null | undefined,
|
||||
): string[] {
|
||||
if (!relayOrigin || !pubkey) return [];
|
||||
const key = membershipKey(relayOrigin, pubkey);
|
||||
let addresses = membershipByScope.get(key);
|
||||
if (!addresses) {
|
||||
addresses = readStoredMembership(key);
|
||||
membershipByScope.set(key, addresses);
|
||||
}
|
||||
return [...addresses];
|
||||
return selectedProjectAddressesFromStore(
|
||||
readProjectSidebarMembershipStore(relayOrigin, pubkey),
|
||||
);
|
||||
}
|
||||
|
||||
function writeProjectSidebarMembership(
|
||||
export function mergeProjectSidebarMembershipStores(
|
||||
local: ProjectSidebarMembershipStore,
|
||||
remote: ProjectSidebarMembershipStore,
|
||||
): ProjectSidebarMembershipStore {
|
||||
const addresses = new Set([
|
||||
...Object.keys(local.projects),
|
||||
...Object.keys(remote.projects),
|
||||
]);
|
||||
const projects: Record<string, ProjectSidebarMembershipEntry> = {};
|
||||
for (const address of addresses) {
|
||||
const localEntry = local.projects[address];
|
||||
const remoteEntry = remote.projects[address];
|
||||
if (localEntry && remoteEntry) {
|
||||
if (localEntry.updatedAt > remoteEntry.updatedAt) {
|
||||
projects[address] = localEntry;
|
||||
} else if (remoteEntry.updatedAt > localEntry.updatedAt) {
|
||||
projects[address] = remoteEntry;
|
||||
} else {
|
||||
projects[address] =
|
||||
localEntry.selected === remoteEntry.selected
|
||||
? localEntry
|
||||
: { selected: false, updatedAt: localEntry.updatedAt };
|
||||
}
|
||||
} else {
|
||||
projects[address] = (localEntry ??
|
||||
remoteEntry) as ProjectSidebarMembershipEntry;
|
||||
}
|
||||
}
|
||||
return { version: 1, projects };
|
||||
}
|
||||
|
||||
export function projectSidebarMembershipStoresEqual(
|
||||
left: ProjectSidebarMembershipStore,
|
||||
right: ProjectSidebarMembershipStore,
|
||||
): boolean {
|
||||
const leftKeys = Object.keys(left.projects);
|
||||
const rightKeys = Object.keys(right.projects);
|
||||
if (leftKeys.length !== rightKeys.length) return false;
|
||||
return leftKeys.every((address) => {
|
||||
const leftEntry = left.projects[address];
|
||||
const rightEntry = right.projects[address];
|
||||
return (
|
||||
rightEntry !== undefined &&
|
||||
leftEntry.selected === rightEntry.selected &&
|
||||
leftEntry.updatedAt === rightEntry.updatedAt
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Records `store` as the authoritative in-session state for the scope, then
|
||||
* mirrors it to localStorage. Persistence is best-effort: on failure the
|
||||
* in-memory scope stays authoritative, so sequential mutations never lose
|
||||
* earlier unpersisted changes and the next successful write persists the
|
||||
* accumulated state. Returns whether the mirror write landed.
|
||||
*/
|
||||
export function writeProjectSidebarMembershipStore(
|
||||
relayOrigin: string,
|
||||
pubkey: string,
|
||||
addresses: readonly string[],
|
||||
) {
|
||||
const deduped = dedupe(addresses);
|
||||
membershipByScope.set(membershipKey(relayOrigin, pubkey), deduped);
|
||||
store: ProjectSidebarMembershipStore,
|
||||
notify = true,
|
||||
): boolean {
|
||||
const key = projectSidebarMembershipStorageKey(relayOrigin, pubkey);
|
||||
membershipStoreByScope.set(key, store);
|
||||
let persisted = true;
|
||||
try {
|
||||
globalThis.localStorage?.setItem(
|
||||
membershipKey(relayOrigin, pubkey),
|
||||
JSON.stringify(deduped),
|
||||
);
|
||||
globalThis.localStorage?.setItem(key, JSON.stringify(store));
|
||||
} catch {
|
||||
// Persistence is best-effort; the in-memory scope above stays
|
||||
// authoritative, so sequential add/remove never loses earlier
|
||||
// unpersisted changes, and the event below updates every mounted view.
|
||||
persisted = false;
|
||||
}
|
||||
globalThis.dispatchEvent?.(
|
||||
new CustomEvent<ProjectSidebarMembershipChange>(
|
||||
PROJECT_SIDEBAR_MEMBERSHIP_EVENT,
|
||||
{ detail: { addresses: deduped, pubkey, relayOrigin } },
|
||||
),
|
||||
);
|
||||
if (notify) {
|
||||
globalThis.dispatchEvent?.(
|
||||
new CustomEvent<ProjectSidebarMembershipChange>(
|
||||
PROJECT_SIDEBAR_MEMBERSHIP_EVENT,
|
||||
{
|
||||
detail: {
|
||||
addresses: selectedProjectAddressesFromStore(store),
|
||||
pubkey,
|
||||
relayOrigin,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
return persisted;
|
||||
}
|
||||
|
||||
export function addProjectToSidebar(
|
||||
@@ -95,10 +249,14 @@ export function addProjectToSidebar(
|
||||
pubkey: string | null | undefined,
|
||||
) {
|
||||
if (!relayOrigin || !pubkey) return;
|
||||
writeProjectSidebarMembership(relayOrigin, pubkey, [
|
||||
...readProjectSidebarMembership(relayOrigin, pubkey),
|
||||
projectAddress,
|
||||
]);
|
||||
const current = readProjectSidebarMembershipStore(relayOrigin, pubkey);
|
||||
writeProjectSidebarMembershipStore(relayOrigin, pubkey, {
|
||||
version: 1,
|
||||
projects: {
|
||||
...current.projects,
|
||||
[projectAddress]: { selected: true, updatedAt: Date.now() },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function removeProjectFromSidebar(
|
||||
@@ -107,11 +265,12 @@ export function removeProjectFromSidebar(
|
||||
pubkey: string | null | undefined,
|
||||
) {
|
||||
if (!relayOrigin || !pubkey) return;
|
||||
writeProjectSidebarMembership(
|
||||
relayOrigin,
|
||||
pubkey,
|
||||
readProjectSidebarMembership(relayOrigin, pubkey).filter(
|
||||
(address) => address !== projectAddress,
|
||||
),
|
||||
);
|
||||
const current = readProjectSidebarMembershipStore(relayOrigin, pubkey);
|
||||
writeProjectSidebarMembershipStore(relayOrigin, pubkey, {
|
||||
version: 1,
|
||||
projects: {
|
||||
...current.projects,
|
||||
[projectAddress]: { selected: false, updatedAt: Date.now() },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test, { mock } from "node:test";
|
||||
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import {
|
||||
installFakeWindow,
|
||||
makeFakeWindow,
|
||||
} from "../../sidebar/lib/sidebarSyncTestHelpers.mjs";
|
||||
import { ProjectSidebarMembershipSyncManager } from "./projectSidebarMembershipSync.ts";
|
||||
|
||||
const RELAY = "wss://projects.test";
|
||||
|
||||
function store(projects = {}) {
|
||||
return { version: 1, projects };
|
||||
}
|
||||
|
||||
test("first sync seeds non-empty local membership when remote is absent", async () => {
|
||||
mock.method(relayClient, "fetchEvents", () => Promise.resolve([]));
|
||||
const fakeWindow = makeFakeWindow();
|
||||
const restore = installFakeWindow(fakeWindow);
|
||||
try {
|
||||
const manager = new ProjectSidebarMembershipSyncManager("pubkey", RELAY);
|
||||
const result = await manager.bootstrap(
|
||||
store({
|
||||
"30621:alice:buzz": { selected: true, updatedAt: 1 },
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(result.action, "hold");
|
||||
assert.notEqual(manager.getPendingStore(), null);
|
||||
manager.destroy();
|
||||
} finally {
|
||||
restore();
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
test("failed fetch never seeds local membership", async () => {
|
||||
mock.method(relayClient, "fetchEvents", () =>
|
||||
Promise.reject(new Error("offline")),
|
||||
);
|
||||
const fakeWindow = makeFakeWindow();
|
||||
const restore = installFakeWindow(fakeWindow);
|
||||
try {
|
||||
const manager = new ProjectSidebarMembershipSyncManager("pubkey", RELAY);
|
||||
const result = await manager.bootstrap(
|
||||
store({
|
||||
"30621:alice:buzz": { selected: true, updatedAt: 1 },
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(result.action, "hold");
|
||||
assert.equal(manager.getPendingStore(), null);
|
||||
manager.destroy();
|
||||
} finally {
|
||||
restore();
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
|
||||
test("destroy cancels a pending membership publish", () => {
|
||||
mock.method(relayClient, "fetchEvents", () => Promise.resolve([]));
|
||||
const fakeWindow = makeFakeWindow();
|
||||
const restore = installFakeWindow(fakeWindow);
|
||||
try {
|
||||
const manager = new ProjectSidebarMembershipSyncManager("pubkey", RELAY);
|
||||
manager.publishMembership(
|
||||
store({
|
||||
"30621:alice:buzz": { selected: true, updatedAt: 1 },
|
||||
}),
|
||||
);
|
||||
manager.destroy();
|
||||
|
||||
assert.equal(manager.getPendingStore(), null);
|
||||
} finally {
|
||||
restore();
|
||||
mock.reset();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import {
|
||||
nip44DecryptFromSelf,
|
||||
nip44EncryptToSelf,
|
||||
signRelayEvent,
|
||||
} from "@/shared/api/tauri";
|
||||
import type { RelayEvent } from "@/shared/api/types";
|
||||
import { KIND_PROJECT_SIDEBAR_MEMBERSHIP } from "@/shared/constants/kinds";
|
||||
import {
|
||||
mergeProjectSidebarMembershipStores,
|
||||
parseProjectSidebarMembershipPayload,
|
||||
projectSidebarMembershipStoresEqual,
|
||||
type ProjectSidebarMembershipStore,
|
||||
} from "./projectSidebarMembership";
|
||||
import {
|
||||
advanceWatermark,
|
||||
readWatermark,
|
||||
runBootstrap,
|
||||
type FetchResult,
|
||||
} from "@/features/sidebar/lib/sidebarSyncWatermark";
|
||||
|
||||
const D_TAG = "project-sidebar-membership";
|
||||
const BLOB_TYPE = D_TAG;
|
||||
const DEBOUNCE_MS = 2_000;
|
||||
|
||||
export type RemoteProjectSidebarMembership = {
|
||||
store: ProjectSidebarMembershipStore;
|
||||
createdAt: number;
|
||||
eventId: string;
|
||||
};
|
||||
|
||||
async function decryptAndParse(
|
||||
event: RelayEvent,
|
||||
): Promise<RemoteProjectSidebarMembership | null> {
|
||||
try {
|
||||
const plaintext = await nip44DecryptFromSelf(event.content);
|
||||
const store = parseProjectSidebarMembershipPayload(JSON.parse(plaintext));
|
||||
if (!store) return null;
|
||||
return { store, createdAt: event.created_at, eventId: event.id };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class ProjectSidebarMembershipSyncManager {
|
||||
private pubkey: string;
|
||||
private relayUrl: string;
|
||||
private debounceTimer: number | null = null;
|
||||
private lastRemoteCreatedAt: number;
|
||||
private pendingStore: ProjectSidebarMembershipStore | null = null;
|
||||
private lastPublishedStore: ProjectSidebarMembershipStore | null = null;
|
||||
private destroyed = false;
|
||||
|
||||
constructor(pubkey: string, relayUrl: string) {
|
||||
this.pubkey = pubkey;
|
||||
this.relayUrl = relayUrl;
|
||||
this.lastRemoteCreatedAt = readWatermark(pubkey, BLOB_TYPE, relayUrl);
|
||||
}
|
||||
|
||||
async fetchRemoteMembership(): Promise<
|
||||
FetchResult<RemoteProjectSidebarMembership>
|
||||
> {
|
||||
try {
|
||||
const events = await relayClient.fetchEvents({
|
||||
kinds: [KIND_PROJECT_SIDEBAR_MEMBERSHIP],
|
||||
authors: [this.pubkey],
|
||||
"#d": [D_TAG],
|
||||
limit: 1,
|
||||
});
|
||||
if (events.length === 0 || events[0].pubkey !== this.pubkey) {
|
||||
return { status: "absent" };
|
||||
}
|
||||
const event = events[0];
|
||||
this.recordRemoteHead(event.created_at);
|
||||
const result = await decryptAndParse(event);
|
||||
if (!result) {
|
||||
return { status: "failed", createdAt: event.created_at };
|
||||
}
|
||||
return {
|
||||
status: "found",
|
||||
data: result,
|
||||
createdAt: result.createdAt,
|
||||
eventId: result.eventId,
|
||||
};
|
||||
} catch {
|
||||
return { status: "failed" };
|
||||
}
|
||||
}
|
||||
|
||||
private recordRemoteHead(createdAt: number): void {
|
||||
if (createdAt > this.lastRemoteCreatedAt) {
|
||||
this.lastRemoteCreatedAt = createdAt;
|
||||
}
|
||||
advanceWatermark(this.pubkey, BLOB_TYPE, this.relayUrl, createdAt);
|
||||
}
|
||||
|
||||
cancelPendingPublish(): void {
|
||||
if (this.debounceTimer !== null) {
|
||||
window.clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
getPendingStore(): ProjectSidebarMembershipStore | null {
|
||||
return this.pendingStore;
|
||||
}
|
||||
|
||||
publishMembership(store: ProjectSidebarMembershipStore): void {
|
||||
this.pendingStore = store;
|
||||
if (this.debounceTimer !== null) {
|
||||
window.clearTimeout(this.debounceTimer);
|
||||
}
|
||||
this.debounceTimer = window.setTimeout(() => {
|
||||
this.debounceTimer = null;
|
||||
void this.doPublish(store);
|
||||
}, DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
private async fetchOwnBlobBeforePublish(
|
||||
store: ProjectSidebarMembershipStore,
|
||||
): Promise<ProjectSidebarMembershipStore> {
|
||||
try {
|
||||
const events = await relayClient.fetchEvents({
|
||||
kinds: [KIND_PROJECT_SIDEBAR_MEMBERSHIP],
|
||||
authors: [this.pubkey],
|
||||
"#d": [D_TAG],
|
||||
limit: 1,
|
||||
});
|
||||
if (events.length === 0 || events[0].pubkey !== this.pubkey) return store;
|
||||
const event = events[0];
|
||||
this.recordRemoteHead(event.created_at);
|
||||
const remote = await decryptAndParse(event);
|
||||
if (!remote) return store;
|
||||
return mergeProjectSidebarMembershipStores(store, remote.store);
|
||||
} catch {
|
||||
return store;
|
||||
}
|
||||
}
|
||||
|
||||
private isIdenticalToLastPublished(
|
||||
store: ProjectSidebarMembershipStore,
|
||||
): boolean {
|
||||
return (
|
||||
this.lastPublishedStore !== null &&
|
||||
projectSidebarMembershipStoresEqual(this.lastPublishedStore, store)
|
||||
);
|
||||
}
|
||||
|
||||
private async doPublish(store: ProjectSidebarMembershipStore): Promise<void> {
|
||||
try {
|
||||
const merged = await this.fetchOwnBlobBeforePublish(store);
|
||||
if (this.destroyed) return;
|
||||
if (this.isIdenticalToLastPublished(merged)) {
|
||||
this.pendingStore = null;
|
||||
return;
|
||||
}
|
||||
const ciphertext = await nip44EncryptToSelf(JSON.stringify(merged));
|
||||
const createdAt = Math.max(
|
||||
Math.floor(Date.now() / 1_000),
|
||||
this.lastRemoteCreatedAt + 1,
|
||||
);
|
||||
const event = await signRelayEvent({
|
||||
kind: KIND_PROJECT_SIDEBAR_MEMBERSHIP,
|
||||
content: ciphertext,
|
||||
createdAt,
|
||||
tags: [
|
||||
["d", D_TAG],
|
||||
["t", D_TAG],
|
||||
],
|
||||
});
|
||||
if (this.destroyed) return;
|
||||
await relayClient.publishEvent(
|
||||
event,
|
||||
"Timed out publishing project sidebar membership.",
|
||||
"Failed to publish project sidebar membership.",
|
||||
);
|
||||
this.recordRemoteHead(event.created_at);
|
||||
this.lastPublishedStore = merged;
|
||||
this.pendingStore = null;
|
||||
} catch (error) {
|
||||
console.warn("[projectSidebarMembershipSync] publish failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async subscribe(
|
||||
onUpdate: (remote: RemoteProjectSidebarMembership) => void,
|
||||
): Promise<() => Promise<void>> {
|
||||
return relayClient.subscribeLive(
|
||||
{
|
||||
kinds: [KIND_PROJECT_SIDEBAR_MEMBERSHIP],
|
||||
authors: [this.pubkey],
|
||||
"#d": [D_TAG],
|
||||
limit: 0,
|
||||
},
|
||||
(event: RelayEvent) => {
|
||||
if (event.pubkey !== this.pubkey) return;
|
||||
this.recordRemoteHead(event.created_at);
|
||||
void decryptAndParse(event).then((result) => {
|
||||
if (result) onUpdate(result);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async bootstrap(localStore: ProjectSidebarMembershipStore) {
|
||||
const fetchResult = await this.fetchRemoteMembership();
|
||||
return runBootstrap({
|
||||
fetchResult,
|
||||
lastHead: this.lastRemoteCreatedAt,
|
||||
localStore,
|
||||
isLocalNonEmpty: (store) => Object.keys(store.projects).length > 0,
|
||||
publishFn: (store) => this.publishMembership(store),
|
||||
});
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.destroyed = true;
|
||||
this.cancelPendingPublish();
|
||||
this.pendingStore = null;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
isProjectAccessibleToViewer,
|
||||
isProjectMine,
|
||||
isRepositoryAccessibleToViewer,
|
||||
listRowDescription,
|
||||
nextRepositoryEntryLimit,
|
||||
relativeTime,
|
||||
} from "./projectsViewHelpers.ts";
|
||||
@@ -226,3 +227,21 @@ test("repository entry pagination advances and clamps to the total", () => {
|
||||
assert.equal(nextRepositoryEntryLimit(400, 450), 450);
|
||||
assert.equal(nextRepositoryEntryLimit(450, 450), 450);
|
||||
});
|
||||
|
||||
test("listRowDescription keeps real copy and drops empty or title-duplicate text", () => {
|
||||
assert.equal(
|
||||
listRowDescription("The complete Buzz community platform."),
|
||||
"The complete Buzz community platform.",
|
||||
);
|
||||
assert.equal(listRowDescription(" "), undefined);
|
||||
assert.equal(listRowDescription(""), undefined);
|
||||
assert.equal(listRowDescription(null), undefined);
|
||||
assert.equal(
|
||||
listRowDescription(
|
||||
"Fix reconnect backoff jitter",
|
||||
"Fix reconnect backoff jitter",
|
||||
),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(listRowDescription("**Hello** world"), "Hello world");
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@ export type ProjectsFilter =
|
||||
| "local"
|
||||
| "projects"
|
||||
| "repositories"
|
||||
| "channels"
|
||||
| "prs"
|
||||
| "issues"
|
||||
| "agents"
|
||||
@@ -87,6 +88,7 @@ export function readStoredFilter(): ProjectsFilter {
|
||||
value === "local" ||
|
||||
value === "projects" ||
|
||||
value === "repositories" ||
|
||||
value === "channels" ||
|
||||
value === "prs" ||
|
||||
value === "issues" ||
|
||||
value === "agents" ||
|
||||
@@ -236,6 +238,24 @@ export function markdownToPlainText(input: string): string {
|
||||
);
|
||||
}
|
||||
|
||||
/** One-line list subtitle. Empty, whitespace-only, and title-duplicate bodies stay hidden. */
|
||||
export function listRowDescription(
|
||||
value: string | null | undefined,
|
||||
title?: string,
|
||||
): string | undefined {
|
||||
const text = markdownToPlainText(value ?? "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
if (text.length === 0) return undefined;
|
||||
if (
|
||||
title &&
|
||||
text.localeCompare(title.trim(), undefined, { sensitivity: "accent" }) === 0
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
export function formatCreatedDate(createdAt: number) {
|
||||
return new Date(createdAt * 1_000).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
|
||||
173
desktop/src/features/projects/lib/useProjectSidebarMembership.ts
Normal file
173
desktop/src/features/projects/lib/useProjectSidebarMembership.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import {
|
||||
EMPTY_PROJECT_SIDEBAR_MEMBERSHIP_STORE,
|
||||
mergeProjectSidebarMembershipStores,
|
||||
PROJECT_SIDEBAR_MEMBERSHIP_EVENT,
|
||||
projectSidebarMembershipStoresEqual,
|
||||
projectSidebarMembershipStorageKey,
|
||||
readProjectSidebarMembershipStore,
|
||||
selectedProjectAddressesFromStore,
|
||||
writeProjectSidebarMembershipStore,
|
||||
type ProjectSidebarMembershipStore,
|
||||
} from "./projectSidebarMembership";
|
||||
import {
|
||||
ProjectSidebarMembershipSyncManager,
|
||||
type RemoteProjectSidebarMembership,
|
||||
} from "./projectSidebarMembershipSync";
|
||||
|
||||
export function useProjectSidebarMembership(
|
||||
relayUrl: string | null,
|
||||
pubkey: string | undefined,
|
||||
): string[] {
|
||||
const [store, setStore] = React.useState<ProjectSidebarMembershipStore>(
|
||||
EMPTY_PROJECT_SIDEBAR_MEMBERSHIP_STORE,
|
||||
);
|
||||
const managerRef = React.useRef<ProjectSidebarMembershipSyncManager | null>(
|
||||
null,
|
||||
);
|
||||
const lastAppliedRemoteTs = React.useRef(0);
|
||||
const lastAppliedEventId = React.useRef("");
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!relayUrl || !pubkey) {
|
||||
setStore(EMPTY_PROJECT_SIDEBAR_MEMBERSHIP_STORE);
|
||||
lastAppliedRemoteTs.current = 0;
|
||||
lastAppliedEventId.current = "";
|
||||
return;
|
||||
}
|
||||
setStore(readProjectSidebarMembershipStore(relayUrl, pubkey));
|
||||
lastAppliedRemoteTs.current = 0;
|
||||
lastAppliedEventId.current = "";
|
||||
managerRef.current = new ProjectSidebarMembershipSyncManager(
|
||||
pubkey,
|
||||
relayUrl,
|
||||
);
|
||||
return () => {
|
||||
managerRef.current?.destroy();
|
||||
managerRef.current = null;
|
||||
};
|
||||
}, [pubkey, relayUrl]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!relayUrl || !pubkey) return;
|
||||
const refreshAndPublish = () => {
|
||||
const next = readProjectSidebarMembershipStore(relayUrl, pubkey);
|
||||
setStore(next);
|
||||
managerRef.current?.publishMembership(next);
|
||||
};
|
||||
globalThis.addEventListener(
|
||||
PROJECT_SIDEBAR_MEMBERSHIP_EVENT,
|
||||
refreshAndPublish,
|
||||
);
|
||||
return () => {
|
||||
globalThis.removeEventListener(
|
||||
PROJECT_SIDEBAR_MEMBERSHIP_EVENT,
|
||||
refreshAndPublish,
|
||||
);
|
||||
};
|
||||
}, [pubkey, relayUrl]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!relayUrl || !pubkey) return;
|
||||
const key = projectSidebarMembershipStorageKey(relayUrl, pubkey);
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (event.key === key) {
|
||||
setStore(readProjectSidebarMembershipStore(relayUrl, pubkey));
|
||||
}
|
||||
};
|
||||
globalThis.addEventListener("storage", handleStorage);
|
||||
return () => globalThis.removeEventListener("storage", handleStorage);
|
||||
}, [pubkey, relayUrl]);
|
||||
|
||||
const applyRemote = React.useCallback(
|
||||
(
|
||||
remote: RemoteProjectSidebarMembership,
|
||||
): ((
|
||||
current: ProjectSidebarMembershipStore,
|
||||
) => ProjectSidebarMembershipStore) =>
|
||||
(current) => {
|
||||
if (!relayUrl || !pubkey) return current;
|
||||
if (remote.createdAt < lastAppliedRemoteTs.current) return current;
|
||||
if (
|
||||
remote.createdAt === lastAppliedRemoteTs.current &&
|
||||
remote.eventId <= lastAppliedEventId.current
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
lastAppliedRemoteTs.current = remote.createdAt;
|
||||
lastAppliedEventId.current = remote.eventId;
|
||||
managerRef.current?.cancelPendingPublish();
|
||||
const merged = mergeProjectSidebarMembershipStores(
|
||||
current,
|
||||
remote.store,
|
||||
);
|
||||
// The write records merged as the authoritative in-session state even
|
||||
// when the localStorage mirror fails, so the merge always applies.
|
||||
writeProjectSidebarMembershipStore(relayUrl, pubkey, merged, false);
|
||||
if (!projectSidebarMembershipStoresEqual(merged, remote.store)) {
|
||||
managerRef.current?.publishMembership(merged);
|
||||
}
|
||||
return merged;
|
||||
},
|
||||
[pubkey, relayUrl],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!relayUrl || !pubkey) return;
|
||||
let cancelled = false;
|
||||
const local = readProjectSidebarMembershipStore(relayUrl, pubkey);
|
||||
void managerRef.current?.bootstrap(local).then((result) => {
|
||||
if (cancelled) return;
|
||||
if (result.action === "apply-remote") {
|
||||
setStore(applyRemote(result.data));
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [applyRemote, pubkey, relayUrl]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!relayUrl || !pubkey) return;
|
||||
let unsubscribe: (() => Promise<void>) | null = null;
|
||||
let cancelled = false;
|
||||
void managerRef.current
|
||||
?.subscribe((remote) => {
|
||||
if (!cancelled) setStore(applyRemote(remote));
|
||||
})
|
||||
.then((dispose) => {
|
||||
if (cancelled) {
|
||||
void dispose();
|
||||
} else {
|
||||
unsubscribe = dispose;
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (unsubscribe) void unsubscribe();
|
||||
};
|
||||
}, [applyRemote, pubkey, relayUrl]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!relayUrl || !pubkey) return;
|
||||
let cancelled = false;
|
||||
const unsubscribe = relayClient.subscribeToReconnects(() => {
|
||||
void managerRef.current?.fetchRemoteMembership().then((result) => {
|
||||
if (cancelled) return;
|
||||
if (result.status === "found") {
|
||||
setStore(applyRemote(result.data));
|
||||
}
|
||||
const pending = managerRef.current?.getPendingStore();
|
||||
if (pending) managerRef.current?.publishMembership(pending);
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
};
|
||||
}, [applyRemote, pubkey, relayUrl]);
|
||||
|
||||
return React.useMemo(() => selectedProjectAddressesFromStore(store), [store]);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Hash, MessageSquare } from "lucide-react";
|
||||
import { Hash } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
|
||||
@@ -22,7 +22,10 @@ import { KIND_FORUM_COMMENT, KIND_FORUM_POST } from "@/shared/constants/kinds";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState";
|
||||
import { Markdown } from "@/shared/ui/markdown";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import {
|
||||
ProjectEntityFacepile,
|
||||
ProjectEntityListRow,
|
||||
} from "./ProjectEntityListRow";
|
||||
import { useProjectConversationPanel } from "./ProjectConversationPanelContext";
|
||||
|
||||
// Relay search caps a page at 500. Use the full page and surface a lower-bound
|
||||
@@ -202,7 +205,7 @@ export function DiscussedInChannels({
|
||||
type="button"
|
||||
/>
|
||||
<span className="relative z-10 pt-0.5">
|
||||
<ParticipantFacepile
|
||||
<ProjectEntityFacepile
|
||||
interactive
|
||||
participants={channel.participants}
|
||||
profiles={profiles}
|
||||
@@ -325,69 +328,6 @@ function DiscussionNameList({
|
||||
);
|
||||
}
|
||||
|
||||
function ParticipantFacepile({
|
||||
interactive = false,
|
||||
participants,
|
||||
profiles,
|
||||
}: {
|
||||
/** Wrap each avatar in a profile popover. Leave off when the facepile is
|
||||
* nested inside another button (nested interactive elements are invalid). */
|
||||
interactive?: boolean;
|
||||
participants: string[];
|
||||
profiles: UserProfileLookup | undefined;
|
||||
}) {
|
||||
const shown = participants.slice(0, 4);
|
||||
const overflow = participants.length - shown.length;
|
||||
return (
|
||||
<span className="flex shrink-0 items-center">
|
||||
{shown.map((pubkey, index) => {
|
||||
const label = resolveUserLabel({ profiles, pubkey });
|
||||
if (!interactive) {
|
||||
return (
|
||||
<span
|
||||
className={cn(index > 0 && "-ml-1.5")}
|
||||
key={pubkey}
|
||||
title={label}
|
||||
>
|
||||
<UserAvatar
|
||||
avatarUrl={profiles?.[pubkey]?.avatarUrl ?? null}
|
||||
className="rounded-full ring-2 ring-background"
|
||||
displayName={label}
|
||||
size="xs"
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<UserProfilePopover
|
||||
key={pubkey}
|
||||
pubkey={pubkey}
|
||||
triggerElement="span"
|
||||
>
|
||||
<button
|
||||
className={cn("rounded-full", index > 0 && "-ml-1.5")}
|
||||
title={label}
|
||||
type="button"
|
||||
>
|
||||
<UserAvatar
|
||||
avatarUrl={profiles?.[pubkey]?.avatarUrl ?? null}
|
||||
className="rounded-full ring-2 ring-background"
|
||||
displayName={label}
|
||||
size="xs"
|
||||
/>
|
||||
</button>
|
||||
</UserProfilePopover>
|
||||
);
|
||||
})}
|
||||
{overflow > 0 ? (
|
||||
<span className="-ml-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-muted text-3xs text-muted-foreground ring-2 ring-background">
|
||||
+{overflow}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-width channel list for the workspace "Channels" tab: every channel
|
||||
* where the repository (or its PRs/issues) is linked in chat, with the
|
||||
@@ -428,56 +368,26 @@ export function DiscussionChannelsPanel({
|
||||
const name = channelName(channel.id, channel.name);
|
||||
return (
|
||||
<li className="relative" key={channel.id}>
|
||||
<button
|
||||
className="flex min-h-9 w-full min-w-0 items-center gap-2 px-4 py-1.5 text-left transition-colors hover:bg-muted/30"
|
||||
data-testid="project-channel-row"
|
||||
<ProjectEntityListRow
|
||||
affiliation={repositoryName}
|
||||
affiliationTestId="project-channel-repository"
|
||||
count={channel.messageCount}
|
||||
countSuffix={isTruncated ? "+" : undefined}
|
||||
countTestId="project-channel-message-count"
|
||||
countTitle={`${channel.messageCount}${isTruncated ? "+" : ""} ${
|
||||
channel.messageCount === 1 ? "message" : "messages"
|
||||
}`}
|
||||
dateSeconds={channel.lastActivityAt}
|
||||
dateTestId="project-channel-row-date"
|
||||
icon={<Hash className="h-3.5 w-3.5 text-muted-foreground/70" />}
|
||||
onClick={() => void goChannel(channel.id)}
|
||||
title={`Open #${name}`}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
<Hash className="h-3.5 w-3.5 text-muted-foreground/70" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
|
||||
#{name}
|
||||
</span>
|
||||
<span
|
||||
className="hidden w-28 shrink-0 truncate text-right text-xs text-muted-foreground md:block"
|
||||
data-testid="project-channel-repository"
|
||||
title={repositoryName}
|
||||
>
|
||||
{repositoryName}
|
||||
</span>
|
||||
<span
|
||||
className="flex w-20 shrink-0 justify-end"
|
||||
data-testid="project-channel-participants"
|
||||
>
|
||||
<ParticipantFacepile
|
||||
participants={channel.participants}
|
||||
profiles={profiles}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className="flex w-12 shrink-0 items-center justify-end gap-1 text-xs text-muted-foreground"
|
||||
data-testid="project-channel-message-count"
|
||||
title={`${channel.messageCount}${isTruncated ? "+" : ""} ${
|
||||
channel.messageCount === 1 ? "message" : "messages"
|
||||
}`}
|
||||
>
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
{channel.messageCount}
|
||||
{isTruncated ? "+" : ""}
|
||||
</span>
|
||||
<span
|
||||
className="hidden w-20 shrink-0 whitespace-nowrap text-right text-xs text-muted-foreground/70 sm:block"
|
||||
data-testid="project-channel-row-date"
|
||||
title={new Date(
|
||||
channel.lastActivityAt * 1_000,
|
||||
).toLocaleString()}
|
||||
>
|
||||
{relativeTime(channel.lastActivityAt)}
|
||||
</span>
|
||||
</button>
|
||||
people={channel.participants}
|
||||
peopleTestId="project-channel-participants"
|
||||
profiles={profiles}
|
||||
testId="project-channel-row"
|
||||
title={`#${name}`}
|
||||
titleAttr={`Open #${name}`}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -7,11 +7,13 @@ import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
/** Compact work-item author identity with a minimal hover summary. */
|
||||
export function ProjectAuthorIdentity({
|
||||
label,
|
||||
labelClassName,
|
||||
profiles,
|
||||
pubkey,
|
||||
testId,
|
||||
}: {
|
||||
label: string;
|
||||
labelClassName?: string;
|
||||
profiles?: UserProfileLookup;
|
||||
pubkey: string;
|
||||
testId?: string;
|
||||
@@ -29,6 +31,7 @@ export function ProjectAuthorIdentity({
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
aria-label={label}
|
||||
className="relative z-10 inline-flex items-center gap-1 rounded-sm hover:underline focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
|
||||
data-testid={testId}
|
||||
type="button"
|
||||
@@ -41,7 +44,10 @@ export function ProjectAuthorIdentity({
|
||||
size="xs"
|
||||
testId={testId ? `${testId}-avatar` : undefined}
|
||||
/>
|
||||
<span data-testid={testId ? `${testId}-label` : undefined}>
|
||||
<span
|
||||
className={labelClassName}
|
||||
data-testid={testId ? `${testId}-label` : undefined}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -21,19 +21,13 @@ import type {
|
||||
import {
|
||||
formatExactTimestamp,
|
||||
getProjectUpdatedAt,
|
||||
listRowDescription,
|
||||
relativeTime,
|
||||
} from "@/features/projects/lib/projectsViewHelpers";
|
||||
import type { ProjectRepoUnavailableReason } from "@/features/projects/lib/projectRepoAvailability";
|
||||
import { projectShareLink } from "@/features/projects/lib/projectShareLinks";
|
||||
import { projectTerminalLabel } from "@/features/projects/ui/useOpenProjectTerminal";
|
||||
import {
|
||||
PROJECT_LIST_ROW_CLASS,
|
||||
PROJECT_LIST_ROW_DATE_CLASS,
|
||||
PROJECT_LIST_ROW_META_TEXT_CLASS,
|
||||
PROJECT_LIST_ROW_PREVIEW_CLASS,
|
||||
PROJECT_LIST_ROW_TITLE_CLASS,
|
||||
PROJECT_LIST_ROW_TRAILING_CLASS,
|
||||
} from "@/features/projects/ui/projectListRowStyles";
|
||||
import { PROJECT_LIST_ROW_META_TEXT_CLASS } from "@/features/projects/ui/projectListRowStyles";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import {
|
||||
@@ -52,6 +46,7 @@ import { DropdownMenuItem } from "@/shared/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import { CopyShareLinkMenuItem } from "./CopyShareLinkMenuItem";
|
||||
import { ProjectEntityListRow } from "./ProjectEntityListRow";
|
||||
import { ProjectListRowMenu } from "./ProjectListRowMenu";
|
||||
|
||||
function ProjectUpdatedLabel({
|
||||
@@ -575,89 +570,46 @@ export function ProjectListRow({
|
||||
onOpen,
|
||||
onOpenTerminal,
|
||||
}: ProjectItemProps) {
|
||||
const repositoryCount = project.repositoryAddresses.length;
|
||||
return (
|
||||
<div
|
||||
className={cn(PROJECT_LIST_ROW_CLASS, "py-3")}
|
||||
data-testid={`project-row-${project.dtag}`}
|
||||
>
|
||||
<ProjectCardButton onOpen={onOpen} project={project} />
|
||||
<div className="pointer-events-none relative z-10 flex min-w-0 items-center gap-2.5">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2.5">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-border/60 bg-muted/40">
|
||||
<Folders className="h-4.5 w-4.5 text-muted-foreground" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className={PROJECT_LIST_ROW_TITLE_CLASS}>
|
||||
{project.name}
|
||||
</span>
|
||||
<StatusPill status={project.status} />
|
||||
</div>
|
||||
<p className={PROJECT_LIST_ROW_PREVIEW_CLASS}>
|
||||
{project.description || "A shared space for internal git work."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(PROJECT_LIST_ROW_TRAILING_CLASS, "pointer-events-auto")}
|
||||
>
|
||||
<div
|
||||
className="hidden w-24 shrink-0 text-xs text-muted-foreground md:block"
|
||||
data-testid="projects-row-context"
|
||||
>
|
||||
{project.repositoryAddresses.length}{" "}
|
||||
{project.repositoryAddresses.length === 1
|
||||
? "repository"
|
||||
: "repositories"}
|
||||
</div>
|
||||
<div
|
||||
className="flex w-6 shrink-0 justify-center"
|
||||
data-testid="projects-row-repository-status"
|
||||
>
|
||||
<ProjectEntityListRow
|
||||
affiliation={`${repositoryCount} ${
|
||||
repositoryCount === 1 ? "repository" : "repositories"
|
||||
}`}
|
||||
affiliationTestId="projects-row-context"
|
||||
dateSeconds={getProjectUpdatedAt(project, summary)}
|
||||
dateTestId="projects-row-date"
|
||||
description={listRowDescription(project.description, project.name)}
|
||||
descriptionTestId="projects-row-description"
|
||||
icon={<Folders className="h-3.5 w-3.5 text-muted-foreground/70" />}
|
||||
onClick={() => onOpen(project)}
|
||||
people={people}
|
||||
peopleTestId="projects-row-people"
|
||||
profiles={profiles}
|
||||
testId={`project-row-${project.dtag}`}
|
||||
title={
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate">{project.name}</span>
|
||||
<StatusPill status={project.status} />
|
||||
<span data-testid="projects-row-repository-status">
|
||||
<RepositoryUnavailableIndicator
|
||||
reason={repositoryUnavailableReason}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="hidden items-center xl:flex"
|
||||
data-testid="projects-row-summary"
|
||||
>
|
||||
<div className="w-32 shrink-0">
|
||||
<ProjectActivityBar summary={summary} />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="hidden w-24 shrink-0 justify-end lg:flex"
|
||||
data-testid="projects-row-people"
|
||||
>
|
||||
<ProjectPeopleStack
|
||||
profiles={profiles}
|
||||
pubkeys={people}
|
||||
workOwnerPubkey={project.owner}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={PROJECT_LIST_ROW_DATE_CLASS}
|
||||
data-testid="projects-row-date"
|
||||
>
|
||||
<ProjectUpdatedLabel
|
||||
profiles={profiles}
|
||||
project={project}
|
||||
summary={summary}
|
||||
/>
|
||||
</div>
|
||||
<ProjectActionsMenu
|
||||
canDelete={canDelete}
|
||||
disabled={deleteDisabled}
|
||||
hasLocal={hasLocal}
|
||||
onDelete={onDelete}
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
project={project}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
titleAttr={project.name}
|
||||
trailing={
|
||||
<ProjectActionsMenu
|
||||
canDelete={canDelete}
|
||||
disabled={deleteDisabled}
|
||||
hasLocal={hasLocal}
|
||||
onDelete={onDelete}
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
project={project}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -132,3 +132,62 @@ export function ProjectDetailChrome({
|
||||
</AppTopChromePortal>
|
||||
);
|
||||
}
|
||||
|
||||
const BREADCRUMB_BUTTON_CLASS =
|
||||
"flex shrink-0 items-center gap-1.5 rounded-md px-1 py-1 font-medium transition-colors hover:text-sidebar-accent-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring";
|
||||
|
||||
export function ProjectsWorkspaceChrome({
|
||||
actions,
|
||||
onGoActivity,
|
||||
section,
|
||||
}: {
|
||||
actions: React.ReactNode;
|
||||
onGoActivity: () => void;
|
||||
section: string;
|
||||
}) {
|
||||
const onActivity = section === "Activity";
|
||||
|
||||
return (
|
||||
<AppTopChromePortal>
|
||||
<div
|
||||
className="flex min-w-0 flex-1 items-center justify-between gap-3 pl-2"
|
||||
data-tauri-drag-region
|
||||
data-testid="projects-workspace-chrome"
|
||||
>
|
||||
<nav
|
||||
aria-label="Projects breadcrumb"
|
||||
className="absolute flex max-w-[50%] min-w-0 -translate-x-1/2 -translate-y-px items-center gap-0.5 text-xs text-sidebar-foreground/65"
|
||||
style={{
|
||||
left: "calc(50% + var(--app-top-chrome-center-offset, 0rem))",
|
||||
}}
|
||||
>
|
||||
{onActivity ? (
|
||||
<span className="flex min-w-0 items-center gap-1.5 px-1 py-1 font-medium">
|
||||
<Folders className="h-3.5 w-3.5 shrink-0" />
|
||||
Projects
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
className={BREADCRUMB_BUTTON_CLASS}
|
||||
onClick={onGoActivity}
|
||||
type="button"
|
||||
>
|
||||
<Folders className="h-3.5 w-3.5 shrink-0" />
|
||||
Projects
|
||||
</button>
|
||||
)}
|
||||
<ChevronRight className="h-3 w-3 shrink-0 opacity-60" />
|
||||
<span
|
||||
aria-current="page"
|
||||
className="min-w-0 truncate px-0.5 font-medium opacity-60"
|
||||
>
|
||||
{section}
|
||||
</span>
|
||||
</nav>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-0.5">
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
</AppTopChromePortal>
|
||||
);
|
||||
}
|
||||
|
||||
256
desktop/src/features/projects/ui/ProjectEntityListRow.tsx
Normal file
256
desktop/src/features/projects/ui/ProjectEntityListRow.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
|
||||
import {
|
||||
resolveUserLabel,
|
||||
type UserProfileLookup,
|
||||
} from "@/features/profile/lib/identity";
|
||||
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
|
||||
import { relativeTime } from "@/features/projects/lib/projectsViewHelpers";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
|
||||
/** One-line list row: title, optional description column, then trailing metadata. */
|
||||
export const PROJECT_ENTITY_LIST_ROW_CLASS =
|
||||
"flex min-h-9 w-full min-w-0 items-center gap-3 px-4 py-1.5 text-left transition-colors hover:bg-muted/30";
|
||||
|
||||
export function ProjectEntityFacepile({
|
||||
interactive = false,
|
||||
participants,
|
||||
profiles,
|
||||
}: {
|
||||
/** Wrap each avatar in a profile popover. Leave off when the facepile is
|
||||
* nested inside another button (nested interactive elements are invalid). */
|
||||
interactive?: boolean;
|
||||
participants: string[];
|
||||
profiles: UserProfileLookup | undefined;
|
||||
}) {
|
||||
const shown = participants.slice(0, 4);
|
||||
const overflow = participants.length - shown.length;
|
||||
if (shown.length === 0) return null;
|
||||
return (
|
||||
<span className="flex shrink-0 items-center">
|
||||
{shown.map((pubkey, index) => {
|
||||
const label = resolveUserLabel({ profiles, pubkey });
|
||||
if (!interactive) {
|
||||
return (
|
||||
<span
|
||||
className={cn(index > 0 && "-ml-1.5")}
|
||||
key={pubkey}
|
||||
title={label}
|
||||
>
|
||||
<UserAvatar
|
||||
avatarUrl={profiles?.[pubkey]?.avatarUrl ?? null}
|
||||
className="rounded-full ring-2 ring-background"
|
||||
displayName={label}
|
||||
size="xs"
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<UserProfilePopover
|
||||
key={pubkey}
|
||||
pubkey={pubkey}
|
||||
triggerElement="span"
|
||||
>
|
||||
<button
|
||||
className={cn("rounded-full", index > 0 && "-ml-1.5")}
|
||||
title={label}
|
||||
type="button"
|
||||
>
|
||||
<UserAvatar
|
||||
avatarUrl={profiles?.[pubkey]?.avatarUrl ?? null}
|
||||
className="rounded-full ring-2 ring-background"
|
||||
displayName={label}
|
||||
size="xs"
|
||||
/>
|
||||
</button>
|
||||
</UserProfilePopover>
|
||||
);
|
||||
})}
|
||||
{overflow > 0 ? (
|
||||
<span className="-ml-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-muted text-3xs text-muted-foreground ring-2 ring-background">
|
||||
+{overflow}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectEntityListRow({
|
||||
affiliation,
|
||||
affiliationTestId,
|
||||
affiliationTitle,
|
||||
count,
|
||||
countSuffix,
|
||||
countTestId,
|
||||
countTitle,
|
||||
dateSeconds,
|
||||
dateTestId,
|
||||
description,
|
||||
descriptionTestId,
|
||||
icon,
|
||||
onClick,
|
||||
people,
|
||||
peopleSlot,
|
||||
peopleTestId,
|
||||
profiles,
|
||||
testId,
|
||||
title,
|
||||
titleAttr,
|
||||
trailing,
|
||||
}: {
|
||||
affiliation?: React.ReactNode;
|
||||
affiliationTestId?: string;
|
||||
affiliationTitle?: string;
|
||||
count?: number | null;
|
||||
countSuffix?: string;
|
||||
countTestId?: string;
|
||||
countTitle?: string;
|
||||
dateSeconds?: number | null;
|
||||
dateTestId?: string;
|
||||
description?: string;
|
||||
descriptionTestId?: string;
|
||||
icon: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
people?: string[];
|
||||
peopleSlot?: React.ReactNode;
|
||||
peopleTestId?: string;
|
||||
profiles?: UserProfileLookup;
|
||||
testId?: string;
|
||||
title: React.ReactNode;
|
||||
titleAttr?: string;
|
||||
trailing?: React.ReactNode;
|
||||
}) {
|
||||
const peopleContent =
|
||||
peopleSlot ??
|
||||
(people && people.length > 0 ? (
|
||||
<ProjectEntityFacepile
|
||||
interactive={Boolean(trailing)}
|
||||
participants={people}
|
||||
profiles={profiles}
|
||||
/>
|
||||
) : null);
|
||||
|
||||
const interactiveSlotClass = trailing ? "pointer-events-auto" : undefined;
|
||||
const body = (
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
"flex h-4 w-4 shrink-0 items-center justify-center",
|
||||
interactiveSlotClass,
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 truncate text-sm font-medium text-foreground",
|
||||
description ? "flex-1 lg:w-44 lg:flex-none lg:shrink-0" : "flex-1",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
{description ? (
|
||||
<span
|
||||
className="hidden min-w-0 flex-1 truncate text-xs text-muted-foreground lg:block"
|
||||
data-testid={descriptionTestId}
|
||||
title={description}
|
||||
>
|
||||
{description}
|
||||
</span>
|
||||
) : null}
|
||||
{affiliation ? (
|
||||
<span
|
||||
className="hidden w-36 shrink-0 truncate text-right text-xs text-muted-foreground md:block"
|
||||
data-testid={affiliationTestId}
|
||||
title={
|
||||
affiliationTitle ??
|
||||
(typeof affiliation === "string" ? affiliation : undefined)
|
||||
}
|
||||
>
|
||||
{affiliation}
|
||||
</span>
|
||||
) : null}
|
||||
<span
|
||||
className={cn("flex w-24 shrink-0 justify-end", interactiveSlotClass)}
|
||||
data-testid={peopleTestId}
|
||||
>
|
||||
{peopleContent}
|
||||
</span>
|
||||
{count != null ? (
|
||||
<span
|
||||
className="flex w-12 shrink-0 items-center justify-end gap-1 text-xs text-muted-foreground"
|
||||
data-testid={countTestId}
|
||||
title={countTitle}
|
||||
>
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
{count}
|
||||
{countSuffix}
|
||||
</span>
|
||||
) : null}
|
||||
{dateSeconds ? (
|
||||
<span
|
||||
className="hidden w-24 shrink-0 whitespace-nowrap text-right text-xs text-muted-foreground/70 sm:block"
|
||||
data-testid={dateTestId}
|
||||
title={new Date(dateSeconds * 1_000).toLocaleString()}
|
||||
>
|
||||
{relativeTime(dateSeconds)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="hidden w-24 shrink-0 sm:block" />
|
||||
)}
|
||||
{trailing ? (
|
||||
<span className="pointer-events-auto relative z-10 shrink-0">
|
||||
{trailing}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
if (trailing) {
|
||||
return (
|
||||
<div className="group relative" data-testid={testId}>
|
||||
{onClick ? (
|
||||
<button
|
||||
className="absolute inset-0"
|
||||
onClick={onClick}
|
||||
title={titleAttr}
|
||||
type="button"
|
||||
>
|
||||
<span className="sr-only">{titleAttr}</span>
|
||||
</button>
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
PROJECT_ENTITY_LIST_ROW_CLASS,
|
||||
"pointer-events-none relative z-10 group-hover:bg-muted/30",
|
||||
)}
|
||||
>
|
||||
{body}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<button
|
||||
className={PROJECT_ENTITY_LIST_ROW_CLASS}
|
||||
data-testid={testId}
|
||||
onClick={onClick}
|
||||
title={titleAttr}
|
||||
type="button"
|
||||
>
|
||||
{body}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={PROJECT_ENTITY_LIST_ROW_CLASS} data-testid={testId}>
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -411,7 +411,7 @@ export function ProjectRepositoryActionsPanel({
|
||||
<RepositoryPeople people={people} profiles={profiles} />
|
||||
<dl className="text-sm [&>div]:h-7 [&_dd]:ml-auto [&_dd]:tabular-nums [&_dt]:gap-3 [&_dt]:text-foreground [&_dt_svg]:h-4 [&_dt_svg]:w-4 [&_dt_svg]:shrink-0 [&_dt_svg]:text-muted-foreground">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<dt className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<dt className="flex items-center gap-3 text-muted-foreground">
|
||||
<GitCommitHorizontal className="h-3.5 w-3.5" />
|
||||
Latest
|
||||
</dt>
|
||||
@@ -420,7 +420,7 @@ export function ProjectRepositoryActionsPanel({
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<dt className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<dt className="flex items-center gap-3 text-muted-foreground">
|
||||
<FileCode2 className="h-3.5 w-3.5" />
|
||||
Files
|
||||
</dt>
|
||||
@@ -432,7 +432,7 @@ export function ProjectRepositoryActionsPanel({
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<dt className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<dt className="flex items-center gap-3 text-muted-foreground">
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
Contributors
|
||||
</dt>
|
||||
@@ -463,7 +463,7 @@ export function ProjectRepositoryActionsPanel({
|
||||
{activeTab !== "prs" ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<dt className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<dt className="flex items-center gap-3 text-muted-foreground">
|
||||
<CircleDot className="h-3.5 w-3.5" />
|
||||
{activeTab === "issues" ? "Tasks" : "Active tasks"}
|
||||
</dt>
|
||||
@@ -476,7 +476,7 @@ export function ProjectRepositoryActionsPanel({
|
||||
{activeTab === "issues" ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<dt className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<dt className="flex items-center gap-3 text-muted-foreground">
|
||||
<CircleDot className="h-3.5 w-3.5" />
|
||||
Active
|
||||
</dt>
|
||||
@@ -485,7 +485,7 @@ export function ProjectRepositoryActionsPanel({
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<dt className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<dt className="flex items-center gap-3 text-muted-foreground">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
Completed
|
||||
</dt>
|
||||
@@ -500,7 +500,7 @@ export function ProjectRepositoryActionsPanel({
|
||||
{activeTab !== "issues" ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<dt className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<dt className="flex items-center gap-3 text-muted-foreground">
|
||||
<GitPullRequest className="h-3.5 w-3.5" />
|
||||
{activeTab === "prs" ? "Reviews" : "Open reviews"}
|
||||
</dt>
|
||||
@@ -513,7 +513,7 @@ export function ProjectRepositoryActionsPanel({
|
||||
{activeTab === "prs" ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<dt className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<dt className="flex items-center gap-3 text-muted-foreground">
|
||||
<GitPullRequest className="h-3.5 w-3.5" />
|
||||
Open
|
||||
</dt>
|
||||
@@ -522,7 +522,7 @@ export function ProjectRepositoryActionsPanel({
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<dt className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<dt className="flex items-center gap-3 text-muted-foreground">
|
||||
<GitMerge className="h-3.5 w-3.5" />
|
||||
Merged
|
||||
</dt>
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from "@/features/terminal/terminalPanelStore";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import terminalIcon from "@/shared/ui/assets/terminal.svg";
|
||||
import { TerminalPanelIcon } from "@/shared/ui/TerminalPanelIcon";
|
||||
|
||||
export type ProjectRightPanelMode = "chat" | "repository";
|
||||
|
||||
@@ -95,16 +95,10 @@ export function ProjectRightPanelControls({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="h-4 w-[1.1rem] bg-current [mask-position:center] [mask-repeat:no-repeat]"
|
||||
<TerminalPanelIcon
|
||||
className="w-[1.1rem]"
|
||||
data-testid="project-terminal-icon"
|
||||
style={{
|
||||
maskImage: `url("${terminalIcon}")`,
|
||||
maskSize: "calc(100% - 2px) calc(100% - 2px)",
|
||||
WebkitMaskImage: `url("${terminalIcon}")`,
|
||||
WebkitMaskSize: "calc(100% - 2px) calc(100% - 2px)",
|
||||
}}
|
||||
open={terminalOpen}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { type LucideIcon, Plus } from "lucide-react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
|
||||
export function ProjectSectionHeader({
|
||||
action,
|
||||
className,
|
||||
icon: Icon,
|
||||
testId = "project-section-header",
|
||||
title,
|
||||
}: {
|
||||
action?: {
|
||||
@@ -13,13 +16,15 @@ export function ProjectSectionHeader({
|
||||
onClick: () => void;
|
||||
title?: string;
|
||||
};
|
||||
className?: string;
|
||||
icon: LucideIcon;
|
||||
testId?: string;
|
||||
title: string;
|
||||
}) {
|
||||
return (
|
||||
<header
|
||||
className="flex min-h-12 items-center gap-3 px-4 py-2"
|
||||
data-testid="project-section-header"
|
||||
className={cn("flex min-h-12 items-center gap-3 px-4 py-2", className)}
|
||||
data-testid={testId}
|
||||
>
|
||||
<Icon
|
||||
className="h-4 w-4 shrink-0 text-muted-foreground"
|
||||
|
||||
@@ -3,10 +3,10 @@ import { Glasses } from "lucide-react";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { TabsList, TabsTrigger } from "@/shared/ui/tabs";
|
||||
|
||||
const PROJECT_TAB_TRIGGER_CLASS =
|
||||
export const PROJECT_TAB_TRIGGER_CLASS =
|
||||
"h-7 shrink-0 rounded-full bg-muted/30 px-3 text-xs font-medium leading-5 tracking-tight text-muted-foreground shadow-none transition-colors hover:bg-muted/55 hover:text-foreground data-[state=active]:bg-muted data-[state=active]:text-foreground data-[state=active]:shadow-none";
|
||||
|
||||
const PROJECT_TAB_SELECTED_CLASS = "bg-muted text-foreground";
|
||||
export const PROJECT_TAB_SELECTED_CLASS = "bg-muted text-foreground";
|
||||
const PROJECT_OVERVIEW_TAB_CLASS =
|
||||
"h-7 w-7 shrink-0 rounded-full bg-muted/30 p-1.5 text-muted-foreground shadow-none transition-colors hover:bg-muted/55 hover:text-foreground data-[state=active]:bg-muted data-[state=active]:text-foreground data-[state=active]:shadow-none";
|
||||
|
||||
|
||||
@@ -62,6 +62,12 @@ type ProjectActivityItem = {
|
||||
target: ActivityTarget;
|
||||
};
|
||||
|
||||
type ProjectActivityGroup = {
|
||||
key: string;
|
||||
label: string;
|
||||
items: ProjectActivityItem[];
|
||||
};
|
||||
|
||||
type ProjectsActivityFeedProps = {
|
||||
compact?: boolean;
|
||||
isLoading: boolean;
|
||||
@@ -85,6 +91,7 @@ type ProjectsActivityFeedProps = {
|
||||
};
|
||||
|
||||
const ACTIVITY_LIMIT = 30;
|
||||
const WEEK_SECONDS = 7 * 24 * 60 * 60;
|
||||
|
||||
function contentPreview(content: string) {
|
||||
return markdownToPlainText(content).replace(/\s+/g, " ").trim().slice(0, 280);
|
||||
@@ -251,6 +258,36 @@ function buildActivityItems({
|
||||
.slice(0, ACTIVITY_LIMIT);
|
||||
}
|
||||
|
||||
function startOfWeek(timestamp: number) {
|
||||
const date = new Date(timestamp * 1_000);
|
||||
date.setHours(0, 0, 0, 0);
|
||||
const daysSinceMonday = (date.getDay() + 6) % 7;
|
||||
date.setDate(date.getDate() - daysSinceMonday);
|
||||
return Math.floor(date.getTime() / 1_000);
|
||||
}
|
||||
|
||||
function groupActivityItems(items: ProjectActivityItem[]) {
|
||||
const thisWeek = startOfWeek(Math.floor(Date.now() / 1_000));
|
||||
const lastWeek = thisWeek - WEEK_SECONDS;
|
||||
const groups: ProjectActivityGroup[] = [
|
||||
{ key: "this-week", label: "This week", items: [] },
|
||||
{ key: "last-week", label: "Last week", items: [] },
|
||||
{ key: "earlier", label: "Earlier", items: [] },
|
||||
];
|
||||
|
||||
for (const item of items) {
|
||||
if (item.createdAt >= thisWeek) {
|
||||
groups[0].items.push(item);
|
||||
} else if (item.createdAt >= lastWeek) {
|
||||
groups[1].items.push(item);
|
||||
} else {
|
||||
groups[2].items.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return groups.filter((group) => group.items.length > 0);
|
||||
}
|
||||
|
||||
function ActivityCard({
|
||||
compact,
|
||||
isFirst,
|
||||
@@ -430,6 +467,7 @@ function ActivityCard({
|
||||
/** Mixed GitHub-style workspace activity shown beneath the overview callouts. */
|
||||
export function ProjectsActivityFeed(props: ProjectsActivityFeedProps) {
|
||||
const items = buildActivityItems(props);
|
||||
const groups = groupActivityItems(items);
|
||||
|
||||
if (props.isLoading && items.length === 0) {
|
||||
return <BuzzLoadingState label="Loading project activity" />;
|
||||
@@ -450,45 +488,59 @@ export function ProjectsActivityFeed(props: ProjectsActivityFeedProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative bg-transparent"
|
||||
className="relative space-y-7 bg-transparent"
|
||||
data-testid="projects-activity-timeline"
|
||||
>
|
||||
{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") {
|
||||
props.onOpenProject(item.target.project);
|
||||
} else if (item.target.type === "commit") {
|
||||
props.onOpenCommit(
|
||||
item.target.project,
|
||||
item.target.commitHash,
|
||||
);
|
||||
} else if (item.target.type === "pull-request") {
|
||||
props.onOpenPullRequest(
|
||||
item.target.project,
|
||||
item.target.repository,
|
||||
item.target.pullRequest,
|
||||
);
|
||||
} else {
|
||||
props.onOpenIssue(
|
||||
item.target.project,
|
||||
item.target.repository,
|
||||
item.target.issue,
|
||||
);
|
||||
}
|
||||
}}
|
||||
onOpenProject={() => props.onOpenProject(item.target.project)}
|
||||
profiles={props.profiles}
|
||||
/>
|
||||
{groups.map((group) => (
|
||||
<section data-testid="projects-activity-group" key={group.key}>
|
||||
<div className="mb-1 flex items-center gap-3">
|
||||
<h3 className="shrink-0 text-xs font-medium text-muted-foreground">
|
||||
{group.label}
|
||||
</h3>
|
||||
<span aria-hidden="true" className="h-px flex-1 bg-border/70" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div>
|
||||
{group.items.map((item, index) => {
|
||||
return (
|
||||
<div className="relative" key={item.id}>
|
||||
<ActivityCard
|
||||
compact={props.compact === true}
|
||||
isFirst={index === 0}
|
||||
isLast={index === group.items.length - 1}
|
||||
item={item}
|
||||
onOpen={() => {
|
||||
if (item.target.type === "project") {
|
||||
props.onOpenProject(item.target.project);
|
||||
} else if (item.target.type === "commit") {
|
||||
props.onOpenCommit(
|
||||
item.target.project,
|
||||
item.target.commitHash,
|
||||
);
|
||||
} else if (item.target.type === "pull-request") {
|
||||
props.onOpenPullRequest(
|
||||
item.target.project,
|
||||
item.target.repository,
|
||||
item.target.pullRequest,
|
||||
);
|
||||
} else {
|
||||
props.onOpenIssue(
|
||||
item.target.project,
|
||||
item.target.repository,
|
||||
item.target.issue,
|
||||
);
|
||||
}
|
||||
}}
|
||||
onOpenProject={() =>
|
||||
props.onOpenProject(item.target.project)
|
||||
}
|
||||
profiles={props.profiles}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
135
desktop/src/features/projects/ui/ProjectsChannelsList.tsx
Normal file
135
desktop/src/features/projects/ui/ProjectsChannelsList.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { Hash } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
|
||||
import { useChannelsQuery } from "@/features/channels/hooks";
|
||||
import { useUsersBatchQuery } from "@/features/profile/hooks";
|
||||
import type { Project } from "@/features/projects/hooks";
|
||||
import {
|
||||
collectProjectRelatedChannelRows,
|
||||
projectRelatedChannelRowKey,
|
||||
} from "@/features/projects/lib/projectRelatedChannels";
|
||||
import { listRowDescription } from "@/features/projects/lib/projectsViewHelpers";
|
||||
import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState";
|
||||
import { ProjectEntityListRow } from "./ProjectEntityListRow";
|
||||
|
||||
function lastMessageAtSeconds(value: string | null | undefined) {
|
||||
if (!value) return null;
|
||||
const ms = Date.parse(value);
|
||||
return Number.isFinite(ms) ? Math.floor(ms / 1_000) : null;
|
||||
}
|
||||
|
||||
function affiliationLabel(projectName: string, repositoryName: string | null) {
|
||||
if (!repositoryName || repositoryName === projectName) return projectName;
|
||||
return `${projectName} · ${repositoryName}`;
|
||||
}
|
||||
|
||||
export function ProjectsChannelsList({ projects }: { projects: Project[] }) {
|
||||
const { goChannel } = useAppNavigation();
|
||||
const channelsQuery = useChannelsQuery({ enabled: projects.length > 0 });
|
||||
const channelsById = React.useMemo(() => {
|
||||
return new Map(
|
||||
(channelsQuery.data ?? []).map((channel) => [channel.id, channel]),
|
||||
);
|
||||
}, [channelsQuery.data]);
|
||||
const rows = React.useMemo(() => {
|
||||
const collected = collectProjectRelatedChannelRows(projects);
|
||||
return [...collected].sort((left, right) => {
|
||||
const leftChannel = channelsById.get(left.channelId);
|
||||
const rightChannel = channelsById.get(right.channelId);
|
||||
const leftName = leftChannel?.name ?? left.channelId;
|
||||
const rightName = rightChannel?.name ?? right.channelId;
|
||||
const leftActivity =
|
||||
lastMessageAtSeconds(leftChannel?.lastMessageAt) ?? 0;
|
||||
const rightActivity =
|
||||
lastMessageAtSeconds(rightChannel?.lastMessageAt) ?? 0;
|
||||
return (
|
||||
rightActivity - leftActivity ||
|
||||
leftName.localeCompare(rightName) ||
|
||||
left.projectName.localeCompare(right.projectName) ||
|
||||
(left.repositoryName ?? "").localeCompare(right.repositoryName ?? "") ||
|
||||
left.channelId.localeCompare(right.channelId)
|
||||
);
|
||||
});
|
||||
}, [channelsById, projects]);
|
||||
const participantPubkeys = React.useMemo(
|
||||
() => [
|
||||
...new Set(
|
||||
rows.flatMap((row) => {
|
||||
const channel = channelsById.get(row.channelId);
|
||||
return channel?.participantPubkeys ?? channel?.participants ?? [];
|
||||
}),
|
||||
),
|
||||
],
|
||||
[channelsById, rows],
|
||||
);
|
||||
const profilesQuery = useUsersBatchQuery(participantPubkeys, {
|
||||
enabled: participantPubkeys.length > 0,
|
||||
});
|
||||
const profiles = profilesQuery.data?.profiles;
|
||||
|
||||
if (channelsQuery.isLoading && rows.length === 0) {
|
||||
return <BuzzLoadingState label="Loading project channels" />;
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
<p className="px-4 py-6 text-sm text-muted-foreground">
|
||||
No channels are bound to these projects yet. Link a discussion channel
|
||||
to a project or repository and it will show up here.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul
|
||||
className="divide-y divide-border/60"
|
||||
data-testid="projects-channels-list"
|
||||
>
|
||||
{rows.map((row) => {
|
||||
const channel = channelsById.get(row.channelId);
|
||||
const name = channel?.name ?? row.channelId.slice(0, 8);
|
||||
const lastActivityAt = lastMessageAtSeconds(channel?.lastMessageAt);
|
||||
const affiliation = affiliationLabel(
|
||||
row.projectName,
|
||||
row.repositoryName,
|
||||
);
|
||||
const people =
|
||||
channel?.participantPubkeys ?? channel?.participants ?? [];
|
||||
return (
|
||||
<li key={projectRelatedChannelRowKey(row)}>
|
||||
<ProjectEntityListRow
|
||||
affiliation={
|
||||
<span data-testid="project-channel-project">
|
||||
<span data-testid="project-channel-repository">
|
||||
{affiliation}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
affiliationTitle={affiliation}
|
||||
count={channel?.memberCount}
|
||||
countTestId="project-channel-message-count"
|
||||
countTitle={
|
||||
channel
|
||||
? `${channel.memberCount} ${
|
||||
channel.memberCount === 1 ? "member" : "members"
|
||||
}`
|
||||
: undefined
|
||||
}
|
||||
dateSeconds={lastActivityAt}
|
||||
dateTestId="project-channel-row-date"
|
||||
description={listRowDescription(channel?.description, name)}
|
||||
icon={<Hash className="h-3.5 w-3.5 text-muted-foreground/70" />}
|
||||
onClick={() => void goChannel(row.channelId)}
|
||||
people={people}
|
||||
peopleTestId="project-channel-participants"
|
||||
profiles={profiles}
|
||||
testId="project-channel-row"
|
||||
title={`#${name}`}
|
||||
titleAttr={`Open #${name}`}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -11,10 +11,12 @@ const MENU_ITEM_CLASS =
|
||||
"flex min-h-9 w-full items-center gap-2 rounded-lg py-2 pl-2 pr-4 text-left text-sm outline-hidden transition-colors hover:bg-muted/50 focus:bg-muted/50 focus:text-foreground focus-visible:ring-1 focus-visible:ring-ring [&_svg]:size-4 [&_svg]:shrink-0";
|
||||
|
||||
export function ProjectsCreateMenu({
|
||||
compact = false,
|
||||
onCreateIssue,
|
||||
onCreateProject,
|
||||
onCreatePullRequest,
|
||||
}: {
|
||||
compact?: boolean;
|
||||
onCreateIssue: () => void;
|
||||
onCreateProject: () => void;
|
||||
onCreatePullRequest: () => void;
|
||||
@@ -70,12 +72,16 @@ export function ProjectsCreateMenu({
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
aria-label="Create"
|
||||
className="h-8 w-8 rounded-full"
|
||||
className={
|
||||
compact
|
||||
? "h-6 w-6 shrink-0 rounded-md text-muted-foreground hover:bg-muted/70 hover:text-foreground"
|
||||
: "h-8 w-8 rounded-full"
|
||||
}
|
||||
data-testid="projects-create-menu"
|
||||
onClick={() => setOpen(true)}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="default"
|
||||
variant={compact ? "ghost" : "default"}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -7,7 +7,10 @@ import type {
|
||||
Repository,
|
||||
} from "@/features/projects/hooks";
|
||||
import { issueShareLink } from "@/features/projects/lib/projectShareLinks";
|
||||
import { relativeTime } from "@/features/projects/lib/projectsViewHelpers";
|
||||
import {
|
||||
listRowDescription,
|
||||
relativeTime,
|
||||
} from "@/features/projects/lib/projectsViewHelpers";
|
||||
import {
|
||||
projectTaskCategoryLabel,
|
||||
projectTaskUserLabels,
|
||||
@@ -23,19 +26,15 @@ import { BuzzLoadingState } from "@/shared/ui/BuzzLoadingState";
|
||||
import { Card } from "@/shared/ui/card";
|
||||
import { DropdownMenuItem } from "@/shared/ui/dropdown-menu";
|
||||
import { CopyShareLinkMenuItem } from "./CopyShareLinkMenuItem";
|
||||
import { IssueAssigneeFacepile } from "./IssueAssigneesRow";
|
||||
import { ProjectAuthorIdentity } from "./ProjectAuthorIdentity";
|
||||
import { ProjectEntityListRow } from "./ProjectEntityListRow";
|
||||
import { ProjectEventTypeIcon } from "./ProjectEventTypeIcon";
|
||||
import { ProjectListRowMenu } from "./ProjectListRowMenu";
|
||||
import { ProjectsWorkItemsLoadNotice } from "./ProjectsWorkItemsLoadNotice";
|
||||
import {
|
||||
PROJECT_LIST_ROW_SUBTEXT_CLASS,
|
||||
PROJECT_LIST_ROW_TITLE_CLASS,
|
||||
} from "./projectListRowStyles";
|
||||
import {
|
||||
ProjectsWorkItemTableHeader,
|
||||
WORK_ITEM_TABLE_GRID_CLASS,
|
||||
} from "./ProjectsWorkItemTable";
|
||||
import { ProjectsWorkItemsLoadNotice } from "./ProjectsWorkItemsLoadNotice";
|
||||
|
||||
type ProjectsIssuesListProps = {
|
||||
/** Render without container chrome — a parent table container provides border and rounding. */
|
||||
@@ -207,101 +206,42 @@ function IssueListRow({
|
||||
repository: Repository;
|
||||
}) {
|
||||
const authorLabel = resolveUserLabel({ profiles, pubkey: issue.author });
|
||||
const labelsSummary = issueLabelsSummary(issue);
|
||||
|
||||
return (
|
||||
<tr
|
||||
className={cn(
|
||||
WORK_ITEM_TABLE_GRID_CLASS,
|
||||
"group relative cursor-pointer px-3 py-2.5 transition-colors duration-150 hover:bg-muted/20 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring",
|
||||
)}
|
||||
aria-label={`Open task ${issue.title}`}
|
||||
data-testid={`projects-issue-row-${issue.id}`}
|
||||
onClick={(event) => {
|
||||
if ((event.target as Element).closest("button, a, [role='menuitem']")) {
|
||||
return;
|
||||
}
|
||||
onOpen(project, issue);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.target === event.currentTarget &&
|
||||
(event.key === "Enter" || event.key === " ")
|
||||
) {
|
||||
event.preventDefault();
|
||||
onOpen(project, issue);
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
>
|
||||
<td className="min-w-0">
|
||||
<div className="flex min-w-0 items-start gap-2 text-left">
|
||||
<ProjectEventTypeIcon className="h-5 w-5" kind="issue" />
|
||||
<div className="-mt-0.5 min-w-0 flex-1">
|
||||
<p className={PROJECT_LIST_ROW_TITLE_CLASS}>{issue.title}</p>
|
||||
<div
|
||||
className={`flex min-w-0 items-center gap-x-1 overflow-hidden whitespace-nowrap ${PROJECT_LIST_ROW_SUBTEXT_CLASS}`}
|
||||
>
|
||||
<ProjectAuthorIdentity
|
||||
label={authorLabel}
|
||||
profiles={profiles}
|
||||
pubkey={issue.author}
|
||||
testId="projects-issue-author"
|
||||
/>
|
||||
<span>opened this in</span>
|
||||
<span className="truncate">{repository.name}</span>
|
||||
{labelsSummary ? (
|
||||
<>
|
||||
<span>and tagged it</span>
|
||||
<span className="truncate">{labelsSummary}</span>
|
||||
</>
|
||||
) : null}
|
||||
<span className="-ml-1">.</span>
|
||||
</div>
|
||||
</div>
|
||||
<IssueAssigneeFacepile
|
||||
assignees={issue.assignees}
|
||||
profiles={profiles}
|
||||
<ProjectEntityListRow
|
||||
affiliation={repository.name}
|
||||
count={issue.comments.length}
|
||||
dateSeconds={issue.updatedAt}
|
||||
dateTestId="projects-row-date"
|
||||
description={listRowDescription(issue.content, issue.title)}
|
||||
icon={<ProjectEventTypeIcon className="h-4 w-4" kind="issue" />}
|
||||
onClick={() => onOpen(project, issue)}
|
||||
peopleSlot={
|
||||
<ProjectAuthorIdentity
|
||||
label={authorLabel}
|
||||
labelClassName="sr-only"
|
||||
profiles={profiles}
|
||||
pubkey={issue.author}
|
||||
testId="projects-issue-author"
|
||||
/>
|
||||
}
|
||||
testId={`projects-issue-row-${issue.id}`}
|
||||
title={issue.title}
|
||||
titleAttr={`Open task ${issue.title}`}
|
||||
trailing={
|
||||
<ProjectListRowMenu label={`More options for ${issue.title}`}>
|
||||
<DropdownMenuItem onSelect={() => onOpen(project, issue)}>
|
||||
<Eye className="h-4 w-4" />
|
||||
{nextStepLabel(issue.status)}
|
||||
</DropdownMenuItem>
|
||||
<CopyShareLinkMenuItem
|
||||
link={issueShareLink(issue)}
|
||||
label="Copy task link"
|
||||
testId={`projects-issue-copy-link-${issue.id}`}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
<td className="min-w-0">
|
||||
<span className="inline-flex max-w-full truncate rounded-full border border-border/60 px-1.5 py-0.5 text-2xs font-medium text-muted-foreground">
|
||||
{projectTaskCategoryLabel(issue.category)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="truncate text-2xs text-muted-foreground">
|
||||
{issue.status}
|
||||
</td>
|
||||
<td className="text-2xs text-muted-foreground">
|
||||
<span className="flex items-center justify-end gap-1">
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
{issue.comments.length}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
className="truncate text-right text-xs text-muted-foreground/70"
|
||||
data-testid="projects-row-date"
|
||||
title={new Date(issue.updatedAt * 1_000).toLocaleString()}
|
||||
>
|
||||
{relativeTime(issue.updatedAt)}
|
||||
</td>
|
||||
<td className="relative z-10">
|
||||
<div className="flex justify-end">
|
||||
<ProjectListRowMenu label={`More options for ${issue.title}`}>
|
||||
<DropdownMenuItem onSelect={() => onOpen(project, issue)}>
|
||||
<Eye className="h-4 w-4" />
|
||||
{nextStepLabel(issue.status)}
|
||||
</DropdownMenuItem>
|
||||
<CopyShareLinkMenuItem
|
||||
link={issueShareLink(issue)}
|
||||
label="Copy task link"
|
||||
testId={`projects-issue-copy-link-${issue.id}`}
|
||||
/>
|
||||
</ProjectListRowMenu>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</ProjectListRowMenu>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -377,19 +317,14 @@ export function ProjectsIssuesList({
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{loadNotice}
|
||||
<table
|
||||
className={cn(
|
||||
"block w-full overflow-x-auto bg-transparent",
|
||||
!embedded && "rounded-xl border border-border/60",
|
||||
)}
|
||||
<ul
|
||||
className="divide-y divide-border/60 bg-transparent"
|
||||
data-testid="projects-list-container"
|
||||
>
|
||||
<ProjectsWorkItemTableHeader itemLabel="Task" typeLabel="Type" />
|
||||
<tbody className="block divide-y divide-border/60">
|
||||
{issues.map(({ project, issue, repository }) => (
|
||||
{issues.map(({ project, issue, repository }) => (
|
||||
<li key={`${repository.id}:${issue.id}`}>
|
||||
<IssueListRow
|
||||
issue={issue}
|
||||
key={`${repository.id}:${issue.id}`}
|
||||
onOpen={(selectedProject, selectedIssue) =>
|
||||
onOpen(selectedProject, repository, selectedIssue)
|
||||
}
|
||||
@@ -397,9 +332,9 @@ export function ProjectsIssuesList({
|
||||
project={project}
|
||||
repository={repository}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type * as React from "react";
|
||||
|
||||
import { Sheet, SheetContent, SheetTitle } from "@/shared/ui/sheet";
|
||||
|
||||
/**
|
||||
* Narrow-layout fallback for the Projects context rail: below the detached
|
||||
* breakpoint the rail cannot dock beside the content, so the same
|
||||
* section-following context panel opens as a dismissible right-hand sheet.
|
||||
* Radix supplies the focus trap, Escape handling, and close button.
|
||||
*/
|
||||
export function ProjectsOverviewContextSheet({
|
||||
children,
|
||||
onCloseAutoFocus,
|
||||
onOpenChange,
|
||||
open,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onCloseAutoFocus?: (event: Event) => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
open: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Sheet onOpenChange={onOpenChange} open={open}>
|
||||
<SheetContent
|
||||
aria-describedby={undefined}
|
||||
className="w-80 overflow-y-auto p-0 pt-10 sm:max-w-none"
|
||||
data-testid="projects-overview-context-sheet"
|
||||
onCloseAutoFocus={onCloseAutoFocus}
|
||||
side="right"
|
||||
>
|
||||
<SheetTitle className="sr-only">Project context</SheetTitle>
|
||||
{children}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
180
desktop/src/features/projects/ui/ProjectsOverviewItems.tsx
Normal file
180
desktop/src/features/projects/ui/ProjectsOverviewItems.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import type {
|
||||
Project,
|
||||
ProjectActivitySummary,
|
||||
Repository,
|
||||
} from "@/features/projects/hooks";
|
||||
import {
|
||||
hasLocalCheckout,
|
||||
hasLocalRepositoryCheckout,
|
||||
} from "@/features/projects/lib/projectLocalRepos";
|
||||
import type { ProjectRepoUnavailableReason } from "@/features/projects/lib/projectRepoAvailability";
|
||||
import {
|
||||
isProjectOwnedByCurrentUser,
|
||||
projectPeople,
|
||||
type ProjectsFilter,
|
||||
type ProjectsViewMode,
|
||||
} from "@/features/projects/lib/projectsViewHelpers";
|
||||
import {
|
||||
EmptyFilteredState,
|
||||
ProjectGridCard,
|
||||
ProjectListRow,
|
||||
} from "@/features/projects/ui/ProjectCards";
|
||||
import {
|
||||
RepositoryGridCard,
|
||||
RepositoryListRow,
|
||||
} from "@/features/projects/ui/RepositoryCards";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
export function ProjectsOverviewProjectItems({
|
||||
currentPubkey,
|
||||
deleteDisabled,
|
||||
filter,
|
||||
localRepoNames,
|
||||
onDelete,
|
||||
onOpen,
|
||||
onOpenTerminal,
|
||||
profiles,
|
||||
repositoryUnavailableReasonFor,
|
||||
summaries,
|
||||
viewMode,
|
||||
visibleProjects,
|
||||
}: {
|
||||
currentPubkey: string | undefined;
|
||||
deleteDisabled: boolean;
|
||||
filter: ProjectsFilter;
|
||||
localRepoNames: Set<string>;
|
||||
onDelete: (project: Project) => void;
|
||||
onOpen: (project: Project) => void;
|
||||
onOpenTerminal: (project: Project) => void;
|
||||
profiles?: UserProfileLookup;
|
||||
repositoryUnavailableReasonFor: (
|
||||
project: Project,
|
||||
) => ProjectRepoUnavailableReason | undefined;
|
||||
summaries?: Record<string, ProjectActivitySummary>;
|
||||
viewMode: ProjectsViewMode;
|
||||
visibleProjects: Project[];
|
||||
}) {
|
||||
if (visibleProjects.length === 0) {
|
||||
return <EmptyFilteredState />;
|
||||
}
|
||||
if (viewMode === "grid") {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-3 md:grid-cols-2",
|
||||
filter !== "all" && "xl:grid-cols-3",
|
||||
)}
|
||||
>
|
||||
{visibleProjects.map((project) => {
|
||||
const summary = summaries?.[project.id];
|
||||
return (
|
||||
<ProjectGridCard
|
||||
canDelete={isProjectOwnedByCurrentUser(project, currentPubkey)}
|
||||
deleteDisabled={deleteDisabled}
|
||||
hasLocal={hasLocalCheckout(project, localRepoNames)}
|
||||
key={project.id}
|
||||
onDelete={onDelete}
|
||||
onOpen={onOpen}
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
people={projectPeople(project, summary)}
|
||||
profiles={profiles}
|
||||
project={project}
|
||||
repositoryUnavailableReason={repositoryUnavailableReasonFor(
|
||||
project,
|
||||
)}
|
||||
summary={summary}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="divide-y divide-border/60"
|
||||
data-testid="projects-list-container"
|
||||
>
|
||||
{visibleProjects.map((project) => {
|
||||
const summary = summaries?.[project.id];
|
||||
return (
|
||||
<ProjectListRow
|
||||
canDelete={isProjectOwnedByCurrentUser(project, currentPubkey)}
|
||||
deleteDisabled={deleteDisabled}
|
||||
hasLocal={hasLocalCheckout(project, localRepoNames)}
|
||||
key={project.id}
|
||||
onDelete={onDelete}
|
||||
onOpen={onOpen}
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
people={projectPeople(project, summary)}
|
||||
profiles={profiles}
|
||||
project={project}
|
||||
repositoryUnavailableReason={repositoryUnavailableReasonFor(
|
||||
project,
|
||||
)}
|
||||
summary={summary}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectsOverviewRepositoryItems({
|
||||
localRepoNames,
|
||||
onOpen,
|
||||
onOpenTerminal,
|
||||
profiles,
|
||||
summaries,
|
||||
viewMode,
|
||||
visibleRepositories,
|
||||
}: {
|
||||
localRepoNames: Set<string>;
|
||||
onOpen: (project: Project, repository: Repository) => void;
|
||||
onOpenTerminal: (repository: Repository) => void;
|
||||
profiles?: UserProfileLookup;
|
||||
summaries?: Record<string, ProjectActivitySummary>;
|
||||
viewMode: ProjectsViewMode;
|
||||
visibleRepositories: Array<{ project: Project; repository: Repository }>;
|
||||
}) {
|
||||
if (visibleRepositories.length === 0) {
|
||||
return <EmptyFilteredState />;
|
||||
}
|
||||
if (viewMode === "grid") {
|
||||
return (
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{visibleRepositories.map(({ project, repository }) => (
|
||||
<RepositoryGridCard
|
||||
hasLocal={hasLocalRepositoryCheckout(repository, localRepoNames)}
|
||||
key={repository.repoAddress}
|
||||
onOpen={onOpen}
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
profiles={profiles}
|
||||
project={project}
|
||||
repository={repository}
|
||||
summary={summaries?.[repository.repoAddress]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="divide-y divide-border/60"
|
||||
data-testid="projects-list-container"
|
||||
>
|
||||
{visibleRepositories.map(({ project, repository }) => (
|
||||
<RepositoryListRow
|
||||
hasLocal={hasLocalRepositoryCheckout(repository, localRepoNames)}
|
||||
key={repository.repoAddress}
|
||||
onOpen={onOpen}
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
profiles={profiles}
|
||||
project={project}
|
||||
repository={repository}
|
||||
summary={summaries?.[repository.repoAddress]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +1,91 @@
|
||||
import { CircleDot, FolderGit2, Folders, GitPullRequest } from "lucide-react";
|
||||
import {
|
||||
CheckCircle2,
|
||||
CircleDot,
|
||||
FolderGit2,
|
||||
Folders,
|
||||
GitMerge,
|
||||
GitPullRequest,
|
||||
Hash,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import type * as React from "react";
|
||||
|
||||
import type {
|
||||
Project,
|
||||
ProjectActivitySummary,
|
||||
ProjectIssue,
|
||||
ProjectPullRequest,
|
||||
} from "@/features/projects/hooks";
|
||||
import type { ProjectsFilter } from "@/features/projects/lib/projectsViewHelpers";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { ProjectsCreateMenu } from "./ProjectsCreateMenu";
|
||||
import {
|
||||
type OverviewContextStatIcon,
|
||||
type ProjectsOverviewSection,
|
||||
projectsOverviewContext,
|
||||
} from "./projectsOverviewContext";
|
||||
import {
|
||||
ProjectsOverviewActivityGraph,
|
||||
ProjectsOverviewPeople,
|
||||
} from "./ProjectsOverviewRail";
|
||||
|
||||
export type ProjectsOverviewSection =
|
||||
| "projects"
|
||||
| "repositories"
|
||||
| "prs"
|
||||
| "issues";
|
||||
export type { ProjectsOverviewSection };
|
||||
|
||||
const STAT_ICONS: Record<
|
||||
OverviewContextStatIcon,
|
||||
React.ComponentType<{ className?: string }>
|
||||
> = {
|
||||
channels: Hash,
|
||||
completed: CheckCircle2,
|
||||
merged: GitMerge,
|
||||
projects: Folders,
|
||||
repositories: FolderGit2,
|
||||
reviews: GitPullRequest,
|
||||
tasks: CircleDot,
|
||||
};
|
||||
|
||||
type ProjectsOverviewPanelProps = {
|
||||
children: React.ReactNode;
|
||||
metadata: React.ReactNode;
|
||||
};
|
||||
|
||||
type ProjectsOverviewContextPanelProps = {
|
||||
filter: ProjectsFilter;
|
||||
issues: ProjectIssue[];
|
||||
onCreateIssue: () => void;
|
||||
onCreateProject: () => void;
|
||||
onCreatePullRequest: () => void;
|
||||
onSelectSection: (section: ProjectsOverviewSection) => void;
|
||||
profiles?: UserProfileLookup;
|
||||
projects: Project[];
|
||||
pullRequests: ProjectPullRequest[];
|
||||
summaries?: Record<string, ProjectActivitySummary>;
|
||||
};
|
||||
|
||||
function overviewStats(
|
||||
projects: Project[],
|
||||
summaries: Record<string, ProjectActivitySummary> | undefined,
|
||||
) {
|
||||
return projects.reduce(
|
||||
(stats, project) => {
|
||||
const summary = summaries?.[project.id];
|
||||
return {
|
||||
issues: stats.issues + (summary?.issueCount ?? 0),
|
||||
prs: stats.prs + (summary?.prCount ?? 0),
|
||||
};
|
||||
},
|
||||
{ issues: 0, prs: 0 },
|
||||
function OverviewActionButton({
|
||||
children,
|
||||
onClick,
|
||||
testId,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClick: () => void;
|
||||
testId?: string;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
className="-mx-2 h-7 w-[calc(100%+1rem)] justify-start gap-3 rounded-md px-2 text-left text-sm font-normal hover:bg-muted/70 [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0 [&_svg]:text-muted-foreground"
|
||||
data-testid={testId}
|
||||
onClick={onClick}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function StatPill({
|
||||
function OverviewStatRow({
|
||||
count,
|
||||
icon: Icon,
|
||||
label,
|
||||
@@ -49,79 +98,136 @@ function StatPill({
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className="flex flex-col rounded-lg border border-border/60 bg-transparent px-3.5 py-3 text-left transition-colors hover:bg-muted/30"
|
||||
className="-mx-2 flex h-7 w-[calc(100%+1rem)] items-center justify-between gap-3 rounded-md px-2 text-left text-sm hover:bg-muted/70 [&_svg]:h-4 [&_svg]:w-4 [&_svg]:shrink-0 [&_svg]:text-muted-foreground"
|
||||
data-testid="projects-overview-stat"
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex w-full items-center justify-between gap-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
<Icon className="h-3.5 w-3.5 text-muted-foreground/70" />
|
||||
</span>
|
||||
<span className="mt-auto pt-3 text-2xl font-semibold leading-none tracking-tight text-foreground">
|
||||
{count}
|
||||
<span className="flex min-w-0 items-center gap-3 text-muted-foreground">
|
||||
<Icon className="h-3.5 w-3.5 shrink-0" />
|
||||
{label}
|
||||
</span>
|
||||
<span className="font-medium tabular-nums text-foreground">{count}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectsOverviewPanel({
|
||||
children,
|
||||
metadata,
|
||||
onSelectSection,
|
||||
projects,
|
||||
summaries,
|
||||
}: 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="-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 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.issues}
|
||||
icon={CircleDot}
|
||||
label="Tasks"
|
||||
onClick={() => onSelectSection("issues")}
|
||||
/>
|
||||
<StatPill
|
||||
count={stats.prs}
|
||||
icon={GitPullRequest}
|
||||
label="Reviews"
|
||||
onClick={() => onSelectSection("prs")}
|
||||
/>
|
||||
</div>
|
||||
{metadata}
|
||||
</div>
|
||||
</div>
|
||||
<section data-testid="projects-overview-panel">
|
||||
<div className="min-w-0">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectsActivityIntro() {
|
||||
return (
|
||||
<section
|
||||
className="pb-8 pt-5 text-center"
|
||||
data-testid="projects-activity-intro"
|
||||
>
|
||||
<h2
|
||||
className="text-xl font-semibold tracking-tight text-foreground"
|
||||
data-testid="projects-page-header"
|
||||
>
|
||||
Welcome to Activity
|
||||
</h2>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm text-muted-foreground">
|
||||
Keep up with commits, reviews, and tasks across your projects.
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectsOverviewContextPanel({
|
||||
filter,
|
||||
issues,
|
||||
onCreateIssue,
|
||||
onCreateProject,
|
||||
onCreatePullRequest,
|
||||
onSelectSection,
|
||||
profiles,
|
||||
projects,
|
||||
pullRequests,
|
||||
summaries,
|
||||
}: ProjectsOverviewContextPanelProps) {
|
||||
const context = projectsOverviewContext({
|
||||
filter,
|
||||
issues,
|
||||
projects,
|
||||
pullRequests,
|
||||
summaries,
|
||||
});
|
||||
const actionHandler =
|
||||
context.action?.kind === "issue"
|
||||
? onCreateIssue
|
||||
: context.action?.kind === "pullRequest"
|
||||
? onCreatePullRequest
|
||||
: onCreateProject;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-w-0 overflow-hidden rounded-2xl bg-background"
|
||||
data-testid="projects-overview-context-panel"
|
||||
>
|
||||
<div className="px-4 pb-3 pt-3">
|
||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||
<h2
|
||||
className="min-w-0 truncate text-sm font-normal text-muted-foreground/70"
|
||||
data-testid="projects-overview-context-title"
|
||||
>
|
||||
{context.title}
|
||||
</h2>
|
||||
<ProjectsCreateMenu
|
||||
compact
|
||||
onCreateIssue={onCreateIssue}
|
||||
onCreateProject={onCreateProject}
|
||||
onCreatePullRequest={onCreatePullRequest}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-0.5 pt-2">
|
||||
{context.action ? (
|
||||
<OverviewActionButton
|
||||
onClick={actionHandler}
|
||||
testId={context.action.testId}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{context.action.label}
|
||||
</OverviewActionButton>
|
||||
) : null}
|
||||
<section
|
||||
className="text-sm"
|
||||
data-testid="projects-overview-stats-pod"
|
||||
>
|
||||
{context.stats.map((stat) => (
|
||||
<OverviewStatRow
|
||||
count={stat.count}
|
||||
icon={STAT_ICONS[stat.icon]}
|
||||
key={`${stat.section}:${stat.label}`}
|
||||
label={stat.label}
|
||||
onClick={() => onSelectSection(stat.section)}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
{context.people.length > 0 || context.activityByDay ? (
|
||||
<div className="mt-3 space-y-3 border-border/50 border-t py-3">
|
||||
<ProjectsOverviewPeople
|
||||
people={context.people}
|
||||
profiles={profiles}
|
||||
/>
|
||||
{context.activityByDay ? (
|
||||
<div data-testid="projects-overview-activity">
|
||||
<ProjectsOverviewActivityGraph
|
||||
activityByDay={context.activityByDay}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { visibleStackedAvatarCount } from "./ProjectsOverviewRail.tsx";
|
||||
|
||||
test("shows every stacked avatar when they fit in two rows", () => {
|
||||
assert.deepEqual(
|
||||
visibleStackedAvatarCount({
|
||||
avatarSize: 24,
|
||||
containerWidth: 288,
|
||||
overlap: 6,
|
||||
total: 30,
|
||||
}),
|
||||
{ columns: 15, remaining: 0, visible: 30 },
|
||||
);
|
||||
});
|
||||
|
||||
test("reserves the last stacked slot for a +N chip when avatars overflow two rows", () => {
|
||||
assert.deepEqual(
|
||||
visibleStackedAvatarCount({
|
||||
avatarSize: 24,
|
||||
containerWidth: 288,
|
||||
overlap: 6,
|
||||
total: 31,
|
||||
}),
|
||||
{ columns: 15, remaining: 2, visible: 29 },
|
||||
);
|
||||
});
|
||||
|
||||
test("waits for a measured width before clamping", () => {
|
||||
assert.deepEqual(
|
||||
visibleStackedAvatarCount({
|
||||
avatarSize: 24,
|
||||
containerWidth: 0,
|
||||
overlap: 6,
|
||||
total: 40,
|
||||
}),
|
||||
{ columns: 40, remaining: 0, visible: 40 },
|
||||
);
|
||||
});
|
||||
@@ -1,109 +1,190 @@
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
resolveUserLabel,
|
||||
type UserProfileLookup,
|
||||
} from "@/features/profile/lib/identity";
|
||||
import type {
|
||||
Project,
|
||||
ProjectActivitySummary,
|
||||
} from "@/features/projects/hooks";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import { OverviewRailSection } from "./ProjectOverviewPanel";
|
||||
import { ProjectsContributionGraph } from "./ProjectsContributionGraph";
|
||||
|
||||
type ProjectsOverviewRailProps = {
|
||||
profiles?: UserProfileLookup;
|
||||
projects: Project[];
|
||||
summaries?: Record<string, ProjectActivitySummary>;
|
||||
};
|
||||
/** Matches Tailwind `-space-x-1.5` so stacked rows stay zoom-safe. */
|
||||
const STACK_OVERLAP_REM = 0.375;
|
||||
|
||||
function overviewPeople(
|
||||
projects: Project[],
|
||||
summaries: Record<string, ProjectActivitySummary> | undefined,
|
||||
) {
|
||||
return [
|
||||
...new Set(
|
||||
projects.flatMap((project) =>
|
||||
[
|
||||
project.owner,
|
||||
...project.repositories.flatMap((repository) => [
|
||||
repository.owner,
|
||||
...repository.contributors,
|
||||
]),
|
||||
...(summaries?.[project.id]?.participantPubkeys ?? []),
|
||||
].map(normalizePubkey),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function overviewActivityByDay(
|
||||
projects: Project[],
|
||||
summaries: Record<string, ProjectActivitySummary> | undefined,
|
||||
) {
|
||||
const merged: Record<string, number> = {};
|
||||
for (const project of projects) {
|
||||
const byDay = summaries?.[project.id]?.activityByDay;
|
||||
if (!byDay) continue;
|
||||
for (const [day, count] of Object.entries(byDay)) {
|
||||
merged[day] = (merged[day] ?? 0) + count;
|
||||
}
|
||||
/** How many overlapping avatars fit in a wrapping stack before the +N chip. */
|
||||
export function visibleStackedAvatarCount({
|
||||
avatarSize,
|
||||
containerWidth,
|
||||
maxRows = 2,
|
||||
overlap,
|
||||
total,
|
||||
}: {
|
||||
avatarSize: number;
|
||||
containerWidth: number;
|
||||
maxRows?: number;
|
||||
overlap: number;
|
||||
total: number;
|
||||
}): { columns: number; remaining: number; visible: number } {
|
||||
if (total <= 0) return { columns: 1, remaining: 0, visible: 0 };
|
||||
if (containerWidth <= 0 || avatarSize <= 0) {
|
||||
return { columns: total, remaining: 0, visible: total };
|
||||
}
|
||||
return merged;
|
||||
const stride = Math.max(1, avatarSize - overlap);
|
||||
const columns = Math.max(
|
||||
1,
|
||||
1 + Math.floor(Math.max(0, containerWidth - avatarSize) / stride),
|
||||
);
|
||||
const slots = columns * maxRows;
|
||||
if (total <= slots) return { columns, remaining: 0, visible: total };
|
||||
const visible = Math.max(1, slots - 1);
|
||||
return { columns, remaining: total - visible, visible };
|
||||
}
|
||||
|
||||
/** Workspace people and contribution activity for the overview side rail. */
|
||||
export function ProjectsOverviewRail({
|
||||
function OverviewPerson({
|
||||
index,
|
||||
profiles,
|
||||
projects,
|
||||
summaries,
|
||||
}: ProjectsOverviewRailProps) {
|
||||
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.
|
||||
pubkey,
|
||||
stackSize,
|
||||
}: {
|
||||
index: number;
|
||||
profiles?: UserProfileLookup;
|
||||
pubkey: string;
|
||||
stackSize: number;
|
||||
}) {
|
||||
const profile = profiles?.[normalizePubkey(pubkey)];
|
||||
const label = resolveUserLabel({ profiles, pubkey });
|
||||
return (
|
||||
<>
|
||||
<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">
|
||||
{people.slice(0, 18).map((pubkey) => {
|
||||
const profile = profiles?.[normalizePubkey(pubkey)];
|
||||
const label = resolveUserLabel({ profiles, pubkey });
|
||||
return (
|
||||
<Tooltip key={pubkey}>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex">
|
||||
<UserAvatar
|
||||
accent={profile?.isAgent === true}
|
||||
avatarUrl={profile?.avatarUrl ?? null}
|
||||
displayName={label}
|
||||
size="sm"
|
||||
/>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No people yet.</p>
|
||||
)}
|
||||
</OverviewRailSection>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 rounded-lg border border-border/60 p-4">
|
||||
<OverviewRailSection
|
||||
title="Contribution Activity"
|
||||
titleClassName="text-base"
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className="relative inline-flex rounded-full ring-2 ring-background"
|
||||
data-overview-person=""
|
||||
style={{ zIndex: stackSize - index }}
|
||||
>
|
||||
<ProjectsContributionGraph activityByDay={activityByDay} compact />
|
||||
</OverviewRailSection>
|
||||
</div>
|
||||
</>
|
||||
<UserAvatar
|
||||
accent={profile?.isAgent === true}
|
||||
avatarUrl={profile?.avatarUrl ?? null}
|
||||
displayName={label}
|
||||
size="sm"
|
||||
/>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectsOverviewPeople({
|
||||
className,
|
||||
people,
|
||||
profiles,
|
||||
}: {
|
||||
className?: string;
|
||||
people: string[];
|
||||
profiles?: UserProfileLookup;
|
||||
}) {
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const [layout, setLayout] = React.useState({
|
||||
columns: Math.min(people.length, 48) || 1,
|
||||
visible: Math.min(people.length, 48),
|
||||
});
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container || people.length === 0) return;
|
||||
|
||||
function measure() {
|
||||
const node = containerRef.current;
|
||||
if (!node) return;
|
||||
const probe = node.querySelector(
|
||||
"[data-overview-person]",
|
||||
) as HTMLElement | null;
|
||||
const avatarSize = probe?.offsetWidth ?? 24;
|
||||
const rootSize = Number.parseFloat(
|
||||
getComputedStyle(document.documentElement).fontSize,
|
||||
);
|
||||
const next = visibleStackedAvatarCount({
|
||||
avatarSize,
|
||||
containerWidth: node.clientWidth,
|
||||
overlap: STACK_OVERLAP_REM * rootSize,
|
||||
total: people.length,
|
||||
});
|
||||
setLayout((current) =>
|
||||
current.columns === next.columns && current.visible === next.visible
|
||||
? current
|
||||
: { columns: next.columns, visible: next.visible },
|
||||
);
|
||||
}
|
||||
|
||||
measure();
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
}, [people.length]);
|
||||
|
||||
if (people.length === 0) return null;
|
||||
|
||||
const visible = people.slice(0, layout.visible);
|
||||
const remaining = people.length - visible.length;
|
||||
const remainingLabels = people
|
||||
.slice(visible.length)
|
||||
.map((pubkey) => resolveUserLabel({ profiles, pubkey }));
|
||||
const rows: string[][] = [];
|
||||
for (let index = 0; index < visible.length; index += layout.columns) {
|
||||
rows.push(visible.slice(index, index + layout.columns));
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col gap-1", className)}
|
||||
data-testid="projects-overview-people"
|
||||
ref={containerRef}
|
||||
>
|
||||
{rows.map((row, rowIndex) => {
|
||||
const isLastRow = rowIndex === rows.length - 1;
|
||||
const showOverflow = isLastRow && remaining > 0;
|
||||
const stackSize = row.length + (showOverflow ? 1 : 0);
|
||||
return (
|
||||
<div className="flex -space-x-1.5" key={row[0] ?? rowIndex}>
|
||||
{row.map((pubkey, index) => (
|
||||
<OverviewPerson
|
||||
index={index}
|
||||
key={pubkey}
|
||||
profiles={profiles}
|
||||
pubkey={pubkey}
|
||||
stackSize={stackSize}
|
||||
/>
|
||||
))}
|
||||
{showOverflow ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className="relative z-0 inline-flex h-6 min-w-6 items-center justify-center rounded-full bg-muted px-1 text-3xs font-semibold tabular-nums text-muted-foreground ring-2 ring-background"
|
||||
data-testid="projects-overview-people-overflow"
|
||||
>
|
||||
+{remaining}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{remainingLabels.length === 1
|
||||
? remainingLabels[0]
|
||||
: `${remaining} more`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectsOverviewActivityGraph({
|
||||
activityByDay,
|
||||
}: {
|
||||
activityByDay: Record<string, number>;
|
||||
}) {
|
||||
return <ProjectsContributionGraph activityByDay={activityByDay} compact />;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,10 @@ import type {
|
||||
Repository,
|
||||
} from "@/features/projects/hooks";
|
||||
import { pullRequestShareLink } from "@/features/projects/lib/projectShareLinks";
|
||||
import { relativeTime } from "@/features/projects/lib/projectsViewHelpers";
|
||||
import {
|
||||
listRowDescription,
|
||||
relativeTime,
|
||||
} from "@/features/projects/lib/projectsViewHelpers";
|
||||
import type { ProjectWorkItemSection } from "@/features/projects/projectWorkItems";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import {
|
||||
@@ -20,17 +23,10 @@ import { Card } from "@/shared/ui/card";
|
||||
import { DropdownMenuItem } from "@/shared/ui/dropdown-menu";
|
||||
import { CopyShareLinkMenuItem } from "./CopyShareLinkMenuItem";
|
||||
import { ProjectAuthorIdentity } from "./ProjectAuthorIdentity";
|
||||
import { ProjectEntityListRow } from "./ProjectEntityListRow";
|
||||
import { ProjectEventTypeIcon } from "./ProjectEventTypeIcon";
|
||||
import { ProjectListRowMenu } from "./ProjectListRowMenu";
|
||||
import { ProjectsWorkItemsLoadNotice } from "./ProjectsWorkItemsLoadNotice";
|
||||
import {
|
||||
PROJECT_LIST_ROW_SUBTEXT_CLASS,
|
||||
PROJECT_LIST_ROW_TITLE_CLASS,
|
||||
} from "./projectListRowStyles";
|
||||
import {
|
||||
ProjectsWorkItemTableHeader,
|
||||
WORK_ITEM_TABLE_GRID_CLASS,
|
||||
} from "./ProjectsWorkItemTable";
|
||||
|
||||
type ProjectsPullRequestsListProps = {
|
||||
/** Render without container chrome — a parent table container provides border and rounding. */
|
||||
@@ -224,84 +220,40 @@ function PullRequestListRow({
|
||||
});
|
||||
|
||||
return (
|
||||
<tr
|
||||
className={cn(
|
||||
WORK_ITEM_TABLE_GRID_CLASS,
|
||||
"group relative cursor-pointer px-3 py-2.5 transition-colors duration-150 hover:bg-muted/20 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring",
|
||||
)}
|
||||
aria-label={`Open review ${pullRequest.title}`}
|
||||
data-testid={`projects-pr-row-${pullRequest.id}`}
|
||||
onClick={(event) => {
|
||||
if ((event.target as Element).closest("button, a, [role='menuitem']")) {
|
||||
return;
|
||||
}
|
||||
onOpen(project, pullRequest);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.target === event.currentTarget &&
|
||||
(event.key === "Enter" || event.key === " ")
|
||||
) {
|
||||
event.preventDefault();
|
||||
onOpen(project, pullRequest);
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
>
|
||||
<td className="min-w-0">
|
||||
<div className="flex min-w-0 items-start gap-2 text-left">
|
||||
<ProjectEventTypeIcon className="h-5 w-5" kind="pull-request" />
|
||||
<div className="-mt-0.5 min-w-0 flex-1">
|
||||
<p className={PROJECT_LIST_ROW_TITLE_CLASS}>{pullRequest.title}</p>
|
||||
<PullRequestContext
|
||||
authorLabel={authorLabel}
|
||||
authorTestId="projects-pr-author"
|
||||
className={PROJECT_LIST_ROW_SUBTEXT_CLASS}
|
||||
profiles={profiles}
|
||||
pullRequest={pullRequest}
|
||||
repository={repository}
|
||||
showMobileStatus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="min-w-0">
|
||||
<span className="inline-flex max-w-full truncate rounded-full border border-border/60 px-1.5 py-0.5 text-2xs font-medium text-muted-foreground">
|
||||
Review
|
||||
</span>
|
||||
</td>
|
||||
<td className="truncate text-2xs text-muted-foreground">
|
||||
{pullRequest.status}
|
||||
</td>
|
||||
<td className="text-2xs text-muted-foreground">
|
||||
<span className="flex items-center justify-end gap-1">
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
{pullRequest.comments.length}
|
||||
</span>
|
||||
</td>
|
||||
<td
|
||||
className="truncate text-right text-xs text-muted-foreground/70"
|
||||
data-testid="projects-row-date"
|
||||
title={new Date(pullRequest.updatedAt * 1_000).toLocaleString()}
|
||||
>
|
||||
{relativeTime(pullRequest.updatedAt)}
|
||||
</td>
|
||||
<td className="relative z-10">
|
||||
<div className="flex justify-end">
|
||||
<ProjectListRowMenu label={`More options for ${pullRequest.title}`}>
|
||||
<DropdownMenuItem onSelect={() => onOpen(project, pullRequest)}>
|
||||
<GitPullRequest className="h-4 w-4" />
|
||||
{nextStepLabel(pullRequest.status)}
|
||||
</DropdownMenuItem>
|
||||
<CopyShareLinkMenuItem
|
||||
link={pullRequestShareLink(pullRequest)}
|
||||
label="Copy review link"
|
||||
testId={`projects-pull-request-copy-link-${pullRequest.id}`}
|
||||
/>
|
||||
</ProjectListRowMenu>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<ProjectEntityListRow
|
||||
affiliation={repository.name}
|
||||
count={pullRequest.comments.length}
|
||||
dateSeconds={pullRequest.updatedAt}
|
||||
dateTestId="projects-row-date"
|
||||
description={listRowDescription(pullRequest.content, pullRequest.title)}
|
||||
icon={<ProjectEventTypeIcon className="h-4 w-4" kind="pull-request" />}
|
||||
onClick={() => onOpen(project, pullRequest)}
|
||||
peopleSlot={
|
||||
<ProjectAuthorIdentity
|
||||
label={authorLabel}
|
||||
labelClassName="sr-only"
|
||||
profiles={profiles}
|
||||
pubkey={pullRequest.author}
|
||||
testId="projects-pr-author"
|
||||
/>
|
||||
}
|
||||
testId={`projects-pr-row-${pullRequest.id}`}
|
||||
title={pullRequest.title}
|
||||
titleAttr={`Open review ${pullRequest.title}`}
|
||||
trailing={
|
||||
<ProjectListRowMenu label={`More options for ${pullRequest.title}`}>
|
||||
<DropdownMenuItem onSelect={() => onOpen(project, pullRequest)}>
|
||||
<GitPullRequest className="h-4 w-4" />
|
||||
{nextStepLabel(pullRequest.status)}
|
||||
</DropdownMenuItem>
|
||||
<CopyShareLinkMenuItem
|
||||
link={pullRequestShareLink(pullRequest)}
|
||||
label="Copy review link"
|
||||
testId={`projects-pull-request-copy-link-${pullRequest.id}`}
|
||||
/>
|
||||
</ProjectListRowMenu>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -376,18 +328,13 @@ export function ProjectsPullRequestsList({
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{loadNotice}
|
||||
<table
|
||||
className={cn(
|
||||
"block w-full overflow-x-auto bg-transparent",
|
||||
!embedded && "rounded-xl border border-border/60",
|
||||
)}
|
||||
<ul
|
||||
className="divide-y divide-border/60 bg-transparent"
|
||||
data-testid="projects-list-container"
|
||||
>
|
||||
<ProjectsWorkItemTableHeader itemLabel="Review" typeLabel="Type" />
|
||||
<tbody className="block divide-y divide-border/60">
|
||||
{pullRequests.map(({ project, pullRequest, repository }) => (
|
||||
{pullRequests.map(({ project, pullRequest, repository }) => (
|
||||
<li key={`${repository.id}:${pullRequest.id}`}>
|
||||
<PullRequestListRow
|
||||
key={`${repository.id}:${pullRequest.id}`}
|
||||
onOpen={(selectedProject, selectedPullRequest) =>
|
||||
onOpen(selectedProject, repository, selectedPullRequest)
|
||||
}
|
||||
@@ -396,9 +343,9 @@ export function ProjectsPullRequestsList({
|
||||
pullRequest={pullRequest}
|
||||
repository={repository}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,12 +5,13 @@ import type {
|
||||
ProjectsFilter,
|
||||
ProjectsViewMode,
|
||||
} from "@/features/projects/lib/projectsViewHelpers";
|
||||
import {
|
||||
PROJECT_TAB_SELECTED_CLASS,
|
||||
PROJECT_TAB_TRIGGER_CLASS,
|
||||
} from "@/features/projects/ui/ProjectWorkspaceTabList";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
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
|
||||
@@ -122,19 +123,20 @@ export function ProjectsToolbar({
|
||||
{ label: "Activity", value: "all" },
|
||||
{ label: "Projects", value: "projects" },
|
||||
{ label: "Repositories", value: "repositories" },
|
||||
{ label: "Channels", value: "channels" },
|
||||
{ label: "Tasks", value: "issues" },
|
||||
{ label: "Reviews", value: "prs" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-auto flex h-full min-w-0 items-center"
|
||||
className="pointer-events-auto flex min-w-0 items-center"
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<div className="flex h-full min-w-0 flex-1 items-center gap-0.5 overflow-hidden">
|
||||
<div className="flex min-w-0 flex-1 items-center overflow-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",
|
||||
"flex min-w-0 flex-1 flex-nowrap items-center gap-1.5 overflow-x-auto scrollbar-none [&::-webkit-scrollbar]:hidden",
|
||||
overflow.left && overflow.right
|
||||
? MASK_BOTH
|
||||
: overflow.left
|
||||
@@ -151,9 +153,8 @@ export function ProjectsToolbar({
|
||||
aria-label={option.label}
|
||||
aria-pressed={filter === option.value}
|
||||
className={cn(
|
||||
"relative h-full shrink-0 gap-1.5 rounded-none px-2.5 text-base leading-5 tracking-tight text-muted-foreground after:absolute after:inset-x-2.5 after:bottom-0 after:h-0.5 after:bg-current after:opacity-0 after:transition-opacity after:content-[''] hover:bg-transparent hover:text-foreground hover:after:opacity-100",
|
||||
option.value === "all" && "pl-0 after:left-0",
|
||||
filter === option.value && SELECTED_MENU_ITEM_CLASSES,
|
||||
PROJECT_TAB_TRIGGER_CLASS,
|
||||
filter === option.value && PROJECT_TAB_SELECTED_CLASS,
|
||||
)}
|
||||
data-testid={`projects-section-${option.value}`}
|
||||
key={option.value}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Search } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -27,36 +28,43 @@ import {
|
||||
projectRepoHostForRepository,
|
||||
} from "@/features/projects/lib/projectRepoHost";
|
||||
import { ProjectsActivityFeed } from "@/features/projects/ui/ProjectsActivityFeed";
|
||||
import { ProjectsChannelsList } from "@/features/projects/ui/ProjectsChannelsList";
|
||||
import { ProjectsOverviewContextSheet } from "@/features/projects/ui/ProjectsOverviewContextSheet";
|
||||
import {
|
||||
EmptyFilteredState,
|
||||
EmptyState,
|
||||
ProjectGridCard,
|
||||
ProjectListRow,
|
||||
} from "@/features/projects/ui/ProjectCards";
|
||||
ProjectsActivityIntro,
|
||||
ProjectsOverviewContextPanel,
|
||||
ProjectsOverviewPanel,
|
||||
} from "@/features/projects/ui/ProjectsOverviewPanel";
|
||||
import {
|
||||
openAppSearch,
|
||||
projectsSectionIcon,
|
||||
projectsSectionTitle,
|
||||
} from "@/features/projects/ui/projectsSectionMeta";
|
||||
import { EmptyState } from "@/features/projects/ui/ProjectCards";
|
||||
import {
|
||||
ProjectsOverviewProjectItems,
|
||||
ProjectsOverviewRepositoryItems,
|
||||
} from "@/features/projects/ui/ProjectsOverviewItems";
|
||||
import { CreateProjectDialog } from "@/features/projects/ui/CreateProjectDialog";
|
||||
import { CreateProjectIssueDialog } from "@/features/projects/ui/CreateProjectIssueDialog";
|
||||
import { CreatePullRequestDialog } from "@/features/projects/ui/CreatePullRequestDialog";
|
||||
import { ProjectsCreateMenu } from "@/features/projects/ui/ProjectsCreateMenu";
|
||||
import { ProjectsIssuesList } from "@/features/projects/ui/ProjectsIssuesList";
|
||||
import { ProjectsOverviewPanel } from "@/features/projects/ui/ProjectsOverviewPanel";
|
||||
import { ProjectsOverviewRail } from "@/features/projects/ui/ProjectsOverviewRail";
|
||||
import { ProjectsWorkspaceChrome } from "@/features/projects/ui/ProjectDetailChrome";
|
||||
import { ProjectsPullRequestsList } from "@/features/projects/ui/ProjectsPullRequestsList";
|
||||
import { ProjectsWorkItemsLoadNotice } from "@/features/projects/ui/ProjectsWorkItemsLoadNotice";
|
||||
import { ProjectsListHeaderBar } from "@/features/projects/ui/ProjectsListHeaderBar";
|
||||
import { ProjectSectionHeader } from "@/features/projects/ui/ProjectSectionHeader";
|
||||
import { PROJECT_COLUMN_HEADER_BACKDROP_CLASS } from "@/features/projects/ui/projectPanelStyles";
|
||||
import { ProjectsToolbar } from "@/features/projects/ui/ProjectsToolbar";
|
||||
import {
|
||||
hasLocalCheckout,
|
||||
hasLocalRepositoryCheckout,
|
||||
} from "@/features/projects/lib/projectLocalRepos";
|
||||
import {
|
||||
RepositoryGridCard,
|
||||
RepositoryListRow,
|
||||
} from "@/features/projects/ui/RepositoryCards";
|
||||
import {
|
||||
getProjectUpdatedAt,
|
||||
isProjectAccessibleToViewer,
|
||||
isProjectMine,
|
||||
isProjectOwnedByCurrentUser,
|
||||
isRepositoryAccessibleToViewer,
|
||||
projectHasAgent,
|
||||
projectOwnerIsUser,
|
||||
@@ -80,6 +88,8 @@ import {
|
||||
writeStoredViewMode,
|
||||
} from "@/features/projects/lib/projectsViewHelpers";
|
||||
import { useOpenProjectTerminal } from "@/features/projects/ui/useOpenProjectTerminal";
|
||||
import { PROJECT_CONTEXT_PANEL_DEFAULT_WIDTH_PX } from "@/features/projects/ui/useProjectPanelWidths";
|
||||
import { useMediaBreakpoint } from "@/shared/hooks/use-mobile";
|
||||
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
|
||||
import { useCommunities } from "@/features/communities/useCommunities";
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
@@ -88,9 +98,10 @@ import { cn } from "@/shared/lib/cn";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { useRelayOrigin } from "@/shared/lib/useRelayOrigin";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { PageHeader } from "@/shared/ui/PageHeader";
|
||||
import { DrawerPanelIcon } from "@/shared/ui/DrawerPanelIcon";
|
||||
|
||||
const MANY_PROJECTS_THRESHOLD = 12;
|
||||
const PROJECTS_CONTEXT_POD_MIN_VIEWPORT_PX = 1024;
|
||||
|
||||
export function ProjectsView() {
|
||||
const { goProject } = useAppNavigation();
|
||||
@@ -147,11 +158,15 @@ export function ProjectsView() {
|
||||
? "repositories"
|
||||
: storedFilter;
|
||||
});
|
||||
const activitySummariesQuery = useProjectActivitySummariesQuery(
|
||||
filter === "prs" || filter === "issues" || filter === "repositories"
|
||||
? []
|
||||
: projects,
|
||||
const [overviewPanelOpen, setOverviewPanelOpen] = React.useState(true);
|
||||
// Narrow layouts present the same context as a dismissible sheet instead of
|
||||
// the docked rail; the sheet starts closed so resizing never pops a modal.
|
||||
const [narrowContextOpen, setNarrowContextOpen] = React.useState(false);
|
||||
const contextToggleRef = React.useRef<HTMLButtonElement | null>(null);
|
||||
const isNarrowProjectsLayout = useMediaBreakpoint(
|
||||
PROJECTS_CONTEXT_POD_MIN_VIEWPORT_PX,
|
||||
);
|
||||
const activitySummariesQuery = useProjectActivitySummariesQuery(projects);
|
||||
const repositoryActivitySummariesQuery = useRepositoryActivitySummariesQuery(
|
||||
filter === "repositories" ? projects : [],
|
||||
);
|
||||
@@ -168,9 +183,7 @@ export function ProjectsView() {
|
||||
const [issueScope, setIssueScope] = React.useState<ProjectsWorkItemScope>(
|
||||
() => readStoredIssueScope(),
|
||||
);
|
||||
const projectsWorkItemsQuery = useProjectsWorkItemsQuery(
|
||||
filter === "all" || filter === "prs" || filter === "issues" ? projects : [],
|
||||
);
|
||||
const projectsWorkItemsQuery = useProjectsWorkItemsQuery(projects);
|
||||
// One blobless clone per primary Buzz repository, only while the overview
|
||||
// header is visible.
|
||||
const snapshotProjects = React.useMemo(
|
||||
@@ -601,110 +614,39 @@ export function ProjectsView() {
|
||||
return <EmptyState />;
|
||||
}
|
||||
|
||||
const projectItems =
|
||||
visibleProjects.length === 0 ? (
|
||||
<EmptyFilteredState />
|
||||
) : viewMode === "grid" ? (
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-3 md:grid-cols-2",
|
||||
filter !== "all" && "xl:grid-cols-3",
|
||||
)}
|
||||
>
|
||||
{visibleProjects.map((project) => {
|
||||
const summary = activitySummariesQuery.data?.[project.id];
|
||||
return (
|
||||
<ProjectGridCard
|
||||
canDelete={isProjectOwnedByCurrentUser(project, currentPubkey)}
|
||||
deleteDisabled={deleteProjectMutation.isPending}
|
||||
hasLocal={hasLocalCheckout(project, localRepoNames)}
|
||||
key={project.id}
|
||||
onDelete={handleDeleteProject}
|
||||
onOpen={handleOpenProject}
|
||||
onOpenTerminal={handleOpenTerminal}
|
||||
people={projectPeople(project, summary)}
|
||||
profiles={profiles}
|
||||
project={project}
|
||||
repositoryUnavailableReason={repositoryUnavailableReasonFor(
|
||||
project,
|
||||
)}
|
||||
summary={summary}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="divide-y divide-border/60"
|
||||
data-testid="projects-list-container"
|
||||
>
|
||||
{visibleProjects.map((project) => {
|
||||
const summary = activitySummariesQuery.data?.[project.id];
|
||||
return (
|
||||
<ProjectListRow
|
||||
canDelete={isProjectOwnedByCurrentUser(project, currentPubkey)}
|
||||
deleteDisabled={deleteProjectMutation.isPending}
|
||||
hasLocal={hasLocalCheckout(project, localRepoNames)}
|
||||
key={project.id}
|
||||
onDelete={handleDeleteProject}
|
||||
onOpen={handleOpenProject}
|
||||
onOpenTerminal={handleOpenTerminal}
|
||||
people={projectPeople(project, summary)}
|
||||
profiles={profiles}
|
||||
project={project}
|
||||
repositoryUnavailableReason={repositoryUnavailableReasonFor(
|
||||
project,
|
||||
)}
|
||||
summary={summary}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
const projectItems = (
|
||||
<ProjectsOverviewProjectItems
|
||||
currentPubkey={currentPubkey}
|
||||
deleteDisabled={deleteProjectMutation.isPending}
|
||||
filter={filter}
|
||||
localRepoNames={localRepoNames}
|
||||
onDelete={handleDeleteProject}
|
||||
onOpen={handleOpenProject}
|
||||
onOpenTerminal={handleOpenTerminal}
|
||||
profiles={profiles}
|
||||
repositoryUnavailableReasonFor={repositoryUnavailableReasonFor}
|
||||
summaries={activitySummariesQuery.data}
|
||||
viewMode={viewMode}
|
||||
visibleProjects={visibleProjects}
|
||||
/>
|
||||
);
|
||||
|
||||
const repositoryItems =
|
||||
visibleRepositories.length === 0 ? (
|
||||
<EmptyFilteredState />
|
||||
) : viewMode === "grid" ? (
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{visibleRepositories.map(({ project, repository }) => (
|
||||
<RepositoryGridCard
|
||||
hasLocal={hasLocalRepositoryCheckout(repository, localRepoNames)}
|
||||
key={repository.repoAddress}
|
||||
onOpen={handleOpenRepository}
|
||||
onOpenTerminal={handleOpenRepositoryTerminal}
|
||||
profiles={profiles}
|
||||
project={project}
|
||||
repository={repository}
|
||||
summary={
|
||||
repositoryActivitySummariesQuery.data?.[repository.repoAddress]
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/60">
|
||||
{visibleRepositories.map(({ project, repository }) => (
|
||||
<RepositoryListRow
|
||||
hasLocal={hasLocalRepositoryCheckout(repository, localRepoNames)}
|
||||
key={repository.repoAddress}
|
||||
onOpen={handleOpenRepository}
|
||||
onOpenTerminal={handleOpenRepositoryTerminal}
|
||||
profiles={profiles}
|
||||
project={project}
|
||||
repository={repository}
|
||||
summary={
|
||||
repositoryActivitySummariesQuery.data?.[repository.repoAddress]
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
const repositoryItems = (
|
||||
<ProjectsOverviewRepositoryItems
|
||||
localRepoNames={localRepoNames}
|
||||
onOpen={handleOpenRepository}
|
||||
onOpenTerminal={handleOpenRepositoryTerminal}
|
||||
profiles={profiles}
|
||||
summaries={repositoryActivitySummariesQuery.data}
|
||||
viewMode={viewMode}
|
||||
visibleRepositories={visibleRepositories}
|
||||
/>
|
||||
);
|
||||
|
||||
const listHeaderBar = (
|
||||
<ProjectsListHeaderBar
|
||||
filter={filter}
|
||||
variant={viewMode === "list" ? "row" : "bar"}
|
||||
variant="bar"
|
||||
issueScope={issueScope}
|
||||
onIssueScopeChange={handleIssueScopeChange}
|
||||
onPullRequestScopeChange={handlePullRequestScopeChange}
|
||||
@@ -760,181 +702,281 @@ export function ProjectsView() {
|
||||
/>
|
||||
);
|
||||
|
||||
const projectsHeader = (
|
||||
<PageHeader
|
||||
className="pointer-events-auto mb-4"
|
||||
description="Set up and manage your projects."
|
||||
title="Projects"
|
||||
/>
|
||||
);
|
||||
const contextPanelProps = {
|
||||
filter,
|
||||
issues:
|
||||
projectsWorkItemsQuery.data?.issues.items.map(({ issue }) => issue) ?? [],
|
||||
onCreateIssue: () => setCreateIssueOpen(true),
|
||||
onCreateProject: () => setCreateProjectOpen(true),
|
||||
onCreatePullRequest: () => setCreatePullRequestOpen(true),
|
||||
profiles,
|
||||
projects,
|
||||
pullRequests:
|
||||
projectsWorkItemsQuery.data?.pullRequests.items.map(
|
||||
({ pullRequest }) => pullRequest,
|
||||
) ?? [],
|
||||
summaries: activitySummariesQuery.data,
|
||||
};
|
||||
|
||||
const projectsNavigation = (
|
||||
<div className="flex h-[3.25rem] min-w-0 items-center">
|
||||
<div className="h-full min-w-0 flex-1 overflow-hidden">
|
||||
<ProjectsToolbar filter={filter} onFilterChange={handleFilterChange} />
|
||||
</div>
|
||||
</div>
|
||||
const overviewDetached = overviewPanelOpen && !isNarrowProjectsLayout;
|
||||
const contextOpen = isNarrowProjectsLayout
|
||||
? narrowContextOpen
|
||||
: overviewPanelOpen;
|
||||
const chromeActions = (
|
||||
<>
|
||||
<Button
|
||||
aria-label={
|
||||
contextOpen ? "Hide project context" : "Show project context"
|
||||
}
|
||||
aria-pressed={contextOpen}
|
||||
className="h-7 w-7 text-sidebar-foreground/65 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
data-testid="projects-overview-context-toggle"
|
||||
onClick={() =>
|
||||
isNarrowProjectsLayout
|
||||
? setNarrowContextOpen((open) => !open)
|
||||
: setOverviewPanelOpen((open) => !open)
|
||||
}
|
||||
ref={contextToggleRef}
|
||||
size="icon"
|
||||
title={contextOpen ? "Hide project context" : "Show project context"}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<DrawerPanelIcon
|
||||
className="-scale-x-100"
|
||||
side={contextOpen ? "left" : "right"}
|
||||
/>
|
||||
</Button>
|
||||
{overviewDetached ? null : createMenu}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-tl-xl",
|
||||
topChromeInset.divider,
|
||||
"relative flex min-h-0 min-w-0 flex-1 flex-row overflow-hidden",
|
||||
overviewDetached && "gap-2 bg-sidebar pb-2 pr-2 pt-px",
|
||||
)}
|
||||
data-project-context-detached={overviewDetached ? "true" : undefined}
|
||||
data-testid="projects-overview-layout"
|
||||
>
|
||||
{/* Scroll indicator painted over the scrollbar gutter; only visible
|
||||
while scrolling (native thumb is transparent). */}
|
||||
<ProjectsWorkspaceChrome
|
||||
actions={chromeActions}
|
||||
onGoActivity={() => handleFilterChange("all")}
|
||||
section={projectsSectionTitle(filter)}
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
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) => {
|
||||
const result = await createProjectMutation.mutateAsync(input);
|
||||
if (result.compatibilityWarning) {
|
||||
toast.warning("Created as a standalone project", {
|
||||
description: result.compatibilityWarning,
|
||||
});
|
||||
} else {
|
||||
toast.success(`Project "${result.project.name}" created.`);
|
||||
}
|
||||
// Land on the complete project list after creation.
|
||||
handleRepositoryScopeChange("all");
|
||||
handleFilterChange("projects");
|
||||
}}
|
||||
onOpenChange={setCreateProjectOpen}
|
||||
open={createProjectOpen}
|
||||
/>
|
||||
{createPullRequestOpen ? (
|
||||
<CreatePullRequestDialog
|
||||
onCreated={async (
|
||||
createdProject,
|
||||
createdRepository,
|
||||
pullRequestId,
|
||||
) => {
|
||||
await goProject(createdProject.id, {
|
||||
className={cn(
|
||||
"relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden",
|
||||
overviewDetached
|
||||
? "ml-px rounded-2xl bg-background"
|
||||
: cn("rounded-tl-xl", topChromeInset.divider),
|
||||
)}
|
||||
data-testid={
|
||||
overviewDetached ? "projects-overview-content-pod" : undefined
|
||||
}
|
||||
>
|
||||
<CreateProjectDialog
|
||||
isCreating={createProjectMutation.isPending}
|
||||
onCreate={async (input) => {
|
||||
const result = await createProjectMutation.mutateAsync(input);
|
||||
if (result.compatibilityWarning) {
|
||||
toast.warning("Created as a standalone project", {
|
||||
description: result.compatibilityWarning,
|
||||
});
|
||||
} else {
|
||||
toast.success(`Project "${result.project.name}" created.`);
|
||||
}
|
||||
handleRepositoryScopeChange("all");
|
||||
handleFilterChange("projects");
|
||||
}}
|
||||
onOpenChange={setCreateProjectOpen}
|
||||
open={createProjectOpen}
|
||||
/>
|
||||
{createPullRequestOpen ? (
|
||||
<CreatePullRequestDialog
|
||||
onCreated={async (
|
||||
createdProject,
|
||||
createdRepository,
|
||||
pullRequestId,
|
||||
) => {
|
||||
await goProject(createdProject.id, {
|
||||
pullRequestId,
|
||||
repositoryId: createdRepository.id,
|
||||
});
|
||||
}}
|
||||
onOpenChange={setCreatePullRequestOpen}
|
||||
open
|
||||
projects={projects}
|
||||
reposDir={activeCommunity?.reposDir}
|
||||
/>
|
||||
) : null}
|
||||
<CreateProjectIssueDialog
|
||||
onCreated={async (createdProject, createdRepository, issueId) => {
|
||||
await goProject(createdProject.id, {
|
||||
issueId,
|
||||
repositoryId: createdRepository.id,
|
||||
});
|
||||
}}
|
||||
onOpenChange={setCreatePullRequestOpen}
|
||||
open
|
||||
onOpenChange={setCreateIssueOpen}
|
||||
open={createIssueOpen}
|
||||
projects={projects}
|
||||
reposDir={activeCommunity?.reposDir}
|
||||
/>
|
||||
) : null}
|
||||
<CreateProjectIssueDialog
|
||||
onCreated={async (createdProject, createdRepository, issueId) => {
|
||||
await goProject(createdProject.id, {
|
||||
issueId,
|
||||
repositoryId: createdRepository.id,
|
||||
});
|
||||
}}
|
||||
onOpenChange={setCreateIssueOpen}
|
||||
open={createIssueOpen}
|
||||
projects={projects}
|
||||
/>
|
||||
<div
|
||||
className="buzz-content-scrollbar min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-scroll"
|
||||
onScroll={handleContentScroll}
|
||||
>
|
||||
<div className="px-4 pb-7 pt-7 sm:px-6 sm:pb-8 sm:pt-8">
|
||||
<div className="mx-auto w-full max-w-6xl">{projectsHeader}</div>
|
||||
<div className="sticky top-0 z-30 -mx-4 bg-background/80 backdrop-blur-xl supports-backdrop-filter:bg-background/65 dark:bg-background/75 dark:supports-backdrop-filter:bg-background/60 sm:-mx-6">
|
||||
<div className="px-4 sm:px-6">
|
||||
<div className="mx-auto w-full max-w-6xl">
|
||||
{projectsNavigation}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto w-full max-w-6xl">
|
||||
<div className="w-full min-w-0 pb-4 pt-4">
|
||||
{filter === "all" ? (
|
||||
<ProjectsOverviewPanel
|
||||
metadata={
|
||||
<ProjectsOverviewRail
|
||||
profiles={profiles}
|
||||
projects={projects}
|
||||
summaries={activitySummariesQuery.data}
|
||||
/>
|
||||
}
|
||||
onSelectSection={(section) => {
|
||||
handleFilterChange(section);
|
||||
}}
|
||||
projects={projects}
|
||||
summaries={activitySummariesQuery.data}
|
||||
<div className="relative min-h-0 min-w-0 flex-1">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
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}
|
||||
/>
|
||||
<div
|
||||
className="buzz-content-scrollbar h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-scroll"
|
||||
onScroll={handleContentScroll}
|
||||
>
|
||||
<div className="px-4 pb-4">
|
||||
<div className="w-full space-y-3">
|
||||
<div
|
||||
className={cn(
|
||||
"sticky top-0 z-30 -mx-4 flex h-13 min-w-0 items-center gap-1.5 px-4",
|
||||
PROJECT_COLUMN_HEADER_BACKDROP_CLASS,
|
||||
overviewDetached && "rounded-t-2xl",
|
||||
)}
|
||||
data-testid="projects-page-tabs"
|
||||
>
|
||||
<section className="space-y-3">{activityFeed}</section>
|
||||
</ProjectsOverviewPanel>
|
||||
) : (
|
||||
<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" ? (
|
||||
<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 === "all" ? (
|
||||
<Button
|
||||
aria-label="Search everything"
|
||||
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
data-testid="projects-activity-search"
|
||||
onClick={openAppSearch}
|
||||
size="icon"
|
||||
title="Search everything"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
<ProjectsToolbar
|
||||
filter={filter}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
filter === "all" ? "mx-auto w-full max-w-xl" : "w-full"
|
||||
}
|
||||
>
|
||||
{filter === "all" ? (
|
||||
<ProjectsOverviewPanel>
|
||||
<ProjectsActivityIntro />
|
||||
<section className="space-y-3">{activityFeed}</section>
|
||||
</ProjectsOverviewPanel>
|
||||
) : (
|
||||
<>
|
||||
<ProjectSectionHeader
|
||||
className="-mx-4"
|
||||
icon={projectsSectionIcon(filter)}
|
||||
testId="projects-page-header"
|
||||
title={projectsSectionTitle(filter)}
|
||||
/>
|
||||
) : filter === "issues" ? (
|
||||
<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" ? (
|
||||
projectItems
|
||||
) : (
|
||||
repositoryItems
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<section>
|
||||
<div className="space-y-3">
|
||||
{filter === "channels" ? null : listHeaderBar}
|
||||
{filter === "prs" ? (
|
||||
<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" ? (
|
||||
<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 === "channels" ? (
|
||||
<ProjectsChannelsList projects={projects} />
|
||||
) : filter === "projects" ? (
|
||||
projectItems
|
||||
) : (
|
||||
repositoryItems
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{overviewDetached ? (
|
||||
<aside
|
||||
aria-label="Project context"
|
||||
className="relative z-30 flex h-full shrink-0 flex-col overflow-hidden bg-transparent"
|
||||
style={{ width: PROJECT_CONTEXT_PANEL_DEFAULT_WIDTH_PX }}
|
||||
>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<ProjectsOverviewContextPanel
|
||||
{...contextPanelProps}
|
||||
onSelectSection={(section) => {
|
||||
handleFilterChange(section);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
) : null}
|
||||
{isNarrowProjectsLayout ? (
|
||||
<ProjectsOverviewContextSheet
|
||||
onCloseAutoFocus={(event) => {
|
||||
// Return focus to the chrome toggle so the keyboard journey can
|
||||
// continue where it started.
|
||||
event.preventDefault();
|
||||
contextToggleRef.current?.focus();
|
||||
}}
|
||||
onOpenChange={setNarrowContextOpen}
|
||||
open={narrowContextOpen}
|
||||
>
|
||||
<ProjectsOverviewContextPanel
|
||||
{...contextPanelProps}
|
||||
onSelectSection={(section) => {
|
||||
handleFilterChange(section);
|
||||
setNarrowContextOpen(false);
|
||||
}}
|
||||
/>
|
||||
</ProjectsOverviewContextSheet>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FolderGit2, GitBranch, Globe, SquareTerminal } from "lucide-react";
|
||||
import { FolderGit2, Globe, SquareTerminal } from "lucide-react";
|
||||
|
||||
import type {
|
||||
Project,
|
||||
@@ -16,9 +16,9 @@ import {
|
||||
import { repositoryShareLink } from "@/features/projects/lib/projectShareLinks";
|
||||
import {
|
||||
formatExactTimestamp,
|
||||
listRowDescription,
|
||||
relativeTime,
|
||||
} from "@/features/projects/lib/projectsViewHelpers";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { useRelayOrigin } from "@/shared/lib/useRelayOrigin";
|
||||
import { BuzzMark } from "@/shared/ui/buzz-logo/BuzzMark";
|
||||
@@ -26,12 +26,10 @@ import { Card } from "@/shared/ui/card";
|
||||
import { DropdownMenuItem } from "@/shared/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import {
|
||||
PROJECT_LIST_ROW_CLASS,
|
||||
PROJECT_LIST_ROW_DATE_CLASS,
|
||||
PROJECT_LIST_ROW_PREVIEW_CLASS,
|
||||
PROJECT_LIST_ROW_TITLE_CLASS,
|
||||
PROJECT_LIST_ROW_TRAILING_CLASS,
|
||||
} from "./projectListRowStyles";
|
||||
import { ProjectEntityListRow } from "./ProjectEntityListRow";
|
||||
import {
|
||||
ProjectActivityBar,
|
||||
ProjectPeopleStack,
|
||||
@@ -55,7 +53,13 @@ type RepositoryItemProps = RepositoryListItem & {
|
||||
summary?: ProjectActivitySummary;
|
||||
};
|
||||
|
||||
function RepositoryHostIcon({ repository }: { repository: Repository }) {
|
||||
function RepositoryHostIcon({
|
||||
compact = false,
|
||||
repository,
|
||||
}: {
|
||||
compact?: boolean;
|
||||
repository: Repository;
|
||||
}) {
|
||||
const host = projectRepoHostForRepository(repository, useRelayOrigin());
|
||||
const label =
|
||||
host.kind === "buzz"
|
||||
@@ -63,25 +67,31 @@ function RepositoryHostIcon({ repository }: { repository: Repository }) {
|
||||
: host.kind === "external"
|
||||
? `Git data hosted on ${host.host}`
|
||||
: "Repository host";
|
||||
const mark =
|
||||
host.kind === "buzz" ? (
|
||||
<BuzzMark className={compact ? "h-3.5 w-4" : "h-4.5 w-5"} />
|
||||
) : host.kind === "external" && host.host === "github.com" ? (
|
||||
<GitHubMark className={compact ? "h-3.5 w-3.5" : "h-4.5 w-4.5"} />
|
||||
) : host.kind === "external" ? (
|
||||
<Globe className={compact ? "h-3.5 w-3.5" : "h-4.5 w-4.5"} />
|
||||
) : (
|
||||
<FolderGit2 className={compact ? "h-3.5 w-3.5" : "h-4.5 w-4.5"} />
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
aria-label={label}
|
||||
className="pointer-events-auto flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-border/60 bg-muted/40 text-muted-foreground"
|
||||
className={
|
||||
compact
|
||||
? "pointer-events-auto flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground/70"
|
||||
: "pointer-events-auto flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-border/60 bg-muted/40 text-muted-foreground"
|
||||
}
|
||||
data-testid="repository-host-icon"
|
||||
role="img"
|
||||
>
|
||||
{host.kind === "buzz" ? (
|
||||
<BuzzMark className="h-4.5 w-5" />
|
||||
) : host.kind === "external" && host.host === "github.com" ? (
|
||||
<GitHubMark className="h-4.5 w-4.5" />
|
||||
) : host.kind === "external" ? (
|
||||
<Globe className="h-4.5 w-4.5" />
|
||||
) : (
|
||||
<FolderGit2 className="h-4.5 w-4.5" />
|
||||
)}
|
||||
{mark}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
@@ -268,66 +278,30 @@ export function RepositoryListRow(props: RepositoryItemProps) {
|
||||
repository,
|
||||
summary,
|
||||
} = props;
|
||||
const updatedAt = summary?.updatedAt || repository.createdAt;
|
||||
return (
|
||||
<div
|
||||
className={cn(PROJECT_LIST_ROW_CLASS, "py-3")}
|
||||
data-testid={`repository-row-${repository.dtag}`}
|
||||
>
|
||||
<RepositoryOpenButton
|
||||
onOpen={onOpen}
|
||||
project={project}
|
||||
repository={repository}
|
||||
/>
|
||||
<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
|
||||
profiles={profiles}
|
||||
project={project}
|
||||
repository={repository}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={cn(PROJECT_LIST_ROW_TRAILING_CLASS, "pointer-events-auto")}
|
||||
>
|
||||
<div
|
||||
className="hidden w-24 shrink-0 items-center gap-1.5 text-xs text-muted-foreground md:flex"
|
||||
data-testid="repositories-row-branch"
|
||||
>
|
||||
<GitBranch className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">{repository.defaultBranch}</span>
|
||||
</div>
|
||||
<div
|
||||
className="hidden items-center gap-3 xl:flex"
|
||||
data-testid="repositories-row-summary"
|
||||
>
|
||||
<ProjectStatsRow fixedColumns summary={summary} />
|
||||
<div className="w-20 shrink-0">
|
||||
<ProjectActivityBar summary={summary} />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="hidden w-24 shrink-0 justify-end lg:flex"
|
||||
data-testid="repositories-row-people"
|
||||
>
|
||||
<ProjectPeopleStack
|
||||
profiles={profiles}
|
||||
pubkeys={repositoryPeople(repository, summary)}
|
||||
workOwnerPubkey={repository.owner}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={PROJECT_LIST_ROW_DATE_CLASS}
|
||||
data-testid="repositories-row-date"
|
||||
>
|
||||
<RepositoryUpdatedLabel repository={repository} summary={summary} />
|
||||
</div>
|
||||
<RepositoryActionsMenu
|
||||
hasLocal={hasLocal}
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
repository={repository}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ProjectEntityListRow
|
||||
affiliation={project.name}
|
||||
affiliationTestId="repositories-row-project"
|
||||
dateSeconds={updatedAt}
|
||||
dateTestId="repositories-row-date"
|
||||
description={listRowDescription(repository.description, repository.name)}
|
||||
descriptionTestId="repositories-row-description"
|
||||
icon={<RepositoryHostIcon compact repository={repository} />}
|
||||
onClick={() => onOpen(project, repository)}
|
||||
people={repositoryPeople(repository, summary)}
|
||||
peopleTestId="repositories-row-people"
|
||||
profiles={profiles}
|
||||
testId={`repository-row-${repository.dtag}`}
|
||||
title={repository.name}
|
||||
titleAttr={repository.name}
|
||||
trailing={
|
||||
<RepositoryActionsMenu
|
||||
hasLocal={hasLocal}
|
||||
onOpenTerminal={onOpenTerminal}
|
||||
repository={repository}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { projectsOverviewContext } from "./projectsOverviewContext.ts";
|
||||
|
||||
const CHANNEL_A = "11111111-1111-4111-8111-111111111111";
|
||||
const CHANNEL_B = "22222222-2222-4222-8222-222222222222";
|
||||
const OWNER = "a".repeat(64);
|
||||
const TASK_AUTHOR = "b".repeat(64);
|
||||
const REVIEW_AUTHOR = "c".repeat(64);
|
||||
const BYSTANDER = "d".repeat(64);
|
||||
|
||||
function makeProject(overrides = {}) {
|
||||
return {
|
||||
id: "project-buzz",
|
||||
name: "buzz",
|
||||
owner: OWNER,
|
||||
projectChannelId: null,
|
||||
repositories: [
|
||||
{
|
||||
id: "repo-buzz",
|
||||
name: "buzz",
|
||||
owner: OWNER,
|
||||
channelId: CHANNEL_A,
|
||||
contributors: [BYSTANDER],
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeIssue(status, author = TASK_AUTHOR) {
|
||||
return {
|
||||
author,
|
||||
comments: [],
|
||||
createdAt: 1_700_000_000,
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
function makePullRequest(status, author = REVIEW_AUTHOR) {
|
||||
return {
|
||||
approvals: [],
|
||||
author,
|
||||
comments: [],
|
||||
commit: "1".repeat(40),
|
||||
createdAt: 1_700_086_400,
|
||||
initialCommit: "1".repeat(40),
|
||||
status,
|
||||
updates: [],
|
||||
};
|
||||
}
|
||||
|
||||
test("activity pod shows workspace details and a create-project action", () => {
|
||||
const context = projectsOverviewContext({
|
||||
filter: "all",
|
||||
issues: [],
|
||||
projects: [
|
||||
makeProject(),
|
||||
makeProject({
|
||||
id: "project-relay",
|
||||
name: "relay",
|
||||
repositories: [
|
||||
{ id: "repo-relay", name: "relay-tools", channelId: CHANNEL_B },
|
||||
],
|
||||
}),
|
||||
],
|
||||
pullRequests: [],
|
||||
summaries: {
|
||||
"project-buzz": { issueCount: 4, prCount: 2 },
|
||||
"project-relay": { issueCount: 1, prCount: 3 },
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(context.title, "Projects");
|
||||
assert.equal(context.detailsTitle, "Details");
|
||||
assert.equal(context.action?.label, "Create project");
|
||||
assert.deepEqual(
|
||||
context.stats.map((stat) => [stat.label, stat.count]),
|
||||
[
|
||||
["Projects", 2],
|
||||
["Repositories", 2],
|
||||
["Channels", 2],
|
||||
["Tasks", 5],
|
||||
["Reviews", 5],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("projects pod keeps create-project and drops task/review totals", () => {
|
||||
const context = projectsOverviewContext({
|
||||
filter: "projects",
|
||||
issues: [],
|
||||
projects: [makeProject(), makeProject({ id: "project-relay" })],
|
||||
pullRequests: [],
|
||||
});
|
||||
|
||||
assert.equal(context.title, "Projects");
|
||||
assert.equal(context.action?.kind, "project");
|
||||
assert.deepEqual(
|
||||
context.stats.map((stat) => stat.label),
|
||||
["Projects", "Repositories", "Channels"],
|
||||
);
|
||||
});
|
||||
|
||||
test("repositories pod matches repository activity copy", () => {
|
||||
const context = projectsOverviewContext({
|
||||
filter: "repositories",
|
||||
issues: [makeIssue("In Progress"), makeIssue("Done")],
|
||||
projects: [makeProject()],
|
||||
pullRequests: [makePullRequest("Open"), makePullRequest("Merged")],
|
||||
});
|
||||
|
||||
assert.equal(context.title, "Repositories");
|
||||
assert.equal(context.detailsTitle, "Repository activity");
|
||||
assert.equal(context.action, null);
|
||||
assert.deepEqual(
|
||||
context.stats.map((stat) => [stat.label, stat.count]),
|
||||
[
|
||||
["Repositories", 1],
|
||||
["Active tasks", 1],
|
||||
["Open reviews", 1],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("channels pod titles itself and omits a primary create action", () => {
|
||||
const context = projectsOverviewContext({
|
||||
filter: "channels",
|
||||
issues: [],
|
||||
projects: [makeProject()],
|
||||
pullRequests: [],
|
||||
});
|
||||
|
||||
assert.equal(context.title, "Channels");
|
||||
assert.equal(context.detailsTitle, "Details");
|
||||
assert.equal(context.action, null);
|
||||
assert.deepEqual(
|
||||
context.stats.map((stat) => stat.label),
|
||||
["Channels", "Projects", "Repositories"],
|
||||
);
|
||||
});
|
||||
|
||||
test("tasks pod breaks down active and completed work", () => {
|
||||
const context = projectsOverviewContext({
|
||||
filter: "issues",
|
||||
issues: [makeIssue("Triage"), makeIssue("Done"), makeIssue("Closed")],
|
||||
projects: [makeProject()],
|
||||
pullRequests: [],
|
||||
});
|
||||
|
||||
assert.equal(context.title, "Tasks");
|
||||
assert.equal(context.action?.label, "Create task");
|
||||
assert.deepEqual(
|
||||
context.stats.map((stat) => [stat.label, stat.count]),
|
||||
[
|
||||
["Tasks", 3],
|
||||
["Active", 1],
|
||||
["Completed", 2],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("reviews pod breaks down open and merged work", () => {
|
||||
const context = projectsOverviewContext({
|
||||
filter: "prs",
|
||||
issues: [],
|
||||
projects: [makeProject()],
|
||||
pullRequests: [
|
||||
makePullRequest("Open"),
|
||||
makePullRequest("Draft"),
|
||||
makePullRequest("Merged"),
|
||||
makePullRequest("Closed"),
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(context.title, "Reviews");
|
||||
assert.equal(context.detailsTitle, "Review activity");
|
||||
assert.equal(context.action?.label, "Create review");
|
||||
assert.deepEqual(
|
||||
context.stats.map((stat) => [stat.label, stat.count]),
|
||||
[
|
||||
["Reviews", 4],
|
||||
["Open", 2],
|
||||
["Merged", 1],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
function peopleContext(filter, overrides = {}) {
|
||||
return projectsOverviewContext({
|
||||
filter,
|
||||
issues: [makeIssue("In Progress")],
|
||||
projects: [makeProject()],
|
||||
pullRequests: [makePullRequest("Open")],
|
||||
summaries: {
|
||||
"project-buzz": {
|
||||
activityByDay: { "2026-08-01": 4 },
|
||||
latestCommit: { author: REVIEW_AUTHOR },
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
test("people on activity, tasks, reviews, and repositories are actual actors", () => {
|
||||
const activity = peopleContext("all");
|
||||
const tasks = peopleContext("issues");
|
||||
const reviews = peopleContext("prs");
|
||||
const repositories = peopleContext("repositories");
|
||||
|
||||
assert.deepEqual([...activity.people].sort(), [TASK_AUTHOR, REVIEW_AUTHOR]);
|
||||
assert.deepEqual(tasks.people, [TASK_AUTHOR]);
|
||||
assert.deepEqual(reviews.people, [REVIEW_AUTHOR]);
|
||||
assert.deepEqual(repositories.people, [REVIEW_AUTHOR]);
|
||||
assert.equal(activity.people.includes(BYSTANDER), false);
|
||||
assert.equal(activity.people.includes(OWNER), false);
|
||||
});
|
||||
|
||||
test("listed repository contributors without commits stay off the repositories pod", () => {
|
||||
const context = peopleContext("repositories", {
|
||||
issues: [],
|
||||
pullRequests: [],
|
||||
summaries: {},
|
||||
});
|
||||
assert.deepEqual(context.people, []);
|
||||
});
|
||||
|
||||
test("projects pod shows owners and keeps the activity graph; channels hides both", () => {
|
||||
const projects = peopleContext("projects");
|
||||
const channels = peopleContext("channels");
|
||||
|
||||
assert.deepEqual(projects.people, [OWNER]);
|
||||
assert.deepEqual(projects.activityByDay, { "2026-08-01": 4 });
|
||||
assert.deepEqual(channels.people, []);
|
||||
assert.equal(channels.activityByDay, null);
|
||||
});
|
||||
|
||||
test("activity, projects, and repositories pods show the contribution graph", () => {
|
||||
const expected = { "2026-08-01": 4 };
|
||||
assert.deepEqual(peopleContext("all").activityByDay, expected);
|
||||
assert.deepEqual(peopleContext("projects").activityByDay, expected);
|
||||
assert.deepEqual(peopleContext("repositories").activityByDay, expected);
|
||||
assert.equal(peopleContext("issues").activityByDay, null);
|
||||
assert.equal(peopleContext("prs").activityByDay, null);
|
||||
assert.equal(peopleContext("channels").activityByDay, null);
|
||||
});
|
||||
451
desktop/src/features/projects/ui/projectsOverviewContext.ts
Normal file
451
desktop/src/features/projects/ui/projectsOverviewContext.ts
Normal file
@@ -0,0 +1,451 @@
|
||||
import type {
|
||||
Project,
|
||||
ProjectActivitySummary,
|
||||
ProjectIssue,
|
||||
ProjectPullRequest,
|
||||
} from "@/features/projects/hooks";
|
||||
import { projectContributorActivityCounts } from "@/features/projects/lib/projectContributorMatching";
|
||||
import { uniqueProjectRelatedChannelCount } from "@/features/projects/lib/projectRelatedChannels";
|
||||
import type { ProjectsFilter } from "@/features/projects/lib/projectsViewHelpers";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
|
||||
import {
|
||||
projectReviewActivity,
|
||||
projectTaskActivity,
|
||||
} from "./projectRightPanelContext";
|
||||
import { projectsSectionTitle } from "./projectsSectionMeta";
|
||||
|
||||
export type ProjectsOverviewSection =
|
||||
| "projects"
|
||||
| "repositories"
|
||||
| "channels"
|
||||
| "prs"
|
||||
| "issues";
|
||||
|
||||
export type OverviewContextStatIcon =
|
||||
| "projects"
|
||||
| "repositories"
|
||||
| "channels"
|
||||
| "tasks"
|
||||
| "reviews"
|
||||
| "completed"
|
||||
| "merged";
|
||||
|
||||
export type OverviewContextAction = {
|
||||
kind: "project" | "issue" | "pullRequest";
|
||||
label: string;
|
||||
testId: string;
|
||||
} | null;
|
||||
|
||||
export type OverviewContextStat = {
|
||||
count: number;
|
||||
icon: OverviewContextStatIcon;
|
||||
label: string;
|
||||
section: ProjectsOverviewSection;
|
||||
};
|
||||
|
||||
export type OverviewContextPresentation = {
|
||||
action: OverviewContextAction;
|
||||
activityByDay: Record<string, number> | null;
|
||||
detailsTitle: string;
|
||||
people: string[];
|
||||
stats: OverviewContextStat[];
|
||||
title: string;
|
||||
};
|
||||
|
||||
type OverviewContextInput = {
|
||||
filter: ProjectsFilter;
|
||||
issues: ProjectIssue[];
|
||||
projects: Project[];
|
||||
pullRequests: ProjectPullRequest[];
|
||||
summaries?: Record<string, ProjectActivitySummary>;
|
||||
};
|
||||
|
||||
function summaryWorkItemCounts(
|
||||
projects: Project[],
|
||||
summaries: Record<string, ProjectActivitySummary> | undefined,
|
||||
) {
|
||||
return projects.reduce(
|
||||
(stats, project) => {
|
||||
const summary = summaries?.[project.id];
|
||||
return {
|
||||
issues: stats.issues + (summary?.issueCount ?? 0),
|
||||
prs: stats.prs + (summary?.prCount ?? 0),
|
||||
};
|
||||
},
|
||||
{ issues: 0, prs: 0 },
|
||||
);
|
||||
}
|
||||
|
||||
function repositoryCount(projects: Project[]) {
|
||||
return projects.reduce(
|
||||
(count, project) => count + project.repositories.length,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
function resolvedTaskActivity(
|
||||
issues: ProjectIssue[],
|
||||
summaryIssueCount: number,
|
||||
) {
|
||||
if (issues.length > 0) return projectTaskActivity(issues);
|
||||
return {
|
||||
active: summaryIssueCount,
|
||||
completed: 0,
|
||||
total: summaryIssueCount,
|
||||
};
|
||||
}
|
||||
|
||||
function resolvedReviewActivity(
|
||||
pullRequests: ProjectPullRequest[],
|
||||
summaryPrCount: number,
|
||||
) {
|
||||
if (pullRequests.length > 0) return projectReviewActivity(pullRequests);
|
||||
return {
|
||||
merged: 0,
|
||||
open: summaryPrCount,
|
||||
total: summaryPrCount,
|
||||
};
|
||||
}
|
||||
|
||||
function createProjectAction(): OverviewContextAction {
|
||||
return {
|
||||
kind: "project",
|
||||
label: "Create project",
|
||||
testId: "projects-overview-create-project",
|
||||
};
|
||||
}
|
||||
|
||||
function uniquePubkeys(pubkeys: Array<string | null | undefined>) {
|
||||
return [
|
||||
...new Set(
|
||||
pubkeys
|
||||
.filter(
|
||||
(pubkey): pubkey is string =>
|
||||
typeof pubkey === "string" && pubkey.trim().length > 0,
|
||||
)
|
||||
.map((pubkey) => normalizePubkey(pubkey)),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function summaryActivityByDay(
|
||||
projects: Project[],
|
||||
summaries: Record<string, ProjectActivitySummary> | undefined,
|
||||
) {
|
||||
const merged: Record<string, number> = {};
|
||||
for (const project of projects) {
|
||||
const byDay = summaries?.[project.id]?.activityByDay;
|
||||
if (!byDay) continue;
|
||||
for (const [day, count] of Object.entries(byDay)) {
|
||||
merged[day] = (merged[day] ?? 0) + count;
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function latestCommitAuthors(
|
||||
summaries: Record<string, ProjectActivitySummary> | undefined,
|
||||
) {
|
||||
if (!summaries) return [];
|
||||
return uniquePubkeys(
|
||||
Object.values(summaries)
|
||||
.map((summary) => summary.latestCommit?.author)
|
||||
.filter((author): author is string => Boolean(author)),
|
||||
);
|
||||
}
|
||||
|
||||
function workspaceActorCounts(
|
||||
issues: ProjectIssue[],
|
||||
pullRequests: ProjectPullRequest[],
|
||||
) {
|
||||
return projectContributorActivityCounts({
|
||||
owner: "",
|
||||
contributors: [],
|
||||
issues: issues
|
||||
.filter((issue) => issue.author)
|
||||
.map((issue) => ({
|
||||
author: issue.author,
|
||||
comments: issue.comments ?? [],
|
||||
})),
|
||||
pullRequests: pullRequests
|
||||
.filter((pullRequest) => pullRequest.author)
|
||||
.map((pullRequest) => ({
|
||||
author: pullRequest.author,
|
||||
approvals: pullRequest.approvals ?? [],
|
||||
comments: pullRequest.comments ?? [],
|
||||
commit: pullRequest.commit,
|
||||
initialCommit: pullRequest.initialCommit,
|
||||
updates: pullRequest.updates ?? [],
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
function peopleWithKind(
|
||||
counts: ReturnType<typeof workspaceActorCounts>,
|
||||
kind: "any" | "commits" | "reviews" | "tasks",
|
||||
) {
|
||||
return Object.entries(counts)
|
||||
.filter(([, activity]) => {
|
||||
if (kind === "tasks") return activity.tasks > 0;
|
||||
if (kind === "reviews") return activity.reviews > 0;
|
||||
if (kind === "commits") return activity.commits > 0;
|
||||
return activity.tasks + activity.reviews + activity.commits > 0;
|
||||
})
|
||||
.map(([pubkey]) => pubkey);
|
||||
}
|
||||
|
||||
function projectOwners(projects: Project[]) {
|
||||
return uniquePubkeys(
|
||||
projects.flatMap((project) => [
|
||||
project.owner,
|
||||
...project.repositories.map((repository) => repository.owner),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function overviewContextPeople({
|
||||
filter,
|
||||
issues,
|
||||
projects,
|
||||
pullRequests,
|
||||
summaries,
|
||||
}: OverviewContextInput) {
|
||||
const counts = workspaceActorCounts(issues, pullRequests);
|
||||
const commitAuthors = uniquePubkeys([
|
||||
...peopleWithKind(counts, "commits"),
|
||||
...latestCommitAuthors(summaries),
|
||||
]);
|
||||
|
||||
if (filter === "issues") return peopleWithKind(counts, "tasks");
|
||||
if (filter === "prs") return peopleWithKind(counts, "reviews");
|
||||
if (filter === "repositories") return commitAuthors;
|
||||
if (filter === "channels") return [];
|
||||
if (filter === "all") {
|
||||
return uniquePubkeys([...peopleWithKind(counts, "any"), ...commitAuthors]);
|
||||
}
|
||||
return projectOwners(projects);
|
||||
}
|
||||
|
||||
function overviewContextActivityByDay({
|
||||
filter,
|
||||
projects,
|
||||
summaries,
|
||||
}: OverviewContextInput): Record<string, number> | null {
|
||||
if (filter === "all" || filter === "projects" || filter === "repositories") {
|
||||
return summaryActivityByDay(projects, summaries);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Title, people, activity, and stats for the Projects overview context pod. */
|
||||
export function projectsOverviewContext(
|
||||
input: OverviewContextInput,
|
||||
): OverviewContextPresentation {
|
||||
const { filter, issues, projects, pullRequests, summaries } = input;
|
||||
const summaryCounts = summaryWorkItemCounts(projects, summaries);
|
||||
const tasks = resolvedTaskActivity(issues, summaryCounts.issues);
|
||||
const reviews = resolvedReviewActivity(pullRequests, summaryCounts.prs);
|
||||
const channelCount = uniqueProjectRelatedChannelCount(projects);
|
||||
const repositories = repositoryCount(projects);
|
||||
const people = overviewContextPeople(input);
|
||||
const activityByDay = overviewContextActivityByDay(input);
|
||||
|
||||
if (filter === "repositories") {
|
||||
return {
|
||||
action: null,
|
||||
activityByDay,
|
||||
detailsTitle: "Repository activity",
|
||||
people,
|
||||
stats: [
|
||||
{
|
||||
count: repositories,
|
||||
icon: "repositories",
|
||||
label: "Repositories",
|
||||
section: "repositories",
|
||||
},
|
||||
{
|
||||
count: tasks.active,
|
||||
icon: "tasks",
|
||||
label: "Active tasks",
|
||||
section: "issues",
|
||||
},
|
||||
{
|
||||
count: reviews.open,
|
||||
icon: "reviews",
|
||||
label: "Open reviews",
|
||||
section: "prs",
|
||||
},
|
||||
],
|
||||
title: "Repositories",
|
||||
};
|
||||
}
|
||||
|
||||
if (filter === "channels") {
|
||||
return {
|
||||
action: null,
|
||||
activityByDay,
|
||||
detailsTitle: "Details",
|
||||
people,
|
||||
stats: [
|
||||
{
|
||||
count: channelCount,
|
||||
icon: "channels",
|
||||
label: "Channels",
|
||||
section: "channels",
|
||||
},
|
||||
{
|
||||
count: projects.length,
|
||||
icon: "projects",
|
||||
label: "Projects",
|
||||
section: "projects",
|
||||
},
|
||||
{
|
||||
count: repositories,
|
||||
icon: "repositories",
|
||||
label: "Repositories",
|
||||
section: "repositories",
|
||||
},
|
||||
],
|
||||
title: "Channels",
|
||||
};
|
||||
}
|
||||
|
||||
if (filter === "issues") {
|
||||
return {
|
||||
action: {
|
||||
kind: "issue",
|
||||
label: "Create task",
|
||||
testId: "projects-overview-create-issue",
|
||||
},
|
||||
activityByDay,
|
||||
detailsTitle: "Details",
|
||||
people,
|
||||
stats: [
|
||||
{
|
||||
count: tasks.total,
|
||||
icon: "tasks",
|
||||
label: "Tasks",
|
||||
section: "issues",
|
||||
},
|
||||
{
|
||||
count: tasks.active,
|
||||
icon: "tasks",
|
||||
label: "Active",
|
||||
section: "issues",
|
||||
},
|
||||
{
|
||||
count: tasks.completed,
|
||||
icon: "completed",
|
||||
label: "Completed",
|
||||
section: "issues",
|
||||
},
|
||||
],
|
||||
title: "Tasks",
|
||||
};
|
||||
}
|
||||
|
||||
if (filter === "prs") {
|
||||
return {
|
||||
action: {
|
||||
kind: "pullRequest",
|
||||
label: "Create review",
|
||||
testId: "projects-overview-create-pull-request",
|
||||
},
|
||||
activityByDay,
|
||||
detailsTitle: "Review activity",
|
||||
people,
|
||||
stats: [
|
||||
{
|
||||
count: reviews.total,
|
||||
icon: "reviews",
|
||||
label: "Reviews",
|
||||
section: "prs",
|
||||
},
|
||||
{
|
||||
count: reviews.open,
|
||||
icon: "reviews",
|
||||
label: "Open",
|
||||
section: "prs",
|
||||
},
|
||||
{
|
||||
count: reviews.merged,
|
||||
icon: "merged",
|
||||
label: "Merged",
|
||||
section: "prs",
|
||||
},
|
||||
],
|
||||
title: "Reviews",
|
||||
};
|
||||
}
|
||||
|
||||
if (filter === "all") {
|
||||
return {
|
||||
action: createProjectAction(),
|
||||
activityByDay,
|
||||
detailsTitle: "Details",
|
||||
people,
|
||||
stats: [
|
||||
{
|
||||
count: projects.length,
|
||||
icon: "projects",
|
||||
label: "Projects",
|
||||
section: "projects",
|
||||
},
|
||||
{
|
||||
count: repositories,
|
||||
icon: "repositories",
|
||||
label: "Repositories",
|
||||
section: "repositories",
|
||||
},
|
||||
{
|
||||
count: channelCount,
|
||||
icon: "channels",
|
||||
label: "Channels",
|
||||
section: "channels",
|
||||
},
|
||||
{
|
||||
count: tasks.total,
|
||||
icon: "tasks",
|
||||
label: "Tasks",
|
||||
section: "issues",
|
||||
},
|
||||
{
|
||||
count: reviews.total,
|
||||
icon: "reviews",
|
||||
label: "Reviews",
|
||||
section: "prs",
|
||||
},
|
||||
],
|
||||
title: "Projects",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
action: createProjectAction(),
|
||||
activityByDay,
|
||||
detailsTitle: "Details",
|
||||
people,
|
||||
stats: [
|
||||
{
|
||||
count: projects.length,
|
||||
icon: "projects",
|
||||
label: "Projects",
|
||||
section: "projects",
|
||||
},
|
||||
{
|
||||
count: repositories,
|
||||
icon: "repositories",
|
||||
label: "Repositories",
|
||||
section: "repositories",
|
||||
},
|
||||
{
|
||||
count: channelCount,
|
||||
icon: "channels",
|
||||
label: "Channels",
|
||||
section: "channels",
|
||||
},
|
||||
],
|
||||
title: projectsSectionTitle(filter),
|
||||
};
|
||||
}
|
||||
33
desktop/src/features/projects/ui/projectsSectionMeta.ts
Normal file
33
desktop/src/features/projects/ui/projectsSectionMeta.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
CircleDot,
|
||||
FolderGit2,
|
||||
Folders,
|
||||
GitPullRequest,
|
||||
Hash,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import type { ProjectsFilter } from "@/features/projects/lib/projectsViewHelpers";
|
||||
|
||||
export function projectsSectionTitle(filter: ProjectsFilter) {
|
||||
if (filter === "all") return "Activity";
|
||||
if (filter === "prs") return "Reviews";
|
||||
if (filter === "issues") return "Tasks";
|
||||
if (filter === "repositories") return "Repositories";
|
||||
if (filter === "channels") return "Channels";
|
||||
return "Projects";
|
||||
}
|
||||
|
||||
export function projectsSectionIcon(filter: ProjectsFilter): LucideIcon {
|
||||
if (filter === "prs") return GitPullRequest;
|
||||
if (filter === "issues") return CircleDot;
|
||||
if (filter === "repositories") return FolderGit2;
|
||||
if (filter === "channels") return Hash;
|
||||
return Folders;
|
||||
}
|
||||
|
||||
export function openAppSearch() {
|
||||
document
|
||||
.querySelector<HTMLButtonElement>('[data-testid="open-search"]')
|
||||
?.click();
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
import { useThreadPanelWidth } from "@/shared/hooks/useThreadPanelWidth";
|
||||
import type { ProjectRightPanelMode } from "./ProjectRightPanelControls";
|
||||
|
||||
/** Default width of the project context rail (repository details and overview). */
|
||||
export const PROJECT_CONTEXT_PANEL_DEFAULT_WIDTH_PX = 280;
|
||||
const PROJECT_CONTEXT_PANEL_MIN_WIDTH_PX = 240;
|
||||
|
||||
export function useProjectPanelWidths(mode: ProjectRightPanelMode) {
|
||||
const threadPanelWidth = useThreadPanelWidth();
|
||||
const repositoryContextWidth = useThreadPanelWidth(undefined, {
|
||||
defaultWidthPx: 280,
|
||||
minWidthPx: 240,
|
||||
defaultWidthPx: PROJECT_CONTEXT_PANEL_DEFAULT_WIDTH_PX,
|
||||
minWidthPx: PROJECT_CONTEXT_PANEL_MIN_WIDTH_PX,
|
||||
sessionKey: "buzz.desktop.project-context-width",
|
||||
});
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Persisted remote-head watermark for sidebar-preference sync managers.
|
||||
*
|
||||
* Each manager (sections, sort, stars, mutes) persists the highest
|
||||
* Each manager (sections, sort, stars, mutes, project membership) persists the highest
|
||||
* `created_at` it has ever observed from the relay under a key scoped to
|
||||
* pubkey + relay + blob type. On the next boot the manager reads this value
|
||||
* back: if it is > 0 a remote blob has existed before and seed-publishing
|
||||
@@ -96,7 +96,7 @@ export type BootstrapResult<T> =
|
||||
| { action: "hold" };
|
||||
|
||||
/**
|
||||
* Shared boot policy for all four sidebar-preference sync managers.
|
||||
* Shared boot policy for all sidebar-preference sync managers.
|
||||
*
|
||||
* Each manager calls this from its `bootstrap()` method, supplying its
|
||||
* surface-specific fetch, publish, and local-store accessors. The full
|
||||
|
||||
@@ -25,11 +25,9 @@ import { isProjectOwnedByCurrentUser } from "@/features/projects/lib/projectsVie
|
||||
import { projectShareLink } from "@/features/projects/lib/projectShareLinks";
|
||||
import {
|
||||
addProjectToSidebar,
|
||||
PROJECT_SIDEBAR_MEMBERSHIP_EVENT,
|
||||
type ProjectSidebarMembershipChange,
|
||||
readProjectSidebarMembership,
|
||||
removeProjectFromSidebar,
|
||||
} from "@/features/projects/lib/projectSidebarMembership";
|
||||
import { useProjectSidebarMembership } from "@/features/projects/lib/useProjectSidebarMembership";
|
||||
import { selectProjectRepository } from "@/features/projects/projectModels";
|
||||
import { projectMatchesRouteId } from "@/features/projects/projectRoutes";
|
||||
import { ProjectBrowserDialog } from "@/features/projects/ui/ProjectBrowserDialog";
|
||||
@@ -152,42 +150,13 @@ function SidebarProjectsSectionContent() {
|
||||
React.useState<SidebarProjectExpansionState>(() =>
|
||||
readSidebarProjectExpansion(relayOrigin, currentPubkey),
|
||||
);
|
||||
const [addedProjectAddresses, setAddedProjectAddresses] = React.useState<
|
||||
string[]
|
||||
>(() => readProjectSidebarMembership(relayOrigin, currentPubkey));
|
||||
const addedProjectAddresses = useProjectSidebarMembership(
|
||||
relayOrigin,
|
||||
currentPubkey,
|
||||
);
|
||||
const createProjectMutation = useCreateProjectMutation();
|
||||
const deleteProjectMutation = useDeleteProjectMutation();
|
||||
const isPending = projectsQuery.isPending || identityQuery.isPending;
|
||||
React.useEffect(() => {
|
||||
setAddedProjectAddresses(
|
||||
readProjectSidebarMembership(relayOrigin, currentPubkey),
|
||||
);
|
||||
// Consume the membership carried on the event: when persistence is
|
||||
// unavailable the change lives only in the event detail, and re-reading
|
||||
// localStorage would silently revert the user's add/remove.
|
||||
const onChange = (event: Event) => {
|
||||
const detail = (event as CustomEvent<ProjectSidebarMembershipChange>)
|
||||
.detail;
|
||||
if (
|
||||
detail &&
|
||||
detail.relayOrigin === relayOrigin &&
|
||||
currentPubkey &&
|
||||
detail.pubkey.toLowerCase() === currentPubkey.toLowerCase()
|
||||
) {
|
||||
setAddedProjectAddresses(detail.addresses);
|
||||
return;
|
||||
}
|
||||
setAddedProjectAddresses(
|
||||
readProjectSidebarMembership(relayOrigin, currentPubkey),
|
||||
);
|
||||
};
|
||||
globalThis.addEventListener(PROJECT_SIDEBAR_MEMBERSHIP_EVENT, onChange);
|
||||
return () =>
|
||||
globalThis.removeEventListener(
|
||||
PROJECT_SIDEBAR_MEMBERSHIP_EVENT,
|
||||
onChange,
|
||||
);
|
||||
}, [currentPubkey, relayOrigin]);
|
||||
React.useEffect(() => {
|
||||
setProjectExpansion(
|
||||
readSidebarProjectExpansion(relayOrigin, currentPubkey),
|
||||
@@ -357,14 +326,7 @@ function SidebarProjectsSectionContent() {
|
||||
isActive={isActive}
|
||||
isExpanded={isExpanded}
|
||||
onDelete={() => setProjectToDelete(project)}
|
||||
onOpen={() => {
|
||||
if (isActive) {
|
||||
setProjectExpanded(project, !isExpanded);
|
||||
return;
|
||||
}
|
||||
setProjectExpanded(project, true);
|
||||
void goProject(project.id);
|
||||
}}
|
||||
onOpen={() => setProjectExpanded(project, !isExpanded)}
|
||||
onRemove={() => handleRemove(project)}
|
||||
project={project}
|
||||
/>
|
||||
@@ -631,8 +593,10 @@ function SidebarProjectRow({
|
||||
tooltip={project.name}
|
||||
type="button"
|
||||
>
|
||||
<ProjectIcon className="h-4 w-4" />
|
||||
<SidebarMenuLabel>{project.name}</SidebarMenuLabel>
|
||||
<ProjectIcon className={cn("h-4 w-4", !isActive && "opacity-80")} />
|
||||
<SidebarMenuLabel className={cn(!isActive && "opacity-80")}>
|
||||
{project.name}
|
||||
</SidebarMenuLabel>
|
||||
</SidebarMenuButton>
|
||||
{canDelete ? (
|
||||
<SidebarMenuAction
|
||||
|
||||
@@ -42,12 +42,14 @@ export const KIND_HUDDLE_PARTICIPANT_JOINED = 48101;
|
||||
export const KIND_HUDDLE_PARTICIPANT_LEFT = 48102;
|
||||
export const KIND_HUDDLE_ENDED = 48103;
|
||||
// NIP-78 application-specific data. All use kind 30078; the relay
|
||||
// differentiates them by d-tag ("read-state:<slotId>", "channel-sections", "channel-mutes", "channel-stars", "channel-sort").
|
||||
// differentiates them by d-tag ("read-state:<slotId>", "channel-sections",
|
||||
// "channel-mutes", "channel-stars", "channel-sort", "project-sidebar-membership").
|
||||
export const KIND_READ_STATE = 30078;
|
||||
export const KIND_CHANNEL_SECTIONS = 30078;
|
||||
export const KIND_CHANNEL_MUTES = 30078;
|
||||
export const KIND_CHANNEL_STARS = 30078;
|
||||
export const KIND_CHANNEL_SORT = 30078;
|
||||
export const KIND_PROJECT_SIDEBAR_MEMBERSHIP = 30078;
|
||||
export const KIND_COMMUNITY_THEME = 30078;
|
||||
// NIP-33 persona/team/managed-agent projection events (d-tag keyed). Published
|
||||
// backend-side as secrets-stripped snapshots; the inbound sync hook subscribes
|
||||
|
||||
@@ -786,10 +786,11 @@
|
||||
}
|
||||
|
||||
/*
|
||||
* Repository context is a sibling pod, not a column inside the app's
|
||||
* standard content card. This deliberately sits outside the cascade layers
|
||||
* so it can unframe the utility-styled content surface. Chat removes the
|
||||
* marker and restores the normal framed surface.
|
||||
* Project context (repository detail and the projects overview rail) is a
|
||||
* sibling pod, not a column inside the app's standard content card. This
|
||||
* deliberately sits outside the cascade layers so it can unframe the
|
||||
* utility-styled content surface. Closing the panel or opening chat removes
|
||||
* the marker and restores the normal framed surface.
|
||||
*/
|
||||
[data-buzz-content-surface]:has([data-project-detail-screen]),
|
||||
:root[data-buzz-sidebar]:not(.dark)
|
||||
|
||||
52
desktop/src/shared/ui/TerminalPanelIcon.tsx
Normal file
52
desktop/src/shared/ui/TerminalPanelIcon.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { motion, useReducedMotion } from "motion/react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
export function TerminalPanelIcon({
|
||||
className,
|
||||
open,
|
||||
...props
|
||||
}: ComponentProps<"svg"> & {
|
||||
open: boolean;
|
||||
}) {
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={cn("h-4 w-auto shrink-0", className)}
|
||||
fill="none"
|
||||
height="22"
|
||||
viewBox="0 0 24 22"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<rect
|
||||
height="20"
|
||||
rx="5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
width="22"
|
||||
x="1"
|
||||
y="1"
|
||||
/>
|
||||
<motion.rect
|
||||
animate={{
|
||||
height: open ? 5 : 2,
|
||||
rx: open ? 2.5 : 1,
|
||||
y: open ? 13 : 16,
|
||||
}}
|
||||
fill="currentColor"
|
||||
initial={false}
|
||||
transition={{
|
||||
duration: prefersReducedMotion ? 0 : 0.2,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
width="16"
|
||||
x="4"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg width="24" height="22" viewBox="0 0 24 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1" y="1" width="22" height="20" rx="5" stroke="#F5EFF7" stroke-width="2"/>
|
||||
<rect x="4" y="4" width="2" height="14" rx="1" fill="#F5EFF7"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 251 B |
4
desktop/src/shared/ui/assets/drawer-panel-open-state.svg
Normal file
4
desktop/src/shared/ui/assets/drawer-panel-open-state.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="24" height="22" viewBox="0 0 24 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1" y="1" width="22" height="20" rx="5" stroke="#F5EFF7" stroke-width="2"/>
|
||||
<rect x="4" y="4" width="5" height="14" rx="2.5" fill="#F5EFF7"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 253 B |
4
desktop/src/shared/ui/assets/terminal-closed-state.svg
Normal file
4
desktop/src/shared/ui/assets/terminal-closed-state.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="24" height="22" viewBox="0 0 24 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1" y="1" width="22" height="20" rx="5" stroke="#F5EFF7" stroke-width="2"/>
|
||||
<rect x="4" y="16" width="16" height="2" rx="1" fill="#F5EFF7"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 252 B |
4
desktop/src/shared/ui/assets/terminal-open-state.svg
Normal file
4
desktop/src/shared/ui/assets/terminal-open-state.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="24" height="22" viewBox="0 0 24 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1" y="1" width="22" height="20" rx="5" stroke="#F5EFF7" stroke-width="2"/>
|
||||
<rect x="4" y="13" width="16" height="5" rx="2.5" fill="#F5EFF7"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 254 B |
@@ -57,7 +57,7 @@ test("top-level project lists align dates and overflow actions", async ({
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByTestId("open-projects-view").click();
|
||||
await expect(
|
||||
page.getByRole("heading", { level: 1, name: "Projects" }),
|
||||
page.getByRole("heading", { level: 2, name: "Projects", exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
async function trailingPositions(
|
||||
@@ -103,18 +103,9 @@ test("top-level project lists align dates and overflow actions", async ({
|
||||
await expect(page.getByRole("menuitem", { name: "Local" })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
const projectRow = page.locator('[data-testid^="project-row-"]').first();
|
||||
const projectPositions = await trailingPositions(projectRow, {
|
||||
summaryTestId: "projects-row-summary",
|
||||
});
|
||||
// Project rows show the activity bar alone — counts stay in its tooltips.
|
||||
await expect(
|
||||
projectRow
|
||||
.getByTestId("projects-row-summary")
|
||||
.getByTestId("project-activity-bar"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
projectRow.getByTestId("projects-row-summary"),
|
||||
).not.toContainText("commits");
|
||||
const projectPositions = await trailingPositions(projectRow);
|
||||
await expect(projectRow.getByTestId("projects-row-context")).toBeVisible();
|
||||
await expect(projectRow.getByTestId("projects-row-people")).toBeVisible();
|
||||
|
||||
await page.getByTestId("projects-section-repositories").click();
|
||||
await page.getByRole("button", { name: "Filter repositories" }).click();
|
||||
@@ -126,17 +117,14 @@ test("top-level project lists align dates and overflow actions", async ({
|
||||
await expect(page.getByTestId("repository-row-relay-tools")).toBeVisible();
|
||||
const repositoryRow = page.getByTestId("repository-row-buzz");
|
||||
await expect(
|
||||
repositoryRow.getByTestId("repositories-row-summary"),
|
||||
).toContainText("commits");
|
||||
repositoryRow.getByTestId("repositories-row-project"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
repositoryRow.getByTestId("repositories-row-branch"),
|
||||
).toContainText("main");
|
||||
// Subtitle is the repository location (owner/repo for Buzz-hosted repos).
|
||||
await expect(repositoryRow.locator("p")).toHaveText(/\/buzz$/);
|
||||
repositoryRow.getByTestId("repositories-row-description"),
|
||||
).toContainText(/Relay, desktop, and mobile|community platform/);
|
||||
const repositoryPositions = await trailingPositions(repositoryRow, {
|
||||
actionName: /More options for/,
|
||||
dateTestId: "repositories-row-date",
|
||||
summaryTestId: "repositories-row-summary",
|
||||
});
|
||||
// No summaryX comparison: repository rows carry text stats next to the bar
|
||||
// while project rows show the bar alone, so the columns differ in width by
|
||||
@@ -219,11 +207,11 @@ test("top-level project lists align dates and overflow actions", async ({
|
||||
.locator('[data-testid^="project-row-"]')
|
||||
.first();
|
||||
await expect(
|
||||
responsiveRepositoryRow.getByTestId("projects-row-summary"),
|
||||
).toBeHidden();
|
||||
responsiveRepositoryRow.getByTestId("projects-row-context"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
responsiveRepositoryRow.getByTestId("projects-row-people"),
|
||||
).toBeHidden();
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
responsiveRepositoryRow.getByTestId("projects-row-date"),
|
||||
).toBeVisible();
|
||||
@@ -470,7 +458,13 @@ test("multi-repository projects switch the active repository", async ({
|
||||
},
|
||||
);
|
||||
await projectRow.click();
|
||||
await expect(page).toHaveURL(/\/projects\//);
|
||||
await expect(page).not.toHaveURL(/\/projects\//);
|
||||
await expect(projectRow).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(relayToolsRepository).toBeHidden();
|
||||
|
||||
await projectRow.click();
|
||||
await expect(page).not.toHaveURL(/\/projects\//);
|
||||
await expect(projectRow).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(relayToolsRepository).toBeVisible();
|
||||
const projectSidebarMetrics = await sidebarScrollContent.evaluate(
|
||||
(element) => {
|
||||
@@ -496,7 +490,7 @@ test("multi-repository projects switch the active repository", async ({
|
||||
return element.scrollTop;
|
||||
});
|
||||
await projectRow.click();
|
||||
await expect(page).toHaveURL(/\/projects\//);
|
||||
await expect(page).not.toHaveURL(/\/projects\//);
|
||||
await expect(projectRow).toHaveAttribute("aria-expanded", "true");
|
||||
await expect
|
||||
.poll(() =>
|
||||
|
||||
@@ -867,9 +867,11 @@ test("project pull request author rollover stays identity-only", async ({
|
||||
),
|
||||
).toBeVisible();
|
||||
|
||||
const authorLabel = (
|
||||
await author.getByTestId("projects-pr-author-label").innerText()
|
||||
).trim();
|
||||
const authorLabel =
|
||||
(
|
||||
await author.getByTestId("projects-pr-author-label").textContent()
|
||||
)?.trim() ?? "";
|
||||
expect(authorLabel.length).toBeGreaterThan(0);
|
||||
await author.hover();
|
||||
const rollover = page.getByTestId("projects-pr-author-rollover");
|
||||
await expect(rollover).toBeVisible();
|
||||
@@ -898,9 +900,11 @@ test("project issue author rollover matches pull requests", async ({
|
||||
),
|
||||
).toBeVisible();
|
||||
|
||||
const authorLabel = (
|
||||
await author.getByTestId("projects-issue-author-label").innerText()
|
||||
).trim();
|
||||
const authorLabel =
|
||||
(
|
||||
await author.getByTestId("projects-issue-author-label").textContent()
|
||||
)?.trim() ?? "";
|
||||
expect(authorLabel.length).toBeGreaterThan(0);
|
||||
await author.hover();
|
||||
const rollover = page.getByTestId("projects-issue-author-rollover");
|
||||
await expect(rollover).toBeVisible();
|
||||
@@ -993,7 +997,7 @@ test("project overview reports aggregate work-item failures", async ({
|
||||
);
|
||||
});
|
||||
|
||||
test("project overview does not paint a background behind its cards", async ({
|
||||
test("project overview presents collapsible context beside grouped activity", async ({
|
||||
page,
|
||||
}) => {
|
||||
await enableProjectsFeature(page);
|
||||
@@ -1005,16 +1009,138 @@ test("project overview does not paint a background behind its cards", async ({
|
||||
"background-color",
|
||||
"rgba(0, 0, 0, 0)",
|
||||
);
|
||||
await expect(page.getByTestId("projects-overview-layout")).toHaveAttribute(
|
||||
"data-project-context-detached",
|
||||
"true",
|
||||
);
|
||||
const overviewLayout = page.getByTestId("projects-overview-layout");
|
||||
const overviewContentPod = page.getByTestId("projects-overview-content-pod");
|
||||
const appContentSurface = page
|
||||
.locator("[data-buzz-content-surface]")
|
||||
.filter({ has: overviewLayout })
|
||||
.first();
|
||||
await expect(appContentSurface).toHaveCSS(
|
||||
"background-color",
|
||||
"rgba(0, 0, 0, 0)",
|
||||
);
|
||||
await expect(appContentSurface).toHaveCSS("border-radius", "0px");
|
||||
await expect(overviewContentPod).toHaveCSS("border-radius", "16px");
|
||||
await expect(page.getByTestId("projects-workspace-chrome")).toContainText(
|
||||
"Projects",
|
||||
);
|
||||
await expect(page.getByTestId("projects-workspace-chrome")).toContainText(
|
||||
"Activity",
|
||||
);
|
||||
await expect(page.getByTestId("projects-page-tabs")).toBeVisible();
|
||||
await expect(page.getByTestId("projects-page-header")).toContainText(
|
||||
"Welcome to Activity",
|
||||
);
|
||||
await expect(page.getByTestId("projects-activity-search")).toBeVisible();
|
||||
await expect(page.getByTestId("projects-activity-intro")).toContainText(
|
||||
"Keep up with commits, reviews, and tasks",
|
||||
);
|
||||
await expect(
|
||||
page.getByTestId("projects-overview-context-panel"),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("projects-overview-context-title")).toHaveText(
|
||||
"Projects",
|
||||
);
|
||||
expect(
|
||||
Math.round(
|
||||
(await page.getByTestId("projects-overview-context-panel").boundingBox())
|
||||
?.width ?? 0,
|
||||
),
|
||||
).toBe(280);
|
||||
await expect(
|
||||
page.getByTestId("projects-overview-create-project"),
|
||||
).toContainText("Create project");
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("projects-overview-context-panel")
|
||||
.getByTestId("projects-create-menu"),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("projects-overview-stats-pod")).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("projects-overview-context-panel")
|
||||
.getByTestId("projects-overview-people"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("projects-overview-stats-pod")
|
||||
.getByTestId("projects-overview-people"),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("projects-overview-context-panel")
|
||||
.getByTestId("projects-overview-context-title"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("projects-overview-context-panel")
|
||||
.getByRole("heading", { name: "Details" }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("projects-overview-context-panel")
|
||||
.getByRole("heading", { name: "Activity" }),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("projects-overview-context-panel")
|
||||
.getByTestId("projects-overview-activity"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("projects-activity-group").first(),
|
||||
).toContainText(/This week|Last week|Earlier/);
|
||||
const searchBox = await page
|
||||
.getByTestId("projects-activity-search")
|
||||
.boundingBox();
|
||||
const tabMenuBox = await page.getByTestId("projects-page-tabs").boundingBox();
|
||||
const firstTabBox = await page
|
||||
.getByTestId("projects-section-all")
|
||||
.boundingBox();
|
||||
const introBox = await page
|
||||
.getByTestId("projects-activity-intro")
|
||||
.boundingBox();
|
||||
const feedBox = await page
|
||||
.getByTestId("projects-activity-group")
|
||||
.first()
|
||||
.boundingBox();
|
||||
const contentPodBox = await overviewContentPod.boundingBox();
|
||||
expect(searchBox).toBeTruthy();
|
||||
expect(tabMenuBox).toBeTruthy();
|
||||
expect(firstTabBox).toBeTruthy();
|
||||
expect(introBox).toBeTruthy();
|
||||
expect(feedBox).toBeTruthy();
|
||||
expect(contentPodBox).toBeTruthy();
|
||||
expect(searchBox?.x ?? 0).toBeLessThan(firstTabBox?.x ?? 0);
|
||||
expect(Math.abs((searchBox?.y ?? 0) - (firstTabBox?.y ?? 0))).toBeLessThan(8);
|
||||
expect(tabMenuBox?.y ?? 0).toBeLessThan(introBox?.y ?? 0);
|
||||
expect(introBox?.y ?? 0).toBeLessThan(feedBox?.y ?? 0);
|
||||
expect(feedBox?.width ?? 0).toBeLessThan((contentPodBox?.width ?? 0) - 48);
|
||||
const feedCenter = (feedBox?.x ?? 0) + (feedBox?.width ?? 0) / 2;
|
||||
const contentCenter =
|
||||
(contentPodBox?.x ?? 0) + (contentPodBox?.width ?? 0) / 2;
|
||||
expect(Math.abs(feedCenter - contentCenter)).toBeLessThan(24);
|
||||
|
||||
const stats = page.getByTestId("projects-overview-stat");
|
||||
await expect(stats).toHaveCount(4);
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
await expect(stats.nth(index)).toHaveCSS(
|
||||
"background-color",
|
||||
"rgba(0, 0, 0, 0)",
|
||||
);
|
||||
await expect(stats.nth(index)).toHaveCSS("border-style", "solid");
|
||||
}
|
||||
await expect(stats).toHaveCount(5);
|
||||
await expect(stats.nth(2)).toContainText("Channels");
|
||||
const createBox = await page
|
||||
.getByTestId("projects-overview-create-project")
|
||||
.boundingBox();
|
||||
const lastStatBox = await stats.last().boundingBox();
|
||||
const peopleBox = await page
|
||||
.getByTestId("projects-overview-people")
|
||||
.boundingBox();
|
||||
expect(createBox).toBeTruthy();
|
||||
expect(lastStatBox).toBeTruthy();
|
||||
expect(peopleBox).toBeTruthy();
|
||||
expect(peopleBox?.y ?? 0).toBeGreaterThan(createBox?.y ?? 0);
|
||||
expect(peopleBox?.y ?? 0).toBeGreaterThan(
|
||||
(lastStatBox?.y ?? 0) + (lastStatBox?.height ?? 0) - 1,
|
||||
);
|
||||
|
||||
const activityCards = page.getByTestId("projects-activity-card");
|
||||
await expect(activityCards.first()).toBeVisible();
|
||||
@@ -1026,6 +1152,104 @@ test("project overview does not paint a background behind its cards", async ({
|
||||
);
|
||||
await expect(activityCards.nth(index)).toHaveCSS("border-style", "solid");
|
||||
}
|
||||
|
||||
await page.getByTestId("projects-section-channels").click();
|
||||
await expect(page.getByTestId("projects-page-header")).toContainText(
|
||||
"Channels",
|
||||
);
|
||||
await expect(
|
||||
page.getByTestId("projects-overview-context-panel"),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("projects-overview-context-title")).toHaveText(
|
||||
"Channels",
|
||||
);
|
||||
await expect(
|
||||
page.getByTestId("projects-overview-create-project"),
|
||||
).toHaveCount(0);
|
||||
await expect(page.getByTestId("projects-overview-people")).toHaveCount(0);
|
||||
await expect(page.getByTestId("projects-overview-activity")).toHaveCount(0);
|
||||
await expect(stats).toHaveCount(3);
|
||||
await expect(stats.first()).toContainText("Channels");
|
||||
await expect(page.getByTestId("projects-overview-layout")).toHaveAttribute(
|
||||
"data-project-context-detached",
|
||||
"true",
|
||||
);
|
||||
const channelRows = page.getByTestId("project-channel-row");
|
||||
// One row per project or repository binding: buzz, buzz · relay-tools,
|
||||
// design-system, and relay-tools all bind the shared mock channel.
|
||||
await expect(channelRows).toHaveCount(4);
|
||||
await expect(channelRows.first()).toContainText("#general");
|
||||
const channelsList = page.getByTestId("projects-channels-list");
|
||||
await expect(channelsList).toContainText("buzz");
|
||||
await expect(channelsList).toContainText("relay-tools");
|
||||
await expect(channelsList).toContainText("design-system");
|
||||
await expect(page.getByTestId("project-channel-project")).toHaveCount(4);
|
||||
await expect(page.getByTestId("project-channel-repository")).toHaveCount(4);
|
||||
await page.getByTestId("projects-section-projects").click();
|
||||
await expect(page.getByTestId("projects-page-header")).toContainText(
|
||||
"Projects",
|
||||
);
|
||||
await expect(
|
||||
page.getByTestId("projects-overview-context-panel"),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("projects-overview-context-title")).toHaveText(
|
||||
"Projects",
|
||||
);
|
||||
await expect(
|
||||
page.getByTestId("projects-overview-create-project"),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("projects-overview-people")).toBeVisible();
|
||||
await expect(page.getByTestId("projects-overview-activity")).toBeVisible();
|
||||
await page.getByTestId("projects-section-repositories").click();
|
||||
await expect(page.getByTestId("projects-overview-context-title")).toHaveText(
|
||||
"Repositories",
|
||||
);
|
||||
await expect(
|
||||
page.getByTestId("projects-overview-create-project"),
|
||||
).toHaveCount(0);
|
||||
await expect(stats.nth(1)).toContainText("Active tasks");
|
||||
await expect(page.getByTestId("projects-overview-activity")).toBeVisible();
|
||||
await page.getByTestId("projects-section-issues").click();
|
||||
await expect(page.getByTestId("projects-overview-context-title")).toHaveText(
|
||||
"Tasks",
|
||||
);
|
||||
await expect(
|
||||
page.getByTestId("projects-overview-create-issue"),
|
||||
).toContainText("Create task");
|
||||
await expect(page.getByTestId("projects-overview-activity")).toHaveCount(0);
|
||||
await page.getByTestId("projects-section-prs").click();
|
||||
await expect(page.getByTestId("projects-overview-context-title")).toHaveText(
|
||||
"Reviews",
|
||||
);
|
||||
await expect(
|
||||
page.getByTestId("projects-overview-create-pull-request"),
|
||||
).toContainText("Create review");
|
||||
await expect(page.getByTestId("projects-overview-people")).toBeVisible();
|
||||
await expect(page.getByTestId("projects-overview-activity")).toHaveCount(0);
|
||||
await page.getByTestId("projects-section-all").click();
|
||||
await expect(
|
||||
page.getByTestId("projects-overview-context-panel"),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("projects-overview-context-title")).toHaveText(
|
||||
"Projects",
|
||||
);
|
||||
await expect(page.getByTestId("projects-overview-activity")).toBeVisible();
|
||||
|
||||
await page.getByTestId("projects-overview-create-project").click();
|
||||
await expect(page.getByTestId("create-project-dialog")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("create-project-dialog")).toBeHidden();
|
||||
|
||||
await page.getByTestId("projects-overview-context-toggle").click();
|
||||
await expect(
|
||||
page.getByTestId("projects-overview-context-panel"),
|
||||
).not.toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("projects-overview-layout"),
|
||||
).not.toHaveAttribute("data-project-context-detached", "true");
|
||||
await expect(stats).toHaveCount(0);
|
||||
await expect(activityCards.first()).toBeVisible();
|
||||
await expect(page.getByTestId("projects-create-menu")).toBeVisible();
|
||||
});
|
||||
|
||||
test("repository rows identify their git host", async ({ page }) => {
|
||||
@@ -1081,7 +1305,6 @@ test("project subsections do not paint backgrounds behind list or grid items", a
|
||||
"background-color",
|
||||
"rgba(0, 0, 0, 0)",
|
||||
);
|
||||
await expect(listItems.nth(index)).toHaveCSS("border-style", "solid");
|
||||
}
|
||||
|
||||
await page.getByRole("button", { name: "Grid layout" }).click();
|
||||
@@ -1595,3 +1818,68 @@ test("project task can be created with a category from the tasks header", async
|
||||
]);
|
||||
expect(createdEvent?.tags).toContainEqual(["t", "change-request"]);
|
||||
});
|
||||
|
||||
test("narrow layouts keep section context reachable through a sheet", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ height: 720, width: 900 });
|
||||
await enableProjectsFeature(page);
|
||||
await installMockBridge(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByTestId("open-projects-view").click();
|
||||
|
||||
// Below the detached breakpoint the docked rail never renders, but the
|
||||
// context toggle stays available.
|
||||
await expect(page.getByTestId("projects-page-tabs")).toBeVisible();
|
||||
await expect(page.getByTestId("projects-overview-context-panel")).toHaveCount(
|
||||
0,
|
||||
);
|
||||
const contextToggle = page.getByTestId("projects-overview-context-toggle");
|
||||
await expect(contextToggle).toBeVisible();
|
||||
await expect(contextToggle).toHaveAttribute("aria-pressed", "false");
|
||||
|
||||
// Keyboard journey into the Tasks section: open the sheet from the toggle.
|
||||
await page.getByTestId("projects-section-issues").click();
|
||||
await expect(page.getByTestId("projects-page-header")).toContainText("Tasks");
|
||||
await contextToggle.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
const contextSheet = page.getByTestId("projects-overview-context-sheet");
|
||||
await expect(contextSheet).toBeVisible();
|
||||
await expect(
|
||||
contextSheet.getByTestId("projects-overview-context-title"),
|
||||
).toHaveText("Tasks");
|
||||
await expect(contextToggle).toHaveAttribute("aria-pressed", "true");
|
||||
|
||||
// Escape dismisses the sheet and returns focus to the toggle.
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(contextSheet).toBeHidden();
|
||||
await expect(contextToggle).toBeFocused();
|
||||
await expect(contextToggle).toHaveAttribute("aria-pressed", "false");
|
||||
|
||||
// The same journey follows the Reviews section.
|
||||
await page.getByTestId("projects-section-prs").click();
|
||||
await expect(page.getByTestId("projects-page-header")).toContainText(
|
||||
"Reviews",
|
||||
);
|
||||
await contextToggle.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(contextSheet).toBeVisible();
|
||||
await expect(
|
||||
contextSheet.getByTestId("projects-overview-context-title"),
|
||||
).toHaveText("Reviews");
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(contextSheet).toBeHidden();
|
||||
|
||||
// Selecting a section inside the sheet navigates and dismisses it: the
|
||||
// Activity context lists every section as a stat row.
|
||||
await page.getByTestId("projects-section-all").click();
|
||||
await contextToggle.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(contextSheet).toBeVisible();
|
||||
await contextSheet
|
||||
.getByTestId("projects-overview-stat")
|
||||
.filter({ hasText: "Tasks" })
|
||||
.click();
|
||||
await expect(contextSheet).toBeHidden();
|
||||
await expect(page.getByTestId("projects-page-header")).toContainText("Tasks");
|
||||
});
|
||||
|
||||
@@ -18,6 +18,32 @@ async function openBuzzProject(page: import("@playwright/test").Page) {
|
||||
await projectEntry.click();
|
||||
}
|
||||
|
||||
test("projects activity overview screenshot", async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem("buzz-theme", "light");
|
||||
});
|
||||
await installMockBridge(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByTestId("open-projects-view").click();
|
||||
await expect(page.getByTestId("projects-page-tabs")).toBeVisible();
|
||||
await expect(page.getByTestId("projects-page-header")).toBeVisible();
|
||||
await expect(page.getByTestId("projects-activity-search")).toBeVisible();
|
||||
await expect(page.getByTestId("projects-activity-intro")).toContainText(
|
||||
"Welcome to Activity",
|
||||
);
|
||||
await expect(
|
||||
page.getByTestId("projects-overview-context-panel"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("projects-overview-create-project"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("projects-activity-group").first(),
|
||||
).toBeVisible();
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({ path: `${SHOTS}/00-projects-pulse.png` });
|
||||
});
|
||||
|
||||
test("sidebar project add flow browses before creating", async ({ page }) => {
|
||||
await installMockBridge(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
@@ -473,20 +499,24 @@ test("projects v3 workspace screenshot states", async ({ page }) => {
|
||||
"Hide project context",
|
||||
);
|
||||
await expect(terminalIcon).toBeVisible();
|
||||
// The icon is an inline SVG drawn with currentColor, so it must inherit
|
||||
// the toggle button's text color to stay tinted with button state.
|
||||
expect(
|
||||
await terminalIcon.evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
backgroundColor: style.backgroundColor,
|
||||
maskImage: style.maskImage || style.webkitMaskImage,
|
||||
parentColor: getComputedStyle(element.parentElement ?? element).color,
|
||||
};
|
||||
}),
|
||||
await terminalIcon.evaluate((element) => ({
|
||||
color: getComputedStyle(element).color,
|
||||
strokesCurrentColor: Array.from(element.querySelectorAll("rect")).some(
|
||||
(rect) =>
|
||||
rect.getAttribute("stroke") === "currentColor" ||
|
||||
rect.getAttribute("fill") === "currentColor",
|
||||
),
|
||||
tagName: element.tagName.toLowerCase(),
|
||||
})),
|
||||
).toMatchObject({
|
||||
backgroundColor: await terminalButton.evaluate(
|
||||
color: await terminalButton.evaluate(
|
||||
(element) => getComputedStyle(element).color,
|
||||
),
|
||||
maskImage: expect.not.stringMatching(/^none$/),
|
||||
strokesCurrentColor: true,
|
||||
tagName: "svg",
|
||||
});
|
||||
const [repositoryTabBounds, chatTabBounds] = await Promise.all([
|
||||
repositoryPanelTab.boundingBox(),
|
||||
@@ -511,6 +541,9 @@ test("projects v3 workspace screenshot states", async ({ page }) => {
|
||||
await expect(agentContext).toBeVisible();
|
||||
await expect(agentContext).toContainText("Overview");
|
||||
await expect(agentContext).not.toContainText("Buzz /");
|
||||
// The context rail reveals the chat panel with a width transition; measure
|
||||
// only after it settles or the panel's unclipped box overhangs the rail.
|
||||
await waitForAnimations(page);
|
||||
const [
|
||||
attachedSharedHeaderBackdropBounds,
|
||||
tabMenuHeaderBounds,
|
||||
@@ -1012,25 +1045,33 @@ test("projects v3 work-item list metadata", async ({ page }) => {
|
||||
await page.getByTestId("open-projects-view").click();
|
||||
|
||||
await page.getByTestId("projects-section-prs").click();
|
||||
const reviewTable = page.getByTestId("projects-list-container");
|
||||
await expect(reviewTable.locator("thead")).toHaveClass(/sr-only/);
|
||||
const reviewList = page.getByTestId("projects-list-container");
|
||||
await expect(reviewList).toBeVisible();
|
||||
const pullRequestRow = page.getByTestId(/^projects-pr-row-/).first();
|
||||
await expect(pullRequestRow).toBeVisible();
|
||||
await expect(pullRequestRow).toContainText("Review");
|
||||
await expect(pullRequestRow).toContainText("opened this in");
|
||||
await expect(pullRequestRow).toContainText(/from\s*feature\/mock-2-0/);
|
||||
await expect(pullRequestRow).not.toContainText(/#[0-9a-f]{8}/);
|
||||
await expect(pullRequestRow).toContainText(/relay-tools|buzz|design-system/);
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({ path: `${SHOTS}/05-pr-list-metadata.png` });
|
||||
|
||||
await page.getByTestId("projects-section-issues").click();
|
||||
const taskTable = page.getByTestId("projects-list-container");
|
||||
await expect(taskTable.locator("thead")).toHaveClass(/sr-only/);
|
||||
const taskList = page.getByTestId("projects-list-container");
|
||||
await expect(taskList).toBeVisible();
|
||||
const issueRow = page.getByTestId(/^projects-issue-row-/).first();
|
||||
await expect(issueRow).toBeVisible();
|
||||
await expect(issueRow).toContainText("Issue");
|
||||
await expect(issueRow).toContainText(/opened this in\s*relay-tools/);
|
||||
await expect(issueRow).not.toContainText(/#[0-9a-f]{8}/);
|
||||
await expect(issueRow).toContainText(/relay-tools|buzz|design-system/);
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({ path: `${SHOTS}/06-issue-list-metadata.png` });
|
||||
|
||||
await page.getByTestId("projects-section-channels").click();
|
||||
const channelRow = page.getByTestId("project-channel-row").first();
|
||||
await expect(channelRow).toBeVisible();
|
||||
await expect(channelRow).toContainText("#general");
|
||||
await expect(
|
||||
page.getByTestId("project-channel-project").first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("project-channel-repository").first(),
|
||||
).toBeVisible();
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({ path: `${SHOTS}/07-channels-list.png` });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user