mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
feat(desktop): aggregated overview rail, commit detail page, and full breadcrumbs (#1573)
This commit is contained in:
@@ -74,6 +74,7 @@ export default defineConfig({
|
||||
"**/reaction-order.spec.ts",
|
||||
"**/reaction-names.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",
|
||||
|
||||
@@ -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
|
||||
|
||||
45
desktop/src/features/projects/lib/projectLanguages.ts
Normal file
45
desktop/src/features/projects/lib/projectLanguages.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/** Language display names by file extension, used for language breakdowns. */
|
||||
export const LANGUAGE_LABELS: Record<string, string> = {
|
||||
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<string, number>,
|
||||
): Array<[string, number]> {
|
||||
return Object.entries(counts)
|
||||
.sort(
|
||||
(left, right) => right[1] - left[1] || left[0].localeCompare(right[0]),
|
||||
)
|
||||
.slice(0, 5);
|
||||
}
|
||||
128
desktop/src/features/projects/ui/ProjectCommitDetailPanel.tsx
Normal file
128
desktop/src/features/projects/ui/ProjectCommitDetailPanel.tsx
Normal file
@@ -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}
|
||||
|
||||
@@ -9,6 +9,11 @@ import type {
|
||||
ProjectRepoSnapshot,
|
||||
} from "@/features/projects/hooks";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import {
|
||||
LANGUAGE_DOT_CLASSES,
|
||||
languageForPath,
|
||||
topLanguagesFromCounts,
|
||||
} from "@/features/projects/lib/projectLanguages";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import { ReadmePanel } from "./ProjectRepositoryPanel";
|
||||
@@ -24,53 +29,17 @@ type ProjectOverviewPanelProps = {
|
||||
snapshot: ProjectRepoSnapshot | null | undefined;
|
||||
};
|
||||
|
||||
const LANGUAGE_LABELS: Record<string, string> = {
|
||||
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<string, number>();
|
||||
const counts: Record<string, number> = {};
|
||||
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 (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{languages.map(([language], index) => (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-muted/70 px-2 py-1 text-xs text-muted-foreground"
|
||||
key={language}
|
||||
>
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${
|
||||
LANGUAGE_DOT_CLASSES[index % LANGUAGE_DOT_CLASSES.length]
|
||||
}`}
|
||||
/>
|
||||
{language}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OverviewRailSection({
|
||||
children,
|
||||
title,
|
||||
@@ -121,9 +114,7 @@ export function OverviewRailSection({
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-2 border-border/50 border-b pb-4 last:border-b-0 last:pb-0">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{title}
|
||||
</h3>
|
||||
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
@@ -173,21 +164,7 @@ export function ProjectOverviewPanel({
|
||||
</OverviewRailSection>
|
||||
<OverviewRailSection title="Top Languages">
|
||||
{languages.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{languages.map(([language], index) => (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-muted/70 px-2 py-1 text-xs text-muted-foreground"
|
||||
key={language}
|
||||
>
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${
|
||||
LANGUAGE_DOT_CLASSES[index % LANGUAGE_DOT_CLASSES.length]
|
||||
}`}
|
||||
/>
|
||||
{language}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<LanguageChips languages={languages} />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No language data is available yet.
|
||||
|
||||
@@ -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>
|
||||
|
||||
318
desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx
Normal file
318
desktop/src/features/projects/ui/ProjectWorkspaceTabs.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<span
|
||||
className={cn(
|
||||
"aspect-square w-full rounded-[3px]",
|
||||
LEVEL_CLASSES[levelFor(count)],
|
||||
)}
|
||||
key={key}
|
||||
title={
|
||||
count > 0
|
||||
<Tooltip key={key}>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={cn(
|
||||
"aspect-square w-full rounded-[3px]",
|
||||
LEVEL_CLASSES[levelFor(count)],
|
||||
)}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{count > 0
|
||||
? `${count} ${count === 1 ? "event" : "events"} · ${dateLabel}`
|
||||
: `No activity · ${dateLabel}`
|
||||
}
|
||||
/>
|
||||
: `No activity · ${dateLabel}`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}),
|
||||
)}
|
||||
|
||||
@@ -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<string, ProjectRepoSnapshot>;
|
||||
snapshotsLoading?: boolean;
|
||||
summaries?: Record<string, ProjectActivitySummary>;
|
||||
};
|
||||
|
||||
@@ -79,6 +95,74 @@ function overviewStats(
|
||||
);
|
||||
}
|
||||
|
||||
function overviewLanguages(
|
||||
snapshots: Record<string, ProjectRepoSnapshot> | undefined,
|
||||
) {
|
||||
const counts: Record<string, number> = {};
|
||||
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<string, ProjectRepoSnapshot> | undefined,
|
||||
) {
|
||||
const contributorEmails = new Set<string>();
|
||||
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 (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<dt className="flex items-center gap-1.5 text-muted-foreground">
|
||||
{Icon ? <Icon className="h-3.5 w-3.5" /> : null}
|
||||
{label}
|
||||
</dt>
|
||||
<dd
|
||||
className={
|
||||
mono
|
||||
? "font-mono text-xs text-foreground"
|
||||
: "font-medium text-foreground"
|
||||
}
|
||||
>
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function overviewActivityByDay(
|
||||
projects: Project[],
|
||||
summaries: Record<string, ProjectActivitySummary> | 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 (
|
||||
<section className="mb-4 grid gap-4 xl:grid-cols-[minmax(0,1fr)_18rem]">
|
||||
@@ -189,14 +278,9 @@ export function ProjectsOverviewPanel({
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-hidden rounded-2xl border border-border/50 bg-muted/20 p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
Contribution activity
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Commits, PRs & issues across all projects
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
Contribution activity
|
||||
</h3>
|
||||
<ProjectsContributionGraph
|
||||
activityByDay={activityByDay}
|
||||
className="mt-3"
|
||||
@@ -227,6 +311,55 @@ export function ProjectsOverviewPanel({
|
||||
})}
|
||||
</div>
|
||||
</OverviewRailSection>
|
||||
<OverviewRailSection title="Top Languages">
|
||||
{languages.length > 0 ? (
|
||||
<LanguageChips languages={languages} />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{scanning
|
||||
? "Scanning repositories..."
|
||||
: "No language data is available yet."}
|
||||
</p>
|
||||
)}
|
||||
</OverviewRailSection>
|
||||
<OverviewRailSection title="Repositories">
|
||||
<dl className="space-y-2 text-sm">
|
||||
<RepoTotalRow
|
||||
icon={FolderGit2}
|
||||
label="Repositories"
|
||||
value={projects.length}
|
||||
/>
|
||||
<RepoTotalRow
|
||||
icon={GitCommitHorizontal}
|
||||
label="Latest"
|
||||
mono
|
||||
value={
|
||||
repoTotals.latestCommit
|
||||
? repoTotals.latestCommit.shortHash
|
||||
: scanning
|
||||
? "..."
|
||||
: "None"
|
||||
}
|
||||
/>
|
||||
<RepoTotalRow
|
||||
icon={FileCode2}
|
||||
label="Files"
|
||||
value={
|
||||
scanning && repoTotals.files === 0 ? "..." : repoTotals.files
|
||||
}
|
||||
/>
|
||||
<RepoTotalRow
|
||||
icon={Users}
|
||||
label="Contributors"
|
||||
value={
|
||||
scanning && repoTotals.contributors === 0
|
||||
? "..."
|
||||
: repoTotals.contributors
|
||||
}
|
||||
/>
|
||||
<RepoTotalRow label="PRs" value={stats.prs} />
|
||||
</dl>
|
||||
</OverviewRailSection>
|
||||
</aside>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -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<ProjectsViewMode | null>(() => readStoredViewMode());
|
||||
const [sort, setSort] = React.useState<ProjectsSort>(() => 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}
|
||||
|
||||
69
desktop/src/features/projects/useProjectCommitDiff.ts
Normal file
69
desktop/src/features/projects/useProjectCommitDiff.ts
Normal file
@@ -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,
|
||||
});
|
||||
}
|
||||
101
desktop/src/features/projects/useProjectsRepoSnapshots.ts
Normal file
101
desktop/src/features/projects/useProjectsRepoSnapshots.ts
Normal file
@@ -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<ProjectRepoSnapshot | null> {
|
||||
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<Record<string, ProjectRepoSnapshot>> {
|
||||
const snapshots: Record<string, ProjectRepoSnapshot> = {};
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -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 (",
|
||||
' <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,
|
||||
|
||||
106
desktop/tests/e2e/project-commit-detail.spec.ts
Normal file
106
desktop/tests/e2e/project-commit-detail.spec.ts
Normal file
@@ -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