Skip to content
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
61 changes: 56 additions & 5 deletions desktop/src-tauri/src/commands/project_git_diff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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}"))
Expand Down Expand Up @@ -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
Expand Down
45 changes: 45 additions & 0 deletions desktop/src/features/projects/lib/projectLanguages.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 128 additions & 0 deletions desktop/src/features/projects/ui/ProjectCommitDetailPanel.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
25 changes: 20 additions & 5 deletions desktop/src/features/projects/ui/ProjectDetailFeedPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
ProjectRepoContributor,
ProjectRepoSnapshot,
} from "@/features/projects/hooks";
import type { ProjectRepoCommit } from "@/shared/api/types";
import {
resolveUserLabel,
type UserProfileLookup,
Expand Down Expand Up @@ -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[];
}) {
Expand Down Expand Up @@ -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>
);
Expand Down
Loading
Loading