mirror of
https://github.com/block/buzz.git
synced 2026-10-31 06:21:34 +00:00
## Summary Follow-up batch on the Projects overview (continues merged #1677): - **Repository access restrictions** — repositories the viewer can't reach are surfaced with a reason instead of failing silently. Channel-ACL denials (which arrive as the same 404 as a missing repo, for anti-enumeration) are re-classified using the repository's channel binding and the viewer's memberships (`useRepositoryAccess.ts`, `projectRepoAvailability.ts`). - **Projects loads in seconds instead of minutes** — enumeration no longer crawls every kind:5 deletion event on the relay. It fetches project/repo announcements first, then queries deletions scoped to those coordinates via chunked `#a` filters (3 queries instead of hundreds on staging). - **Activity feed layout polish** — bare event-type glyph beside the headline (no badge circle), timeline spine runs through the avatars connecting consecutive cards, linkable actor/project names are bold in theme foreground, rounded hover state, alignment fixes. - **Create button pinned** — the "+" create menu is pinned to the pane's top-right corner (equal 16px insets) and no longer scrolls away with the page header. - **List controls as a table header** — the scope selector (left) and sort + layout toggle (right) render as the first row of the list container on the Projects/Repositories/PRs/Issues tabs; in card view the identical bar stands alone with the cards below (`ProjectsListHeaderBar.tsx`). - **Repository rows show the git location** — subtitle is `github.com/org/repo` for external repos or `owner/repo` (resolved profile name) for Buzz-hosted ones, instead of repeating the project name (`repositoryDisplayPath`). - **Uniform work-item row heights** — issue rows previously ran the author chip in inline flow, letting the 20px avatar grow the line box ~3px taller than PR rows; both lists now share the same flex subtitle. 📸 Screenshots: [feed layout / pinned button](https://github.com/block/buzz/pull/5073#issuecomment-5214114069) · [list header / repo subtitles / row heights](https://github.com/block/buzz/pull/5073#issuecomment-5217480712). Note: two empty `chore: retrigger CI` commits exist on the branch from working around the Aug 6 GitHub Actions incident; happy to drop them with a signoff rebase before undrafting if preferred. Latest `main` is merged in (`a0cc35220`). ## Test plan - [x] Desktop unit tests (4,493 pass after merging main), Biome, tsc - [x] New unit tests for scoped deletion enumeration and repo availability re-classification - [x] New unit tests for `repositoryDisplayPath` (external, Buzz-hosted, unresolvable) - [x] Screenshot verification of feed layout, connector spine, and pinned button (top + scrolled states) — posted to the PR - [x] Screenshot verification of the list header row (list + card), repo subtitles, and matching PR/issue row heights — posted to the PR - [ ] Manual pass against staging (projects list load time, restricted-repo states) --------- Signed-off-by: Thomas Petersen <thomasp@squareup.com>
936 lines
32 KiB
TypeScript
936 lines
32 KiB
TypeScript
import * as React from "react";
|
|
import { toast } from "sonner";
|
|
|
|
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
|
|
import { useUsersBatchQuery } from "@/features/profile/hooks";
|
|
import {
|
|
type Project,
|
|
type ProjectIssue,
|
|
type ProjectPullRequest,
|
|
type Repository,
|
|
useDeleteProjectMutation,
|
|
useProjectActivitySummariesQuery,
|
|
useProjectLocalRepositoriesQuery,
|
|
useProjectsQuery,
|
|
useProjectsWorkItemsQuery,
|
|
} from "@/features/projects/hooks";
|
|
import { useRepositoryActivitySummariesQuery } from "@/features/projects/repositoryActivityHooks";
|
|
import { useCreateProjectMutation } from "@/features/projects/useCreateProject";
|
|
import { selectProjectRepository } from "@/features/projects/projectModels";
|
|
import { useProjectsRepoSnapshotsQuery } from "@/features/projects/useProjectsRepoSnapshots";
|
|
import {
|
|
useMemberChannelIds,
|
|
useRepositoryUnavailableReasonFor,
|
|
} from "@/features/projects/useRepositoryAccess";
|
|
import {
|
|
projectRepoHostForProject,
|
|
projectRepoHostForRepository,
|
|
} from "@/features/projects/lib/projectRepoHost";
|
|
import { ProjectsActivityFeed } from "@/features/projects/ui/ProjectsActivityFeed";
|
|
import {
|
|
EmptyFilteredState,
|
|
EmptyState,
|
|
ProjectGridCard,
|
|
ProjectListRow,
|
|
} from "@/features/projects/ui/ProjectCards";
|
|
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 { ProjectsPullRequestsList } from "@/features/projects/ui/ProjectsPullRequestsList";
|
|
import { ProjectsWorkItemsLoadNotice } from "@/features/projects/ui/ProjectsWorkItemsLoadNotice";
|
|
import { ProjectsListHeaderBar } from "@/features/projects/ui/ProjectsListHeaderBar";
|
|
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,
|
|
projectPeople,
|
|
type ProjectsFilter,
|
|
type ProjectsRepositoryScope,
|
|
type ProjectsSort,
|
|
type ProjectsViewMode,
|
|
type ProjectsWorkItemScope,
|
|
readStoredFilter,
|
|
readStoredIssueScope,
|
|
readStoredPullRequestScope,
|
|
readStoredRepositoryScope,
|
|
readStoredSort,
|
|
readStoredViewMode,
|
|
writeStoredFilter,
|
|
writeStoredIssueScope,
|
|
writeStoredPullRequestScope,
|
|
writeStoredRepositoryScope,
|
|
writeStoredSort,
|
|
writeStoredViewMode,
|
|
} from "@/features/projects/lib/projectsViewHelpers";
|
|
import { useOpenProjectTerminal } from "@/features/projects/ui/useOpenProjectTerminal";
|
|
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
|
|
import { useCommunities } from "@/features/communities/useCommunities";
|
|
import { useIdentityQuery } from "@/shared/api/hooks";
|
|
import { topChromeInset } from "@/shared/layout/chromeLayout";
|
|
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";
|
|
|
|
const MANY_PROJECTS_THRESHOLD = 12;
|
|
|
|
export function ProjectsView() {
|
|
const { goProject } = useAppNavigation();
|
|
const { activeCommunity } = useCommunities();
|
|
const relayOrigin = useRelayOrigin();
|
|
const scrollIdleTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(
|
|
null,
|
|
);
|
|
const scrollIndicatorRef = React.useRef<HTMLDivElement | null>(null);
|
|
// The native scrollbar thumb is permanently transparent (WebKit won't
|
|
// re-resolve ::-webkit-scrollbar styles dynamically), so we paint our own
|
|
// indicator over the gutter and show it only while the area is scrolling.
|
|
const handleContentScroll = React.useCallback(
|
|
(event: React.UIEvent<HTMLDivElement>) => {
|
|
const element = event.currentTarget;
|
|
const indicator = scrollIndicatorRef.current;
|
|
if (!indicator) return;
|
|
|
|
const { clientHeight, scrollHeight, scrollTop } = element;
|
|
if (scrollHeight <= clientHeight) {
|
|
indicator.style.opacity = "0";
|
|
return;
|
|
}
|
|
|
|
const thumbHeight = Math.max(
|
|
24,
|
|
(clientHeight / scrollHeight) * clientHeight,
|
|
);
|
|
const maxOffset = clientHeight - thumbHeight;
|
|
const offset = (scrollTop / (scrollHeight - clientHeight)) * maxOffset;
|
|
indicator.style.height = `${thumbHeight}px`;
|
|
indicator.style.transform = `translateY(${offset}px)`;
|
|
indicator.style.opacity = "1";
|
|
|
|
if (scrollIdleTimerRef.current !== null) {
|
|
globalThis.clearTimeout(scrollIdleTimerRef.current);
|
|
}
|
|
scrollIdleTimerRef.current = globalThis.setTimeout(() => {
|
|
indicator.style.opacity = "0";
|
|
scrollIdleTimerRef.current = null;
|
|
}, 700);
|
|
},
|
|
[],
|
|
);
|
|
const projectsQuery = useProjectsQuery();
|
|
const identityQuery = useIdentityQuery();
|
|
const projects = projectsQuery.data ?? [];
|
|
const localRepositoriesQuery = useProjectLocalRepositoriesQuery(
|
|
activeCommunity?.reposDir,
|
|
);
|
|
const [filter, setFilter] = React.useState<ProjectsFilter>(() => {
|
|
const storedFilter = readStoredFilter();
|
|
return storedFilter === "mine" || storedFilter === "local"
|
|
? "repositories"
|
|
: storedFilter;
|
|
});
|
|
const activitySummariesQuery = useProjectActivitySummariesQuery(
|
|
filter === "prs" || filter === "issues" || filter === "repositories"
|
|
? []
|
|
: projects,
|
|
);
|
|
const repositoryActivitySummariesQuery = useRepositoryActivitySummariesQuery(
|
|
filter === "repositories" ? projects : [],
|
|
);
|
|
const [repositoryScope, setRepositoryScope] =
|
|
React.useState<ProjectsRepositoryScope>(() => {
|
|
const storedScope = readStoredRepositoryScope();
|
|
return filter === "projects" &&
|
|
(storedScope === "buzz" || storedScope === "linked")
|
|
? "all"
|
|
: storedScope;
|
|
});
|
|
const [pullRequestScope, setPullRequestScope] =
|
|
React.useState<ProjectsWorkItemScope>(() => readStoredPullRequestScope());
|
|
const [issueScope, setIssueScope] = React.useState<ProjectsWorkItemScope>(
|
|
() => readStoredIssueScope(),
|
|
);
|
|
const projectsWorkItemsQuery = useProjectsWorkItemsQuery(
|
|
filter === "all" || filter === "prs" || filter === "issues" ? projects : [],
|
|
);
|
|
// One blobless clone per primary Buzz repository, only while the overview
|
|
// header is visible.
|
|
const snapshotProjects = React.useMemo(
|
|
() =>
|
|
filter === "all"
|
|
? projects.filter(
|
|
(project) =>
|
|
projectRepoHostForProject(project, relayOrigin).kind === "buzz",
|
|
)
|
|
: [],
|
|
[filter, projects, relayOrigin],
|
|
);
|
|
const repoSnapshotsQuery = useProjectsRepoSnapshotsQuery(
|
|
snapshotProjects,
|
|
activeCommunity?.reposDir,
|
|
);
|
|
const memberChannelIds = useMemberChannelIds();
|
|
const repositoryUnavailableReasonFor = useRepositoryUnavailableReasonFor(
|
|
repoSnapshotsQuery.data?.unavailable,
|
|
memberChannelIds,
|
|
);
|
|
const [createProjectOpen, setCreateProjectOpen] = React.useState(false);
|
|
const [createIssueOpen, setCreateIssueOpen] = React.useState(false);
|
|
const [createPullRequestOpen, setCreatePullRequestOpen] =
|
|
React.useState(false);
|
|
const createProjectMutation = useCreateProjectMutation();
|
|
const [storedViewMode, setStoredViewMode] =
|
|
React.useState<ProjectsViewMode | null>(() => readStoredViewMode());
|
|
const [sort, setSort] = React.useState<ProjectsSort>(() => readStoredSort());
|
|
const viewMode =
|
|
storedViewMode ??
|
|
(projects.length > MANY_PROJECTS_THRESHOLD ? "list" : "grid");
|
|
|
|
const projectPubkeys = React.useMemo(
|
|
() => [
|
|
...new Set(
|
|
[
|
|
...projects.flatMap((project) =>
|
|
projectPeople(project, activitySummariesQuery.data?.[project.id]),
|
|
),
|
|
...(projectsWorkItemsQuery.data?.pullRequests.items.flatMap(
|
|
({ pullRequest }) => [
|
|
pullRequest.author,
|
|
...pullRequest.recipients,
|
|
...pullRequest.reviewers,
|
|
...pullRequest.approvals.map((approval) => approval.author),
|
|
...pullRequest.updates.map((update) => update.author),
|
|
...pullRequest.comments.map((comment) => comment.author),
|
|
],
|
|
) ?? []),
|
|
...(projectsWorkItemsQuery.data?.issues.items.flatMap(({ issue }) => [
|
|
issue.author,
|
|
...issue.recipients,
|
|
...issue.comments.map((comment) => comment.author),
|
|
]) ?? []),
|
|
].map(normalizePubkey),
|
|
),
|
|
],
|
|
[activitySummariesQuery.data, projects, projectsWorkItemsQuery.data],
|
|
);
|
|
const profilesQuery = useUsersBatchQuery(projectPubkeys, {
|
|
enabled: projectPubkeys.length > 0,
|
|
});
|
|
const profiles = profilesQuery.data?.profiles;
|
|
const deleteProjectMutation = useDeleteProjectMutation();
|
|
const currentPubkey = identityQuery.data?.pubkey;
|
|
|
|
const handleViewModeChange = React.useCallback(
|
|
(nextViewMode: ProjectsViewMode) => {
|
|
setStoredViewMode(nextViewMode);
|
|
writeStoredViewMode(nextViewMode);
|
|
},
|
|
[],
|
|
);
|
|
|
|
const handleFilterChange = React.useCallback(
|
|
(nextFilter: ProjectsFilter) => {
|
|
if (
|
|
nextFilter === "projects" &&
|
|
(repositoryScope === "buzz" || repositoryScope === "linked")
|
|
) {
|
|
setRepositoryScope("all");
|
|
writeStoredRepositoryScope("all");
|
|
}
|
|
setFilter(nextFilter);
|
|
writeStoredFilter(nextFilter);
|
|
},
|
|
[repositoryScope],
|
|
);
|
|
|
|
const handleRepositoryScopeChange = React.useCallback(
|
|
(scope: ProjectsRepositoryScope) => {
|
|
setRepositoryScope(scope);
|
|
writeStoredRepositoryScope(scope);
|
|
},
|
|
[],
|
|
);
|
|
|
|
const handlePullRequestScopeChange = React.useCallback(
|
|
(scope: ProjectsWorkItemScope) => {
|
|
setPullRequestScope(scope);
|
|
writeStoredPullRequestScope(scope);
|
|
},
|
|
[],
|
|
);
|
|
|
|
const handleIssueScopeChange = React.useCallback(
|
|
(scope: ProjectsWorkItemScope) => {
|
|
setIssueScope(scope);
|
|
writeStoredIssueScope(scope);
|
|
},
|
|
[],
|
|
);
|
|
|
|
const handleSortChange = React.useCallback((nextSort: ProjectsSort) => {
|
|
setSort(nextSort);
|
|
writeStoredSort(nextSort);
|
|
}, []);
|
|
|
|
const localRepoNames = React.useMemo(
|
|
() =>
|
|
new Set(
|
|
(localRepositoriesQuery.data ?? []).map(
|
|
(repository) => repository.name,
|
|
),
|
|
),
|
|
[localRepositoriesQuery.data],
|
|
);
|
|
|
|
const repositoryAccessInput = React.useMemo(
|
|
() => ({
|
|
currentPubkey,
|
|
localRepoNames,
|
|
memberChannelIds,
|
|
relayOrigin,
|
|
}),
|
|
[currentPubkey, localRepoNames, memberChannelIds, relayOrigin],
|
|
);
|
|
|
|
const visibleProjects = React.useMemo(() => {
|
|
if (filter !== "projects" && filter !== "agents" && filter !== "users") {
|
|
return [];
|
|
}
|
|
|
|
const sortedProjects = projects
|
|
.filter((project) => {
|
|
const summary = activitySummariesQuery.data?.[project.id];
|
|
const people = projectPeople(project, summary);
|
|
if (repositoryScope === "accessible")
|
|
return isProjectAccessibleToViewer(project, repositoryAccessInput);
|
|
if (repositoryScope === "mine")
|
|
return isProjectMine(project, currentPubkey);
|
|
if (repositoryScope === "local")
|
|
return hasLocalCheckout(project, localRepoNames);
|
|
if (repositoryScope === "buzz")
|
|
return (
|
|
projectRepoHostForProject(project, relayOrigin).kind === "buzz"
|
|
);
|
|
if (repositoryScope === "linked")
|
|
return (
|
|
projectRepoHostForProject(project, relayOrigin).kind === "external"
|
|
);
|
|
if (filter === "agents") {
|
|
return projectHasAgent(project, people, profiles);
|
|
}
|
|
if (filter === "users") return projectOwnerIsUser(project, profiles);
|
|
return true;
|
|
})
|
|
.sort((left, right) => {
|
|
const leftSummary = activitySummariesQuery.data?.[left.id];
|
|
const rightSummary = activitySummariesQuery.data?.[right.id];
|
|
if (sort === "name") {
|
|
return left.name.localeCompare(right.name);
|
|
}
|
|
if (sort === "created") {
|
|
return right.createdAt - left.createdAt;
|
|
}
|
|
return (
|
|
getProjectUpdatedAt(right, rightSummary) -
|
|
getProjectUpdatedAt(left, leftSummary)
|
|
);
|
|
});
|
|
|
|
return sortedProjects;
|
|
}, [
|
|
activitySummariesQuery.data,
|
|
currentPubkey,
|
|
filter,
|
|
localRepoNames,
|
|
profiles,
|
|
projects,
|
|
relayOrigin,
|
|
repositoryAccessInput,
|
|
repositoryScope,
|
|
sort,
|
|
]);
|
|
|
|
const visibleRepositories = React.useMemo(() => {
|
|
if (filter !== "repositories") return [];
|
|
const repositories = [
|
|
...new Map(
|
|
projects
|
|
.flatMap((project) =>
|
|
project.repositories.map((repository) => ({
|
|
project,
|
|
repository,
|
|
})),
|
|
)
|
|
.map((item) => [item.repository.repoAddress, item]),
|
|
).values(),
|
|
];
|
|
return repositories
|
|
.filter(({ repository }) => {
|
|
if (repositoryScope === "accessible") {
|
|
return isRepositoryAccessibleToViewer(
|
|
repository,
|
|
repositoryAccessInput,
|
|
);
|
|
}
|
|
if (repositoryScope === "mine") {
|
|
if (!currentPubkey) return false;
|
|
const normalizedCurrentPubkey = normalizePubkey(currentPubkey);
|
|
return (
|
|
normalizePubkey(repository.owner) === normalizedCurrentPubkey ||
|
|
repository.contributors.some(
|
|
(pubkey) => normalizePubkey(pubkey) === normalizedCurrentPubkey,
|
|
)
|
|
);
|
|
}
|
|
if (repositoryScope === "local") {
|
|
return hasLocalRepositoryCheckout(repository, localRepoNames);
|
|
}
|
|
if (repositoryScope === "buzz") {
|
|
return (
|
|
projectRepoHostForRepository(repository, relayOrigin).kind ===
|
|
"buzz"
|
|
);
|
|
}
|
|
if (repositoryScope === "linked") {
|
|
return (
|
|
projectRepoHostForRepository(repository, relayOrigin).kind ===
|
|
"external"
|
|
);
|
|
}
|
|
return true;
|
|
})
|
|
.sort((left, right) => {
|
|
if (sort === "name") {
|
|
return left.repository.name.localeCompare(right.repository.name);
|
|
}
|
|
if (sort === "created") {
|
|
return right.repository.createdAt - left.repository.createdAt;
|
|
}
|
|
const leftUpdatedAt =
|
|
repositoryActivitySummariesQuery.data?.[left.repository.repoAddress]
|
|
?.updatedAt ?? left.repository.createdAt;
|
|
const rightUpdatedAt =
|
|
repositoryActivitySummariesQuery.data?.[right.repository.repoAddress]
|
|
?.updatedAt ?? right.repository.createdAt;
|
|
return rightUpdatedAt - leftUpdatedAt;
|
|
});
|
|
}, [
|
|
currentPubkey,
|
|
filter,
|
|
localRepoNames,
|
|
projects,
|
|
relayOrigin,
|
|
repositoryAccessInput,
|
|
repositoryActivitySummariesQuery.data,
|
|
repositoryScope,
|
|
sort,
|
|
]);
|
|
|
|
const visiblePullRequests = React.useMemo(() => {
|
|
const pullRequests = projectsWorkItemsQuery.data?.pullRequests.items ?? [];
|
|
const scopedPullRequests =
|
|
pullRequestScope === "mine" && currentPubkey
|
|
? pullRequests.filter(
|
|
({ pullRequest }) =>
|
|
normalizePubkey(pullRequest.author) ===
|
|
normalizePubkey(currentPubkey),
|
|
)
|
|
: pullRequests;
|
|
return [...scopedPullRequests].sort((left, right) => {
|
|
if (sort === "name") {
|
|
return left.pullRequest.title.localeCompare(right.pullRequest.title);
|
|
}
|
|
if (sort === "created") {
|
|
return right.pullRequest.createdAt - left.pullRequest.createdAt;
|
|
}
|
|
return right.pullRequest.updatedAt - left.pullRequest.updatedAt;
|
|
});
|
|
}, [currentPubkey, projectsWorkItemsQuery.data, pullRequestScope, sort]);
|
|
|
|
const visibleIssues = React.useMemo(() => {
|
|
const issues = projectsWorkItemsQuery.data?.issues.items ?? [];
|
|
const scopedIssues =
|
|
issueScope === "mine" && currentPubkey
|
|
? issues.filter(
|
|
({ issue }) =>
|
|
normalizePubkey(issue.author) === normalizePubkey(currentPubkey),
|
|
)
|
|
: issues;
|
|
return [...scopedIssues].sort((left, right) => {
|
|
if (sort === "name") {
|
|
return left.issue.title.localeCompare(right.issue.title);
|
|
}
|
|
if (sort === "created") {
|
|
return right.issue.createdAt - left.issue.createdAt;
|
|
}
|
|
return right.issue.updatedAt - left.issue.updatedAt;
|
|
});
|
|
}, [currentPubkey, issueScope, projectsWorkItemsQuery.data, sort]);
|
|
|
|
// Route by the canonical `owner:dtag` project ID — a bare dtag is
|
|
// ambiguous across owners (forks can share the same dtag).
|
|
const handleOpenProject = React.useCallback(
|
|
(project: Project) => {
|
|
void goProject(project.id);
|
|
},
|
|
[goProject],
|
|
);
|
|
|
|
const handleOpenRepository = React.useCallback(
|
|
(project: Project, repository: Repository) => {
|
|
void goProject(project.id, { repositoryId: repository.id });
|
|
},
|
|
[goProject],
|
|
);
|
|
|
|
const handleOpenCommit = React.useCallback(
|
|
(project: Project, commitHash: string) => {
|
|
void goProject(project.id, { commitHash });
|
|
},
|
|
[goProject],
|
|
);
|
|
|
|
const handleOpenPullRequest = React.useCallback(
|
|
(
|
|
project: Project,
|
|
repository: Repository,
|
|
pullRequest: ProjectPullRequest,
|
|
) => {
|
|
void goProject(project.id, {
|
|
pullRequestId: pullRequest.id,
|
|
repositoryId: repository.id,
|
|
});
|
|
},
|
|
[goProject],
|
|
);
|
|
|
|
const handleOpenIssue = React.useCallback(
|
|
(project: Project, repository: Repository, issue: ProjectIssue) => {
|
|
void goProject(project.id, {
|
|
issueId: issue.id,
|
|
repositoryId: repository.id,
|
|
});
|
|
},
|
|
[goProject],
|
|
);
|
|
|
|
const openTerminal = useOpenProjectTerminal(activeCommunity?.reposDir);
|
|
const handleOpenTerminal = React.useCallback(
|
|
(project: Project) => {
|
|
const repository = selectProjectRepository(project, null);
|
|
if (!repository) return Promise.resolve();
|
|
return openTerminal(repository, {
|
|
// Check the selected repository only — not all members — so the
|
|
// terminal affordance reflects the repository the button will open.
|
|
hasLocalCheckout: hasLocalRepositoryCheckout(
|
|
repository,
|
|
localRepoNames,
|
|
),
|
|
});
|
|
},
|
|
[localRepoNames, openTerminal],
|
|
);
|
|
const handleOpenRepositoryTerminal = React.useCallback(
|
|
(repository: Repository) =>
|
|
openTerminal(repository, {
|
|
hasLocalCheckout: hasLocalRepositoryCheckout(
|
|
repository,
|
|
localRepoNames,
|
|
),
|
|
}),
|
|
[localRepoNames, openTerminal],
|
|
);
|
|
|
|
const handleDeleteProject = React.useCallback(
|
|
async (project: Project) => {
|
|
try {
|
|
await deleteProjectMutation.mutateAsync(project);
|
|
toast.success("Project deleted");
|
|
} catch (error) {
|
|
toast.error(
|
|
error instanceof Error ? error.message : "Failed to delete project",
|
|
);
|
|
}
|
|
},
|
|
[deleteProjectMutation],
|
|
);
|
|
|
|
if (projectsQuery.isLoading) {
|
|
return <ViewLoadingFallback kind="projects" />;
|
|
}
|
|
|
|
if (projectsQuery.isError) {
|
|
return (
|
|
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-muted-foreground">
|
|
<p className="text-sm text-red-400">Failed to load projects</p>
|
|
<Button
|
|
onClick={() => void projectsQuery.refetch()}
|
|
size="sm"
|
|
variant="outline"
|
|
>
|
|
Retry
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (projects.length === 0) {
|
|
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 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 listHeaderBar = (
|
|
<ProjectsListHeaderBar
|
|
filter={filter}
|
|
variant={viewMode === "list" ? "row" : "bar"}
|
|
issueScope={issueScope}
|
|
onIssueScopeChange={handleIssueScopeChange}
|
|
onPullRequestScopeChange={handlePullRequestScopeChange}
|
|
onRepositoryScopeChange={handleRepositoryScopeChange}
|
|
onSortChange={handleSortChange}
|
|
onViewModeChange={handleViewModeChange}
|
|
pullRequestScope={pullRequestScope}
|
|
repositoryScope={repositoryScope}
|
|
sort={sort}
|
|
viewMode={viewMode}
|
|
/>
|
|
);
|
|
|
|
const workItemFailedSections = [
|
|
...new Set([
|
|
...(projectsWorkItemsQuery.data?.issues.failedSections ?? []),
|
|
...(projectsWorkItemsQuery.data?.pullRequests.failedSections ?? []),
|
|
]),
|
|
];
|
|
const activityFeed = (
|
|
<>
|
|
<ProjectsWorkItemsLoadNotice
|
|
error={projectsWorkItemsQuery.error}
|
|
failedSections={workItemFailedSections}
|
|
isRetrying={
|
|
projectsWorkItemsQuery.isFetching && !projectsWorkItemsQuery.isLoading
|
|
}
|
|
onRetry={() => void projectsWorkItemsQuery.refetch()}
|
|
subject="project activity"
|
|
/>
|
|
<ProjectsActivityFeed
|
|
isLoading={
|
|
repoSnapshotsQuery.isLoading || projectsWorkItemsQuery.isLoading
|
|
}
|
|
issues={projectsWorkItemsQuery.data?.issues.items ?? []}
|
|
onOpenCommit={handleOpenCommit}
|
|
onOpenIssue={handleOpenIssue}
|
|
onOpenProject={handleOpenProject}
|
|
onOpenPullRequest={handleOpenPullRequest}
|
|
profiles={profiles}
|
|
projects={projects}
|
|
pullRequests={projectsWorkItemsQuery.data?.pullRequests.items ?? []}
|
|
snapshots={repoSnapshotsQuery.data?.snapshots}
|
|
/>
|
|
</>
|
|
);
|
|
|
|
const createMenu = (
|
|
<ProjectsCreateMenu
|
|
onCreateIssue={() => setCreateIssueOpen(true)}
|
|
onCreateProject={() => setCreateProjectOpen(true)}
|
|
onCreatePullRequest={() => setCreatePullRequestOpen(true)}
|
|
/>
|
|
);
|
|
|
|
const projectsHeader = (
|
|
<PageHeader
|
|
className="pointer-events-auto mb-8"
|
|
description="Set up and manage your projects."
|
|
title="Projects"
|
|
/>
|
|
);
|
|
|
|
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>
|
|
);
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-tl-xl",
|
|
topChromeInset.divider,
|
|
)}
|
|
>
|
|
{/* Scroll indicator painted over the scrollbar gutter; only visible
|
|
while scrolling (native thumb is transparent). */}
|
|
<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, {
|
|
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={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}
|
|
>
|
|
<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 === "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>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|