diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index a1461f52c5..320ef058c9 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -73,6 +73,7 @@ export default defineConfig({ "**/human-edit-agent-content.spec.ts", "**/reaction-order.spec.ts", "**/send-channel-binding.spec.ts", + "**/project-commit-detail.spec.ts", "**/persona-model-combobox-screenshots.spec.ts", "**/drafts-screenshots.spec.ts", "**/channel-sort.spec.ts", diff --git a/desktop/src-tauri/src/commands/project_git_diff.rs b/desktop/src-tauri/src/commands/project_git_diff.rs index f7b3650a06..9111272211 100644 --- a/desktop/src-tauri/src/commands/project_git_diff.rs +++ b/desktop/src-tauri/src/commands/project_git_diff.rs @@ -188,6 +188,40 @@ fn diff_range( .unwrap_or_else(|_| "HEAD^..HEAD".to_string()) } +/// Range for a single commit against its parent, used by the commit detail +/// view. Root commits fall back to the empty tree so the whole initial tree +/// renders as additions. Errors when the commit is not reachable in the +/// available history — diffing an unrelated ref instead would be misleading. +fn commit_parent_range( + repo_dir: &std::path::Path, + auth: &GitAuthConfig, + commit: &str, +) -> Result { + run_git( + &[ + "rev-parse", + "--verify", + "--quiet", + &format!("{commit}^{{commit}}"), + ], + Some(repo_dir), + auth, + ) + .map_err(|_| format!("commit {commit} was not found in the repository history"))?; + let parent = format!("{commit}^"); + if run_git( + &["rev-parse", "--verify", "--quiet", &parent], + Some(repo_dir), + auth, + ) + .is_ok() + { + return Ok(format!("{parent}..{commit}")); + } + let empty_tree = empty_tree_ref(repo_dir, auth)?; + Ok(format!("{empty_tree}..{commit}")) +} + fn local_ref_exists(repo_dir: &std::path::Path, auth: &GitAuthConfig, ref_name: &str) -> bool { run_git( &["rev-parse", "--verify", "--quiet", ref_name], @@ -274,6 +308,17 @@ fn local_diff_range( format!("{base_ref}..{target_ref}") }; } + // With no base at all, a bare commit means "diff against its parent" + // (commit detail view) rather than against the whole tree. + if base_commit.is_none() && base_branch.is_none() { + if let Some(target_commit) = target_commit { + if local_ref_exists(repo_dir, auth, target_commit) { + if let Ok(range) = commit_parent_range(repo_dir, auth, target_commit) { + return range; + } + } + } + } empty_tree_ref(repo_dir, auth) .map(|empty_tree| format!("{empty_tree}..{target_ref}")) .unwrap_or_else(|_| format!("{target_ref}^..{target_ref}")) @@ -374,11 +419,17 @@ pub async fn get_project_repo_diff( target_ref.as_deref(), target_commit.as_deref(), )?; - let range = diff_range( - &repo_dir, - &auth, - diff_base_ref(&repo_dir, &auth, base_branch.as_deref()), - ); + // A commit with no base branch or target ref means "diff this commit + // against its parent" (commit detail view), not "diff HEAD against a + // base". + let range = match (&target_ref, &base_branch, &target_commit) { + (None, None, Some(commit)) => commit_parent_range(&repo_dir, &auth, commit)?, + _ => diff_range( + &repo_dir, + &auth, + diff_base_ref(&repo_dir, &auth, base_branch.as_deref()), + ), + }; diff_from_repo(&repo_dir, &auth, &range) }) .await diff --git a/desktop/src/features/projects/lib/projectLanguages.ts b/desktop/src/features/projects/lib/projectLanguages.ts new file mode 100644 index 0000000000..bb93d57760 --- /dev/null +++ b/desktop/src/features/projects/lib/projectLanguages.ts @@ -0,0 +1,45 @@ +/** Language display names by file extension, used for language breakdowns. */ +export const LANGUAGE_LABELS: Record = { + css: "CSS", + dart: "Dart", + go: "Go", + html: "HTML", + js: "JavaScript", + json: "JSON", + jsx: "JavaScript", + kt: "Kotlin", + mjs: "JavaScript", + py: "Python", + rb: "Ruby", + rs: "Rust", + swift: "Swift", + ts: "TypeScript", + tsx: "TypeScript", +}; + +/** Dot accent colors cycled through language chips. */ +export const LANGUAGE_DOT_CLASSES = [ + "bg-blue-500", + "bg-violet-500", + "bg-emerald-500", + "bg-orange-500", + "bg-pink-500", +]; + +/** Maps a file path to its language label, or undefined when unknown. */ +export function languageForPath(path: string): string | undefined { + const fileName = path.split("/").pop()?.toLowerCase() ?? ""; + const extension = fileName.includes(".") ? fileName.split(".").pop() : ""; + return extension ? LANGUAGE_LABELS[extension] : undefined; +} + +/** Top-5 languages (label + file count) from a language tally. */ +export function topLanguagesFromCounts( + counts: Record, +): Array<[string, number]> { + return Object.entries(counts) + .sort( + (left, right) => right[1] - left[1] || left[0].localeCompare(right[0]), + ) + .slice(0, 5); +} diff --git a/desktop/src/features/projects/ui/ProjectCommitDetailPanel.tsx b/desktop/src/features/projects/ui/ProjectCommitDetailPanel.tsx new file mode 100644 index 0000000000..49fd3e4383 --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectCommitDetailPanel.tsx @@ -0,0 +1,128 @@ +import { Check, Copy, GitCommitHorizontal } from "lucide-react"; +import * as React from "react"; + +import { profileForCommitAuthor } from "@/features/projects/lib/projectContributorMatching"; +import { + resolveUserLabel, + type UserProfileLookup, +} from "@/features/profile/lib/identity"; +import type { ProjectRepoCommit, ProjectRepoDiff } from "@/shared/api/types"; +import { Button } from "@/shared/ui/button"; +import { ProfileIdentityButton } from "./ProjectProfileIdentity"; +import { ProjectDiffFilesPanel } from "./ProjectPullRequestFilesChangedPanel"; + +function commitDateLabel(timestamp: number) { + return new Date(timestamp * 1_000).toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "short", + }); +} + +function CopyHashButton({ hash }: { hash: string }) { + const [copied, setCopied] = React.useState(false); + const handleCopy = React.useCallback(() => { + void navigator.clipboard.writeText(hash).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2_000); + }); + }, [hash]); + + return ( + + ); +} + +/** + * Detail view for a single commit: header with author identity and hash, + * followed by the commit-vs-parent diff rendered with the shared changed + * files panel. + */ +export function ProjectCommitDetailPanel({ + commit, + commitHash, + diff, + diffError, + diffLoading, + profiles, +}: { + commit: ProjectRepoCommit | null; + commitHash: string; + diff: ProjectRepoDiff | null | undefined; + diffError: unknown; + diffLoading: boolean; + profiles?: UserProfileLookup; +}) { + const matchedProfile = commit + ? profileForCommitAuthor(commit, profiles) + : null; + const authorLabel = matchedProfile + ? resolveUserLabel({ pubkey: matchedProfile.pubkey, profiles }) + : (commit?.authorName ?? commit?.authorEmail ?? "Unknown author"); + const shortHash = commit?.shortHash ?? commitHash.slice(0, 7); + + return ( +
+
+

+ + Commit from {authorLabel} +

+
+ +
+

+ {commit?.subject ?? shortHash} +

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

- {commit.subject} -

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

+ {commit.subject} +

+
+ )} ); diff --git a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx index c8b8140f19..25458511e5 100644 --- a/desktop/src/features/projects/ui/ProjectDetailScreen.tsx +++ b/desktop/src/features/projects/ui/ProjectDetailScreen.tsx @@ -4,7 +4,6 @@ import { ExternalLink, FolderGit2, MessageSquare, - TerminalSquare, } from "lucide-react"; import * as React from "react"; import { toast } from "sonner"; @@ -13,10 +12,6 @@ import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useOpenDmMutation } from "@/features/channels/hooks"; import { type Project, - type ProjectLocalRepoSnapshot, - type ProjectPullRequest, - type ProjectRepoContributor, - type ProjectRepoDiff, type ProjectRepoSnapshot, useProjectQuery, useProjectIssuesQuery, @@ -33,7 +28,6 @@ import { useProfileQuery, useUsersBatchQuery } from "@/features/profile/hooks"; import { mergeCurrentProfileIntoLookup, resolveUserLabel, - type UserProfileLookup, } from "@/features/profile/lib/identity"; import { type ProfilePanelTab, @@ -60,20 +54,8 @@ import { useHistorySearchState } from "@/shared/hooks/useHistorySearchState"; import { useThreadPanelWidth } from "@/shared/hooks/useThreadPanelWidth"; import { Button } from "@/shared/ui/button"; import { useWorkspaces } from "@/features/workspaces/useWorkspaces"; -import { Tabs, TabsContent } from "@/shared/ui/tabs"; -import { findReadmeFile, RepositoryFilesPanel } from "./ProjectRepositoryPanel"; -import { ActivityPanel, ContributorsPanel } from "./ProjectDetailFeedPanels"; -import { ProjectIssuesPanel } from "./ProjectIssuesPanel"; -import { ProjectOverviewPanel } from "./ProjectOverviewPanel"; -import { - PullRequestDetailHeader, - PullRequestsPanel, -} from "./ProjectPullRequestsPanel"; -import { - ProjectTabsList, - PullRequestTabsList, -} from "./ProjectWorkspaceTabList"; -import { ProjectPullRequestFilesChangedPanel } from "./ProjectPullRequestFilesChangedPanel"; +import { useProjectCommitDiffQuery } from "@/features/projects/useProjectCommitDiff"; +import { WorkspaceTabs } from "./ProjectWorkspaceTabs"; import { RepositorySourceCard } from "./ProjectRepositorySource"; import { projectTerminalLabel, @@ -101,252 +83,6 @@ function snapshotHasContent(snapshot: ProjectRepoSnapshot | null | undefined) { ); } -function WorkspaceTabs({ - localSnapshot, - localSnapshotError, - localSnapshotLoading, - project, - repoDiff, - repoDiffError, - repoDiffLoading, - selectedIssueId, - selectedPullRequestId, - pullRequests, - pullRequestsError, - pullRequestsLoading, - onSelectedIssueIdChange, - onSelectedPullRequestIdChange, - onBranchChange, - onOpenTerminal, - snapshot, - snapshotError, - snapshotLoading, - profiles, - repoContributors, - repoSource, - terminalTitle, -}: { - localSnapshot: ProjectLocalRepoSnapshot | null | undefined; - localSnapshotError: unknown; - localSnapshotLoading: boolean; - project: Project; - repoDiff: ProjectRepoDiff | null | undefined; - repoDiffError: unknown; - repoDiffLoading: boolean; - selectedIssueId: string | null; - selectedPullRequestId: string | null; - pullRequests: ProjectPullRequest[]; - pullRequestsError: unknown; - pullRequestsLoading: boolean; - onSelectedIssueIdChange: (id: string | null) => void; - onSelectedPullRequestIdChange: (id: string | null) => void; - onBranchChange: (branch: string | null) => void; - onOpenTerminal?: () => void; - snapshot: ProjectRepoSnapshot | null | undefined; - snapshotError: unknown; - snapshotLoading: boolean; - profiles?: UserProfileLookup; - repoContributors: ProjectRepoContributor[]; - repoSource: "remote" | "local"; - terminalTitle?: string; -}) { - const localCheckoutSnapshot = localSnapshot?.snapshot ?? null; - const displayedSnapshot = - repoSource === "local" ? localCheckoutSnapshot : snapshot; - const displayedSnapshotError = - repoSource === "local" ? localSnapshotError : snapshotError; - const displayedSnapshotLoading = - repoSource === "local" ? localSnapshotLoading : snapshotLoading; - const displayedContributors = - displayedSnapshot?.contributors ?? repoContributors; - const files = displayedSnapshot?.files ?? []; - const readmeFile = React.useMemo(() => findReadmeFile(files), [files]); - const selectedPullRequest = - pullRequests.find( - (pullRequest) => pullRequest.id === selectedPullRequestId, - ) ?? null; - const isPullRequestSelected = Boolean(selectedPullRequest); - const [selectedTab, setSelectedTab] = React.useState("overview"); - - React.useEffect(() => { - if (isPullRequestSelected) { - setSelectedTab((currentTab) => - currentTab.startsWith("pr-") ? currentTab : "pr-conversation", - ); - if (selectedPullRequest?.branchName) { - onBranchChange(selectedPullRequest.branchName); - } - } else { - setSelectedTab((currentTab) => - currentTab.startsWith("pr-") ? "prs" : currentTab, - ); - } - }, [isPullRequestSelected, onBranchChange, selectedPullRequest?.branchName]); - - React.useEffect(() => { - if (selectedIssueId) { - setSelectedTab("issues"); - } - }, [selectedIssueId]); - - const handleTabChange = React.useCallback( - (nextTab: string) => { - setSelectedTab(nextTab); - if (!nextTab.startsWith("pr-") && nextTab !== "prs") { - onSelectedPullRequestIdChange(null); - } - if (nextTab !== "issues") { - onSelectedIssueIdChange(null); - } - }, - [onSelectedIssueIdChange, onSelectedPullRequestIdChange], - ); - - return ( - - {selectedPullRequest ? ( -
- - -
- ) : ( -
- - {onOpenTerminal ? ( - - ) : null} -
- )} - - - setSelectedTab("contributors")} - profiles={profiles} - project={project} - pullRequests={pullRequests} - readmeFile={readmeFile} - snapshot={displayedSnapshot} - /> - - - - - - - - - - - - - - - {(["conversation", "commits", "checks"] as const).map((mode) => ( - - - - ))} - - - {repoSource === "local" && !localSnapshot && !localSnapshotLoading ? ( -
-
- No local checkout found. -
-
- ) : null} - -
- - - - - - - - -
- ); -} - type ProjectDetailScreenProps = { projectId: string; pullRequestId?: string; @@ -403,6 +139,44 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { issueId ?? null, ); React.useEffect(() => setSelectedIssueId(issueId ?? null), [issueId]); + const [selectedCommitHash, setSelectedCommitHash] = React.useState< + string | null + >(null); + // Bumped when breadcrumb navigation should land on the project Overview + // tab; remounts WorkspaceTabs, which owns the selected-tab state. + const [tabsResetKey, setTabsResetKey] = React.useState(0); + // Commit selection has no URL param, so reset it when navigating to a + // different project within the same mounted route. + const commitProjectIdRef = React.useRef(projectId); + React.useEffect(() => { + if (commitProjectIdRef.current !== projectId) { + commitProjectIdRef.current = projectId; + setSelectedCommitHash(null); + } + }, [projectId]); + // Commit, PR, and issue details are mutually exclusive views, so opening + // one clears the others. + const handleSelectedPullRequestIdChange = React.useCallback( + (id: string | null) => { + setSelectedPullRequestId(id); + if (id) setSelectedCommitHash(null); + }, + [], + ); + const handleSelectedIssueIdChange = React.useCallback((id: string | null) => { + setSelectedIssueId(id); + if (id) setSelectedCommitHash(null); + }, []); + const handleSelectedCommitHashChange = React.useCallback( + (hash: string | null) => { + setSelectedCommitHash(hash); + if (hash) { + setSelectedPullRequestId(null); + setSelectedIssueId(null); + } + }, + [], + ); const issuesQuery = useProjectIssuesQuery(project); const selectedBranchPullRequest = React.useMemo( () => @@ -435,6 +209,12 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { activeRepoPullRequest, repoSource === "local" && Boolean(activeRepoPullRequest), ); + const commitDiffQuery = useProjectCommitDiffQuery( + project, + selectedCommitHash, + repoSource, + activeWorkspace?.reposDir, + ); const localRepoSnapshotQuery = useProjectLocalRepoSnapshotQuery( project, activeWorkspace?.reposDir, @@ -463,13 +243,14 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { ? localRepoDiffQuery.isLoading : repoDiffQuery.isLoading; const isWorkItemDetailOpen = Boolean( - selectedPullRequestId || selectedIssueId, + selectedPullRequestId || selectedIssueId || selectedCommitHash, ); React.useEffect(() => { if (!project) { setSelectedBranch(null); setSelectedPullRequestId(null); setSelectedIssueId(null); + setSelectedCommitHash(null); return; } setSelectedBranch((currentBranch) => { @@ -636,6 +417,45 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { null; const selectedIssue = issuesQuery.data?.find((item) => item.id === selectedIssueId) ?? null; + const displayedSnapshotCommits = + repoSource === "local" + ? (localRepoSnapshotQuery.data?.snapshot.commits ?? []) + : (repoSnapshotQuery.data?.commits ?? []); + const selectedCommit = selectedCommitHash + ? (displayedSnapshotCommits.find( + (commit) => commit.hash === selectedCommitHash, + ) ?? null) + : null; + + // The active work item drives the breadcrumb trail: Projects › project › + // category › title. `clear` steps back to the item's list tab. + const activeWorkItemCrumb = selectedPullRequest + ? { + category: "Pull request", + title: selectedPullRequest.title, + clear: () => setSelectedPullRequestId(null), + } + : selectedIssue + ? { + category: "Issue", + title: selectedIssue.title, + clear: () => setSelectedIssueId(null), + } + : selectedCommitHash + ? { + category: "Commit", + title: selectedCommit?.subject ?? selectedCommitHash.slice(0, 7), + clear: () => setSelectedCommitHash(null), + } + : null; + const handleGoToProjectHome = () => { + setSelectedPullRequestId(null); + setSelectedIssueId(null); + setSelectedCommitHash(null); + // Remount the workspace tabs so the project page opens on Overview + // instead of whatever tab the work item left behind. + setTabsResetKey((key) => key + 1); + }; return ( @@ -658,42 +478,40 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { className="-ml-1 flex min-w-0 items-center gap-0.5 text-xs text-muted-foreground" > - {selectedPullRequest ? ( - <> - - - - - - {selectedPullRequest.title} - - - ) : selectedIssue ? ( + + + {activeWorkItemCrumb ? ( <> - {selectedIssue.title} + {activeWorkItemCrumb.title} ) : ( @@ -814,7 +632,10 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) { ) : null} = { - css: "CSS", - dart: "Dart", - go: "Go", - html: "HTML", - js: "JavaScript", - json: "JSON", - jsx: "JavaScript", - kt: "Kotlin", - mjs: "JavaScript", - py: "Python", - rb: "Ruby", - rs: "Rust", - swift: "Swift", - ts: "TypeScript", - tsx: "TypeScript", -}; - -const LANGUAGE_DOT_CLASSES = [ - "bg-blue-500", - "bg-violet-500", - "bg-emerald-500", - "bg-orange-500", - "bg-pink-500", -]; - function shortHash(hash: string | undefined) { return hash ? hash.slice(0, 7) : "None"; } -function languageForPath(path: string) { - const fileName = path.split("/").pop()?.toLowerCase() ?? ""; - const extension = fileName.includes(".") ? fileName.split(".").pop() : ""; - return extension ? LANGUAGE_LABELS[extension] : undefined; -} - function topLanguages(files: ProjectRepoFile[]) { - const counts = new Map(); + const counts: Record = {}; for (const file of files) { const language = languageForPath(file.path); - if (language) counts.set(language, (counts.get(language) ?? 0) + 1); + if (language) counts[language] = (counts[language] ?? 0) + 1; } - return [...counts.entries()] - .sort( - (left, right) => right[1] - left[1] || left[0].localeCompare(right[0]), - ) - .slice(0, 5); + return topLanguagesFromCounts(counts); } function projectPeople(project: Project) { @@ -112,6 +81,30 @@ function PeopleAvatars({ ); } +export function LanguageChips({ + languages, +}: { + languages: Array<[string, number]>; +}) { + return ( +
+ {languages.map(([language], index) => ( + + + {language} + + ))} +
+ ); +} + export function OverviewRailSection({ children, title, @@ -121,9 +114,7 @@ export function OverviewRailSection({ }) { return (
-

- {title} -

+

{title}

{children}
); @@ -173,21 +164,7 @@ export function ProjectOverviewPanel({ {languages.length > 0 ? ( -
- {languages.map(([language], index) => ( - - - {language} - - ))} -
+ ) : (

No language data is available yet. diff --git a/desktop/src/features/projects/ui/ProjectPullRequestFilesChangedPanel.tsx b/desktop/src/features/projects/ui/ProjectPullRequestFilesChangedPanel.tsx index 6b9d907f76..c1852c1454 100644 --- a/desktop/src/features/projects/ui/ProjectPullRequestFilesChangedPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectPullRequestFilesChangedPanel.tsx @@ -500,6 +500,34 @@ export function ProjectPullRequestFilesChangedPanel({ diff: ProjectRepoDiff | null | undefined; isLoading: boolean; pullRequest: ProjectPullRequest | null; +}) { + return ( + + ); +} + +export function ProjectDiffFilesPanel({ + error, + diff, + isLoading, + headerLabel, + subjectLabel, +}: { + error: unknown; + diff: ProjectRepoDiff | null | undefined; + isLoading: boolean; + headerLabel: string; + subjectLabel: string; }) { const [query, setQuery] = React.useState(""); const [selectedPath, setSelectedPath] = React.useState(null); @@ -546,7 +574,7 @@ export function ProjectPullRequestFilesChangedPanel({ const message = errorMessage(error); return (

-

Could not load changed files for this pull request.

+

Could not load changed files for this {subjectLabel}.

{message ? (

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

- No changed files are available for this pull request yet. + No changed files are available for this {subjectLabel} yet.
); } @@ -595,9 +623,7 @@ export function ProjectPullRequestFilesChangedPanel({
- - {pullRequest.title} · {pullRequest.commit?.slice(0, 7) ?? "PR"} - + {headerLabel}
{files.length} files changed diff --git a/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx b/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx new file mode 100644 index 0000000000..e00e8bde38 --- /dev/null +++ b/desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx @@ -0,0 +1,318 @@ +import { TerminalSquare } from "lucide-react"; +import * as React from "react"; + +import type { + Project, + ProjectLocalRepoSnapshot, + ProjectPullRequest, + ProjectRepoContributor, + ProjectRepoDiff, + ProjectRepoSnapshot, +} from "@/features/projects/hooks"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { Tabs, TabsContent } from "@/shared/ui/tabs"; +import { findReadmeFile, RepositoryFilesPanel } from "./ProjectRepositoryPanel"; +import { ProjectCommitDetailPanel } from "./ProjectCommitDetailPanel"; +import { ActivityPanel, ContributorsPanel } from "./ProjectDetailFeedPanels"; +import { ProjectIssuesPanel } from "./ProjectIssuesPanel"; +import { ProjectOverviewPanel } from "./ProjectOverviewPanel"; +import { + PullRequestDetailHeader, + PullRequestsPanel, +} from "./ProjectPullRequestsPanel"; +import { + ProjectTabsList, + PullRequestTabsList, +} from "./ProjectWorkspaceTabList"; +import { ProjectPullRequestFilesChangedPanel } from "./ProjectPullRequestFilesChangedPanel"; + +export function WorkspaceTabs({ + commitDiff, + commitDiffError, + commitDiffLoading, + localSnapshot, + localSnapshotError, + localSnapshotLoading, + project, + repoDiff, + repoDiffError, + repoDiffLoading, + selectedCommitHash, + selectedIssueId, + selectedPullRequestId, + pullRequests, + pullRequestsError, + pullRequestsLoading, + onSelectedCommitHashChange, + onSelectedIssueIdChange, + onSelectedPullRequestIdChange, + onBranchChange, + onOpenTerminal, + snapshot, + snapshotError, + snapshotLoading, + profiles, + repoContributors, + repoSource, + terminalTitle, +}: { + commitDiff: ProjectRepoDiff | null | undefined; + commitDiffError: unknown; + commitDiffLoading: boolean; + localSnapshot: ProjectLocalRepoSnapshot | null | undefined; + localSnapshotError: unknown; + localSnapshotLoading: boolean; + project: Project; + repoDiff: ProjectRepoDiff | null | undefined; + repoDiffError: unknown; + repoDiffLoading: boolean; + selectedCommitHash: string | null; + selectedIssueId: string | null; + selectedPullRequestId: string | null; + pullRequests: ProjectPullRequest[]; + pullRequestsError: unknown; + pullRequestsLoading: boolean; + onSelectedCommitHashChange: (hash: string | null) => void; + onSelectedIssueIdChange: (id: string | null) => void; + onSelectedPullRequestIdChange: (id: string | null) => void; + onBranchChange: (branch: string | null) => void; + onOpenTerminal?: () => void; + snapshot: ProjectRepoSnapshot | null | undefined; + snapshotError: unknown; + snapshotLoading: boolean; + profiles?: UserProfileLookup; + repoContributors: ProjectRepoContributor[]; + repoSource: "remote" | "local"; + terminalTitle?: string; +}) { + const localCheckoutSnapshot = localSnapshot?.snapshot ?? null; + const displayedSnapshot = + repoSource === "local" ? localCheckoutSnapshot : snapshot; + const displayedSnapshotError = + repoSource === "local" ? localSnapshotError : snapshotError; + const displayedSnapshotLoading = + repoSource === "local" ? localSnapshotLoading : snapshotLoading; + const displayedContributors = + displayedSnapshot?.contributors ?? repoContributors; + const files = displayedSnapshot?.files ?? []; + const readmeFile = React.useMemo(() => findReadmeFile(files), [files]); + const selectedPullRequest = + pullRequests.find( + (pullRequest) => pullRequest.id === selectedPullRequestId, + ) ?? null; + const isPullRequestSelected = Boolean(selectedPullRequest); + const [selectedTab, setSelectedTab] = React.useState("overview"); + + React.useEffect(() => { + if (isPullRequestSelected) { + setSelectedTab((currentTab) => + currentTab.startsWith("pr-") ? currentTab : "pr-conversation", + ); + if (selectedPullRequest?.branchName) { + onBranchChange(selectedPullRequest.branchName); + } + } else { + setSelectedTab((currentTab) => + currentTab.startsWith("pr-") ? "prs" : currentTab, + ); + } + }, [isPullRequestSelected, onBranchChange, selectedPullRequest?.branchName]); + + React.useEffect(() => { + if (selectedIssueId) { + setSelectedTab("issues"); + } + }, [selectedIssueId]); + + React.useEffect(() => { + if (selectedCommitHash) { + setSelectedTab("activity"); + } + }, [selectedCommitHash]); + + const handleTabChange = React.useCallback( + (nextTab: string) => { + setSelectedTab(nextTab); + if (!nextTab.startsWith("pr-") && nextTab !== "prs") { + onSelectedPullRequestIdChange(null); + } + if (nextTab !== "issues") { + onSelectedIssueIdChange(null); + } + if (nextTab !== "activity") { + onSelectedCommitHashChange(null); + } + }, + [ + onSelectedCommitHashChange, + onSelectedIssueIdChange, + onSelectedPullRequestIdChange, + ], + ); + + return ( + + {selectedPullRequest ? ( +
+ + +
+ ) : ( +
+ + {onOpenTerminal ? ( + + ) : null} +
+ )} + + + setSelectedTab("contributors")} + profiles={profiles} + project={project} + pullRequests={pullRequests} + readmeFile={readmeFile} + snapshot={displayedSnapshot} + /> + + + + {selectedCommitHash ? ( + commit.hash === selectedCommitHash, + ) ?? null + } + commitHash={selectedCommitHash} + diff={commitDiff} + diffError={commitDiffError} + diffLoading={commitDiffLoading} + profiles={profiles} + /> + ) : ( + onSelectedCommitHashChange(commit.hash)} + profiles={profiles} + repoContributors={displayedContributors} + snapshot={displayedSnapshot} + /> + )} + + + + + + + + + + + {(["conversation", "commits", "checks"] as const).map((mode) => ( + + + + ))} + + + {repoSource === "local" && !localSnapshot && !localSnapshotLoading ? ( +
+
+ No local checkout found. +
+
+ ) : null} + +
+ + + + + + + + +
+ ); +} diff --git a/desktop/src/features/projects/ui/ProjectsContributionGraph.tsx b/desktop/src/features/projects/ui/ProjectsContributionGraph.tsx index 53c7c052d5..0070f8dd7b 100644 --- a/desktop/src/features/projects/ui/ProjectsContributionGraph.tsx +++ b/desktop/src/features/projects/ui/ProjectsContributionGraph.tsx @@ -1,4 +1,5 @@ import { cn } from "@/shared/lib/cn"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; const WEEK_COUNT = 26; const DAYS_PER_WEEK = 7; @@ -109,18 +110,21 @@ export function ProjectsContributionGraph({ day: "numeric", }); return ( - 0 + + + + + + {count > 0 ? `${count} ${count === 1 ? "event" : "events"} · ${dateLabel}` - : `No activity · ${dateLabel}` - } - /> + : `No activity · ${dateLabel}`} + + ); }), )} diff --git a/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx b/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx index 3ccf4479f2..ce1639ff89 100644 --- a/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx +++ b/desktop/src/features/projects/ui/ProjectsOverviewPanel.tsx @@ -1,11 +1,24 @@ -import { CircleDot, FolderGit2, GitPullRequest, Radio } from "lucide-react"; +import { + CircleDot, + FileCode2, + FolderGit2, + GitCommitHorizontal, + GitPullRequest, + Radio, + Users, +} from "lucide-react"; import type * as React from "react"; import { WorkspaceEmojiIcon } from "@/features/workspaces/ui/WorkspaceSwitcher"; import type { Project, ProjectActivitySummary, + ProjectRepoSnapshot, } from "@/features/projects/hooks"; +import { + languageForPath, + topLanguagesFromCounts, +} from "@/features/projects/lib/projectLanguages"; import { resolveUserLabel, type UserProfileLookup, @@ -13,7 +26,7 @@ import { import { normalizePubkey } from "@/shared/lib/pubkey"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { UserAvatar } from "@/shared/ui/UserAvatar"; -import { OverviewRailSection } from "./ProjectOverviewPanel"; +import { LanguageChips, OverviewRailSection } from "./ProjectOverviewPanel"; import { ProjectsContributionGraph } from "./ProjectsContributionGraph"; export type ProjectsOverviewSection = @@ -28,6 +41,9 @@ type ProjectsOverviewPanelProps = { profiles?: UserProfileLookup; projects: Project[]; relayName: string; + /** Repo snapshots keyed by project ID, for workspace-wide aggregates. */ + snapshots?: Record; + snapshotsLoading?: boolean; summaries?: Record; }; @@ -79,6 +95,74 @@ function overviewStats( ); } +function overviewLanguages( + snapshots: Record | undefined, +) { + const counts: Record = {}; + for (const snapshot of Object.values(snapshots ?? {})) { + for (const file of snapshot.files) { + const language = languageForPath(file.path); + if (language) counts[language] = (counts[language] ?? 0) + 1; + } + } + return topLanguagesFromCounts(counts); +} + +function overviewRepoTotals( + snapshots: Record | undefined, +) { + const contributorEmails = new Set(); + let files = 0; + let latestCommit: ProjectRepoSnapshot["latestCommit"] = null; + + for (const snapshot of Object.values(snapshots ?? {})) { + files += snapshot.files.length; + for (const contributor of snapshot.contributors) { + contributorEmails.add( + contributor.email.toLowerCase() || contributor.name.toLowerCase(), + ); + } + if ( + snapshot.latestCommit && + snapshot.latestCommit.timestamp > (latestCommit?.timestamp ?? 0) + ) { + latestCommit = snapshot.latestCommit; + } + } + + return { contributors: contributorEmails.size, files, latestCommit }; +} + +function RepoTotalRow({ + icon: Icon, + label, + value, + mono, +}: { + icon?: React.ComponentType<{ className?: string }>; + label: string; + value: React.ReactNode; + mono?: boolean; +}) { + return ( +
+
+ {Icon ? : null} + {label} +
+
+ {value} +
+
+ ); +} + function overviewActivityByDay( projects: Project[], summaries: Record | undefined, @@ -135,11 +219,16 @@ export function ProjectsOverviewPanel({ profiles, projects, relayName, + snapshots, + snapshotsLoading, summaries, }: ProjectsOverviewPanelProps) { const stats = overviewStats(projects, summaries); const people = overviewPeople(projects, summaries); const activityByDay = overviewActivityByDay(projects, summaries); + const languages = overviewLanguages(snapshots); + const repoTotals = overviewRepoTotals(snapshots); + const scanning = Boolean(snapshotsLoading); return (
@@ -189,14 +278,9 @@ export function ProjectsOverviewPanel({
-
-

- Contribution activity -

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

+ Contribution activity +

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

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

+ )} +
+ +
+ + + + + +
+
); diff --git a/desktop/src/features/projects/ui/ProjectsView.tsx b/desktop/src/features/projects/ui/ProjectsView.tsx index 1759ffaab8..be8a06068c 100644 --- a/desktop/src/features/projects/ui/ProjectsView.tsx +++ b/desktop/src/features/projects/ui/ProjectsView.tsx @@ -29,6 +29,7 @@ import { useProjectsPullRequestsQuery, useProjectsQuery, } from "@/features/projects/hooks"; +import { useProjectsRepoSnapshotsQuery } from "@/features/projects/useProjectsRepoSnapshots"; import { ProjectsIssuesList } from "@/features/projects/ui/ProjectsIssuesList"; import { ProjectsOverviewPanel } from "@/features/projects/ui/ProjectsOverviewPanel"; import { ProjectsPullRequestsList } from "@/features/projects/ui/ProjectsPullRequestsList"; @@ -609,6 +610,16 @@ export function ProjectsView() { const projectIssuesQuery = useProjectsIssuesQuery( filter === "issues" ? projects : [], ); + // One blobless clone per unique repository — only scan while the overview + // header (filter === "all") is actually visible. + const snapshotProjects = React.useMemo( + () => (filter === "all" ? uniqueRepositories(projects) : []), + [filter, projects], + ); + const repoSnapshotsQuery = useProjectsRepoSnapshotsQuery( + snapshotProjects, + activeWorkspace?.reposDir, + ); const [storedViewMode, setStoredViewMode] = React.useState(() => readStoredViewMode()); const [sort, setSort] = React.useState(() => readStoredSort()); @@ -845,6 +856,8 @@ export function ProjectsView() { profiles={profiles} projects={projects} relayName={activeWorkspace?.name || "Relay"} + snapshots={repoSnapshotsQuery.data} + snapshotsLoading={repoSnapshotsQuery.isLoading} summaries={activitySummariesQuery.data} /> ) : null} diff --git a/desktop/src/features/projects/useProjectCommitDiff.ts b/desktop/src/features/projects/useProjectCommitDiff.ts new file mode 100644 index 0000000000..ba3ab5fc80 --- /dev/null +++ b/desktop/src/features/projects/useProjectCommitDiff.ts @@ -0,0 +1,69 @@ +import { useQuery } from "@tanstack/react-query"; + +import { + getProjectLocalRepoDiff, + getProjectRepoDiff, +} from "@/shared/api/projectGit"; +import type { ProjectRepoDiff } from "@/shared/api/types"; +import type { Project } from "./hooks"; + +async function fetchProjectCommitDiff( + project: Project, + commitHash: string, + repoSource: "remote" | "local", + reposDir: string | null | undefined, +): Promise { + if (repoSource === "local") { + // Passing only the target commit (no base branch/commit) makes the + // backend diff the commit against its parent. + const local = await getProjectLocalRepoDiff({ + reposDir, + projectDtag: project.dtag, + cloneUrl: project.cloneUrls[0] ?? null, + targetCommit: commitHash, + }); + if (local) return local; + } + + const cloneUrl = project.cloneUrls[0]; + if (!cloneUrl) { + throw new Error("This project has no clone URL to load the commit from."); + } + return getProjectRepoDiff({ + cloneUrl, + defaultBranch: project.defaultBranch, + targetCommit: commitHash, + }); +} + +/** + * Diff of a single commit against its parent, for the commit detail view. + * Prefers the local checkout when the repository source is "local" and falls + * back to a remote fetch when no checkout exists. + */ +export function useProjectCommitDiffQuery( + project: Project | null | undefined, + commitHash: string | null, + repoSource: "remote" | "local", + reposDir?: string | null, +) { + return useQuery({ + enabled: Boolean(project && commitHash), + queryKey: [ + "project", + project?.id ?? "none", + "commit-diff", + repoSource, + commitHash ?? "none", + ], + queryFn: () => { + if (!project || !commitHash) { + return Promise.reject(new Error("No commit selected.")); + } + return fetchProjectCommitDiff(project, commitHash, repoSource, reposDir); + }, + // A commit's diff is immutable, so never refetch it while cached. + staleTime: Number.POSITIVE_INFINITY, + retry: 1, + }); +} diff --git a/desktop/src/features/projects/useProjectsRepoSnapshots.ts b/desktop/src/features/projects/useProjectsRepoSnapshots.ts new file mode 100644 index 0000000000..3ad711a807 --- /dev/null +++ b/desktop/src/features/projects/useProjectsRepoSnapshots.ts @@ -0,0 +1,101 @@ +import { useQuery } from "@tanstack/react-query"; +import * as React from "react"; + +import { + getProjectLocalRepoSnapshot, + getProjectRepoSnapshot, +} from "@/shared/api/projectGit"; +import type { ProjectRepoSnapshot } from "@/shared/api/types"; +import type { Project } from "./hooks"; + +// Remote snapshots are backed by a blobless `git clone` per repository, so the +// overview scan is deliberately throttled and cached for a long time. +const OVERVIEW_SNAPSHOT_CONCURRENCY = 3; + +function snapshotHasData(snapshot: ProjectRepoSnapshot | null | undefined) { + return Boolean( + snapshot && (snapshot.files.length > 0 || snapshot.latestCommit), + ); +} + +/** + * Local checkouts are instant (no network, no clone) and keep working when + * the relay's git storage is empty or unreachable, so they are preferred. + * Only repositories without usable local data fall back to a remote clone. + */ +async function fetchProjectSnapshot( + project: Project, + reposDir: string | null | undefined, +): Promise { + try { + const local = await getProjectLocalRepoSnapshot({ + reposDir, + projectDtag: project.dtag, + cloneUrl: project.cloneUrls[0] ?? null, + defaultBranch: project.defaultBranch, + baseBranch: project.defaultBranch, + }); + if (snapshotHasData(local?.snapshot)) return local?.snapshot ?? null; + } catch { + // Best-effort: fall through to the remote snapshot. + } + + const cloneUrl = project.cloneUrls[0]; + if (!cloneUrl) return null; + return getProjectRepoSnapshot({ + cloneUrl, + defaultBranch: project.defaultBranch, + baseBranch: project.defaultBranch, + }); +} + +async function fetchProjectsRepoSnapshots( + projects: Project[], + reposDir: string | null | undefined, +): Promise> { + const snapshots: Record = {}; + const queue = [...projects]; + + const workers = Array.from( + { length: Math.min(OVERVIEW_SNAPSHOT_CONCURRENCY, queue.length) }, + async () => { + for (;;) { + const project = queue.shift(); + if (!project) return; + try { + const snapshot = await fetchProjectSnapshot(project, reposDir); + if (snapshot) snapshots[project.id] = snapshot; + } catch { + // Best-effort: unreachable or empty repositories are skipped. + } + } + }, + ); + + await Promise.all(workers); + return snapshots; +} + +/** + * Fetches repo snapshots for a set of projects (throttled, failure-tolerant) + * for workspace-wide aggregates like the overview language breakdown. + * Prefers local checkouts under `reposDir`; falls back to remote clones. + * Callers should pre-filter and cap `projects` — up to one git clone per entry. + */ +export function useProjectsRepoSnapshotsQuery( + projects: Project[], + reposDir?: string | null, +) { + const projectIds = React.useMemo( + () => projects.map((project) => project.id).sort(), + [projects], + ); + + return useQuery({ + enabled: projects.length > 0, + queryKey: ["projects", "repo-snapshots", reposDir ?? "default", projectIds], + queryFn: () => fetchProjectsRepoSnapshots(projects, reposDir), + staleTime: 15 * 60_000, + retry: 0, + }); +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 06d8c71c3c..a0e756acf5 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -8206,6 +8206,49 @@ export function maybeInstallE2eTauriMocks() { }; case "get_project_local_repo_snapshot": return null; + case "get_project_repo_diff": + return { + additions: 27, + deletions: 4, + files: [ + { + path: "desktop/src/features/projects/ui/ProjectDetailScreen.tsx", + additions: 18, + deletions: 3, + patch: [ + "@@ -1,6 +1,8 @@", + ' import { Tabs } from "@/shared/ui/tabs";', + "", + "-function WorkspaceTabs() {", + "+function WorkspaceTabs({ selectedCommitHash }) {", + '+ const [selectedTab, setSelectedTab] = useState("overview");', + "+", + " return (", + ' ', + " ", + ].join("\n"), + truncated: false, + }, + { + path: "desktop/src/features/projects/hooks.ts", + additions: 9, + deletions: 1, + patch: [ + "@@ -10,4 +10,12 @@", + " export function useProjectQuery(projectId) {", + " return useQuery({ queryKey: [projectId] });", + " }", + "+", + "+export function useProjectCommitDiffQuery(project, hash) {", + '+ return useQuery({ queryKey: [project?.id, "commit-diff", hash] });', + "+}", + ].join("\n"), + truncated: false, + }, + ], + }; + case "get_project_local_repo_diff": + return null; case "get_project_repo_sync_status": return { local_path: null, diff --git a/desktop/tests/e2e/project-commit-detail.spec.ts b/desktop/tests/e2e/project-commit-detail.spec.ts new file mode 100644 index 0000000000..14a76151eb --- /dev/null +++ b/desktop/tests/e2e/project-commit-detail.spec.ts @@ -0,0 +1,106 @@ +import { expect, test } from "@playwright/test"; + +import { waitForAnimations } from "../helpers/animations"; +import { installMockBridge } from "../helpers/bridge"; + +const SHOTS = "test-results/project-commit-detail"; + +// The projects surface is a preview feature — opt in before the app mounts. +// Must run before installMockBridge so React reads the override on mount. +async function enableProjectsFeature(page: import("@playwright/test").Page) { + await page.addInitScript(() => { + window.localStorage.setItem( + "buzz-feature-overrides-v1", + JSON.stringify({ projects: true }), + ); + }); +} + +test("commit detail opens from the commits feed with a diff", async ({ + page, +}) => { + await enableProjectsFeature(page); + await installMockBridge(page); + // The preview server is a static file server without SPA fallback, so + // enter at "/" and navigate via the sidebar. + await page.goto("/", { waitUntil: "domcontentloaded" }); + await page.getByTestId("open-projects-view").click(); + + // Open the first mock project (dtag "buzz" from the e2e bridge fixture). + const projectEntry = page + .locator( + '[data-testid="project-card-buzz"], [data-testid="project-row-buzz"]', + ) + .first(); + await expect(projectEntry).toBeVisible({ timeout: 10_000 }); + await projectEntry.click(); + + await page.getByRole("tab", { name: "Commits" }).click(); + const commitRows = page.getByTestId("project-activity-feed-item"); + await expect(commitRows.first()).toBeVisible({ timeout: 10_000 }); + + // Open the newest commit via its subject button. + await commitRows + .first() + .getByRole("button", { name: /Add Trello board workflow details/ }) + .click(); + + // Detail header: author line, subject, and hash. + await expect(page.getByText("Commit from")).toBeVisible(); + await expect( + page.getByRole("heading", { name: "Add Trello board workflow details" }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Copy commit hash" }), + ).toBeVisible(); + + // Diff from the mocked get_project_repo_diff renders changed files. + await expect(page.getByText("2 changed files")).toBeVisible({ + timeout: 10_000, + }); + await expect( + page.getByText("WorkspaceTabs({ selectedCommitHash })"), + ).toBeVisible(); + + await waitForAnimations(page); + await page.screenshot({ + fullPage: false, + path: `${SHOTS}/01-commit-detail.png`, + }); + + // Breadcrumb category segment steps back to the commits feed. + await page.getByRole("button", { name: "Commit", exact: true }).click(); + await expect(commitRows.first()).toBeVisible(); + + // The back arrow also steps back one level (detail → project page), + // not all the way to the projects overview. + await commitRows + .first() + .getByRole("button", { name: /Add Trello board workflow details/ }) + .click(); + await expect(page.getByText("Commit from")).toBeVisible(); + await page.getByRole("button", { name: "Back to buzz" }).click(); + await expect(commitRows.first()).toBeVisible(); + + // The project-name segment goes to the project home (Overview tab). + await commitRows + .first() + .getByRole("button", { name: /Add Trello board workflow details/ }) + .click(); + await expect(page.getByText("Commit from")).toBeVisible(); + await page + .getByRole("navigation", { name: "Project breadcrumb" }) + .getByRole("button", { name: "buzz", exact: true }) + .click(); + await expect(page.getByRole("tab", { name: "Overview" })).toHaveAttribute( + "aria-selected", + "true", + ); + + // The Projects root segment leaves the project entirely. + await page + .getByRole("navigation", { name: "Project breadcrumb" }) + .getByRole("button", { name: "Projects", exact: true }) + .click(); + await expect(projectEntry).toBeVisible(); +});