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.
This commit is contained in:
Thomas Petersen
2026-07-07 08:54:15 +02:00
parent e1256eaa9f
commit 1df5a8a894
10 changed files with 897 additions and 315 deletions
+1
View File
@@ -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"],
@@ -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<String, String> {
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
@@ -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 (
<Button
aria-label="Copy commit hash"
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground"
onClick={handleCopy}
size="icon-xs"
variant="ghost"
>
{copied ? (
<Check className="h-3.5 w-3.5 text-green-500" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</Button>
);
}
/**
* 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 (
<div className="space-y-3">
<header className="space-y-2 rounded-xl border border-border/50 bg-card/60 p-4">
<p className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<GitCommitHorizontal className="h-3.5 w-3.5" />
Commit from {authorLabel}
</p>
<div className="flex min-w-0 items-start gap-3">
<ProfileIdentityButton
avatarClassName="mt-0.5 shrink-0"
avatarSize="md"
avatarUrl={matchedProfile?.profile.avatarUrl ?? null}
isAgent={matchedProfile?.profile.isAgent === true}
label={authorLabel}
pubkey={matchedProfile?.pubkey ?? null}
showLabel={false}
/>
<div className="min-w-0 flex-1 space-y-1">
<h3 className="line-clamp-2 text-base font-semibold text-foreground">
{commit?.subject ?? shortHash}
</h3>
<div className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs leading-4 text-muted-foreground">
<span className="flex items-center gap-0.5 font-mono">
{shortHash}
<CopyHashButton hash={commit?.hash ?? commitHash} />
</span>
{commit ? (
<>
<span>·</span>
<span>{commitDateLabel(commit.timestamp)}</span>
</>
) : null}
{diff ? (
<>
<span>·</span>
<span className="text-green-500">+{diff.additions}</span>
<span className="text-destructive">-{diff.deletions}</span>
</>
) : null}
</div>
</div>
</div>
</header>
<ProjectDiffFilesPanel
diff={diff}
error={diffError}
headerLabel={`${commit?.subject ?? "Commit"} · ${shortHash}`}
isLoading={diffLoading}
subjectLabel="commit"
/>
</div>
);
}
@@ -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({
</time>
</div>
</div>
<div className="rounded-lg border border-border/50 bg-background/45 px-3 py-1.5">
<p className="line-clamp-2 text-sm font-medium leading-5 text-foreground">
{commit.subject}
</p>
</div>
{onSelectCommit ? (
<button
className="block w-full rounded-lg border border-border/50 bg-background/45 px-3 py-1.5 text-left transition-colors hover:border-border hover:bg-muted/40 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => onSelectCommit(commit)}
type="button"
>
<p className="line-clamp-2 text-sm font-medium leading-5 text-foreground">
{commit.subject}
</p>
</button>
) : (
<div className="rounded-lg border border-border/50 bg-background/45 px-3 py-1.5">
<p className="line-clamp-2 text-sm font-medium leading-5 text-foreground">
{commit.subject}
</p>
</div>
)}
</div>
</article>
);
@@ -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 (
<Tabs
className="space-y-3"
onValueChange={handleTabChange}
value={selectedTab}
>
{selectedPullRequest ? (
<div className="space-y-4">
<PullRequestDetailHeader
profiles={profiles}
project={project}
pullRequest={selectedPullRequest}
/>
<PullRequestTabsList
filesCount={repoDiff?.files.length ?? files.length}
pullRequest={selectedPullRequest}
/>
</div>
) : (
<div className="flex min-w-0 items-center justify-between gap-2">
<ProjectTabsList />
{onOpenTerminal ? (
<Button
className="h-8 shrink-0 gap-1.5 rounded-full px-3 text-muted-foreground hover:text-foreground"
onClick={onOpenTerminal}
size="sm"
title={terminalTitle}
variant="ghost"
>
<TerminalSquare className="h-3.5 w-3.5" />
Terminal
</Button>
) : null}
</div>
)}
<TabsContent className="m-0" value="overview">
<ProjectOverviewPanel
contributors={displayedContributors}
files={files}
onViewContributors={() => setSelectedTab("contributors")}
profiles={profiles}
project={project}
pullRequests={pullRequests}
readmeFile={readmeFile}
snapshot={displayedSnapshot}
/>
</TabsContent>
<TabsContent
className="m-0 overflow-hidden rounded-xl border border-border/50 bg-card/60"
value="activity"
>
<ActivityPanel
error={displayedSnapshotError}
isLoading={displayedSnapshotLoading}
profiles={profiles}
repoContributors={displayedContributors}
snapshot={displayedSnapshot}
/>
</TabsContent>
<TabsContent
className="m-0 overflow-hidden rounded-xl border border-border/50 bg-card/60"
value="prs"
>
<PullRequestsPanel
error={pullRequestsError}
isLoading={pullRequestsLoading}
onSelectedPullRequestIdChange={onSelectedPullRequestIdChange}
profiles={profiles}
project={project}
pullRequests={pullRequests}
selectedPullRequestId={selectedPullRequestId}
/>
</TabsContent>
<TabsContent
className="m-0 overflow-hidden rounded-xl border border-border/50 bg-card/60"
value="issues"
>
<ProjectIssuesPanel
onSelectedIssueIdChange={onSelectedIssueIdChange}
profiles={profiles}
project={project}
selectedIssueId={selectedIssueId}
/>
</TabsContent>
{(["conversation", "commits", "checks"] as const).map((mode) => (
<TabsContent
className="m-0 overflow-hidden rounded-xl border border-border/50 bg-card/60"
key={mode}
value={`pr-${mode}`}
>
<PullRequestsPanel
error={pullRequestsError}
isLoading={pullRequestsLoading}
mode={mode}
onSelectedPullRequestIdChange={onSelectedPullRequestIdChange}
profiles={profiles}
project={project}
pullRequests={pullRequests}
selectedPullRequestId={selectedPullRequestId}
/>
</TabsContent>
))}
<TabsContent className="m-0" value="files">
{repoSource === "local" && !localSnapshot && !localSnapshotLoading ? (
<div className="mb-3">
<div className="rounded-xl border border-border/50 bg-card/60 p-4 text-sm text-muted-foreground">
No local checkout found.
</div>
</div>
) : null}
<RepositoryFilesPanel
error={displayedSnapshotError}
fallbackAuthorPubkey={project.owner}
files={files}
isLoading={displayedSnapshotLoading}
profiles={profiles}
snapshot={displayedSnapshot}
/>
</TabsContent>
<TabsContent className="m-0" value="pr-files">
<ProjectPullRequestFilesChangedPanel
diff={repoDiff}
error={repoDiffError}
isLoading={repoDiffLoading}
pullRequest={selectedPullRequest}
/>
</TabsContent>
<TabsContent className="m-0" value="contributors">
<ContributorsPanel
profiles={profiles}
repoContributors={displayedContributors}
/>
</TabsContent>
</Tabs>
);
}
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 (
<ProfilePanelProvider onOpenProfilePanel={handleOpenProfilePanel}>
@@ -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"
>
<button
aria-label="Back to projects"
aria-label={
activeWorkItemCrumb
? `Back to ${project.name}`
: "Back to projects"
}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => {
void goProjects();
// One step back: work item detail → project page,
// project page → all projects.
if (activeWorkItemCrumb) {
activeWorkItemCrumb.clear();
} else {
void goProjects();
}
}}
type="button"
>
<ArrowLeft className="h-3.5 w-3.5" />
</button>
{selectedPullRequest ? (
<button
className="shrink-0 rounded-md px-0.5 py-1 font-medium transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => {
void goProjects();
}}
type="button"
>
Projects
</button>
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground/60" />
{activeWorkItemCrumb ? (
<>
<button
className="min-w-0 truncate rounded-md px-0.5 py-1 font-medium transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => setSelectedPullRequestId(null)}
onClick={handleGoToProjectHome}
type="button"
>
{project.name}
@@ -679,36 +519,14 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground/60" />
<button
className="shrink-0 rounded-md px-0.5 py-1 font-medium transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => setSelectedPullRequestId(null)}
onClick={activeWorkItemCrumb.clear}
type="button"
>
Pull request
{activeWorkItemCrumb.category}
</button>
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground/60" />
<span className="min-w-0 truncate px-0.5 font-medium text-foreground">
{selectedPullRequest.title}
</span>
</>
) : selectedIssue ? (
<>
<button
className="min-w-0 truncate rounded-md px-0.5 py-1 font-medium transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => setSelectedIssueId(null)}
type="button"
>
{project.name}
</button>
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground/60" />
<button
className="shrink-0 rounded-md px-0.5 py-1 font-medium transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => setSelectedIssueId(null)}
type="button"
>
Issue
</button>
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground/60" />
<span className="min-w-0 truncate px-0.5 font-medium text-foreground">
{selectedIssue.title}
{activeWorkItemCrumb.title}
</span>
</>
) : (
@@ -814,7 +632,10 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
) : null}
<WorkspaceTabs
key={project.id}
key={`${project.id}:${tabsResetKey}`}
commitDiff={commitDiffQuery.data}
commitDiffError={commitDiffQuery.error}
commitDiffLoading={commitDiffQuery.isLoading}
localSnapshot={localRepoSnapshotQuery.data}
localSnapshotError={localRepoSnapshotQuery.error}
localSnapshotLoading={localRepoSnapshotQuery.isLoading}
@@ -823,8 +644,11 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
void handleOpenTerminal();
}}
terminalTitle={projectTerminalLabel(hasLocalCheckout)}
onSelectedIssueIdChange={setSelectedIssueId}
onSelectedPullRequestIdChange={setSelectedPullRequestId}
onSelectedCommitHashChange={handleSelectedCommitHashChange}
onSelectedIssueIdChange={handleSelectedIssueIdChange}
onSelectedPullRequestIdChange={
handleSelectedPullRequestIdChange
}
profiles={profiles}
project={project}
repoDiff={displayedRepoDiff}
@@ -835,6 +659,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
pullRequestsLoading={pullRequestsQuery.isLoading}
repoContributors={repoContributors}
repoSource={repoSource}
selectedCommitHash={selectedCommitHash}
selectedIssueId={selectedIssueId}
selectedPullRequestId={selectedPullRequestId}
snapshot={repoSnapshotQuery.data}
@@ -500,6 +500,34 @@ export function ProjectPullRequestFilesChangedPanel({
diff: ProjectRepoDiff | null | undefined;
isLoading: boolean;
pullRequest: ProjectPullRequest | null;
}) {
return (
<ProjectDiffFilesPanel
diff={pullRequest ? diff : null}
error={error}
headerLabel={
pullRequest
? `${pullRequest.title} · ${pullRequest.commit?.slice(0, 7) ?? "PR"}`
: ""
}
isLoading={isLoading}
subjectLabel="pull request"
/>
);
}
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<string | null>(null);
@@ -546,7 +574,7 @@ export function ProjectPullRequestFilesChangedPanel({
const message = errorMessage(error);
return (
<div className="space-y-1 rounded-xl border border-border/50 bg-card/60 p-4 text-sm text-muted-foreground">
<p>Could not load changed files for this pull request.</p>
<p>Could not load changed files for this {subjectLabel}.</p>
{message ? (
<p className="font-mono text-xs text-muted-foreground/80">
{message}
@@ -556,10 +584,10 @@ export function ProjectPullRequestFilesChangedPanel({
);
}
if (!pullRequest || files.length === 0) {
if (files.length === 0) {
return (
<div className="rounded-xl border border-border/50 bg-card/60 p-6 text-center text-sm text-muted-foreground">
No changed files are available for this pull request yet.
No changed files are available for this {subjectLabel} yet.
</div>
);
}
@@ -595,9 +623,7 @@ export function ProjectPullRequestFilesChangedPanel({
<div className="flex min-h-12 flex-wrap items-center justify-between gap-3 border-border/50 border-b bg-background/30 px-4 py-2 text-xs text-muted-foreground">
<div className="flex min-w-0 items-center gap-2">
<GitCommitHorizontal className="h-3.5 w-3.5" />
<span className="truncate">
{pullRequest.title} · {pullRequest.commit?.slice(0, 7) ?? "PR"}
</span>
<span className="truncate">{headerLabel}</span>
</div>
<div className="flex items-center gap-3">
<span>{files.length} files changed</span>
@@ -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 (
<Tabs
className="space-y-3"
onValueChange={handleTabChange}
value={selectedTab}
>
{selectedPullRequest ? (
<div className="space-y-4">
<PullRequestDetailHeader
profiles={profiles}
project={project}
pullRequest={selectedPullRequest}
/>
<PullRequestTabsList
filesCount={repoDiff?.files.length ?? files.length}
pullRequest={selectedPullRequest}
/>
</div>
) : (
<div className="flex min-w-0 items-center justify-between gap-2">
<ProjectTabsList />
{onOpenTerminal ? (
<Button
className="h-8 shrink-0 gap-1.5 rounded-full px-3 text-muted-foreground hover:text-foreground"
onClick={onOpenTerminal}
size="sm"
title={terminalTitle}
variant="ghost"
>
<TerminalSquare className="h-3.5 w-3.5" />
Terminal
</Button>
) : null}
</div>
)}
<TabsContent className="m-0" value="overview">
<ProjectOverviewPanel
contributors={displayedContributors}
files={files}
onViewContributors={() => setSelectedTab("contributors")}
profiles={profiles}
project={project}
pullRequests={pullRequests}
readmeFile={readmeFile}
snapshot={displayedSnapshot}
/>
</TabsContent>
<TabsContent
className={cn(
"m-0",
!selectedCommitHash &&
"overflow-hidden rounded-xl border border-border/50 bg-card/60",
)}
value="activity"
>
{selectedCommitHash ? (
<ProjectCommitDetailPanel
commit={
displayedSnapshot?.commits.find(
(commit) => commit.hash === selectedCommitHash,
) ?? null
}
commitHash={selectedCommitHash}
diff={commitDiff}
diffError={commitDiffError}
diffLoading={commitDiffLoading}
profiles={profiles}
/>
) : (
<ActivityPanel
error={displayedSnapshotError}
isLoading={displayedSnapshotLoading}
onSelectCommit={(commit) => onSelectedCommitHashChange(commit.hash)}
profiles={profiles}
repoContributors={displayedContributors}
snapshot={displayedSnapshot}
/>
)}
</TabsContent>
<TabsContent
className="m-0 overflow-hidden rounded-xl border border-border/50 bg-card/60"
value="prs"
>
<PullRequestsPanel
error={pullRequestsError}
isLoading={pullRequestsLoading}
onSelectedPullRequestIdChange={onSelectedPullRequestIdChange}
profiles={profiles}
project={project}
pullRequests={pullRequests}
selectedPullRequestId={selectedPullRequestId}
/>
</TabsContent>
<TabsContent
className="m-0 overflow-hidden rounded-xl border border-border/50 bg-card/60"
value="issues"
>
<ProjectIssuesPanel
onSelectedIssueIdChange={onSelectedIssueIdChange}
profiles={profiles}
project={project}
selectedIssueId={selectedIssueId}
/>
</TabsContent>
{(["conversation", "commits", "checks"] as const).map((mode) => (
<TabsContent
className="m-0 overflow-hidden rounded-xl border border-border/50 bg-card/60"
key={mode}
value={`pr-${mode}`}
>
<PullRequestsPanel
error={pullRequestsError}
isLoading={pullRequestsLoading}
mode={mode}
onSelectedPullRequestIdChange={onSelectedPullRequestIdChange}
profiles={profiles}
project={project}
pullRequests={pullRequests}
selectedPullRequestId={selectedPullRequestId}
/>
</TabsContent>
))}
<TabsContent className="m-0" value="files">
{repoSource === "local" && !localSnapshot && !localSnapshotLoading ? (
<div className="mb-3">
<div className="rounded-xl border border-border/50 bg-card/60 p-4 text-sm text-muted-foreground">
No local checkout found.
</div>
</div>
) : null}
<RepositoryFilesPanel
error={displayedSnapshotError}
fallbackAuthorPubkey={project.owner}
files={files}
isLoading={displayedSnapshotLoading}
profiles={profiles}
snapshot={displayedSnapshot}
/>
</TabsContent>
<TabsContent className="m-0" value="pr-files">
<ProjectPullRequestFilesChangedPanel
diff={repoDiff}
error={repoDiffError}
isLoading={repoDiffLoading}
pullRequest={selectedPullRequest}
/>
</TabsContent>
<TabsContent className="m-0" value="contributors">
<ContributorsPanel
profiles={profiles}
repoContributors={displayedContributors}
/>
</TabsContent>
</Tabs>
);
}
@@ -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<ProjectRepoDiff> {
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,
});
}
+43
View File
@@ -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 (",
' <Tabs value="overview">',
" <ProjectTabsList />",
].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,
@@ -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();
});