From 78744acfa75ff159a1728354bde77645f80eadc5 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Fri, 3 Jul 2026 09:33:41 -0400 Subject: [PATCH 1/5] feat(desktop): aggregate languages and repository totals on projects overview Add Top Languages and Repositories summary cards to the overview's main column, aggregated across every repository instead of a single one. A new throttled useProjectsRepoSnapshotsQuery fans out blobless repo snapshots (3 at a time, cached 15 minutes, only while the overview header is visible) to feed the language tally, file/contributor totals, and latest commit. Language helpers move to a shared lib so the single-project rail and the workspace overview share one implementation. --- .../features/projects/lib/projectLanguages.ts | 45 ++++++ .../projects/ui/ProjectOverviewPanel.tsx | 87 ++++------ .../projects/ui/ProjectsOverviewPanel.tsx | 152 +++++++++++++++++- .../src/features/projects/ui/ProjectsView.tsx | 10 ++ .../projects/useProjectsRepoSnapshots.ts | 61 +++++++ 5 files changed, 299 insertions(+), 56 deletions(-) create mode 100644 desktop/src/features/projects/lib/projectLanguages.ts create mode 100644 desktop/src/features/projects/useProjectsRepoSnapshots.ts diff --git a/desktop/src/features/projects/lib/projectLanguages.ts b/desktop/src/features/projects/lib/projectLanguages.ts new file mode 100644 index 0000000000..bb93d57760 --- /dev/null +++ b/desktop/src/features/projects/lib/projectLanguages.ts @@ -0,0 +1,45 @@ +/** Language display names by file extension, used for language breakdowns. */ +export const LANGUAGE_LABELS: Record = { + css: "CSS", + dart: "Dart", + go: "Go", + html: "HTML", + js: "JavaScript", + json: "JSON", + jsx: "JavaScript", + kt: "Kotlin", + mjs: "JavaScript", + py: "Python", + rb: "Ruby", + rs: "Rust", + swift: "Swift", + ts: "TypeScript", + tsx: "TypeScript", +}; + +/** Dot accent colors cycled through language chips. */ +export const LANGUAGE_DOT_CLASSES = [ + "bg-blue-500", + "bg-violet-500", + "bg-emerald-500", + "bg-orange-500", + "bg-pink-500", +]; + +/** Maps a file path to its language label, or undefined when unknown. */ +export function languageForPath(path: string): string | undefined { + const fileName = path.split("/").pop()?.toLowerCase() ?? ""; + const extension = fileName.includes(".") ? fileName.split(".").pop() : ""; + return extension ? LANGUAGE_LABELS[extension] : undefined; +} + +/** Top-5 languages (label + file count) from a language tally. */ +export function topLanguagesFromCounts( + counts: Record, +): Array<[string, number]> { + return Object.entries(counts) + .sort( + (left, right) => right[1] - left[1] || left[0].localeCompare(right[0]), + ) + .slice(0, 5); +} diff --git a/desktop/src/features/projects/ui/ProjectOverviewPanel.tsx b/desktop/src/features/projects/ui/ProjectOverviewPanel.tsx index dc728bc782..2192a089ad 100644 --- a/desktop/src/features/projects/ui/ProjectOverviewPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectOverviewPanel.tsx @@ -9,6 +9,11 @@ import type { ProjectRepoSnapshot, } from "@/features/projects/hooks"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { + LANGUAGE_DOT_CLASSES, + languageForPath, + topLanguagesFromCounts, +} from "@/features/projects/lib/projectLanguages"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import { ReadmePanel } from "./ProjectRepositoryPanel"; @@ -24,53 +29,17 @@ type ProjectOverviewPanelProps = { snapshot: ProjectRepoSnapshot | null | undefined; }; -const LANGUAGE_LABELS: Record = { - css: "CSS", - dart: "Dart", - go: "Go", - html: "HTML", - js: "JavaScript", - json: "JSON", - jsx: "JavaScript", - kt: "Kotlin", - mjs: "JavaScript", - py: "Python", - rb: "Ruby", - rs: "Rust", - swift: "Swift", - ts: "TypeScript", - tsx: "TypeScript", -}; - -const LANGUAGE_DOT_CLASSES = [ - "bg-blue-500", - "bg-violet-500", - "bg-emerald-500", - "bg-orange-500", - "bg-pink-500", -]; - function shortHash(hash: string | undefined) { return hash ? hash.slice(0, 7) : "None"; } -function languageForPath(path: string) { - const fileName = path.split("/").pop()?.toLowerCase() ?? ""; - const extension = fileName.includes(".") ? fileName.split(".").pop() : ""; - return extension ? LANGUAGE_LABELS[extension] : undefined; -} - function topLanguages(files: ProjectRepoFile[]) { - const counts = new Map(); + const counts: Record = {}; for (const file of files) { const language = languageForPath(file.path); - if (language) counts.set(language, (counts.get(language) ?? 0) + 1); + if (language) counts[language] = (counts[language] ?? 0) + 1; } - return [...counts.entries()] - .sort( - (left, right) => right[1] - left[1] || left[0].localeCompare(right[0]), - ) - .slice(0, 5); + return topLanguagesFromCounts(counts); } function projectPeople(project: Project) { @@ -112,6 +81,30 @@ function PeopleAvatars({ ); } +export function LanguageChips({ + languages, +}: { + languages: Array<[string, number]>; +}) { + return ( +
+ {languages.map(([language], index) => ( + + + {language} + + ))} +
+ ); +} + export function OverviewRailSection({ children, title, @@ -173,21 +166,7 @@ export function ProjectOverviewPanel({ {languages.length > 0 ? ( -
- {languages.map(([language], index) => ( - - - {language} - - ))} -
+ ) : (

No language data is available yet. diff --git a/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx b/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx index 3ccf4479f2..1b913abf57 100644 --- a/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx @@ -1,11 +1,24 @@ -import { CircleDot, FolderGit2, GitPullRequest, Radio } from "lucide-react"; +import { + CircleDot, + FileCode2, + FolderGit2, + GitCommitHorizontal, + GitPullRequest, + Radio, + Users, +} from "lucide-react"; import type * as React from "react"; import { WorkspaceEmojiIcon } from "@/features/workspaces/ui/WorkspaceSwitcher"; import type { Project, ProjectActivitySummary, + ProjectRepoSnapshot, } from "@/features/projects/hooks"; +import { + languageForPath, + topLanguagesFromCounts, +} from "@/features/projects/lib/projectLanguages"; import { resolveUserLabel, type UserProfileLookup, @@ -13,7 +26,7 @@ import { 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 { LanguageChips, OverviewRailSection } from "./ProjectOverviewPanel"; import { ProjectsContributionGraph } from "./ProjectsContributionGraph"; export type ProjectsOverviewSection = @@ -28,6 +41,9 @@ type ProjectsOverviewPanelProps = { profiles?: UserProfileLookup; projects: Project[]; relayName: string; + /** Repo snapshots keyed by project ID, for workspace-wide aggregates. */ + snapshots?: Record; + snapshotsLoading?: boolean; summaries?: Record; }; @@ -79,6 +95,74 @@ function overviewStats( ); } +function overviewLanguages( + snapshots: Record | undefined, +) { + const counts: Record = {}; + for (const snapshot of Object.values(snapshots ?? {})) { + for (const file of snapshot.files) { + const language = languageForPath(file.path); + if (language) counts[language] = (counts[language] ?? 0) + 1; + } + } + return topLanguagesFromCounts(counts); +} + +function overviewRepoTotals( + snapshots: Record | undefined, +) { + const contributorEmails = new Set(); + let files = 0; + let latestCommit: ProjectRepoSnapshot["latestCommit"] = null; + + for (const snapshot of Object.values(snapshots ?? {})) { + files += snapshot.files.length; + for (const contributor of snapshot.contributors) { + contributorEmails.add( + contributor.email.toLowerCase() || contributor.name.toLowerCase(), + ); + } + if ( + snapshot.latestCommit && + snapshot.latestCommit.timestamp > (latestCommit?.timestamp ?? 0) + ) { + latestCommit = snapshot.latestCommit; + } + } + + return { contributors: contributorEmails.size, files, latestCommit }; +} + +function RepoTotalRow({ + icon: Icon, + label, + value, + mono, +}: { + icon?: React.ComponentType<{ className?: string }>; + label: string; + value: React.ReactNode; + mono?: boolean; +}) { + return ( +

+
+ {Icon ? : null} + {label} +
+
+ {value} +
+
+ ); +} + function overviewActivityByDay( projects: Project[], summaries: Record | undefined, @@ -135,11 +219,16 @@ export function ProjectsOverviewPanel({ profiles, projects, relayName, + snapshots, + snapshotsLoading, summaries, }: ProjectsOverviewPanelProps) { const stats = overviewStats(projects, summaries); const people = overviewPeople(projects, summaries); const activityByDay = overviewActivityByDay(projects, summaries); + const languages = overviewLanguages(snapshots); + const repoTotals = overviewRepoTotals(snapshots); + const scanning = Boolean(snapshotsLoading); return (
@@ -202,6 +291,65 @@ export function ProjectsOverviewPanel({ className="mt-3" /> +
+
+

+ Top Languages +

+
+ {languages.length > 0 ? ( + + ) : ( +

+ {scanning + ? "Scanning repositories..." + : "No language data is available yet."} +

+ )} +
+
+
+

+ Repositories +

+
+ + + + + +
+
+
); From 30e9fd987907ba7786aa8590dc8f9a58c0b77895 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Mon, 6 Jul 2026 13:40:35 +0200 Subject: [PATCH 3/5] feat(desktop): prefer local checkouts for overview repo snapshot scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remote git storage can be empty or unreachable (staging relay repos have no refs), leaving the overview language and repository aggregates blank. Scan local checkouts under the workspace repos dir first — no network, no clone — and only fall back to a remote blobless clone for projects without usable local data. --- .../src/features/projects/ui/ProjectsView.tsx | 5 +- .../projects/useProjectsRepoSnapshots.ts | 66 +++++++++++++++---- 2 files changed, 57 insertions(+), 14 deletions(-) diff --git a/desktop/src/features/projects/ui/ProjectsView.tsx b/desktop/src/features/projects/ui/ProjectsView.tsx index 36d535aa0e..be8a06068c 100644 --- a/desktop/src/features/projects/ui/ProjectsView.tsx +++ b/desktop/src/features/projects/ui/ProjectsView.tsx @@ -616,7 +616,10 @@ export function ProjectsView() { () => (filter === "all" ? uniqueRepositories(projects) : []), [filter, projects], ); - const repoSnapshotsQuery = useProjectsRepoSnapshotsQuery(snapshotProjects); + const repoSnapshotsQuery = useProjectsRepoSnapshotsQuery( + snapshotProjects, + activeWorkspace?.reposDir, + ); const [storedViewMode, setStoredViewMode] = React.useState(() => readStoredViewMode()); const [sort, setSort] = React.useState(() => readStoredSort()); diff --git a/desktop/src/features/projects/useProjectsRepoSnapshots.ts b/desktop/src/features/projects/useProjectsRepoSnapshots.ts index 9bc4f93b41..3ad711a807 100644 --- a/desktop/src/features/projects/useProjectsRepoSnapshots.ts +++ b/desktop/src/features/projects/useProjectsRepoSnapshots.ts @@ -1,16 +1,57 @@ import { useQuery } from "@tanstack/react-query"; import * as React from "react"; -import { getProjectRepoSnapshot } from "@/shared/api/projectGit"; +import { + getProjectLocalRepoSnapshot, + getProjectRepoSnapshot, +} from "@/shared/api/projectGit"; import type { ProjectRepoSnapshot } from "@/shared/api/types"; import type { Project } from "./hooks"; -// Repo snapshots are backed by a blobless `git clone` per repository, so the +// Remote snapshots are backed by a blobless `git clone` per repository, so the // overview scan is deliberately throttled and cached for a long time. const OVERVIEW_SNAPSHOT_CONCURRENCY = 3; +function snapshotHasData(snapshot: ProjectRepoSnapshot | null | undefined) { + return Boolean( + snapshot && (snapshot.files.length > 0 || snapshot.latestCommit), + ); +} + +/** + * Local checkouts are instant (no network, no clone) and keep working when + * the relay's git storage is empty or unreachable, so they are preferred. + * Only repositories without usable local data fall back to a remote clone. + */ +async function fetchProjectSnapshot( + project: Project, + reposDir: string | null | undefined, +): Promise { + try { + const local = await getProjectLocalRepoSnapshot({ + reposDir, + projectDtag: project.dtag, + cloneUrl: project.cloneUrls[0] ?? null, + defaultBranch: project.defaultBranch, + baseBranch: project.defaultBranch, + }); + if (snapshotHasData(local?.snapshot)) return local?.snapshot ?? null; + } catch { + // Best-effort: fall through to the remote snapshot. + } + + const cloneUrl = project.cloneUrls[0]; + if (!cloneUrl) return null; + return getProjectRepoSnapshot({ + cloneUrl, + defaultBranch: project.defaultBranch, + baseBranch: project.defaultBranch, + }); +} + async function fetchProjectsRepoSnapshots( projects: Project[], + reposDir: string | null | undefined, ): Promise> { const snapshots: Record = {}; const queue = [...projects]; @@ -21,14 +62,9 @@ async function fetchProjectsRepoSnapshots( for (;;) { const project = queue.shift(); if (!project) return; - const cloneUrl = project.cloneUrls[0]; - if (!cloneUrl) continue; try { - snapshots[project.id] = await getProjectRepoSnapshot({ - cloneUrl, - defaultBranch: project.defaultBranch, - baseBranch: project.defaultBranch, - }); + const snapshot = await fetchProjectSnapshot(project, reposDir); + if (snapshot) snapshots[project.id] = snapshot; } catch { // Best-effort: unreachable or empty repositories are skipped. } @@ -43,9 +79,13 @@ async function fetchProjectsRepoSnapshots( /** * Fetches repo snapshots for a set of projects (throttled, failure-tolerant) * for workspace-wide aggregates like the overview language breakdown. - * Callers should pre-filter and cap `projects` — one git clone per entry. + * Prefers local checkouts under `reposDir`; falls back to remote clones. + * Callers should pre-filter and cap `projects` — up to one git clone per entry. */ -export function useProjectsRepoSnapshotsQuery(projects: Project[]) { +export function useProjectsRepoSnapshotsQuery( + projects: Project[], + reposDir?: string | null, +) { const projectIds = React.useMemo( () => projects.map((project) => project.id).sort(), [projects], @@ -53,8 +93,8 @@ export function useProjectsRepoSnapshotsQuery(projects: Project[]) { return useQuery({ enabled: projects.length > 0, - queryKey: ["projects", "repo-snapshots", projectIds], - queryFn: () => fetchProjectsRepoSnapshots(projects), + queryKey: ["projects", "repo-snapshots", reposDir ?? "default", projectIds], + queryFn: () => fetchProjectsRepoSnapshots(projects, reposDir), staleTime: 15 * 60_000, retry: 0, }); From 10606a0880eea3e74d75fb347d82cb45755e626f Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Mon, 6 Jul 2026 13:41:04 +0200 Subject: [PATCH 4/5] fix(desktop): polish projects overview contribution card and rail headings Drop the redundant caption from the contribution activity header, swap the heatmap cells' native title text for the shared tooltip, and restyle the overview rail headings from uppercase labels to regular text. --- .../projects/ui/ProjectOverviewPanel.tsx | 4 +-- .../projects/ui/ProjectsContributionGraph.tsx | 26 +++++++++++-------- .../projects/ui/ProjectsOverviewPanel.tsx | 11 +++----- 3 files changed, 19 insertions(+), 22 deletions(-) diff --git a/desktop/src/features/projects/ui/ProjectOverviewPanel.tsx b/desktop/src/features/projects/ui/ProjectOverviewPanel.tsx index 2192a089ad..1a6aa155e9 100644 --- a/desktop/src/features/projects/ui/ProjectOverviewPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectOverviewPanel.tsx @@ -114,9 +114,7 @@ export function OverviewRailSection({ }) { return (
-

- {title} -

+

{title}

{children}
); diff --git a/desktop/src/features/projects/ui/ProjectsContributionGraph.tsx b/desktop/src/features/projects/ui/ProjectsContributionGraph.tsx index 53c7c052d5..0070f8dd7b 100644 --- a/desktop/src/features/projects/ui/ProjectsContributionGraph.tsx +++ b/desktop/src/features/projects/ui/ProjectsContributionGraph.tsx @@ -1,4 +1,5 @@ import { cn } from "@/shared/lib/cn"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; const WEEK_COUNT = 26; const DAYS_PER_WEEK = 7; @@ -109,18 +110,21 @@ export function ProjectsContributionGraph({ day: "numeric", }); return ( - 0 + + + + + + {count > 0 ? `${count} ${count === 1 ? "event" : "events"} · ${dateLabel}` - : `No activity · ${dateLabel}` - } - /> + : `No activity · ${dateLabel}`} + + ); }), )} diff --git a/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx b/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx index d951f3363c..ce1639ff89 100644 --- a/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx @@ -278,14 +278,9 @@ export function ProjectsOverviewPanel({
-
-

- Contribution activity -

- - Commits, PRs & issues across all projects - -
+

+ Contribution activity +

Date: Tue, 7 Jul 2026 08:54:15 +0200 Subject: [PATCH 5/5] feat(desktop): add commit detail page with parent diff and full breadcrumbs Clicking a commit in the Commits tab now opens a detail view with the commit header and its diff against the parent, reusing the PR files-changed panel. Breadcrumbs on all work item detail pages (PR, issue, commit) now render the full trail (Projects > project > category > title) with each segment stepping back exactly one level. --- desktop/playwright.config.ts | 1 + .../src/commands/project_git_diff.rs | 61 ++- .../projects/ui/ProjectCommitDetailPanel.tsx | 128 ++++++ .../projects/ui/ProjectDetailFeedPanels.tsx | 25 +- .../projects/ui/ProjectDetailScreen.tsx | 423 +++++------------- .../ProjectPullRequestFilesChangedPanel.tsx | 38 +- .../projects/ui/ProjectWorkspaceTabs.tsx | 318 +++++++++++++ .../features/projects/useProjectCommitDiff.ts | 69 +++ desktop/src/testing/e2eBridge.ts | 43 ++ .../tests/e2e/project-commit-detail.spec.ts | 106 +++++ 10 files changed, 897 insertions(+), 315 deletions(-) create mode 100644 desktop/src/features/projects/ui/ProjectCommitDetailPanel.tsx create mode 100644 desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx create mode 100644 desktop/src/features/projects/useProjectCommitDiff.ts create mode 100644 desktop/tests/e2e/project-commit-detail.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index f5ac116bac..6c5a64b873 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -68,6 +68,7 @@ export default defineConfig({ "**/human-edit-agent-content.spec.ts", "**/reaction-order.spec.ts", "**/send-channel-binding.spec.ts", + "**/project-commit-detail.spec.ts", ], use: { ...devices["Desktop Chrome"], diff --git a/desktop/src-tauri/src/commands/project_git_diff.rs b/desktop/src-tauri/src/commands/project_git_diff.rs index f7b3650a06..9111272211 100644 --- a/desktop/src-tauri/src/commands/project_git_diff.rs +++ b/desktop/src-tauri/src/commands/project_git_diff.rs @@ -188,6 +188,40 @@ fn diff_range( .unwrap_or_else(|_| "HEAD^..HEAD".to_string()) } +/// Range for a single commit against its parent, used by the commit detail +/// view. Root commits fall back to the empty tree so the whole initial tree +/// renders as additions. Errors when the commit is not reachable in the +/// available history — diffing an unrelated ref instead would be misleading. +fn commit_parent_range( + repo_dir: &std::path::Path, + auth: &GitAuthConfig, + commit: &str, +) -> Result { + run_git( + &[ + "rev-parse", + "--verify", + "--quiet", + &format!("{commit}^{{commit}}"), + ], + Some(repo_dir), + auth, + ) + .map_err(|_| format!("commit {commit} was not found in the repository history"))?; + let parent = format!("{commit}^"); + if run_git( + &["rev-parse", "--verify", "--quiet", &parent], + Some(repo_dir), + auth, + ) + .is_ok() + { + return Ok(format!("{parent}..{commit}")); + } + let empty_tree = empty_tree_ref(repo_dir, auth)?; + Ok(format!("{empty_tree}..{commit}")) +} + fn local_ref_exists(repo_dir: &std::path::Path, auth: &GitAuthConfig, ref_name: &str) -> bool { run_git( &["rev-parse", "--verify", "--quiet", ref_name], @@ -274,6 +308,17 @@ fn local_diff_range( format!("{base_ref}..{target_ref}") }; } + // With no base at all, a bare commit means "diff against its parent" + // (commit detail view) rather than against the whole tree. + if base_commit.is_none() && base_branch.is_none() { + if let Some(target_commit) = target_commit { + if local_ref_exists(repo_dir, auth, target_commit) { + if let Ok(range) = commit_parent_range(repo_dir, auth, target_commit) { + return range; + } + } + } + } empty_tree_ref(repo_dir, auth) .map(|empty_tree| format!("{empty_tree}..{target_ref}")) .unwrap_or_else(|_| format!("{target_ref}^..{target_ref}")) @@ -374,11 +419,17 @@ pub async fn get_project_repo_diff( target_ref.as_deref(), target_commit.as_deref(), )?; - let range = diff_range( - &repo_dir, - &auth, - diff_base_ref(&repo_dir, &auth, base_branch.as_deref()), - ); + // A commit with no base branch or target ref means "diff this commit + // against its parent" (commit detail view), not "diff HEAD against a + // base". + let range = match (&target_ref, &base_branch, &target_commit) { + (None, None, Some(commit)) => commit_parent_range(&repo_dir, &auth, commit)?, + _ => diff_range( + &repo_dir, + &auth, + diff_base_ref(&repo_dir, &auth, base_branch.as_deref()), + ), + }; diff_from_repo(&repo_dir, &auth, &range) }) .await diff --git a/desktop/src/features/projects/ui/ProjectCommitDetailPanel.tsx b/desktop/src/features/projects/ui/ProjectCommitDetailPanel.tsx new file mode 100644 index 0000000000..49fd3e4383 --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectCommitDetailPanel.tsx @@ -0,0 +1,128 @@ +import { Check, Copy, GitCommitHorizontal } from "lucide-react"; +import * as React from "react"; + +import { profileForCommitAuthor } from "@/features/projects/lib/projectContributorMatching"; +import { + resolveUserLabel, + type UserProfileLookup, +} from "@/features/profile/lib/identity"; +import type { ProjectRepoCommit, ProjectRepoDiff } from "@/shared/api/types"; +import { Button } from "@/shared/ui/button"; +import { ProfileIdentityButton } from "./ProjectProfileIdentity"; +import { ProjectDiffFilesPanel } from "./ProjectPullRequestFilesChangedPanel"; + +function commitDateLabel(timestamp: number) { + return new Date(timestamp * 1_000).toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "short", + }); +} + +function CopyHashButton({ hash }: { hash: string }) { + const [copied, setCopied] = React.useState(false); + const handleCopy = React.useCallback(() => { + void navigator.clipboard.writeText(hash).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2_000); + }); + }, [hash]); + + return ( + + ); +} + +/** + * Detail view for a single commit: header with author identity and hash, + * followed by the commit-vs-parent diff rendered with the shared changed + * files panel. + */ +export function ProjectCommitDetailPanel({ + commit, + commitHash, + diff, + diffError, + diffLoading, + profiles, +}: { + commit: ProjectRepoCommit | null; + commitHash: string; + diff: ProjectRepoDiff | null | undefined; + diffError: unknown; + diffLoading: boolean; + profiles?: UserProfileLookup; +}) { + const matchedProfile = commit + ? profileForCommitAuthor(commit, profiles) + : null; + const authorLabel = matchedProfile + ? resolveUserLabel({ pubkey: matchedProfile.pubkey, profiles }) + : (commit?.authorName ?? commit?.authorEmail ?? "Unknown author"); + const shortHash = commit?.shortHash ?? commitHash.slice(0, 7); + + return ( +
+
+

+ + Commit from {authorLabel} +

+
+ +
+

+ {commit?.subject ?? shortHash} +

+
+ + {shortHash} + + + {commit ? ( + <> + · + {commitDateLabel(commit.timestamp)} + + ) : null} + {diff ? ( + <> + · + +{diff.additions} + -{diff.deletions} + + ) : null} +
+
+
+
+ + +
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectDetailFeedPanels.tsx b/desktop/src/features/projects/ui/ProjectDetailFeedPanels.tsx index 00f2ee4d33..e2124b36a7 100644 --- a/desktop/src/features/projects/ui/ProjectDetailFeedPanels.tsx +++ b/desktop/src/features/projects/ui/ProjectDetailFeedPanels.tsx @@ -7,6 +7,7 @@ import type { ProjectRepoContributor, ProjectRepoSnapshot, } from "@/features/projects/hooks"; +import type { ProjectRepoCommit } from "@/shared/api/types"; import { resolveUserLabel, type UserProfileLookup, @@ -133,12 +134,14 @@ export function ActivityPanel({ snapshot, isLoading, error, + onSelectCommit, profiles, repoContributors, }: { snapshot: ProjectRepoSnapshot | null | undefined; isLoading: boolean; error: unknown; + onSelectCommit?: (commit: ProjectRepoCommit) => void; profiles?: UserProfileLookup; repoContributors: ProjectRepoContributor[]; }) { @@ -227,11 +230,23 @@ export function ActivityPanel({
-
-

- {commit.subject} -

-
+ {onSelectCommit ? ( + + ) : ( +
+

+ {commit.subject} +

+
+ )} ); diff --git a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx index c8b8140f19..25458511e5 100644 --- a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx +++ b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx @@ -4,7 +4,6 @@ import { ExternalLink, FolderGit2, MessageSquare, - TerminalSquare, } from "lucide-react"; import * as React from "react"; import { toast } from "sonner"; @@ -13,10 +12,6 @@ import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useOpenDmMutation } from "@/features/channels/hooks"; import { type Project, - type ProjectLocalRepoSnapshot, - type ProjectPullRequest, - type ProjectRepoContributor, - type ProjectRepoDiff, type ProjectRepoSnapshot, useProjectQuery, useProjectIssuesQuery, @@ -33,7 +28,6 @@ import { useProfileQuery, useUsersBatchQuery } from "@/features/profile/hooks"; import { mergeCurrentProfileIntoLookup, resolveUserLabel, - type UserProfileLookup, } from "@/features/profile/lib/identity"; import { type ProfilePanelTab, @@ -60,20 +54,8 @@ import { useHistorySearchState } from "@/shared/hooks/useHistorySearchState"; import { useThreadPanelWidth } from "@/shared/hooks/useThreadPanelWidth"; import { Button } from "@/shared/ui/button"; import { useWorkspaces } from "@/features/workspaces/useWorkspaces"; -import { Tabs, TabsContent } from "@/shared/ui/tabs"; -import { findReadmeFile, RepositoryFilesPanel } from "./ProjectRepositoryPanel"; -import { ActivityPanel, ContributorsPanel } from "./ProjectDetailFeedPanels"; -import { ProjectIssuesPanel } from "./ProjectIssuesPanel"; -import { ProjectOverviewPanel } from "./ProjectOverviewPanel"; -import { - PullRequestDetailHeader, - PullRequestsPanel, -} from "./ProjectPullRequestsPanel"; -import { - ProjectTabsList, - PullRequestTabsList, -} from "./ProjectWorkspaceTabList"; -import { ProjectPullRequestFilesChangedPanel } from "./ProjectPullRequestFilesChangedPanel"; +import { useProjectCommitDiffQuery } from "@/features/projects/useProjectCommitDiff"; +import { WorkspaceTabs } from "./ProjectWorkspaceTabs"; import { RepositorySourceCard } from "./ProjectRepositorySource"; import { projectTerminalLabel, @@ -101,252 +83,6 @@ function snapshotHasContent(snapshot: ProjectRepoSnapshot | null | undefined) { ); } -function WorkspaceTabs({ - localSnapshot, - localSnapshotError, - localSnapshotLoading, - project, - repoDiff, - repoDiffError, - repoDiffLoading, - selectedIssueId, - selectedPullRequestId, - pullRequests, - pullRequestsError, - pullRequestsLoading, - onSelectedIssueIdChange, - onSelectedPullRequestIdChange, - onBranchChange, - onOpenTerminal, - snapshot, - snapshotError, - snapshotLoading, - profiles, - repoContributors, - repoSource, - terminalTitle, -}: { - localSnapshot: ProjectLocalRepoSnapshot | null | undefined; - localSnapshotError: unknown; - localSnapshotLoading: boolean; - project: Project; - repoDiff: ProjectRepoDiff | null | undefined; - repoDiffError: unknown; - repoDiffLoading: boolean; - selectedIssueId: string | null; - selectedPullRequestId: string | null; - pullRequests: ProjectPullRequest[]; - pullRequestsError: unknown; - pullRequestsLoading: boolean; - onSelectedIssueIdChange: (id: string | null) => void; - onSelectedPullRequestIdChange: (id: string | null) => void; - onBranchChange: (branch: string | null) => void; - onOpenTerminal?: () => void; - snapshot: ProjectRepoSnapshot | null | undefined; - snapshotError: unknown; - snapshotLoading: boolean; - profiles?: UserProfileLookup; - repoContributors: ProjectRepoContributor[]; - repoSource: "remote" | "local"; - terminalTitle?: string; -}) { - const localCheckoutSnapshot = localSnapshot?.snapshot ?? null; - const displayedSnapshot = - repoSource === "local" ? localCheckoutSnapshot : snapshot; - const displayedSnapshotError = - repoSource === "local" ? localSnapshotError : snapshotError; - const displayedSnapshotLoading = - repoSource === "local" ? localSnapshotLoading : snapshotLoading; - const displayedContributors = - displayedSnapshot?.contributors ?? repoContributors; - const files = displayedSnapshot?.files ?? []; - const readmeFile = React.useMemo(() => findReadmeFile(files), [files]); - const selectedPullRequest = - pullRequests.find( - (pullRequest) => pullRequest.id === selectedPullRequestId, - ) ?? null; - const isPullRequestSelected = Boolean(selectedPullRequest); - const [selectedTab, setSelectedTab] = React.useState("overview"); - - React.useEffect(() => { - if (isPullRequestSelected) { - setSelectedTab((currentTab) => - currentTab.startsWith("pr-") ? currentTab : "pr-conversation", - ); - if (selectedPullRequest?.branchName) { - onBranchChange(selectedPullRequest.branchName); - } - } else { - setSelectedTab((currentTab) => - currentTab.startsWith("pr-") ? "prs" : currentTab, - ); - } - }, [isPullRequestSelected, onBranchChange, selectedPullRequest?.branchName]); - - React.useEffect(() => { - if (selectedIssueId) { - setSelectedTab("issues"); - } - }, [selectedIssueId]); - - const handleTabChange = React.useCallback( - (nextTab: string) => { - setSelectedTab(nextTab); - if (!nextTab.startsWith("pr-") && nextTab !== "prs") { - onSelectedPullRequestIdChange(null); - } - if (nextTab !== "issues") { - onSelectedIssueIdChange(null); - } - }, - [onSelectedIssueIdChange, onSelectedPullRequestIdChange], - ); - - return ( - - {selectedPullRequest ? ( -
- - -
- ) : ( -
- - {onOpenTerminal ? ( - - ) : null} -
- )} - - - setSelectedTab("contributors")} - profiles={profiles} - project={project} - pullRequests={pullRequests} - readmeFile={readmeFile} - snapshot={displayedSnapshot} - /> - - - - - - - - - - - - - - - {(["conversation", "commits", "checks"] as const).map((mode) => ( - - - - ))} - - - {repoSource === "local" && !localSnapshot && !localSnapshotLoading ? ( -
-
- No local checkout found. -
-
- ) : null} - -
- - - - - - - - -
- ); -} - type ProjectDetailScreenProps = { projectId: string; pullRequestId?: string; @@ -403,6 +139,44 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { issueId ?? null, ); React.useEffect(() => setSelectedIssueId(issueId ?? null), [issueId]); + const [selectedCommitHash, setSelectedCommitHash] = React.useState< + string | null + >(null); + // Bumped when breadcrumb navigation should land on the project Overview + // tab; remounts WorkspaceTabs, which owns the selected-tab state. + const [tabsResetKey, setTabsResetKey] = React.useState(0); + // Commit selection has no URL param, so reset it when navigating to a + // different project within the same mounted route. + const commitProjectIdRef = React.useRef(projectId); + React.useEffect(() => { + if (commitProjectIdRef.current !== projectId) { + commitProjectIdRef.current = projectId; + setSelectedCommitHash(null); + } + }, [projectId]); + // Commit, PR, and issue details are mutually exclusive views, so opening + // one clears the others. + const handleSelectedPullRequestIdChange = React.useCallback( + (id: string | null) => { + setSelectedPullRequestId(id); + if (id) setSelectedCommitHash(null); + }, + [], + ); + const handleSelectedIssueIdChange = React.useCallback((id: string | null) => { + setSelectedIssueId(id); + if (id) setSelectedCommitHash(null); + }, []); + const handleSelectedCommitHashChange = React.useCallback( + (hash: string | null) => { + setSelectedCommitHash(hash); + if (hash) { + setSelectedPullRequestId(null); + setSelectedIssueId(null); + } + }, + [], + ); const issuesQuery = useProjectIssuesQuery(project); const selectedBranchPullRequest = React.useMemo( () => @@ -435,6 +209,12 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { activeRepoPullRequest, repoSource === "local" && Boolean(activeRepoPullRequest), ); + const commitDiffQuery = useProjectCommitDiffQuery( + project, + selectedCommitHash, + repoSource, + activeWorkspace?.reposDir, + ); const localRepoSnapshotQuery = useProjectLocalRepoSnapshotQuery( project, activeWorkspace?.reposDir, @@ -463,13 +243,14 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { ? localRepoDiffQuery.isLoading : repoDiffQuery.isLoading; const isWorkItemDetailOpen = Boolean( - selectedPullRequestId || selectedIssueId, + selectedPullRequestId || selectedIssueId || selectedCommitHash, ); React.useEffect(() => { if (!project) { setSelectedBranch(null); setSelectedPullRequestId(null); setSelectedIssueId(null); + setSelectedCommitHash(null); return; } setSelectedBranch((currentBranch) => { @@ -636,6 +417,45 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { null; const selectedIssue = issuesQuery.data?.find((item) => item.id === selectedIssueId) ?? null; + const displayedSnapshotCommits = + repoSource === "local" + ? (localRepoSnapshotQuery.data?.snapshot.commits ?? []) + : (repoSnapshotQuery.data?.commits ?? []); + const selectedCommit = selectedCommitHash + ? (displayedSnapshotCommits.find( + (commit) => commit.hash === selectedCommitHash, + ) ?? null) + : null; + + // The active work item drives the breadcrumb trail: Projects › project › + // category › title. `clear` steps back to the item's list tab. + const activeWorkItemCrumb = selectedPullRequest + ? { + category: "Pull request", + title: selectedPullRequest.title, + clear: () => setSelectedPullRequestId(null), + } + : selectedIssue + ? { + category: "Issue", + title: selectedIssue.title, + clear: () => setSelectedIssueId(null), + } + : selectedCommitHash + ? { + category: "Commit", + title: selectedCommit?.subject ?? selectedCommitHash.slice(0, 7), + clear: () => setSelectedCommitHash(null), + } + : null; + const handleGoToProjectHome = () => { + setSelectedPullRequestId(null); + setSelectedIssueId(null); + setSelectedCommitHash(null); + // Remount the workspace tabs so the project page opens on Overview + // instead of whatever tab the work item left behind. + setTabsResetKey((key) => key + 1); + }; return ( @@ -658,42 +478,40 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { className="-ml-1 flex min-w-0 items-center gap-0.5 text-xs text-muted-foreground" > - {selectedPullRequest ? ( - <> - - - - - - {selectedPullRequest.title} - - - ) : selectedIssue ? ( + + + {activeWorkItemCrumb ? ( <> - {selectedIssue.title} + {activeWorkItemCrumb.title} ) : ( @@ -814,7 +632,10 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { ) : null} + ); +} + +export function ProjectDiffFilesPanel({ + error, + diff, + isLoading, + headerLabel, + subjectLabel, +}: { + error: unknown; + diff: ProjectRepoDiff | null | undefined; + isLoading: boolean; + headerLabel: string; + subjectLabel: string; }) { const [query, setQuery] = React.useState(""); const [selectedPath, setSelectedPath] = React.useState(null); @@ -546,7 +574,7 @@ export function ProjectPullRequestFilesChangedPanel({ const message = errorMessage(error); return (
-

Could not load changed files for this pull request.

+

Could not load changed files for this {subjectLabel}.

{message ? (

{message} @@ -556,10 +584,10 @@ export function ProjectPullRequestFilesChangedPanel({ ); } - if (!pullRequest || files.length === 0) { + if (files.length === 0) { return (

- No changed files are available for this pull request yet. + No changed files are available for this {subjectLabel} yet.
); } @@ -595,9 +623,7 @@ export function ProjectPullRequestFilesChangedPanel({
- - {pullRequest.title} · {pullRequest.commit?.slice(0, 7) ?? "PR"} - + {headerLabel}
{files.length} files changed diff --git a/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx b/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx new file mode 100644 index 0000000000..e00e8bde38 --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx @@ -0,0 +1,318 @@ +import { TerminalSquare } from "lucide-react"; +import * as React from "react"; + +import type { + Project, + ProjectLocalRepoSnapshot, + ProjectPullRequest, + ProjectRepoContributor, + ProjectRepoDiff, + ProjectRepoSnapshot, +} from "@/features/projects/hooks"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { Tabs, TabsContent } from "@/shared/ui/tabs"; +import { findReadmeFile, RepositoryFilesPanel } from "./ProjectRepositoryPanel"; +import { ProjectCommitDetailPanel } from "./ProjectCommitDetailPanel"; +import { ActivityPanel, ContributorsPanel } from "./ProjectDetailFeedPanels"; +import { ProjectIssuesPanel } from "./ProjectIssuesPanel"; +import { ProjectOverviewPanel } from "./ProjectOverviewPanel"; +import { + PullRequestDetailHeader, + PullRequestsPanel, +} from "./ProjectPullRequestsPanel"; +import { + ProjectTabsList, + PullRequestTabsList, +} from "./ProjectWorkspaceTabList"; +import { ProjectPullRequestFilesChangedPanel } from "./ProjectPullRequestFilesChangedPanel"; + +export function WorkspaceTabs({ + commitDiff, + commitDiffError, + commitDiffLoading, + localSnapshot, + localSnapshotError, + localSnapshotLoading, + project, + repoDiff, + repoDiffError, + repoDiffLoading, + selectedCommitHash, + selectedIssueId, + selectedPullRequestId, + pullRequests, + pullRequestsError, + pullRequestsLoading, + onSelectedCommitHashChange, + onSelectedIssueIdChange, + onSelectedPullRequestIdChange, + onBranchChange, + onOpenTerminal, + snapshot, + snapshotError, + snapshotLoading, + profiles, + repoContributors, + repoSource, + terminalTitle, +}: { + commitDiff: ProjectRepoDiff | null | undefined; + commitDiffError: unknown; + commitDiffLoading: boolean; + localSnapshot: ProjectLocalRepoSnapshot | null | undefined; + localSnapshotError: unknown; + localSnapshotLoading: boolean; + project: Project; + repoDiff: ProjectRepoDiff | null | undefined; + repoDiffError: unknown; + repoDiffLoading: boolean; + selectedCommitHash: string | null; + selectedIssueId: string | null; + selectedPullRequestId: string | null; + pullRequests: ProjectPullRequest[]; + pullRequestsError: unknown; + pullRequestsLoading: boolean; + onSelectedCommitHashChange: (hash: string | null) => void; + onSelectedIssueIdChange: (id: string | null) => void; + onSelectedPullRequestIdChange: (id: string | null) => void; + onBranchChange: (branch: string | null) => void; + onOpenTerminal?: () => void; + snapshot: ProjectRepoSnapshot | null | undefined; + snapshotError: unknown; + snapshotLoading: boolean; + profiles?: UserProfileLookup; + repoContributors: ProjectRepoContributor[]; + repoSource: "remote" | "local"; + terminalTitle?: string; +}) { + const localCheckoutSnapshot = localSnapshot?.snapshot ?? null; + const displayedSnapshot = + repoSource === "local" ? localCheckoutSnapshot : snapshot; + const displayedSnapshotError = + repoSource === "local" ? localSnapshotError : snapshotError; + const displayedSnapshotLoading = + repoSource === "local" ? localSnapshotLoading : snapshotLoading; + const displayedContributors = + displayedSnapshot?.contributors ?? repoContributors; + const files = displayedSnapshot?.files ?? []; + const readmeFile = React.useMemo(() => findReadmeFile(files), [files]); + const selectedPullRequest = + pullRequests.find( + (pullRequest) => pullRequest.id === selectedPullRequestId, + ) ?? null; + const isPullRequestSelected = Boolean(selectedPullRequest); + const [selectedTab, setSelectedTab] = React.useState("overview"); + + React.useEffect(() => { + if (isPullRequestSelected) { + setSelectedTab((currentTab) => + currentTab.startsWith("pr-") ? currentTab : "pr-conversation", + ); + if (selectedPullRequest?.branchName) { + onBranchChange(selectedPullRequest.branchName); + } + } else { + setSelectedTab((currentTab) => + currentTab.startsWith("pr-") ? "prs" : currentTab, + ); + } + }, [isPullRequestSelected, onBranchChange, selectedPullRequest?.branchName]); + + React.useEffect(() => { + if (selectedIssueId) { + setSelectedTab("issues"); + } + }, [selectedIssueId]); + + React.useEffect(() => { + if (selectedCommitHash) { + setSelectedTab("activity"); + } + }, [selectedCommitHash]); + + const handleTabChange = React.useCallback( + (nextTab: string) => { + setSelectedTab(nextTab); + if (!nextTab.startsWith("pr-") && nextTab !== "prs") { + onSelectedPullRequestIdChange(null); + } + if (nextTab !== "issues") { + onSelectedIssueIdChange(null); + } + if (nextTab !== "activity") { + onSelectedCommitHashChange(null); + } + }, + [ + onSelectedCommitHashChange, + onSelectedIssueIdChange, + onSelectedPullRequestIdChange, + ], + ); + + return ( + + {selectedPullRequest ? ( +
+ + +
+ ) : ( +
+ + {onOpenTerminal ? ( + + ) : null} +
+ )} + + + setSelectedTab("contributors")} + profiles={profiles} + project={project} + pullRequests={pullRequests} + readmeFile={readmeFile} + snapshot={displayedSnapshot} + /> + + + + {selectedCommitHash ? ( + commit.hash === selectedCommitHash, + ) ?? null + } + commitHash={selectedCommitHash} + diff={commitDiff} + diffError={commitDiffError} + diffLoading={commitDiffLoading} + profiles={profiles} + /> + ) : ( + onSelectedCommitHashChange(commit.hash)} + profiles={profiles} + repoContributors={displayedContributors} + snapshot={displayedSnapshot} + /> + )} + + + + + + + + + + + {(["conversation", "commits", "checks"] as const).map((mode) => ( + + + + ))} + + + {repoSource === "local" && !localSnapshot && !localSnapshotLoading ? ( +
+
+ No local checkout found. +
+
+ ) : null} + +
+ + + + + + + + +
+ ); +} diff --git a/desktop/src/features/projects/useProjectCommitDiff.ts b/desktop/src/features/projects/useProjectCommitDiff.ts new file mode 100644 index 0000000000..ba3ab5fc80 --- /dev/null +++ b/desktop/src/features/projects/useProjectCommitDiff.ts @@ -0,0 +1,69 @@ +import { useQuery } from "@tanstack/react-query"; + +import { + getProjectLocalRepoDiff, + getProjectRepoDiff, +} from "@/shared/api/projectGit"; +import type { ProjectRepoDiff } from "@/shared/api/types"; +import type { Project } from "./hooks"; + +async function fetchProjectCommitDiff( + project: Project, + commitHash: string, + repoSource: "remote" | "local", + reposDir: string | null | undefined, +): Promise { + if (repoSource === "local") { + // Passing only the target commit (no base branch/commit) makes the + // backend diff the commit against its parent. + const local = await getProjectLocalRepoDiff({ + reposDir, + projectDtag: project.dtag, + cloneUrl: project.cloneUrls[0] ?? null, + targetCommit: commitHash, + }); + if (local) return local; + } + + const cloneUrl = project.cloneUrls[0]; + if (!cloneUrl) { + throw new Error("This project has no clone URL to load the commit from."); + } + return getProjectRepoDiff({ + cloneUrl, + defaultBranch: project.defaultBranch, + targetCommit: commitHash, + }); +} + +/** + * Diff of a single commit against its parent, for the commit detail view. + * Prefers the local checkout when the repository source is "local" and falls + * back to a remote fetch when no checkout exists. + */ +export function useProjectCommitDiffQuery( + project: Project | null | undefined, + commitHash: string | null, + repoSource: "remote" | "local", + reposDir?: string | null, +) { + return useQuery({ + enabled: Boolean(project && commitHash), + queryKey: [ + "project", + project?.id ?? "none", + "commit-diff", + repoSource, + commitHash ?? "none", + ], + queryFn: () => { + if (!project || !commitHash) { + return Promise.reject(new Error("No commit selected.")); + } + return fetchProjectCommitDiff(project, commitHash, repoSource, reposDir); + }, + // A commit's diff is immutable, so never refetch it while cached. + staleTime: Number.POSITIVE_INFINITY, + retry: 1, + }); +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index b5e4dfd475..d244be5269 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -8161,6 +8161,49 @@ export function maybeInstallE2eTauriMocks() { }; case "get_project_local_repo_snapshot": return null; + case "get_project_repo_diff": + return { + additions: 27, + deletions: 4, + files: [ + { + path: "desktop/src/features/projects/ui/ProjectDetailScreen.tsx", + additions: 18, + deletions: 3, + patch: [ + "@@ -1,6 +1,8 @@", + ' import { Tabs } from "@/shared/ui/tabs";', + "", + "-function WorkspaceTabs() {", + "+function WorkspaceTabs({ selectedCommitHash }) {", + '+ const [selectedTab, setSelectedTab] = useState("overview");', + "+", + " return (", + ' ', + " ", + ].join("\n"), + truncated: false, + }, + { + path: "desktop/src/features/projects/hooks.ts", + additions: 9, + deletions: 1, + patch: [ + "@@ -10,4 +10,12 @@", + " export function useProjectQuery(projectId) {", + " return useQuery({ queryKey: [projectId] });", + " }", + "+", + "+export function useProjectCommitDiffQuery(project, hash) {", + '+ return useQuery({ queryKey: [project?.id, "commit-diff", hash] });', + "+}", + ].join("\n"), + truncated: false, + }, + ], + }; + case "get_project_local_repo_diff": + return null; case "get_project_repo_sync_status": return { local_path: null, diff --git a/desktop/tests/e2e/project-commit-detail.spec.ts b/desktop/tests/e2e/project-commit-detail.spec.ts new file mode 100644 index 0000000000..14a76151eb --- /dev/null +++ b/desktop/tests/e2e/project-commit-detail.spec.ts @@ -0,0 +1,106 @@ +import { expect, test } from "@playwright/test"; + +import { waitForAnimations } from "../helpers/animations"; +import { installMockBridge } from "../helpers/bridge"; + +const SHOTS = "test-results/project-commit-detail"; + +// The projects surface is a preview feature — opt in before the app mounts. +// Must run before installMockBridge so React reads the override on mount. +async function enableProjectsFeature(page: import("@playwright/test").Page) { + await page.addInitScript(() => { + window.localStorage.setItem( + "buzz-feature-overrides-v1", + JSON.stringify({ projects: true }), + ); + }); +} + +test("commit detail opens from the commits feed with a diff", async ({ + page, +}) => { + await enableProjectsFeature(page); + await installMockBridge(page); + // The preview server is a static file server without SPA fallback, so + // enter at "/" and navigate via the sidebar. + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.getByTestId("open-projects-view").click(); + + // Open the first mock project (dtag "buzz" from the e2e bridge fixture). + const projectEntry = page + .locator( + '[data-testid="project-card-buzz"], [data-testid="project-row-buzz"]', + ) + .first(); + await expect(projectEntry).toBeVisible({ timeout: 10_000 }); + await projectEntry.click(); + + await page.getByRole("tab", { name: "Commits" }).click(); + const commitRows = page.getByTestId("project-activity-feed-item"); + await expect(commitRows.first()).toBeVisible({ timeout: 10_000 }); + + // Open the newest commit via its subject button. + await commitRows + .first() + .getByRole("button", { name: /Add Trello board workflow details/ }) + .click(); + + // Detail header: author line, subject, and hash. + await expect(page.getByText("Commit from")).toBeVisible(); + await expect( + page.getByRole("heading", { name: "Add Trello board workflow details" }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Copy commit hash" }), + ).toBeVisible(); + + // Diff from the mocked get_project_repo_diff renders changed files. + await expect(page.getByText("2 changed files")).toBeVisible({ + timeout: 10_000, + }); + await expect( + page.getByText("WorkspaceTabs({ selectedCommitHash })"), + ).toBeVisible(); + + await waitForAnimations(page); + await page.screenshot({ + fullPage: false, + path: `${SHOTS}/01-commit-detail.png`, + }); + + // Breadcrumb category segment steps back to the commits feed. + await page.getByRole("button", { name: "Commit", exact: true }).click(); + await expect(commitRows.first()).toBeVisible(); + + // The back arrow also steps back one level (detail → project page), + // not all the way to the projects overview. + await commitRows + .first() + .getByRole("button", { name: /Add Trello board workflow details/ }) + .click(); + await expect(page.getByText("Commit from")).toBeVisible(); + await page.getByRole("button", { name: "Back to buzz" }).click(); + await expect(commitRows.first()).toBeVisible(); + + // The project-name segment goes to the project home (Overview tab). + await commitRows + .first() + .getByRole("button", { name: /Add Trello board workflow details/ }) + .click(); + await expect(page.getByText("Commit from")).toBeVisible(); + await page + .getByRole("navigation", { name: "Project breadcrumb" }) + .getByRole("button", { name: "buzz", exact: true }) + .click(); + await expect(page.getByRole("tab", { name: "Overview" })).toHaveAttribute( + "aria-selected", + "true", + ); + + // The Projects root segment leaves the project entirely. + await page + .getByRole("navigation", { name: "Project breadcrumb" }) + .getByRole("button", { name: "Projects", exact: true }) + .click(); + await expect(projectEntry).toBeVisible(); +});