From 1df5a8a894df9af56e2709cbbc1237f3bd256917 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Tue, 7 Jul 2026 08:54:15 +0200 Subject: [PATCH] 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 f5ac116ba..6c5a64b87 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 f7b3650a0..911127221 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 000000000..49fd3e438 --- /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 00f2ee4d3..e2124b36a 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 c8b8140f1..25458511e 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,20 +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 ? ( + + + {activeWorkItemCrumb ? ( <> - {selectedPullRequest.title} - - - ) : selectedIssue ? ( - <> - - - - - - {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 000000000..e00e8bde3 --- /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 000000000..ba3ab5fc8 --- /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 b5e4dfd47..d244be526 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 000000000..14a76151e --- /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(); +});