mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user