feat(desktop): PR review flow — draft/ready toggle, reviewer requests, approvals

Adds GitHub-style review capabilities to the PR detail view, grounded in
what the Buzz protocol supports: draft/ready via NIP-34 status kinds
(1630/1633, same shape as `buzz pr status`), and reviewer requests +
approvals as labeled kind:1 comments (t=review-request / t=approval)
since NIP-34 has no dedicated review kinds. A review card in the
conversation flow (above the composer) shows reviewers, review state,
and the Approve / Ready-for-review / Convert-to-draft actions, gated to
the PR author or repo owner per the existing trusted-actor rules.

Also includes earlier UI polish from this branch: grayscale activity
heatmap, icon-only view toggles, breadcrumb + tab restructure (Code
first, branch picker + local/remote toggle in panel headers), viewer
git-identity commit avatar matching, and StatPill label cleanup. Splits
hooks.ts and ProjectRepositoryPanel.tsx to stay under the file-size
guard. Mock bridge now accepts repo-scoped publishes so PR comments,
approvals, and status changes work in e2e; adds project-pr-review spec.
This commit is contained in:
Thomas Petersen
2026-07-08 11:34:48 +02:00
parent 38dc2b7342
commit a257a77bc6
30 changed files with 2178 additions and 706 deletions

View File

@@ -74,6 +74,7 @@ export default defineConfig({
"**/reaction-order.spec.ts",
"**/send-channel-binding.spec.ts",
"**/project-commit-detail.spec.ts",
"**/project-pr-review.spec.ts",
"**/persona-model-combobox-screenshots.spec.ts",
"**/drafts-screenshots.spec.ts",
"**/channel-sort.spec.ts",

View File

@@ -69,6 +69,11 @@ pub struct ProjectRepoPushResult {
pub pushed: bool,
pub message: String,
}
#[derive(Serialize)]
pub struct GitIdentityInfo {
pub name: Option<String>,
pub email: Option<String>,
}
fn parse_latest_commit(output: &str) -> Option<ProjectRepoCommitInfo> {
let line = output.lines().next()?;
let mut parts = line.split('\0');
@@ -580,6 +585,28 @@ fn compare_local_remote_status(
}
}
/// The viewer's configured git identity (`user.name` / `user.email`), used by
/// the frontend to attribute their own commits to their Buzz profile when git
/// author strings don't match any relay profile fields.
#[tauri::command]
pub async fn get_git_identity(state: State<'_, AppState>) -> Result<GitIdentityInfo, String> {
let auth = build_git_auth_config(&state)?;
tauri::async_runtime::spawn_blocking(move || {
let read = |key: &str| {
run_git(&["config", "--get", key], None, &auth)
.ok()
.map(|output| output.trim().to_string())
.filter(|value| !value.is_empty())
};
Ok(GitIdentityInfo {
name: read("user.name"),
email: read("user.email"),
})
})
.await
.map_err(|error| format!("git identity task failed: {error}"))?
}
#[tauri::command]
pub async fn get_project_repo_snapshot(
clone_url: String,

View File

@@ -442,6 +442,7 @@ pub fn run() {
get_user_profile,
get_users_batch,
get_user_notes,
get_git_identity,
get_project_repo_snapshot,
get_project_repo_diff,
get_project_local_repo_diff,

View File

@@ -0,0 +1,128 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
commitAuthorPubkeysFromPullRequests,
profileForCommit,
} from "./projectContributorMatching.ts";
const AGENT_PUBKEY = "a".repeat(64);
const USER_PUBKEY = "b".repeat(64);
const PROFILES = {
[AGENT_PUBKEY]: {
displayName: "Brain",
avatarUrl: "https://example.com/brain.png",
isAgent: true,
},
[USER_PUBKEY]: {
displayName: "Thomas P",
avatarUrl: "https://example.com/thomas.png",
nip05Handle: "thomasp@example.com",
},
};
function makeCommit(overrides = {}) {
return {
hash: "fed2b2c993896352400f3d8c574fa31a84188f18",
shortHash: "fed2b2c",
authorName: "Thomas Petersen",
authorEmail: "thomasp@squareup.com",
timestamp: 1_700_000_000,
subject: "Add simple score HUD",
...overrides,
};
}
test("maps initial, latest, and update commits to their publishers", () => {
const map = commitAuthorPubkeysFromPullRequests([
{
author: AGENT_PUBKEY,
initialCommit: "AAAA000000000000000000000000000000000000",
commit: "cccc000000000000000000000000000000000000",
updates: [
{
author: USER_PUBKEY,
commit: "cccc000000000000000000000000000000000000",
},
],
},
]);
// Hashes are normalized to lowercase.
assert.equal(
map.get("aaaa000000000000000000000000000000000000"),
AGENT_PUBKEY,
);
// Updates win over the PR-level latest commit: they carry the publisher
// of that specific push.
assert.equal(
map.get("cccc000000000000000000000000000000000000"),
USER_PUBKEY,
);
});
test("profileForCommit prefers the signed PR-event mapping", () => {
const commit = makeCommit();
const map = new Map([[commit.hash, AGENT_PUBKEY]]);
const matched = profileForCommit(commit, PROFILES, map);
assert.equal(matched?.pubkey, AGENT_PUBKEY);
assert.equal(matched?.profile.displayName, "Brain");
});
test("profileForCommit falls back to exact git author matching", () => {
// No mapping entry — the git author email matches the user's NIP-05.
const commit = makeCommit({ authorEmail: "thomasp@example.com" });
const matched = profileForCommit(commit, PROFILES, new Map());
assert.equal(matched?.pubkey, USER_PUBKEY);
});
test("profileForCommit returns null when nothing matches", () => {
const commit = makeCommit();
assert.equal(profileForCommit(commit, PROFILES, new Map()), null);
});
test("mapped pubkey without a fetched profile falls back to git author", () => {
const commit = makeCommit({ authorEmail: "thomasp@example.com" });
const map = new Map([[commit.hash, "f".repeat(64)]]);
const matched = profileForCommit(commit, PROFILES, map);
assert.equal(matched?.pubkey, USER_PUBKEY);
});
test("viewer git identity attributes their own commits", () => {
// Git author "Thomas Petersen <thomasp@squareup.com>" matches no profile
// field, but it is the viewer's own git config identity.
const commit = makeCommit();
const matched = profileForCommit(commit, PROFILES, new Map(), {
pubkey: USER_PUBKEY,
name: "Thomas Petersen",
email: "thomasp@squareup.com",
});
assert.equal(matched?.pubkey, USER_PUBKEY);
assert.equal(matched?.profile.displayName, "Thomas P");
});
test("viewer git identity does not claim other authors' commits", () => {
const commit = makeCommit({
authorName: "Someone Else",
authorEmail: "someone@example.com",
});
const matched = profileForCommit(commit, PROFILES, new Map(), {
pubkey: USER_PUBKEY,
name: "Thomas Petersen",
email: "thomasp@squareup.com",
});
assert.equal(matched, null);
});
test("signed PR mapping wins over the viewer git identity", () => {
const commit = makeCommit();
const map = new Map([[commit.hash, AGENT_PUBKEY]]);
const matched = profileForCommit(commit, PROFILES, map, {
pubkey: USER_PUBKEY,
name: "Thomas Petersen",
email: "thomasp@squareup.com",
});
assert.equal(matched?.pubkey, AGENT_PUBKEY);
});

View File

@@ -68,3 +68,100 @@ export function profileForCommitAuthor(
}
return null;
}
/**
* Map commit hashes to the Nostr pubkey that published them through pull
* request events. Unlike git author strings, this association is backed by a
* signed relay event, so it is the preferred identity source for commits.
*/
export function commitAuthorPubkeysFromPullRequests(
pullRequests: readonly {
author: string;
initialCommit?: string | null;
commit?: string | null;
updates?: readonly { author: string; commit: string | null }[];
}[],
) {
const byHash = new Map<string, string>();
for (const pullRequest of pullRequests) {
if (pullRequest.initialCommit) {
byHash.set(pullRequest.initialCommit.toLowerCase(), pullRequest.author);
}
if (pullRequest.commit) {
byHash.set(pullRequest.commit.toLowerCase(), pullRequest.author);
}
// Updates last: they carry the precise publisher for each pushed commit.
for (const update of pullRequest.updates ?? []) {
if (update.commit) {
byHash.set(update.commit.toLowerCase(), update.author);
}
}
}
return byHash;
}
/**
* The viewer's own git identity (`git config user.name/user.email`) paired
* with their Nostr pubkey. Only used to attribute the viewer's own commits —
* their git author string is known locally, so this match doesn't rely on
* profile fields lining up with the git config.
*/
export type ViewerGitIdentity = {
pubkey: string | null;
name?: string | null;
email?: string | null;
};
function commitMatchesViewerGitIdentity(
commit: ProjectRepoCommit,
viewer: ViewerGitIdentity,
) {
const name = commit.authorName.trim().toLowerCase();
const email = commit.authorEmail.trim().toLowerCase();
const viewerName = viewer.name?.trim().toLowerCase() ?? "";
const viewerEmail = viewer.email?.trim().toLowerCase() ?? "";
return (
(email.length > 0 && email === viewerEmail) ||
(name.length > 0 && name === viewerName)
);
}
/**
* Resolve the best-known profile for a commit, in order of confidence:
* 1. the signed PR-event mapping (verified publisher),
* 2. the exact-match git author heuristic,
* 3. the viewer's own git identity (local git config, display-only) — a last
* resort for the viewer's own commits when their git author string doesn't
* match any profile field.
*/
export function profileForCommit(
commit: ProjectRepoCommit,
profiles: UserProfileLookup | undefined,
commitAuthorPubkeys?: Map<string, string>,
viewerGitIdentity?: ViewerGitIdentity | null,
) {
const mappedPubkey =
commitAuthorPubkeys?.get(commit.hash.toLowerCase()) ??
commitAuthorPubkeys?.get(commit.shortHash.toLowerCase());
if (mappedPubkey) {
const profile = profiles?.[mappedPubkey.toLowerCase()];
if (profile) {
return { pubkey: mappedPubkey.toLowerCase(), profile };
}
}
const authorMatch = profileForCommitAuthor(commit, profiles);
if (authorMatch) {
return authorMatch;
}
if (
viewerGitIdentity?.pubkey &&
commitMatchesViewerGitIdentity(commit, viewerGitIdentity)
) {
const pubkey = viewerGitIdentity.pubkey.toLowerCase();
const profile = profiles?.[pubkey];
if (profile) {
return { pubkey, profile };
}
}
return null;
}

View File

@@ -14,8 +14,20 @@ export type ProjectPullRequestComment = {
content: string;
author: string;
createdAt: number;
isApproval: boolean;
isReviewRequest: boolean;
reviewerPubkeys: string[];
};
export type ProjectPullRequestApproval = {
id: string;
author: string;
createdAt: number;
};
export const PR_REVIEW_REQUEST_LABEL: string;
export const PR_APPROVAL_LABEL: string;
export type ProjectPullRequest = {
id: string;
title: string;
@@ -25,6 +37,10 @@ export type ProjectPullRequest = {
repoAddress: string | null;
labels: string[];
recipients: string[];
/** Requested reviewers (root `p` tags + trusted review-request comments). */
reviewers: string[];
/** Latest approval per reviewer, oldest first. */
approvals: ProjectPullRequestApproval[];
status: "Open" | "Merged" | "Closed" | "Draft";
statusEventId: string | null;
branchName: string | null;

View File

@@ -72,15 +72,69 @@ function eventToPullRequestUpdate(event) {
};
}
// Review requests and approvals are kind:1 comments labeled with a `t` tag —
// NIP-34 has no dedicated review kinds, and labeled text notes stay readable
// for any client (including `buzz` CLI users) that treats them as comments.
export const PR_REVIEW_REQUEST_LABEL = "review-request";
export const PR_APPROVAL_LABEL = "approval";
function eventToPullRequestComment(event) {
const labels = getAllTags(event, "t").map((label) => label.toLowerCase());
const isReviewRequest = labels.includes(PR_REVIEW_REQUEST_LABEL);
return {
id: event.id,
content: event.content,
author: event.pubkey,
createdAt: event.created_at,
isApproval: labels.includes(PR_APPROVAL_LABEL),
isReviewRequest,
// For review requests the `p` tags are the requested reviewers.
reviewerPubkeys: isReviewRequest
? getAllTags(event, "p").map((pubkey) => pubkey.toLowerCase())
: [],
};
}
/**
* Requested reviewers: `p` tags on the PR root plus `p` tags of trusted
* review-request comments (signed by the PR author or repo owner). The PR
* author is never their own reviewer.
*/
function reviewersForPullRequest(pullRequest, comments) {
const allowedActors = allowedActorsForRoot(pullRequest);
const reviewers = new Set(
getAllTags(pullRequest, "p").map((pubkey) => pubkey.toLowerCase()),
);
for (const comment of comments) {
if (
comment.isReviewRequest &&
allowedActors.has(comment.author.toLowerCase())
) {
for (const pubkey of comment.reviewerPubkeys) {
reviewers.add(pubkey);
}
}
}
reviewers.delete(pullRequest.pubkey.toLowerCase());
return [...reviewers];
}
/** Latest approval comment per author, oldest first. */
function approvalsForPullRequest(comments) {
const byAuthor = new Map();
for (const comment of comments) {
if (!comment.isApproval) continue;
const key = comment.author.toLowerCase();
const existing = byAuthor.get(key);
if (!existing || comment.createdAt > existing.createdAt) {
byAuthor.set(key, comment);
}
}
return [...byAuthor.values()]
.map(({ id, author, createdAt }) => ({ id, author, createdAt }))
.sort((left, right) => left.createdAt - right.createdAt);
}
export function eventToProjectPullRequest(
pullRequest,
updateEvents = [],
@@ -96,6 +150,8 @@ export function eventToProjectPullRequest(
const comments = eventsForPullRequest(pullRequest.id, commentEvents).map(
eventToPullRequestComment,
);
const reviewers = reviewersForPullRequest(pullRequest, comments);
const approvals = approvalsForPullRequest(comments);
const title =
getTag(pullRequest, "subject") ||
pullRequest.content.split("\n")[0] ||
@@ -112,6 +168,8 @@ export function eventToProjectPullRequest(
repoAddress: getTag(pullRequest, "a") ?? null,
labels: getAllTags(pullRequest, "t"),
recipients: getAllTags(pullRequest, "p"),
reviewers,
approvals,
status: statusFromEvent(pullRequest, latestStatus),
statusEventId: latestStatus?.id ?? null,
branchName: getTag(pullRequest, "branch-name") ?? null,

View File

@@ -155,6 +155,123 @@ test("honors status events from the PR author and repo owner", () => {
assert.equal(closedPullRequest.status, "Closed");
});
function commentEvent({
pubkey,
createdAt,
content = "",
labels = [],
reviewers = [],
}) {
return {
id: `comment-${pubkey.slice(0, 8)}-${createdAt}`,
kind: 1,
pubkey,
created_at: createdAt,
content,
tags: [
["e", "f".repeat(64), "", "root"],
["a", REPO_ADDRESS],
...reviewers.map((reviewer) => ["p", reviewer]),
...labels.map((label) => ["t", label]),
],
};
}
test("draft/ready toggles via status kinds 1633 and 1630", () => {
const draft = statusEvent({ kind: 1633, pubkey: AUTHOR, createdAt: 300 });
const draftPullRequest = eventToProjectPullRequest(
pullRequestEvent(),
[],
[],
[draft],
);
assert.equal(draftPullRequest.status, "Draft");
const reopened = statusEvent({ kind: 1630, pubkey: AUTHOR, createdAt: 400 });
const openPullRequest = eventToProjectPullRequest(
pullRequestEvent(),
[],
[],
[draft, reopened],
);
assert.equal(openPullRequest.status, "Open");
});
test("reviewers come from root p tags plus trusted review requests", () => {
const reviewer = "d".repeat(64);
const requested = "e".repeat(64);
const event = pullRequestEvent({
tags: [
["a", REPO_ADDRESS],
["subject", "Add feature"],
["c", "1111111111111111111111111111111111111111"],
["p", reviewer],
["p", AUTHOR],
],
});
const request = commentEvent({
pubkey: AUTHOR,
createdAt: 200,
content: "Requested a review from someone",
labels: ["review-request"],
reviewers: [requested],
});
const untrustedRequest = commentEvent({
pubkey: ATTACKER,
createdAt: 300,
labels: ["review-request"],
reviewers: ["9".repeat(64)],
});
const pullRequest = eventToProjectPullRequest(
event,
[],
[request, untrustedRequest],
);
// The author never reviews their own PR; untrusted requests are ignored.
assert.deepEqual(pullRequest.reviewers.sort(), [reviewer, requested].sort());
});
test("approvals keep the latest per author and flag comments", () => {
const reviewer = "d".repeat(64);
const firstApproval = commentEvent({
pubkey: reviewer,
createdAt: 200,
content: "Approved these changes",
labels: ["approval"],
});
const secondApproval = commentEvent({
pubkey: reviewer,
createdAt: 300,
content: "Approved these changes",
labels: ["approval"],
});
const plainComment = commentEvent({
pubkey: OWNER,
createdAt: 250,
content: "Looks good",
});
const pullRequest = eventToProjectPullRequest(
pullRequestEvent(),
[],
[firstApproval, plainComment, secondApproval],
);
assert.equal(pullRequest.approvals.length, 1);
assert.equal(pullRequest.approvals[0].author, reviewer);
assert.equal(pullRequest.approvals[0].createdAt, 300);
assert.equal(
pullRequest.comments.filter((comment) => comment.isApproval).length,
2,
);
assert.equal(
pullRequest.comments.filter((comment) => comment.isReviewRequest).length,
0,
);
});
test("survives malformed value-less tags", () => {
const event = pullRequestEvent({
tags: [

View File

@@ -0,0 +1,208 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import * as React from "react";
import { relayClient } from "@/shared/api/relayClient";
import { signRelayEvent } from "@/shared/api/tauri";
import {
KIND_GIT_STATUS_CLOSED,
KIND_GIT_STATUS_DRAFT,
KIND_GIT_STATUS_OPEN,
KIND_TEXT_NOTE,
} from "@/shared/constants/kinds";
import type { Project } from "./hooks";
import type { ProjectPullRequest } from "./projectPullRequests.mjs";
import {
PR_APPROVAL_LABEL,
PR_REVIEW_REQUEST_LABEL,
} from "./projectPullRequests.mjs";
/** NIP-34 lifecycle states the desktop can publish for a PR. Merged (1631)
* is intentionally excluded — merges happen through git, not this UI. */
export type ProjectPullRequestLifecycleStatus = "open" | "draft" | "closed";
const PR_STATUS_KIND_BY_LIFECYCLE: Record<
ProjectPullRequestLifecycleStatus,
number
> = {
open: KIND_GIT_STATUS_OPEN,
draft: KIND_GIT_STATUS_DRAFT,
closed: KIND_GIT_STATUS_CLOSED,
};
// Same shape as `buzz pr status` (buzz-sdk build_git_status): root `e` tag,
// repo `a` tag, and `p` tags for the repo owner + PR author. Only the PR
// author or repo owner are trusted for status changes (allowedActorsForRoot).
async function updateProjectPullRequestStatus({
project,
pullRequest,
status,
}: {
project: Project;
pullRequest: ProjectPullRequest;
status: ProjectPullRequestLifecycleStatus;
}): Promise<void> {
const recipients = new Set([
project.owner.toLowerCase(),
pullRequest.author.toLowerCase(),
]);
const event = await signRelayEvent({
kind: PR_STATUS_KIND_BY_LIFECYCLE[status],
content: "",
tags: [
["e", pullRequest.id, "", "root"],
["a", project.repoAddress],
...[...recipients].map((recipient) => ["p", recipient]),
],
});
await relayClient.publishEvent(
event,
"Timed out updating pull request status.",
"Failed to update pull request status.",
);
}
// Review requests and approvals are labeled kind:1 comments (see
// projectPullRequests.mjs) — NIP-34 has no dedicated review kinds, and the
// relay does not register kind 1111. `p` tags on a review request are the
// requested reviewers; parsing only trusts requests signed by the PR author
// or repo owner.
async function requestProjectPullRequestReview({
project,
pullRequest,
reviewers,
reviewerLabel,
}: {
project: Project;
pullRequest: ProjectPullRequest;
reviewers: string[];
reviewerLabel: string;
}): Promise<void> {
if (reviewers.length === 0) {
throw new Error("Select at least one reviewer.");
}
const reviewerPubkeys = [
...new Set(reviewers.map((pubkey) => pubkey.toLowerCase())),
];
const event = await signRelayEvent({
kind: KIND_TEXT_NOTE,
content: `Requested a review from ${reviewerLabel}`,
tags: [
["e", pullRequest.id, "", "root"],
["a", project.repoAddress],
...reviewerPubkeys.map((pubkey) => ["p", pubkey]),
["t", PR_REVIEW_REQUEST_LABEL],
],
});
await relayClient.publishEvent(
event,
"Timed out requesting review.",
"Failed to request review.",
);
}
async function approveProjectPullRequest({
project,
pullRequest,
}: {
project: Project;
pullRequest: ProjectPullRequest;
}): Promise<void> {
const recipients = new Set([
project.owner.toLowerCase(),
pullRequest.author.toLowerCase(),
]);
const event = await signRelayEvent({
kind: KIND_TEXT_NOTE,
content: "Approved these changes",
tags: [
["e", pullRequest.id, "", "root"],
["a", project.repoAddress],
...[...recipients].map((recipient) => ["p", recipient]),
["t", PR_APPROVAL_LABEL],
],
});
await relayClient.publishEvent(
event,
"Timed out approving pull request.",
"Failed to approve pull request.",
);
}
function usePullRequestWriteInvalidation(project: Project | null | undefined) {
const queryClient = useQueryClient();
return React.useCallback(() => {
void queryClient.invalidateQueries({
queryKey: ["project", project?.id ?? "none", "pull-requests"],
});
void queryClient.invalidateQueries({
queryKey: ["projects", "pull-requests"],
});
void queryClient.invalidateQueries({
queryKey: ["projects", "activity-summaries"],
});
}, [project?.id, queryClient]);
}
export function useUpdateProjectPullRequestStatusMutation(
project: Project | null | undefined,
) {
const invalidate = usePullRequestWriteInvalidation(project);
return useMutation({
mutationFn: ({
pullRequest,
status,
}: {
pullRequest: ProjectPullRequest;
status: ProjectPullRequestLifecycleStatus;
}) => {
if (!project) throw new Error("No project selected.");
return updateProjectPullRequestStatus({ project, pullRequest, status });
},
onSuccess: invalidate,
});
}
export function useRequestProjectPullRequestReviewMutation(
project: Project | null | undefined,
) {
const invalidate = usePullRequestWriteInvalidation(project);
return useMutation({
mutationFn: ({
pullRequest,
reviewers,
reviewerLabel,
}: {
pullRequest: ProjectPullRequest;
reviewers: string[];
reviewerLabel: string;
}) => {
if (!project) throw new Error("No project selected.");
return requestProjectPullRequestReview({
project,
pullRequest,
reviewers,
reviewerLabel,
});
},
onSuccess: invalidate,
});
}
export function useApproveProjectPullRequestMutation(
project: Project | null | undefined,
) {
const invalidate = usePullRequestWriteInvalidation(project);
return useMutation({
mutationFn: ({ pullRequest }: { pullRequest: ProjectPullRequest }) => {
if (!project) throw new Error("No project selected.");
return approveProjectPullRequest({ project, pullRequest });
},
onSuccess: invalidate,
});
}

View File

@@ -0,0 +1,39 @@
import { Check, Copy } from "lucide-react";
import * as React from "react";
import { cn } from "@/shared/lib/cn";
/** Icon button that copies a full commit hash with a brief check feedback. */
export function CopyCommitHashButton({
className,
hash,
}: {
className?: string;
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={cn(
"flex h-7 w-7 items-center justify-center text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring",
className,
)}
onClick={handleCopy}
type="button"
>
{copied ? (
<Check className="h-3.5 w-3.5 text-green-500" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</button>
);
}

View File

@@ -1,13 +1,15 @@
import { Check, Copy, GitCommitHorizontal } from "lucide-react";
import * as React from "react";
import { GitCommitHorizontal } from "lucide-react";
import { profileForCommitAuthor } from "@/features/projects/lib/projectContributorMatching";
import {
profileForCommit,
type ViewerGitIdentity,
} 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 { CopyCommitHashButton } from "./ProjectCommitCopyButton";
import { ProfileIdentityButton } from "./ProjectProfileIdentity";
import { ProjectDiffFilesPanel } from "./ProjectPullRequestFilesChangedPanel";
@@ -18,32 +20,6 @@ function commitDateLabel(timestamp: number) {
});
}
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
@@ -51,21 +27,26 @@ function CopyHashButton({ hash }: { hash: string }) {
*/
export function ProjectCommitDetailPanel({
commit,
commitAuthorPubkeys,
commitHash,
diff,
diffError,
diffLoading,
profiles,
viewerGitIdentity,
}: {
commit: ProjectRepoCommit | null;
/** Signed commit→pubkey mapping derived from pull request events. */
commitAuthorPubkeys?: Map<string, string>;
commitHash: string;
diff: ProjectRepoDiff | null | undefined;
diffError: unknown;
diffLoading: boolean;
profiles?: UserProfileLookup;
viewerGitIdentity?: ViewerGitIdentity | null;
}) {
const matchedProfile = commit
? profileForCommitAuthor(commit, profiles)
? profileForCommit(commit, profiles, commitAuthorPubkeys, viewerGitIdentity)
: null;
const authorLabel = matchedProfile
? resolveUserLabel({ pubkey: matchedProfile.pubkey, profiles })
@@ -96,7 +77,10 @@ export function ProjectCommitDetailPanel({
<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} />
<CopyCommitHashButton
className="h-6 w-6 rounded-md"
hash={commit?.hash ?? commitHash}
/>
</span>
{commit ? (
<>

View File

@@ -1,9 +1,12 @@
import {
commitAuthorPubkeysFromPullRequests,
contributorKey,
profileForCommitAuthor,
profileForCommit,
profileForContributor,
type ViewerGitIdentity,
} from "@/features/projects/lib/projectContributorMatching";
import type {
ProjectPullRequest,
ProjectRepoContributor,
ProjectRepoSnapshot,
} from "@/features/projects/hooks";
@@ -12,7 +15,15 @@ import {
resolveUserLabel,
type UserProfileLookup,
} from "@/features/profile/lib/identity";
import { GitCommitHorizontal } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { CopyCommitHashButton } from "./ProjectCommitCopyButton";
import {
ProjectFeedRow,
ProjectFeedRowCluster,
ProjectFeedRowMonoCell,
} from "./ProjectFeedRow";
import { ProfileIdentityButton } from "./ProjectProfileIdentity";
function compactDate(createdAt: number) {
@@ -130,32 +141,63 @@ export function ContributorsPanel({
);
}
function commitDayLabel(timestamp: number) {
return new Date(timestamp * 1_000).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
}
function groupCommitsByDay(commits: ProjectRepoCommit[]) {
const groups: { label: string; commits: ProjectRepoCommit[] }[] = [];
for (const commit of commits) {
const label = commitDayLabel(commit.timestamp);
const lastGroup = groups[groups.length - 1];
if (lastGroup && lastGroup.label === label) {
lastGroup.commits.push(commit);
} else {
groups.push({ label, commits: [commit] });
}
}
return groups;
}
export function ActivityPanel({
snapshot,
isLoading,
error,
onSelectCommit,
profiles,
pullRequests,
repoContributors,
viewerGitIdentity,
}: {
snapshot: ProjectRepoSnapshot | null | undefined;
isLoading: boolean;
error: unknown;
onSelectCommit?: (commit: ProjectRepoCommit) => void;
profiles?: UserProfileLookup;
pullRequests?: ProjectPullRequest[];
repoContributors: ProjectRepoContributor[];
viewerGitIdentity?: ViewerGitIdentity | null;
}) {
const commits = snapshot?.commits ?? [];
const commitAuthorPubkeys = commitAuthorPubkeysFromPullRequests(
pullRequests ?? [],
);
if (isLoading) {
return (
<p className="p-4 text-sm text-muted-foreground">Loading activity…</p>
<p className="rounded-xl border border-border/50 bg-card/60 p-4 text-sm text-muted-foreground">
Loading activity…
</p>
);
}
if (commits.length === 0) {
return (
<p className="p-4 text-sm text-muted-foreground">
<p className="rounded-xl border border-border/50 bg-card/60 p-4 text-sm text-muted-foreground">
{error
? "Could not load repository activity from git."
: "No commits are available yet."}
@@ -163,94 +205,94 @@ export function ActivityPanel({
);
}
return (
<div className="relative">
{commits.length > 1 ? (
<div
aria-hidden="true"
className="absolute bottom-5 left-8 top-5 w-px bg-border/45"
/>
) : null}
{commits.map((commit) => {
const matchedProfile = profileForCommitAuthor(commit, profiles);
const authorLabel = matchedProfile
? resolveUserLabel({ pubkey: matchedProfile.pubkey, profiles })
: commit.authorName || commit.authorEmail || "Unknown author";
const authorSubtitle = matchedProfile
? `${
matchedProfile.profile.nip05Handle ||
commit.authorEmail ||
"Git contributor"
} · unverified match`
: commit.authorEmail || "Git contributor";
const matchingContributor = repoContributors.find(
(contributor) =>
contributor.name.trim().toLowerCase() ===
commit.authorName.trim().toLowerCase() ||
contributor.email.trim().toLowerCase() ===
commit.authorEmail.trim().toLowerCase(),
);
const groups = groupCommitsByDay(commits);
return (
<article
className="group/feed-item relative flex min-w-0 items-start gap-3 p-3 transition-colors hover:bg-muted/35"
data-testid="project-activity-feed-item"
key={commit.hash}
>
<ProfileIdentityButton
avatarClassName="relative z-10 mt-0.5 shrink-0 ring-2 ring-card"
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.5">
<div className="space-y-0.5">
<div className="flex min-w-0 items-center gap-1.5">
<p className="truncate text-sm font-semibold leading-5 text-foreground">
{authorLabel}
</p>
<span className="shrink-0 text-sm leading-5 text-muted-foreground">
pushed a commit
</span>
</div>
<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="truncate">{authorSubtitle}</span>
<span>{commit.shortHash}</span>
{matchingContributor?.commitCount ? (
<span className="rounded-full border border-border/50 px-1.5 py-0.5 text-2xs">
{pluralize(matchingContributor.commitCount, "commit")}
</span>
) : null}
<span>·</span>
<time className="transition-colors group-hover/feed-item:text-foreground">
{relativeCommitTime(commit.timestamp)}
</time>
</div>
</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>
);
})}
return (
<div className="relative space-y-5">
{/* Timeline rail connecting the date group markers, GitHub-style. */}
<div
aria-hidden="true"
className="absolute bottom-2 left-[7px] top-2 w-px bg-border/45"
/>
{groups.map((group) => (
<section className="relative pl-7" key={group.label}>
<span className="absolute left-0 top-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-background ring-1 ring-border/60">
<GitCommitHorizontal className="h-3 w-3 text-muted-foreground/80" />
</span>
<h3 className="text-xs font-medium text-muted-foreground">
Commits on {group.label}
</h3>
<div className="mt-2 divide-y divide-border/50 overflow-hidden rounded-xl border border-border/50 bg-card/60">
{group.commits.map((commit) => {
const matchedProfile = profileForCommit(
commit,
profiles,
commitAuthorPubkeys,
viewerGitIdentity,
);
const authorLabel = matchedProfile
? resolveUserLabel({ pubkey: matchedProfile.pubkey, profiles })
: commit.authorName || commit.authorEmail || "Unknown author";
const matchingContributor = repoContributors.find(
(contributor) =>
contributor.name.trim().toLowerCase() ===
commit.authorName.trim().toLowerCase() ||
contributor.email.trim().toLowerCase() ===
commit.authorEmail.trim().toLowerCase(),
);
return (
<ProjectFeedRow
key={commit.hash}
meta={
<>
<ProfileIdentityButton
avatarClassName="shrink-0"
avatarSize="xs"
avatarUrl={matchedProfile?.profile.avatarUrl ?? null}
isAgent={matchedProfile?.profile.isAgent === true}
label={authorLabel}
pubkey={matchedProfile?.pubkey ?? null}
showLabel={false}
/>
<span className="truncate font-medium text-foreground/80">
{authorLabel}
</span>
<span>
authored {relativeCommitTime(commit.timestamp)}
</span>
{matchingContributor?.commitCount ? (
<span className="rounded-full border border-border/50 px-1.5 py-0.5 text-2xs">
{pluralize(matchingContributor.commitCount, "commit")}
</span>
) : null}
</>
}
onOpen={
onSelectCommit ? () => onSelectCommit(commit) : undefined
}
testId="project-activity-feed-item"
title={commit.subject}
trailing={
<ProjectFeedRowCluster>
<ProjectFeedRowMonoCell
label={commit.shortHash}
onClick={
onSelectCommit
? () => onSelectCommit(commit)
: undefined
}
title={`View commit ${commit.shortHash}`}
/>
<CopyCommitHashButton hash={commit.hash} />
</ProjectFeedRowCluster>
}
/>
);
})}
</div>
</section>
))}
</div>
);
}

View File

@@ -55,8 +55,10 @@ import { useThreadPanelWidth } from "@/shared/hooks/useThreadPanelWidth";
import { Button } from "@/shared/ui/button";
import { useWorkspaces } from "@/features/workspaces/useWorkspaces";
import { useProjectCommitDiffQuery } from "@/features/projects/useProjectCommitDiff";
import { useGitIdentityQuery } from "@/features/projects/useGitIdentity";
import type { ViewerGitIdentity } from "@/features/projects/lib/projectContributorMatching";
import { WorkspaceTabs } from "./ProjectWorkspaceTabs";
import { RepositorySourceCard } from "./ProjectRepositorySource";
import type { RepoSourceHeaderControls } from "./ProjectRepositorySource";
import {
projectTerminalLabel,
useOpenProjectTerminal,
@@ -145,6 +147,9 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
// 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);
// Mirror of the WorkspaceTabs selection so the breadcrumb can name the
// active sub-tab. The Overview (readme) tab is "home" and gets no crumb.
const [activeTab, setActiveTab] = React.useState("overview");
// 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);
@@ -242,11 +247,41 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
repoSource === "local"
? localRepoDiffQuery.isLoading
: repoDiffQuery.isLoading;
const isWorkItemDetailOpen = Boolean(
selectedPullRequestId || selectedIssueId || selectedCommitHash,
);
// Compact branch + remote/local controls shared by the readme and Code
// tab headers.
const filesSourceControls: RepoSourceHeaderControls = {
branch: activeBranch ?? "",
branchOptions,
onBranchChange: setSelectedBranch,
source: repoSource,
onSourceChange: setRepoSource,
localDisabled:
!repoSyncStatusQuery.data?.localPath &&
!localRepoSnapshotQuery.data &&
!localRepoSnapshotQuery.isLoading,
localLabel: localRepoSnapshotQuery.isLoading
? "Local checking"
: repoSyncStatusQuery.data?.localPath || localRepoSnapshotQuery.data
? "Local"
: "Local missing",
remoteLabel: repoSnapshotQuery.isLoading ? "Remote checking" : "Remote",
canPush: repoSyncStatusQuery.data?.canPush ?? false,
onPush: () => {
void handlePushLocalRepo();
},
pushDisabled:
pushLocalRepoMutation.isPending || !repoSyncStatusQuery.data?.canPush,
pushPending: pushLocalRepoMutation.isPending,
pushTitle:
repoSyncStatusQuery.data?.pushBlockReason ?? "Push local commits",
};
const projectPending = projectQuery.isPending;
React.useEffect(() => {
if (!project) {
// While the project query is still loading, keep the URL-seeded
// pullRequestId/issueId selections — clearing here would discard them
// before the detail view ever gets a chance to open.
if (projectPending) return;
setSelectedBranch(null);
setSelectedPullRequestId(null);
setSelectedIssueId(null);
@@ -259,7 +294,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
}
return project.defaultBranch ?? branchOptions[0] ?? null;
});
}, [project, branchOptions]);
}, [project, branchOptions, projectPending]);
React.useEffect(() => {
setRepoSource((currentSource) => {
if (currentSource === "local" && !hasLocalCheckout) return "remote";
@@ -273,10 +308,18 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
return currentSource;
});
}, [hasLocalCheckout, hasRemoteSnapshot]);
const peoplePubkeys = React.useMemo(
() => (project ? projectPeople(project) : []),
[project],
);
const peoplePubkeys = React.useMemo(() => {
if (!project) return [];
// Include PR authors/updaters so commit rows can resolve avatars for
// publishers who are not listed as project contributors.
const pullRequestPubkeys = (pullRequestsQuery.data ?? []).flatMap(
(pullRequest) => [
pullRequest.author,
...pullRequest.updates.map((update) => update.author),
],
);
return [...new Set([...projectPeople(project), ...pullRequestPubkeys])];
}, [project, pullRequestsQuery.data]);
const profilesQuery = useUsersBatchQuery(peoplePubkeys, {
enabled: peoplePubkeys.length > 0,
});
@@ -290,6 +333,16 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
[currentProfileQuery.data, profilesQuery.data?.profiles],
);
const identityQuery = useIdentityQuery();
const gitIdentityQuery = useGitIdentityQuery();
const viewerGitIdentity = React.useMemo<ViewerGitIdentity | null>(() => {
const pubkey = identityQuery.data?.pubkey ?? null;
if (!pubkey || !gitIdentityQuery.data) return null;
return {
pubkey,
name: gitIdentityQuery.data.name,
email: gitIdentityQuery.data.email,
};
}, [gitIdentityQuery.data, identityQuery.data?.pubkey]);
const { applyPatch, values } = useHistorySearchState(
PROJECT_DETAIL_PANEL_SEARCH_KEYS,
);
@@ -428,26 +481,38 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
: null;
// The active work item drives the breadcrumb trail: Projects › project ›
// category › title. `clear` steps back to the item's list tab.
// sub-tab › title. `clear` steps back to the item's list tab. Categories
// match the workspace tab labels.
const activeWorkItemCrumb = selectedPullRequest
? {
category: "Pull request",
category: "PRs",
title: selectedPullRequest.title,
clear: () => setSelectedPullRequestId(null),
}
: selectedIssue
? {
category: "Issue",
category: "Issues",
title: selectedIssue.title,
clear: () => setSelectedIssueId(null),
}
: selectedCommitHash
? {
category: "Commit",
category: "Commits",
title: selectedCommit?.subject ?? selectedCommitHash.slice(0, 7),
clear: () => setSelectedCommitHash(null),
}
: null;
// Sub-tab crumb when no work item is open. Overview (readme) is home.
const TAB_CRUMB_LABELS: Record<string, string> = {
files: "Code",
activity: "Commits",
issues: "Issues",
prs: "PRs",
contributors: "Contributors",
};
const activeTabCrumb = activeWorkItemCrumb
? null
: (TAB_CRUMB_LABELS[activeTab] ?? null);
const handleGoToProjectHome = () => {
setSelectedPullRequestId(null);
setSelectedIssueId(null);
@@ -478,32 +543,13 @@ 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={
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={() => {
// 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>
<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"
className="flex shrink-0 items-center gap-1.5 rounded-md px-1 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"
>
<FolderGit2 className="h-3.5 w-3.5" />
Projects
</button>
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground/60" />
@@ -525,12 +571,35 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
{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">
<span
aria-current="page"
className="min-w-0 truncate px-0.5 font-medium text-muted-foreground/60"
>
{activeWorkItemCrumb.title}
</span>
</>
) : activeTabCrumb ? (
<>
<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={handleGoToProjectHome}
type="button"
>
{project.name}
</button>
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground/60" />
<span
aria-current="page"
className="min-w-0 truncate px-0.5 font-medium text-muted-foreground/60"
>
{activeTabCrumb}
</span>
</>
) : (
<span className="min-w-0 truncate px-0.5 font-medium text-foreground">
<span
aria-current="page"
className="min-w-0 truncate px-0.5 font-medium text-muted-foreground/60"
>
{project.name}
</span>
)}
@@ -555,81 +624,39 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-y-auto px-4 pb-4">
<div className="w-full space-y-5 pt-[calc(var(--buzz-channel-content-top-padding,5.75rem)_+_1px)]">
{/* When a PR or issue is open, the breadcrumb already names the
project — let the work item's own title lead the page and
skip the project header section entirely. */}
{!isWorkItemDetailOpen ? (
<section className="space-y-3">
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0 flex-1 space-y-0.5">
<div className="flex min-w-0 items-center gap-1.5">
<h2 className="truncate text-xl font-semibold tracking-tight">
{project.name}
</h2>
{safeWebUrl ? (
<Button
asChild
aria-label="Open project web page"
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground"
size="icon-xs"
variant="ghost"
<section className="space-y-3">
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0 flex-1 space-y-0.5">
<div className="flex min-w-0 items-center gap-1.5">
<h2 className="truncate text-xl font-semibold tracking-tight">
{project.name}
</h2>
{safeWebUrl ? (
<Button
asChild
aria-label="Open project web page"
className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground"
size="icon-xs"
variant="ghost"
>
<a
href={safeWebUrl}
rel="noopener noreferrer"
target="_blank"
>
<a
href={safeWebUrl}
rel="noopener noreferrer"
target="_blank"
>
<ExternalLink className="h-3.5 w-3.5" />
</a>
</Button>
) : null}
</div>
{project.description ? (
<p className="max-w-2xl text-sm font-normal text-muted-foreground">
{project.description}
</p>
<ExternalLink className="h-3.5 w-3.5" />
</a>
</Button>
) : null}
</div>
{project.description ? (
<p className="max-w-2xl text-sm font-normal text-muted-foreground">
{project.description}
</p>
) : null}
</div>
<RepositorySourceCard
branch={activeBranch ?? ""}
branchOptions={branchOptions}
cloneUrls={project.cloneUrls}
localDisabled={
!repoSyncStatusQuery.data?.localPath &&
!localRepoSnapshotQuery.data &&
!localRepoSnapshotQuery.isLoading
}
localLabel={
localRepoSnapshotQuery.isLoading
? "Local checking"
: repoSyncStatusQuery.data?.localPath ||
localRepoSnapshotQuery.data
? "Local"
: "Local missing"
}
localPath={
repoSyncStatusQuery.data?.localPath ??
localRepoSnapshotQuery.data?.path
}
onBranchChange={setSelectedBranch}
onPush={() => {
void handlePushLocalRepo();
}}
onSourceChange={setRepoSource}
pushDisabled={
pushLocalRepoMutation.isPending ||
!repoSyncStatusQuery.data?.canPush
}
pushPending={pushLocalRepoMutation.isPending}
remoteLabel={
repoSnapshotQuery.isLoading ? "Remote checking" : "Remote"
}
source={repoSource}
status={repoSyncStatusQuery.data}
/>
</section>
) : null}
</div>
</section>
<WorkspaceTabs
key={`${project.id}:${tabsResetKey}`}
@@ -649,6 +676,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
onSelectedPullRequestIdChange={
handleSelectedPullRequestIdChange
}
onSelectedTabChange={setActiveTab}
profiles={profiles}
project={project}
repoDiff={displayedRepoDiff}
@@ -665,6 +693,8 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
snapshot={repoSnapshotQuery.data}
snapshotError={repoSnapshotQuery.error}
snapshotLoading={repoSnapshotQuery.isLoading}
sourceControls={filesSourceControls}
viewerGitIdentity={viewerGitIdentity}
/>
<section className="flex min-w-0 items-center gap-2 rounded-xl border border-border/50 bg-card/60 px-4 py-3 text-sm text-muted-foreground">

View File

@@ -0,0 +1,98 @@
import type * as React from "react";
/**
* Shared list-row scaffold for the project detail feeds (commits, pull
* requests, and issues) so every entry has the same structure: a clickable
* title line with an optional status icon, a meta line underneath, and a
* trailing cluster (hash, id, comment count) on the right.
*/
export function ProjectFeedRow({
meta,
onOpen,
statusIcon,
testId,
title,
trailing,
}: {
meta: React.ReactNode;
onOpen?: () => void;
statusIcon?: React.ReactNode;
testId?: string;
title: string;
trailing?: React.ReactNode;
}) {
return (
<article
className="group/feed-item flex min-w-0 items-center justify-between gap-3 p-3 transition-colors hover:bg-muted/35"
data-testid={testId}
>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex min-w-0 items-center gap-1.5">
{onOpen ? (
<button
className="block min-w-0 truncate rounded-sm text-left text-sm font-semibold leading-5 text-foreground transition-colors hover:text-primary hover:underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
onClick={onOpen}
title={title}
type="button"
>
{title}
</button>
) : (
<p className="truncate text-sm font-semibold leading-5 text-foreground">
{title}
</p>
)}
{statusIcon}
</div>
<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">
{meta}
</div>
</div>
{trailing ? (
<div className="flex shrink-0 items-center gap-2">{trailing}</div>
) : null}
</article>
);
}
/** Bordered trailing cluster shared by feed rows (hash + copy, #id, …). */
export function ProjectFeedRowCluster({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex shrink-0 items-center divide-x divide-border/50 overflow-hidden rounded-md border border-border/50 bg-background/45">
{children}
</div>
);
}
/** Monospace cell inside a feed row cluster; a button when onClick is set. */
export function ProjectFeedRowMonoCell({
label,
onClick,
title,
}: {
label: string;
onClick?: () => void;
title?: string;
}) {
if (onClick) {
return (
<button
className="h-7 px-2 font-mono text-xs text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
onClick={onClick}
title={title}
type="button"
>
{label}
</button>
);
}
return (
<span className="flex h-7 items-center px-2 font-mono text-xs text-muted-foreground">
{label}
</span>
);
}

View File

@@ -17,6 +17,11 @@ import { relativeTime } from "@/features/projects/lib/projectsViewHelpers";
import type { ChannelMember } from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { Markdown } from "@/shared/ui/markdown";
import {
ProjectFeedRow,
ProjectFeedRowCluster,
ProjectFeedRowMonoCell,
} from "./ProjectFeedRow";
import { ProfileIdentityButton } from "./ProjectProfileIdentity";
function compactDate(createdAt: number) {
@@ -99,29 +104,27 @@ function IssueRow({
onOpen: () => void;
profiles?: UserProfileLookup;
}) {
const authorProfile = profiles?.[normalizePubkey(issue.author)];
const authorLabel = resolveUserLabel({ profiles, pubkey: issue.author });
const status = issueStatusVisual(issue.status);
return (
<button
className="flex w-full min-w-0 items-start gap-3 p-3 text-left transition-colors hover:bg-muted/30 focus-visible:bg-muted/30 focus-visible:outline-hidden"
onClick={onOpen}
type="button"
>
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<CircleDot className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1 space-y-0.5">
<div className="flex min-w-0 items-center gap-1.5">
<p className="truncate text-sm font-semibold leading-5 text-foreground">
{issue.title}
</p>
<status.icon className={`h-3.5 w-3.5 shrink-0 ${status.className}`} />
</div>
<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>#{issue.id.slice(0, 8)}</span>
<ProjectFeedRow
meta={
<>
<ProfileIdentityButton
avatarClassName="shrink-0"
avatarSize="xs"
avatarUrl={authorProfile?.avatarUrl ?? null}
isAgent={authorProfile?.isAgent === true}
label={authorLabel}
pubkey={issue.author}
showLabel={false}
/>
<span className="truncate font-medium text-foreground/80">
{authorLabel}
</span>
<span>opened {relativeTime(issue.createdAt)}</span>
<span>by {authorLabel}</span>
<span>·</span>
<span>{issue.status}</span>
{issue.labels.map((label) => (
@@ -132,15 +135,32 @@ function IssueRow({
{label}
</span>
))}
</div>
</div>
{issue.comments.length > 0 ? (
<span className="mt-1 flex shrink-0 items-center gap-1 text-xs text-muted-foreground">
<MessageSquare className="h-3.5 w-3.5" />
{issue.comments.length}
</span>
) : null}
</button>
</>
}
onOpen={onOpen}
statusIcon={
<status.icon className={`h-3.5 w-3.5 shrink-0 ${status.className}`} />
}
testId="project-issue-row"
title={issue.title}
trailing={
<>
{issue.comments.length > 0 ? (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<MessageSquare className="h-3.5 w-3.5" />
{issue.comments.length}
</span>
) : null}
<ProjectFeedRowCluster>
<ProjectFeedRowMonoCell
label={`#${issue.id.slice(0, 8)}`}
onClick={onOpen}
title="View issue"
/>
</ProjectFeedRowCluster>
</>
}
/>
);
}

View File

@@ -16,7 +16,8 @@ import {
} from "@/features/projects/lib/projectLanguages";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import { ReadmePanel } from "./ProjectRepositoryPanel";
import { ReadmePanel } from "./ProjectReadmePanel";
import type { RepoSourceHeaderControls } from "./ProjectRepositorySource";
type ProjectOverviewPanelProps = {
contributors: ProjectRepoContributor[];
@@ -27,6 +28,8 @@ type ProjectOverviewPanelProps = {
pullRequests: ProjectPullRequest[];
readmeFile: ProjectRepoFile | null;
snapshot: ProjectRepoSnapshot | null | undefined;
/** Branch picker + remote/local toggle for the readme header. */
sourceControls?: RepoSourceHeaderControls;
};
function shortHash(hash: string | undefined) {
@@ -113,7 +116,7 @@ export function OverviewRailSection({
title: string;
}) {
return (
<section className="space-y-2 border-border/50 border-b pb-4 last:border-b-0 last:pb-0">
<section className="space-y-2">
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
{children}
</section>
@@ -129,6 +132,7 @@ export function ProjectOverviewPanel({
pullRequests,
readmeFile,
snapshot,
sourceControls,
}: ProjectOverviewPanelProps) {
const languages = topLanguages(files);
const people = projectPeople(project);
@@ -137,19 +141,11 @@ export function ProjectOverviewPanel({
return (
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_18rem]">
<div className="min-w-0">
{readmeFile ? (
<ReadmePanel file={readmeFile} />
) : (
<div className="rounded-xl border border-border/50 bg-card/60 p-6 text-sm text-muted-foreground">
<p className="font-medium text-foreground">No README found</p>
<p className="mt-1">
Add a README to this repository to describe the project and help
collaborators get started.
</p>
</div>
)}
{/* ReadmePanel renders its own "no README" fallback while keeping
the branch + source controls reachable. */}
<ReadmePanel file={readmeFile} sourceControls={sourceControls} />
</div>
<aside className="space-y-4 rounded-xl border border-border/50 bg-card/60 p-4">
<aside className="space-y-6 rounded-xl border border-border/50 bg-card/60 p-4">
<OverviewRailSection title="People">
<div className="flex items-center justify-between gap-3">
<PeopleAvatars people={people} profiles={profiles} />

View File

@@ -2,7 +2,9 @@ import {
Check,
GitMerge,
GitPullRequest,
GitPullRequestDraft,
MessageSquare,
UserPlus,
X,
} from "lucide-react";
import * as React from "react";
@@ -14,10 +16,31 @@ import {
type ProjectPullRequest,
useCreateProjectPullRequestCommentMutation,
} from "@/features/projects/hooks";
import {
useApproveProjectPullRequestMutation,
useRequestProjectPullRequestReviewMutation,
useUpdateProjectPullRequestStatusMutation,
} from "@/features/projects/pullRequestReviews";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { useIdentityQuery } from "@/shared/api/hooks";
import type { ChannelMember } from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
import { Markdown } from "@/shared/ui/markdown";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import {
ProjectFeedRow,
ProjectFeedRowCluster,
ProjectFeedRowMonoCell,
} from "./ProjectFeedRow";
import { ProfileIdentityButton } from "./ProjectProfileIdentity";
function compactDate(createdAt: number) {
@@ -134,6 +157,7 @@ function PullRequestRow({
profiles?: UserProfileLookup;
pullRequest: ProjectPullRequest;
}) {
const authorProfile = profileForPubkey(pullRequest.author, profiles);
const authorLabel = labelForPubkey(pullRequest.author, profiles);
const StatusIcon =
pullRequest.status === "Closed" || pullRequest.status === "Draft"
@@ -142,44 +166,346 @@ function PullRequestRow({
const statusClassName = pullRequestStatusClassName(pullRequest.status);
return (
<button
className="flex w-full min-w-0 items-start gap-3 p-3 text-left transition-colors hover:bg-muted/30 focus-visible:bg-muted/30 focus-visible:outline-hidden"
onClick={onOpen}
type="button"
>
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<GitPullRequest className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1 space-y-0.5">
<div className="flex min-w-0 items-center gap-1.5">
<p className="truncate text-sm font-semibold leading-5 text-foreground">
{pullRequest.title}
</p>
<StatusIcon className={`h-3.5 w-3.5 shrink-0 ${statusClassName}`} />
</div>
<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>#{pullRequest.id.slice(0, 8)}</span>
<ProjectFeedRow
meta={
<>
<ProfileIdentityButton
avatarClassName="shrink-0"
avatarSize="xs"
avatarUrl={authorProfile?.avatarUrl ?? null}
isAgent={authorProfile?.isAgent === true}
label={authorLabel}
pubkey={pullRequest.author}
showLabel={false}
/>
<span className="truncate font-medium text-foreground/80">
{authorLabel}
</span>
<span>opened {relativeOpenedAt(pullRequest.createdAt)}</span>
<span>by {authorLabel}</span>
<span className="rounded-full border border-border/50 px-1.5 py-0.5 text-2xs">
Member
</span>
<span>·</span>
<span>{pullRequest.status}</span>
</div>
</div>
{pullRequest.comments.length > 0 ? (
<span className="mt-1 flex shrink-0 items-center gap-1 text-xs text-muted-foreground">
<MessageSquare className="h-3.5 w-3.5" />
{pullRequest.comments.length}
</span>
) : null}
</button>
</>
}
onOpen={onOpen}
statusIcon={
<StatusIcon className={`h-3.5 w-3.5 shrink-0 ${statusClassName}`} />
}
testId="project-pull-request-row"
title={pullRequest.title}
trailing={
<>
{pullRequest.comments.length > 0 ? (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<MessageSquare className="h-3.5 w-3.5" />
{pullRequest.comments.length}
</span>
) : null}
<ProjectFeedRowCluster>
<ProjectFeedRowMonoCell
label={`#${pullRequest.id.slice(0, 8)}`}
onClick={onOpen}
title="View pull request"
/>
</ProjectFeedRowCluster>
</>
}
/>
);
}
export type PullRequestPanelMode = "conversation" | "commits" | "checks";
/** Candidate reviewers: project owner, contributors, and PR recipients —
* minus the PR author and anyone already requested. */
function reviewerCandidates(project: Project, pullRequest: ProjectPullRequest) {
const requested = new Set(pullRequest.reviewers);
const author = normalizePubkey(pullRequest.author);
return [
...new Set(
[project.owner, ...project.contributors, ...pullRequest.recipients].map(
normalizePubkey,
),
),
].filter((pubkey) => pubkey !== author && !requested.has(pubkey));
}
function PullRequestReviewersRow({
canRequest,
profiles,
project,
pullRequest,
}: {
canRequest: boolean;
profiles?: UserProfileLookup;
project: Project;
pullRequest: ProjectPullRequest;
}) {
const requestReviewMutation =
useRequestProjectPullRequestReviewMutation(project);
const candidates = reviewerCandidates(project, pullRequest);
const approvedBy = new Set(
pullRequest.approvals.map((approval) => normalizePubkey(approval.author)),
);
const handleRequest = React.useCallback(
async (pubkey: string) => {
try {
await requestReviewMutation.mutateAsync({
pullRequest,
reviewers: [pubkey],
reviewerLabel: labelForPubkey(pubkey, profiles),
});
toast.success("Review requested.");
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to request review.",
);
}
},
[profiles, pullRequest, requestReviewMutation],
);
if (pullRequest.reviewers.length === 0 && !canRequest) {
return null;
}
return (
<div className="flex min-w-0 flex-wrap items-center gap-1.5 border-border/50 border-b px-3 py-2.5 text-xs text-muted-foreground">
<span className="font-medium">Reviewers</span>
{pullRequest.reviewers.map((pubkey) => {
const profile = profileForPubkey(pubkey, profiles);
const label = labelForPubkey(pubkey, profiles);
const hasApproved = approvedBy.has(normalizePubkey(pubkey));
return (
<Tooltip key={pubkey}>
<TooltipTrigger asChild>
<span className="relative inline-flex">
<UserAvatar
accent={profile?.isAgent === true}
avatarUrl={profile?.avatarUrl ?? null}
displayName={label}
size="xs"
/>
{hasApproved ? (
<span className="-right-1 -bottom-1 absolute flex h-3.5 w-3.5 items-center justify-center rounded-full bg-green-600 text-white ring-2 ring-background">
<Check className="h-2.5 w-2.5" />
</span>
) : null}
</span>
</TooltipTrigger>
<TooltipContent>
{label}
{hasApproved ? " — approved" : " — review requested"}
</TooltipContent>
</Tooltip>
);
})}
{canRequest && candidates.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="h-6 gap-1 rounded-full px-2 text-2xs text-muted-foreground hover:text-foreground"
disabled={requestReviewMutation.isPending}
size="xs"
type="button"
variant="outline"
>
<UserPlus className="h-3 w-3" />
Request
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-52">
<DropdownMenuLabel>Request a review</DropdownMenuLabel>
{candidates.map((pubkey) => {
const profile = profileForPubkey(pubkey, profiles);
const label = labelForPubkey(pubkey, profiles);
return (
<DropdownMenuItem
key={pubkey}
onSelect={() => {
void handleRequest(pubkey);
}}
>
<span className="flex min-w-0 items-center gap-2">
<UserAvatar
accent={profile?.isAgent === true}
avatarUrl={profile?.avatarUrl ?? null}
displayName={label}
size="xs"
/>
<span className="truncate">{label}</span>
</span>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
);
}
/** GitHub-style review box rendered in the conversation flow, above the
* comment composer: reviewers on top, review state + actions below. */
function PullRequestReviewCard({
profiles,
project,
pullRequest,
}: {
profiles?: UserProfileLookup;
project: Project;
pullRequest: ProjectPullRequest;
}) {
const identityQuery = useIdentityQuery();
const statusMutation = useUpdateProjectPullRequestStatusMutation(project);
const approveMutation = useApproveProjectPullRequestMutation(project);
const viewerPubkey = identityQuery.data?.pubkey ?? null;
const viewer = viewerPubkey ? normalizePubkey(viewerPubkey) : null;
const isAuthor = viewer === normalizePubkey(pullRequest.author);
const isOwner = viewer === normalizePubkey(project.owner);
const canChangeStatus = Boolean(viewer) && (isAuthor || isOwner);
const canRequestReview = canChangeStatus;
const hasApproved = Boolean(
viewer &&
pullRequest.approvals.some(
(approval) => normalizePubkey(approval.author) === viewer,
),
);
const canApprove =
Boolean(viewer) &&
!isAuthor &&
!hasApproved &&
(pullRequest.status === "Open" || pullRequest.status === "Draft");
const handleStatusChange = React.useCallback(
async (status: "open" | "draft") => {
try {
await statusMutation.mutateAsync({ pullRequest, status });
toast.success(
status === "draft"
? "Converted to draft."
: "Marked as ready for review.",
);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to update status.",
);
}
},
[pullRequest, statusMutation],
);
const handleApprove = React.useCallback(async () => {
try {
await approveMutation.mutateAsync({ pullRequest });
toast.success("Pull request approved.");
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to approve.",
);
}
}, [approveMutation, pullRequest]);
const approvalCount = pullRequest.approvals.length;
const isDraft = pullRequest.status === "Draft";
const reviewState = isDraft
? "This pull request is still a work in progress."
: approvalCount > 0
? `Approved by ${pluralize(approvalCount, "reviewer")}.`
: pullRequest.reviewers.length > 0
? "Review requested — no approvals yet."
: "No reviews yet.";
const reviewStateDetail = isDraft
? "Draft pull requests cannot be merged."
: approvalCount === 0
? "Approvals from reviewers will show up here."
: null;
return (
<div className="overflow-hidden rounded-lg border border-border/50 bg-background/45">
<PullRequestReviewersRow
canRequest={canRequestReview}
profiles={profiles}
project={project}
pullRequest={pullRequest}
/>
<div className="flex min-w-0 flex-wrap items-center gap-3 p-3">
{isDraft ? (
<GitPullRequestDraft className="h-4 w-4 shrink-0 text-muted-foreground" />
) : approvalCount > 0 ? (
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-green-600 text-white">
<Check className="h-3 w-3" />
</span>
) : (
<GitPullRequest className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground">{reviewState}</p>
{reviewStateDetail ? (
<p className="text-xs text-muted-foreground">{reviewStateDetail}</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
{hasApproved ? (
<span className="inline-flex items-center gap-1.5 rounded-full border border-green-600/40 px-2.5 py-1 text-xs font-medium text-green-600 dark:text-green-500">
<Check className="h-3.5 w-3.5" />
Approved
</span>
) : null}
{canApprove ? (
<Button
className="h-7 gap-1.5 rounded-full bg-green-600 px-3 text-white hover:bg-green-700"
disabled={approveMutation.isPending}
onClick={() => {
void handleApprove();
}}
size="xs"
type="button"
>
<Check className="h-3.5 w-3.5" />
Approve
</Button>
) : null}
{canChangeStatus && isDraft ? (
<Button
className="h-7 gap-1.5 rounded-full px-3"
disabled={statusMutation.isPending}
onClick={() => {
void handleStatusChange("open");
}}
size="xs"
type="button"
variant="secondary"
>
<GitPullRequest className="h-3.5 w-3.5" />
Ready for review
</Button>
) : null}
</div>
</div>
{canChangeStatus && pullRequest.status === "Open" ? (
<div className="border-border/50 border-t px-3 py-2 text-right text-xs text-muted-foreground">
Still in progress?{" "}
<button
className="font-medium underline-offset-2 hover:text-foreground hover:underline disabled:opacity-50"
disabled={statusMutation.isPending}
onClick={() => {
void handleStatusChange("draft");
}}
type="button"
>
Convert to draft
</button>
</div>
) : null}
</div>
);
}
/** GitHub-style PR title + status line, rendered above the PR tab row. */
export function PullRequestDetailHeader({
profiles,
@@ -378,29 +704,61 @@ function PullRequestDetail({
</h4>
{pullRequest.comments.length > 0 ? (
<div className="space-y-3">
{pullRequest.comments.map((item) => (
<article
className="rounded-lg border border-border/50 bg-background/45 p-3"
key={item.id}
>
<div className="mb-2">
<AuthorIdentity
profiles={profiles}
pubkey={item.author}
role={compactDate(item.createdAt)}
{pullRequest.comments.map((item) => {
// Approvals and review requests render as compact timeline
// rows (GitHub-style) rather than full comment cards.
if (item.isApproval || item.isReviewRequest) {
return (
<div
className="flex min-w-0 flex-wrap items-center gap-1.5 px-1 text-xs text-muted-foreground"
key={item.id}
>
{item.isApproval ? (
<Check className="h-3.5 w-3.5 shrink-0 text-green-600 dark:text-green-500" />
) : (
<UserPlus className="h-3.5 w-3.5 shrink-0" />
)}
<span className="font-medium text-foreground">
{labelForPubkey(item.author, profiles)}
</span>
<span className="min-w-0 truncate">
{item.isApproval
? "approved these changes"
: item.content.trim() || "requested a review"}
</span>
<span>· {compactDate(item.createdAt)}</span>
</div>
);
}
return (
<article
className="rounded-lg border border-border/50 bg-background/45 p-3"
key={item.id}
>
<div className="mb-2">
<AuthorIdentity
profiles={profiles}
pubkey={item.author}
role={compactDate(item.createdAt)}
/>
</div>
<Markdown
className="text-sm"
content={item.content}
interactive={false}
/>
</div>
<Markdown
className="text-sm"
content={item.content}
interactive={false}
/>
</article>
))}
</article>
);
})}
</div>
) : (
<p className="text-sm text-muted-foreground">No comments yet.</p>
)}
<PullRequestReviewCard
profiles={profiles}
project={project}
pullRequest={pullRequest}
/>
<ForumComposer
className="rounded-lg border border-border/50 bg-background/45"
disabled={commentMutation.isPending}

View File

@@ -0,0 +1,163 @@
import { BookOpen } from "lucide-react";
import type { ProjectRepoFile } from "@/features/projects/hooks";
import { Markdown, SyntaxHighlightedCode } from "@/shared/ui/markdown";
import {
baseName,
formatLastChangedAt,
languageForPath,
} from "./ProjectRepositoryPanel";
import {
type RepoSourceHeaderControls,
RepoSourceToggle,
RepositoryBranchDropdown,
} from "./ProjectRepositorySource";
export function findReadmeFile(files: ProjectRepoFile[]) {
const readmes = files.filter((file) =>
/^readme(?:\.(?:md|markdown|mdx|txt))?$/i.test(baseName(file.path)),
);
return readmes.find((file) => !file.path.includes("/")) ?? readmes[0] ?? null;
}
function decodeHtmlEntities(value: string) {
return value
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
}
function htmlInlineToMarkdown(value: string): string {
return decodeHtmlEntities(value)
.replace(/<br\s*\/?\s*>/gi, "\n")
.replace(/<img\b([^>]*)>/gi, (_match: string, attrs: string) => {
const src = attrs.match(/\bsrc=["']([^"']+)["']/i)?.[1];
const alt = attrs.match(/\balt=["']([^"']*)["']/i)?.[1] ?? "";
return src ? `![${alt}](${src})` : "";
})
.replace(
/<a\b[^>]*\bhref=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi,
(_match: string, href: string, label: string) =>
`[${htmlInlineToMarkdown(label).trim()}](${href})`,
)
.replace(/<(strong|b)\b[^>]*>([\s\S]*?)<\/\1>/gi, "**$2**")
.replace(/<(em|i)\b[^>]*>([\s\S]*?)<\/\1>/gi, "*$2*")
.replace(/<code\b[^>]*>([\s\S]*?)<\/code>/gi, "`$1`")
.replace(/<sub\b[^>]*>([\s\S]*?)<\/sub>/gi, "$1")
.replace(/<span\b[^>]*>([\s\S]*?)<\/span>/gi, "$1")
.replace(/<[^>]+>/g, "")
.trim();
}
function normalizeReadmeMarkdown(content: string) {
return content
.replace(
/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi,
(_match, depth: string, value: string) =>
`${"#".repeat(Number(depth))} ${htmlInlineToMarkdown(value)}\n\n`,
)
.replace(
/<p\b[^>]*>([\s\S]*?)<\/p>/gi,
(_match, value: string) => `${htmlInlineToMarkdown(value)}\n\n`,
)
.replace(
/<div\b[^>]*>([\s\S]*?)<\/div>/gi,
(_match, value: string) => `${htmlInlineToMarkdown(value)}\n\n`,
)
.replace(
/<center\b[^>]*>([\s\S]*?)<\/center>/gi,
(_match, value: string) => `${htmlInlineToMarkdown(value)}\n\n`,
)
.replace(/\n{3,}/g, "\n\n")
.trim();
}
export function ReadmePanel({
file,
sourceControls,
}: {
file: ProjectRepoFile | null;
/** Branch picker + remote/local toggle rendered in the panel header. */
sourceControls?: RepoSourceHeaderControls;
}) {
// Two header rows, mirroring the files panel: controls on top, then the
// file identity row.
const header = (
<>
{sourceControls ? (
<div className="flex min-h-10 min-w-0 items-center gap-1 border-border/50 border-b px-3 py-1.5">
<RepositoryBranchDropdown
branch={sourceControls.branch}
branchOptions={sourceControls.branchOptions}
compact
onBranchChange={sourceControls.onBranchChange}
/>
<div className="ml-auto flex shrink-0 items-center">
<RepoSourceToggle controls={sourceControls} />
</div>
</div>
) : null}
<div className="flex min-h-10 items-center gap-2 border-border/50 border-b bg-muted/20 px-4">
<BookOpen className="h-4 w-4 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
{file ? baseName(file.path) : "README"}
</span>
{file ? (
<span className="hidden shrink-0 text-2xs text-muted-foreground sm:block">
Last changed {formatLastChangedAt(file.lastChangedAt)}
</span>
) : null}
</div>
</>
);
if (!file?.previewContent) {
return (
<section className="overflow-hidden rounded-xl border border-border/50 bg-card/60">
{sourceControls ? header : null}
<div className="p-6 text-sm text-muted-foreground">
Add a README to this repository to describe setup, usage, and project
context.
</div>
</section>
);
}
const language = languageForPath(file.path);
const isMarkdown = /\.(?:md|markdown|mdx)$/i.test(file.path);
const readmeContent = isMarkdown
? normalizeReadmeMarkdown(file.previewContent)
: file.previewContent;
return (
<section className="overflow-hidden rounded-xl border border-border/50 bg-card/60">
{header}
<div className="p-4">
{isMarkdown ? (
<Markdown
className="text-sm"
content={readmeContent}
interactive={false}
/>
) : language ? (
<pre className="overflow-x-auto rounded-lg bg-muted/40 p-4">
<SyntaxHighlightedCode
className="text-xs leading-relaxed"
code={file.previewContent}
language={language}
/>
</pre>
) : (
<pre className="overflow-x-auto rounded-lg bg-muted/40 p-4">
<code className="block min-w-full whitespace-pre font-mono text-xs leading-relaxed text-foreground">
{file.previewContent}
</code>
</pre>
)}
</div>
</section>
);
}

View File

@@ -1,5 +1,4 @@
import {
BookOpen,
Braces,
ChevronRight,
CodeXml,
@@ -32,8 +31,13 @@ import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { UserSearchResult } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { Markdown, SyntaxHighlightedCode } from "@/shared/ui/markdown";
import { SyntaxHighlightedCode } from "@/shared/ui/markdown";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import {
type RepoSourceHeaderControls,
RepoSourceToggle,
RepositoryBranchDropdown,
} from "./ProjectRepositorySource";
function relativeCommitTime(createdAt: number) {
const elapsedSeconds = Math.max(
@@ -64,7 +68,7 @@ function pluralize(count: number, singular: string) {
return `${count} ${singular}${count === 1 ? "" : "s"}`;
}
function formatLastChangedAt(timestamp: number | null) {
export function formatLastChangedAt(timestamp: number | null) {
if (!timestamp) return "—";
return new Date(timestamp * 1_000).toLocaleString(undefined, {
month: "short",
@@ -175,7 +179,7 @@ function RepositoryCommitCell({
);
}
function baseName(path: string) {
export function baseName(path: string) {
return path.split("/").pop() || path;
}
@@ -213,7 +217,7 @@ const FILE_LANGUAGE_BY_EXTENSION: Record<string, string> = {
zig: "zig",
};
function languageForPath(path: string) {
export function languageForPath(path: string) {
const fileName = baseName(path).toLowerCase();
if (fileName === "dockerfile") return "dockerfile";
if (fileName === "makefile") return "make";
@@ -538,68 +542,6 @@ function handleRepositoryEntryKeyDown(
onOpen();
}
export function findReadmeFile(files: ProjectRepoFile[]) {
const readmes = files.filter((file) =>
/^readme(?:\.(?:md|markdown|mdx|txt))?$/i.test(baseName(file.path)),
);
return readmes.find((file) => !file.path.includes("/")) ?? readmes[0] ?? null;
}
function decodeHtmlEntities(value: string) {
return value
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
}
function htmlInlineToMarkdown(value: string): string {
return decodeHtmlEntities(value)
.replace(/<br\s*\/?\s*>/gi, "\n")
.replace(/<img\b([^>]*)>/gi, (_match: string, attrs: string) => {
const src = attrs.match(/\bsrc=["']([^"']+)["']/i)?.[1];
const alt = attrs.match(/\balt=["']([^"']*)["']/i)?.[1] ?? "";
return src ? `![${alt}](${src})` : "";
})
.replace(
/<a\b[^>]*\bhref=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi,
(_match: string, href: string, label: string) =>
`[${htmlInlineToMarkdown(label).trim()}](${href})`,
)
.replace(/<(strong|b)\b[^>]*>([\s\S]*?)<\/\1>/gi, "**$2**")
.replace(/<(em|i)\b[^>]*>([\s\S]*?)<\/\1>/gi, "*$2*")
.replace(/<code\b[^>]*>([\s\S]*?)<\/code>/gi, "`$1`")
.replace(/<sub\b[^>]*>([\s\S]*?)<\/sub>/gi, "$1")
.replace(/<span\b[^>]*>([\s\S]*?)<\/span>/gi, "$1")
.replace(/<[^>]+>/g, "")
.trim();
}
function normalizeReadmeMarkdown(content: string) {
return content
.replace(
/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi,
(_match, depth: string, value: string) =>
`${"#".repeat(Number(depth))} ${htmlInlineToMarkdown(value)}\n\n`,
)
.replace(
/<p\b[^>]*>([\s\S]*?)<\/p>/gi,
(_match, value: string) => `${htmlInlineToMarkdown(value)}\n\n`,
)
.replace(
/<div\b[^>]*>([\s\S]*?)<\/div>/gi,
(_match, value: string) => `${htmlInlineToMarkdown(value)}\n\n`,
)
.replace(
/<center\b[^>]*>([\s\S]*?)<\/center>/gi,
(_match, value: string) => `${htmlInlineToMarkdown(value)}\n\n`,
)
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function BreadcrumbButton({
children,
onClick,
@@ -694,6 +636,7 @@ export function RepositoryFilesPanel({
error,
profiles,
fallbackAuthorPubkey,
sourceControls,
}: {
files: ProjectRepoFile[];
snapshot: ProjectRepoSnapshot | null | undefined;
@@ -701,6 +644,8 @@ export function RepositoryFilesPanel({
error: unknown;
profiles?: UserProfileLookup;
fallbackAuthorPubkey?: string;
/** Branch picker + remote/local toggle rendered in the panel header. */
sourceControls?: RepoSourceHeaderControls;
}) {
const [currentPath, setCurrentPath] = React.useState("");
const [selectedFile, setSelectedFile] =
@@ -753,26 +698,37 @@ export function RepositoryFilesPanel({
setSelectedFile(null);
}, [filesKey]);
if (isLoading) {
// Loading/error/empty states keep the header controls visible — the
// remote/local toggle must stay reachable when one source fails to load.
const stateMessage = isLoading
? "Loading repository files…"
: error
? "Could not load the repository file tree."
: files.length === 0
? "No files have been pushed yet."
: null;
if (stateMessage) {
if (!sourceControls) {
return (
<div className="rounded-xl border border-border/50 bg-card/60 p-4 text-sm text-muted-foreground">
{stateMessage}
</div>
);
}
return (
<div className="rounded-xl border border-border/50 bg-card/60 p-4 text-sm text-muted-foreground">
Loading repository files…
</div>
);
}
if (error) {
return (
<div className="rounded-xl border border-border/50 bg-card/60 p-4 text-sm text-muted-foreground">
Could not load the repository file tree.
</div>
);
}
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 files have been pushed yet.
<div className="overflow-hidden rounded-xl border border-border/50 bg-card/60">
<div className="flex min-h-10 min-w-0 items-center gap-1 border-border/50 border-b px-3 py-1.5">
<RepositoryBranchDropdown
branch={sourceControls.branch}
branchOptions={sourceControls.branchOptions}
compact
onBranchChange={sourceControls.onBranchChange}
/>
<div className="ml-auto flex shrink-0 items-center">
<RepoSourceToggle controls={sourceControls} />
</div>
</div>
<div className="p-4 text-sm text-muted-foreground">{stateMessage}</div>
</div>
);
}
@@ -791,10 +747,27 @@ export function RepositoryFilesPanel({
return (
<div className="overflow-hidden rounded-xl border border-border/50 bg-card/60">
<div className="flex min-h-10 min-w-0 items-center gap-1 border-border/50 border-b px-3">
<BreadcrumbButton onClick={() => setCurrentPath("")}>
Files
</BreadcrumbButton>
<div className="flex min-h-10 min-w-0 items-center gap-1 border-border/50 border-b px-3 py-1.5">
{sourceControls ? (
<RepositoryBranchDropdown
branch={sourceControls.branch}
branchOptions={sourceControls.branchOptions}
compact
onBranchChange={sourceControls.onBranchChange}
/>
) : (
<BreadcrumbButton onClick={() => setCurrentPath("")}>
Files
</BreadcrumbButton>
)}
{sourceControls && pathSegments.length > 0 ? (
<>
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60" />
<BreadcrumbButton onClick={() => setCurrentPath("")}>
Files
</BreadcrumbButton>
</>
) : null}
{pathSegments.map((segment, index) => {
const nextPath = pathSegments.slice(0, index + 1).join("/");
return (
@@ -806,6 +779,11 @@ export function RepositoryFilesPanel({
</React.Fragment>
);
})}
{sourceControls ? (
<div className="ml-auto flex shrink-0 items-center">
<RepoSourceToggle controls={sourceControls} />
</div>
) : null}
</div>
<div className="overflow-x-auto">
@@ -925,57 +903,3 @@ export function RepositoryFilesPanel({
</div>
);
}
export function ReadmePanel({ file }: { file: ProjectRepoFile | null }) {
if (!file?.previewContent) {
return (
<div className="rounded-xl border border-border/50 bg-card/60 p-6 text-sm text-muted-foreground">
Add a README to this repository to describe setup, usage, and project
context.
</div>
);
}
const language = languageForPath(file.path);
const isMarkdown = /\.(?:md|markdown|mdx)$/i.test(file.path);
const readmeContent = isMarkdown
? normalizeReadmeMarkdown(file.previewContent)
: file.previewContent;
return (
<section className="overflow-hidden rounded-xl border border-border/50 bg-card/60">
<div className="flex min-h-10 items-center gap-2 border-border/50 border-b bg-muted/20 px-4">
<BookOpen className="h-4 w-4 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
{baseName(file.path)}
</span>
<span className="hidden shrink-0 text-2xs text-muted-foreground sm:block">
Last changed {formatLastChangedAt(file.lastChangedAt)}
</span>
</div>
<div className="p-4">
{isMarkdown ? (
<Markdown
className="text-sm"
content={readmeContent}
interactive={false}
/>
) : language ? (
<pre className="overflow-x-auto rounded-lg bg-muted/40 p-4">
<SyntaxHighlightedCode
className="text-xs leading-relaxed"
code={file.previewContent}
language={language}
/>
</pre>
) : (
<pre className="overflow-x-auto rounded-lg bg-muted/40 p-4">
<code className="block min-w-full whitespace-pre font-mono text-xs leading-relaxed text-foreground">
{file.previewContent}
</code>
</pre>
)}
</div>
</section>
);
}

View File

@@ -1,17 +1,6 @@
import {
Check,
ChevronDown,
Copy,
GitBranch,
GitFork,
HardDrive,
UploadCloud,
} from "lucide-react";
import * as React from "react";
import { ChevronDown, GitBranch, UploadCloud } from "lucide-react";
import type { ProjectRepoSyncStatus } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import { Card } from "@/shared/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
@@ -20,186 +9,123 @@ import {
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
function RepositoryPathRow({
action,
path,
title,
type,
}: {
action?: React.ReactNode;
path: string;
title?: string;
type: "remote" | "local";
}) {
const [copied, setCopied] = React.useState(false);
const handleCopy = React.useCallback(() => {
void navigator.clipboard.writeText(path).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2_000);
});
}, [path]);
const Icon = type === "local" ? HardDrive : GitFork;
return (
<div className="flex min-w-0 items-center gap-2" title={title}>
<Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<div className="flex min-w-0 flex-1 items-center gap-2">
<code className="min-w-0 max-w-full truncate text-xs text-muted-foreground">
{path}
</code>
<Button
className="h-6 w-6 shrink-0"
onClick={handleCopy}
size="icon"
variant="ghost"
>
{copied ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
{action}
</div>
);
}
export function RepositorySourceCard({
/** Branch picker shared by the readme and files panel headers. */
export function RepositoryBranchDropdown({
branch,
branchOptions,
cloneUrls,
localDisabled,
localLabel,
localPath,
compact,
onBranchChange,
onPush,
onSourceChange,
pushDisabled,
pushPending,
remoteLabel,
source,
status,
}: {
branch: string;
branchOptions: string[];
cloneUrls: string[];
localDisabled: boolean;
localLabel: string;
localPath?: string | null;
/** Smaller trigger for inline headers. */
compact?: boolean;
onBranchChange: (branch: string) => void;
onPush: () => void;
onSourceChange: (source: "remote" | "local") => void;
pushDisabled: boolean;
pushPending: boolean;
remoteLabel: string;
source: "remote" | "local";
status: ProjectRepoSyncStatus | null | undefined;
}) {
if (cloneUrls.length === 0 && !branch) return null;
const selectableBranches =
branchOptions.length > 0 ? branchOptions : [branch];
const showLocalPath = source === "local" && localPath;
const localRefLabel = status?.localShortHead
? `${status.localBranch ?? "local"} @ ${status.localShortHead}`
: null;
const showPushButton = source === "local" && status?.canPush;
const pushAction = showPushButton ? (
<Button
aria-label={pushPending ? "Pushing local commits" : "Push local commits"}
className="h-6 w-6 shrink-0 rounded-full"
disabled={pushDisabled}
onClick={onPush}
size="icon"
title={status?.pushBlockReason ?? "Push local commits"}
variant="default"
>
<UploadCloud className="h-3.5 w-3.5" />
</Button>
) : null;
if (!branch) {
return (
<span className="truncate font-mono text-sm font-semibold text-foreground">
—
</span>
);
}
return (
<Card className="relative mt-8 border-border/50 bg-card/60 px-4 py-3 shadow-none">
<div className="-translate-x-1/2 -translate-y-1/2 absolute top-0 left-1/2 flex w-fit items-center gap-1 rounded-full border border-border/50 bg-card px-1 py-1 shadow-sm">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="h-7 rounded-full px-3"
onClick={() => onSourceChange("remote")}
className={
compact
? "h-6 max-w-full gap-1 px-1.5 font-mono text-xs font-medium"
: "h-6 max-w-full gap-1.5 px-2 font-mono text-sm font-semibold"
}
size="sm"
variant={source === "remote" ? "secondary" : "ghost"}
type="button"
variant="ghost"
>
{remoteLabel}
<GitBranch className="h-3 w-3 shrink-0 text-muted-foreground" />
<span className="truncate">{branch}</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</Button>
<Button
className="h-7 rounded-full px-3"
disabled={localDisabled}
onClick={() => onSourceChange("local")}
size="sm"
variant={source === "local" ? "secondary" : "ghost"}
>
{localLabel}
</Button>
</div>
<div className="flex min-w-0 flex-col gap-1.5">
<div className="flex min-w-0 items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<GitBranch className="h-3.5 w-3.5 text-muted-foreground" />
{branch ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="h-6 max-w-full gap-1.5 px-2 font-mono text-sm font-semibold"
size="sm"
type="button"
variant="ghost"
>
<span className="truncate">{branch || "—"}</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-56">
<DropdownMenuRadioGroup
onValueChange={onBranchChange}
value={branch}
>
{selectableBranches.map((option) => (
<DropdownMenuRadioItem key={option} value={option}>
<span className="truncate font-mono">{option}</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
) : (
<span className="truncate font-mono text-sm font-semibold text-foreground">
{branch || "—"}
</span>
)}
</div>
</div>
<div className="min-w-0 flex-1">
{showLocalPath ? (
<RepositoryPathRow
action={pushAction}
path={localPath}
title={
localRefLabel ? `Local checkout: ${localRefLabel}` : undefined
}
type="local"
/>
) : cloneUrls.length > 0 ? (
cloneUrls.map((url) => (
<RepositoryPathRow key={url} path={url} type="remote" />
))
) : (
<div className="text-sm text-muted-foreground">
{source === "local"
? "No local checkout found."
: "No clone URL published yet."}
</div>
)}
</div>
</div>
</Card>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-56">
<DropdownMenuRadioGroup onValueChange={onBranchChange} value={branch}>
{selectableBranches.map((option) => (
<DropdownMenuRadioItem key={option} value={option}>
<span className="truncate font-mono">{option}</span>
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
/** Props for the compact branch + remote/local controls in panel headers. */
export type RepoSourceHeaderControls = {
branch: string;
branchOptions: string[];
onBranchChange: (branch: string) => void;
source: "remote" | "local";
onSourceChange: (source: "remote" | "local") => void;
localDisabled: boolean;
localLabel: string;
remoteLabel: string;
/** Push of local commits, shown only when the local source can push. */
canPush?: boolean;
onPush?: () => void;
pushDisabled?: boolean;
pushPending?: boolean;
pushTitle?: string;
};
/** Small pill toggle between the remote and local repository source. */
export function RepoSourceToggle({
controls,
}: {
controls: RepoSourceHeaderControls;
}) {
const showPush =
controls.source === "local" && controls.canPush && controls.onPush;
return (
<div className="flex shrink-0 items-center gap-1">
{showPush ? (
<Button
aria-label={
controls.pushPending
? "Pushing local commits"
: "Push local commits"
}
className="h-6 w-6 shrink-0 rounded-full"
disabled={controls.pushDisabled}
onClick={controls.onPush}
size="icon"
title={controls.pushTitle ?? "Push local commits"}
variant="default"
>
<UploadCloud className="h-3.5 w-3.5" />
</Button>
) : null}
<div className="flex shrink-0 items-center gap-0.5 rounded-full bg-muted/40 p-0.5">
<Button
className="h-6 rounded-full px-2.5 text-xs"
onClick={() => controls.onSourceChange("remote")}
size="sm"
variant={controls.source === "remote" ? "secondary" : "ghost"}
>
{controls.remoteLabel}
</Button>
<Button
className="h-6 rounded-full px-2.5 text-xs"
disabled={controls.localDisabled}
onClick={() => controls.onSourceChange("local")}
size="sm"
variant={controls.source === "local" ? "secondary" : "ghost"}
>
{controls.localLabel}
</Button>
</div>
</div>
);
}

View File

@@ -6,7 +6,7 @@ import { TabsList, TabsTrigger } from "@/shared/ui/tabs";
const PROJECT_TAB_TRIGGER_CLASS =
"h-8 gap-1.5 rounded-full px-3 text-foreground hover:bg-accent hover:text-accent-foreground data-[state=active]:bg-secondary data-[state=active]:text-secondary-foreground data-[state=active]:shadow-xs";
export function ProjectTabsList() {
export function ProjectTabsList({ prsActive }: { prsActive?: boolean }) {
return (
<TabsList className="h-9 w-fit justify-start gap-0.5 bg-transparent p-0">
<TabsTrigger
@@ -17,17 +17,22 @@ export function ProjectTabsList() {
>
<BookOpen className="h-3.5 w-3.5" />
</TabsTrigger>
<TabsTrigger className={PROJECT_TAB_TRIGGER_CLASS} value="files">
Code
</TabsTrigger>
<TabsTrigger className={PROJECT_TAB_TRIGGER_CLASS} value="activity">
Commits
</TabsTrigger>
<TabsTrigger className={PROJECT_TAB_TRIGGER_CLASS} value="prs">
PRs
</TabsTrigger>
<TabsTrigger className={PROJECT_TAB_TRIGGER_CLASS} value="issues">
Issues
</TabsTrigger>
<TabsTrigger className={PROJECT_TAB_TRIGGER_CLASS} value="files">
Files
<TabsTrigger
className={`${PROJECT_TAB_TRIGGER_CLASS}${
prsActive ? " bg-secondary text-secondary-foreground shadow-xs" : ""
}`}
value="prs"
>
PRs
</TabsTrigger>
<TabsTrigger className={PROJECT_TAB_TRIGGER_CLASS} value="contributors">
Contributors

View File

@@ -9,11 +9,16 @@ import type {
ProjectRepoDiff,
ProjectRepoSnapshot,
} from "@/features/projects/hooks";
import {
commitAuthorPubkeysFromPullRequests,
type ViewerGitIdentity,
} from "@/features/projects/lib/projectContributorMatching";
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 { findReadmeFile } from "./ProjectReadmePanel";
import { RepositoryFilesPanel } from "./ProjectRepositoryPanel";
import type { RepoSourceHeaderControls } from "./ProjectRepositorySource";
import { ProjectCommitDetailPanel } from "./ProjectCommitDetailPanel";
import { ActivityPanel, ContributorsPanel } from "./ProjectDetailFeedPanels";
import { ProjectIssuesPanel } from "./ProjectIssuesPanel";
@@ -48,6 +53,7 @@ export function WorkspaceTabs({
onSelectedCommitHashChange,
onSelectedIssueIdChange,
onSelectedPullRequestIdChange,
onSelectedTabChange,
onBranchChange,
onOpenTerminal,
snapshot,
@@ -56,7 +62,9 @@ export function WorkspaceTabs({
profiles,
repoContributors,
repoSource,
sourceControls,
terminalTitle,
viewerGitIdentity,
}: {
commitDiff: ProjectRepoDiff | null | undefined;
commitDiffError: unknown;
@@ -77,6 +85,8 @@ export function WorkspaceTabs({
onSelectedCommitHashChange: (hash: string | null) => void;
onSelectedIssueIdChange: (id: string | null) => void;
onSelectedPullRequestIdChange: (id: string | null) => void;
/** Reports the active tab so the screen breadcrumb can mirror it. */
onSelectedTabChange?: (tab: string) => void;
onBranchChange: (branch: string | null) => void;
onOpenTerminal?: () => void;
snapshot: ProjectRepoSnapshot | null | undefined;
@@ -85,7 +95,10 @@ export function WorkspaceTabs({
profiles?: UserProfileLookup;
repoContributors: ProjectRepoContributor[];
repoSource: "remote" | "local";
/** Branch picker + remote/local toggle for the Code tab header. */
sourceControls?: RepoSourceHeaderControls;
terminalTitle?: string;
viewerGitIdentity?: ViewerGitIdentity | null;
}) {
const localCheckoutSnapshot = localSnapshot?.snapshot ?? null;
const displayedSnapshot =
@@ -98,6 +111,10 @@ export function WorkspaceTabs({
displayedSnapshot?.contributors ?? repoContributors;
const files = displayedSnapshot?.files ?? [];
const readmeFile = React.useMemo(() => findReadmeFile(files), [files]);
const commitAuthorPubkeys = React.useMemo(
() => commitAuthorPubkeysFromPullRequests(pullRequests),
[pullRequests],
);
const selectedPullRequest =
pullRequests.find(
(pullRequest) => pullRequest.id === selectedPullRequestId,
@@ -105,6 +122,10 @@ export function WorkspaceTabs({
const isPullRequestSelected = Boolean(selectedPullRequest);
const [selectedTab, setSelectedTab] = React.useState("overview");
React.useEffect(() => {
onSelectedTabChange?.(selectedTab);
}, [onSelectedTabChange, selectedTab]);
React.useEffect(() => {
if (isPullRequestSelected) {
setSelectedTab((currentTab) =>
@@ -135,7 +156,7 @@ export function WorkspaceTabs({
const handleTabChange = React.useCallback(
(nextTab: string) => {
setSelectedTab(nextTab);
if (!nextTab.startsWith("pr-") && nextTab !== "prs") {
if (!nextTab.startsWith("pr-")) {
onSelectedPullRequestIdChange(null);
}
if (nextTab !== "issues") {
@@ -158,6 +179,22 @@ export function WorkspaceTabs({
onValueChange={handleTabChange}
value={selectedTab}
>
<div className="flex min-w-0 items-center gap-1">
<ProjectTabsList prsActive={isPullRequestSelected} />
{onOpenTerminal ? (
<Button
aria-label="Open terminal"
className="h-8 w-8 shrink-0 rounded-full text-muted-foreground hover:text-foreground"
onClick={onOpenTerminal}
size="icon"
title={terminalTitle ?? "Open terminal"}
variant="ghost"
>
<TerminalSquare className="h-3.5 w-3.5" />
</Button>
) : null}
</div>
{selectedPullRequest ? (
<div className="space-y-4">
<PullRequestDetailHeader
@@ -170,23 +207,7 @@ export function WorkspaceTabs({
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>
)}
) : null}
<TabsContent className="m-0" value="overview">
<ProjectOverviewPanel
@@ -198,17 +219,11 @@ export function WorkspaceTabs({
pullRequests={pullRequests}
readmeFile={readmeFile}
snapshot={displayedSnapshot}
sourceControls={sourceControls}
/>
</TabsContent>
<TabsContent
className={cn(
"m-0",
!selectedCommitHash &&
"overflow-hidden rounded-xl border border-border/50 bg-card/60",
)}
value="activity"
>
<TabsContent className="m-0" value="activity">
{selectedCommitHash ? (
<ProjectCommitDetailPanel
commit={
@@ -216,7 +231,9 @@ export function WorkspaceTabs({
(commit) => commit.hash === selectedCommitHash,
) ?? null
}
commitAuthorPubkeys={commitAuthorPubkeys}
commitHash={selectedCommitHash}
viewerGitIdentity={viewerGitIdentity}
diff={commitDiff}
diffError={commitDiffError}
diffLoading={commitDiffLoading}
@@ -228,8 +245,10 @@ export function WorkspaceTabs({
isLoading={displayedSnapshotLoading}
onSelectCommit={(commit) => onSelectedCommitHashChange(commit.hash)}
profiles={profiles}
pullRequests={pullRequests}
repoContributors={displayedContributors}
snapshot={displayedSnapshot}
viewerGitIdentity={viewerGitIdentity}
/>
)}
</TabsContent>
@@ -295,6 +314,7 @@ export function WorkspaceTabs({
isLoading={displayedSnapshotLoading}
profiles={profiles}
snapshot={displayedSnapshot}
sourceControls={sourceControls}
/>
</TabsContent>

View File

@@ -5,12 +5,14 @@ const WEEK_COUNT = 26;
const DAYS_PER_WEEK = 7;
// Intensity ramp shared by the cells and the "Less … More" legend.
// Grayscale on the theme's foreground token: the most active cells are
// near-black in light mode and near-white in dark mode.
const LEVEL_CLASSES = [
"bg-muted/60 dark:bg-muted/40",
"bg-emerald-500/30",
"bg-emerald-500/50",
"bg-emerald-500/75",
"bg-emerald-500",
"bg-foreground/25",
"bg-foreground/50",
"bg-foreground/75",
"bg-foreground",
];
function dayKeyOf(date: Date) {

View File

@@ -47,10 +47,6 @@ type ProjectsOverviewPanelProps = {
summaries?: Record<string, ProjectActivitySummary>;
};
function unitLabel(count: number, singular: string, plural = `${singular}s`) {
return count === 1 ? singular : plural;
}
function projectPeople(
project: Project,
summary: ProjectActivitySummary | undefined,
@@ -183,13 +179,11 @@ function StatPill({
icon: Icon,
label,
onClick,
unit,
}: {
count: number;
icon: React.ComponentType<{ className?: string }>;
label: string;
onClick: () => void;
unit: string;
}) {
return (
<button
@@ -203,11 +197,8 @@ function StatPill({
</span>
<Icon className="h-3.5 w-3.5 text-muted-foreground/70" />
</span>
<span className="mt-2 flex min-w-0 items-baseline gap-1.5">
<span className="text-2xl font-semibold leading-none tracking-tight text-foreground">
{count}
</span>
<span className="truncate text-xs text-muted-foreground">{unit}</span>
<span className="mt-2 text-2xl font-semibold leading-none tracking-tight text-foreground">
{count}
</span>
</button>
);
@@ -252,28 +243,24 @@ export function ProjectsOverviewPanel({
icon={FolderGit2}
label="Repositories"
onClick={() => onSelectSection("repositories")}
unit={unitLabel(projects.length, "project")}
/>
<StatPill
count={stats.prs}
icon={GitPullRequest}
label="Pull requests"
onClick={() => onSelectSection("prs")}
unit={unitLabel(stats.prs, "PR")}
/>
<StatPill
count={localRepositoryCount}
icon={Radio}
label="Local"
onClick={() => onSelectSection("local")}
unit={unitLabel(localRepositoryCount, "checkout")}
/>
<StatPill
count={stats.issues}
icon={CircleDot}
label="Issues"
onClick={() => onSelectSection("issues")}
unit={unitLabel(stats.issues, "issue")}
/>
</div>
</div>
@@ -287,7 +274,7 @@ export function ProjectsOverviewPanel({
/>
</div>
</div>
<aside className="space-y-4 rounded-xl border border-border/50 bg-card/60 p-4">
<aside className="space-y-6 rounded-xl border border-border/50 bg-card/60 p-4">
<OverviewRailSection title="People">
<div className="flex flex-wrap gap-1.5">
{people.slice(0, 18).map((pubkey) => {

View File

@@ -22,26 +22,26 @@ export function ProjectsViewModeToggle({
<fieldset className="flex items-center rounded-lg bg-muted/30 p-0.5">
<legend className="sr-only">Project layout</legend>
<Button
aria-label="Grid layout"
aria-pressed={viewMode === "grid"}
className="h-7 gap-1.5 px-2"
className="h-7 w-7 px-0"
onClick={() => onViewModeChange("grid")}
size="xs"
type="button"
variant={viewMode === "grid" ? "secondary" : "ghost"}
>
<LayoutGrid className="h-3.5 w-3.5" />
Grid
</Button>
<Button
aria-label="List layout"
aria-pressed={viewMode === "list"}
className="h-7 gap-1.5 px-2"
className="h-7 w-7 px-0"
onClick={() => onViewModeChange("list")}
size="xs"
type="button"
variant={viewMode === "list" ? "secondary" : "ghost"}
>
<List className="h-3.5 w-3.5" />
List
</Button>
</fieldset>
);

View File

@@ -0,0 +1,17 @@
import { useQuery } from "@tanstack/react-query";
import { getGitIdentity } from "@/shared/api/projectGit";
/**
* The viewer's configured git identity (`git config user.name/user.email`),
* used to attribute their own commits to their Buzz profile.
*/
export function useGitIdentityQuery() {
return useQuery({
queryKey: ["git-identity"],
queryFn: getGitIdentity,
// Git config rarely changes while the app runs.
staleTime: Number.POSITIVE_INFINITY,
retry: 1,
});
}

View File

@@ -123,6 +123,16 @@ function fromRawProjectRepoSnapshot(
};
}
export type GitIdentity = {
name: string | null;
email: string | null;
};
/** The viewer's configured git identity (`git config user.name/user.email`). */
export async function getGitIdentity(): Promise<GitIdentity> {
return invokeTauri<GitIdentity>("get_git_identity");
}
export async function getProjectRepoSnapshot(input: {
cloneUrl: string;
defaultBranch?: string | null;

View File

@@ -4302,6 +4302,19 @@ function getMockProjectEventStore(): RelayEvent[] {
return mockProjectEventStore;
}
/** Project-scoped publishes (PR/issue comments, NIP-34 status events) carry
* a repo-address `a` tag instead of a channel `h` tag — store them with the
* seeded project events so refetches see them. */
function isMockProjectScopedEvent(event: RelayEvent): boolean {
const hasRepoAddressTag = event.tags.some(
(tag) => tag[0] === "a" && (tag[1] ?? "").startsWith("30617:"),
);
return (
hasRepoAddressTag &&
(event.kind === 1 || MOCK_PROJECT_KINDS.has(event.kind))
);
}
function filterMockProjectEvents(filter: MockFilter): RelayEvent[] {
const authors = filter.authors?.map((author) => author.toLowerCase());
return getMockProjectEventStore()
@@ -7571,7 +7584,12 @@ function sendToMockSocket(args: {
return;
}
if (filter.kinds?.some((kind) => MOCK_PROJECT_KINDS.has(kind))) {
// Project queries: NIP-34 kinds, or kind:1 comments scoped by repo `a`
// tag (PR/issue discussions, approvals, review requests).
if (
filter.kinds?.some((kind) => MOCK_PROJECT_KINDS.has(kind)) ||
(filter.kinds?.includes(1) && filter["#a"])
) {
for (const event of filterMockProjectEvents(filter)) {
sendWsText(socket.handler, ["EVENT", subId, event]);
}
@@ -7682,6 +7700,12 @@ function sendToMockSocket(args: {
return;
}
if (isMockProjectScopedEvent(event)) {
getMockProjectEventStore().push(event);
sendWsText(socket.handler, ["OK", event.id, true, ""]);
return;
}
const channelId = getChannelIdFromTags(event.tags);
if (!channelId) {
sendWsText(socket.handler, [
@@ -8109,6 +8133,10 @@ export function maybeInstallE2eTauriMocks() {
},
activeConfig,
);
case "get_git_identity":
// Matches the "Thomas P" author on a mock snapshot commit so the
// viewer-identity avatar attribution is exercised in e2e.
return { name: "Thomas P", email: "thomasp@example.com" };
case "get_project_repo_snapshot":
return {
latest_commit: {

View File

@@ -39,6 +39,14 @@ test("commit detail opens from the commits feed with a diff", async ({
const commitRows = page.getByTestId("project-activity-feed-item");
await expect(commitRows.first()).toBeVisible({ timeout: 10_000 });
// GitHub-style feed: commits grouped under a date header with a hash cell.
await expect(page.getByText(/^Commits on /).first()).toBeVisible();
await waitForAnimations(page);
await page.screenshot({
fullPage: false,
path: `${SHOTS}/02-commits-feed.png`,
});
// Open the newest commit via its subject button.
await commitRows
.first()
@@ -69,18 +77,16 @@ test("commit detail opens from the commits feed with a diff", async ({
});
// Breadcrumb category segment steps back to the commits feed.
await page.getByRole("button", { name: "Commit", exact: true }).click();
await page
.getByRole("navigation", { name: "Project breadcrumb" })
.getByRole("button", { name: "Commits", 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 commits feed itself gets a grayed sub-tab crumb.
await expect(
page.getByRole("navigation", { name: "Project breadcrumb" }),
).toContainText("Commits");
// The project-name segment goes to the project home (Overview tab).
await commitRows
@@ -104,3 +110,56 @@ test("commit detail opens from the commits feed with a diff", async ({
.click();
await expect(projectEntry).toBeVisible();
});
test("pull request and issue feeds share the commit row structure", async ({
page,
}) => {
await enableProjectsFeature(page);
await installMockBridge(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("open-projects-view").click();
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();
// PR rows use the shared feed row: title button + #id cluster cell.
await page.getByRole("tab", { name: "PRs" }).click();
const prRows = page.getByTestId("project-pull-request-row");
await expect(prRows.first()).toBeVisible({ timeout: 10_000 });
await expect(
prRows.first().getByRole("button", { name: /^#/ }),
).toBeVisible();
await waitForAnimations(page);
await page.screenshot({ fullPage: false, path: `${SHOTS}/03-prs-feed.png` });
// The #id cell opens the PR detail, same as clicking the title.
await prRows.first().getByRole("button", { name: /^#/ }).click();
await expect(
page.getByRole("navigation", { name: "Project breadcrumb" }),
).toContainText("PRs");
// Step back to the feed so the workspace tabs are available again.
await page
.getByRole("navigation", { name: "Project breadcrumb" })
.getByRole("button", { name: "PRs", exact: true })
.click();
await expect(prRows.first()).toBeVisible();
// Issue rows share the same structure.
await page.getByRole("tab", { name: "Issues" }).click();
const issueRows = page.getByTestId("project-issue-row");
await expect(issueRows.first()).toBeVisible({ timeout: 10_000 });
await expect(
issueRows.first().getByRole("button", { name: /^#/ }),
).toBeVisible();
await waitForAnimations(page);
await page.screenshot({
fullPage: false,
path: `${SHOTS}/04-issues-feed.png`,
});
});

View File

@@ -0,0 +1,111 @@
import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";
const SHOTS = "test-results/project-pr-review";
// 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("PR creator/owner can toggle draft, request reviews, and approve", async ({
page,
}) => {
await enableProjectsFeature(page);
await installMockBridge(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("open-projects-view").click();
// The "buzz" mock project is owned by the viewer, so status changes and
// review requests are always permitted regardless of who authored the PR.
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: "PRs" }).click();
const prRows = page.getByTestId("project-pull-request-row");
await expect(prRows.first()).toBeVisible({ timeout: 10_000 });
// Pick a PR authored by alice: the viewer is not the author, so the
// Approve button must be available alongside the owner status controls.
const aliceRow = prRows.filter({ hasText: "alice" }).first();
await expect(aliceRow).toBeVisible();
await aliceRow.getByRole("button", { name: /^#/ }).click();
const header = page.getByRole("heading", { level: 3 });
await expect(header.first()).toBeVisible();
// Owner viewing an open PR: draft toggle + approve are both offered.
const convertToDraft = page.getByRole("button", {
name: "Convert to draft",
});
const approve = page.getByRole("button", { name: "Approve", exact: true });
await expect(convertToDraft).toBeVisible();
await expect(approve).toBeVisible();
// Request a review from bob via the reviewers dropdown.
await page.getByRole("button", { name: "Request" }).click();
await page.getByRole("menuitem", { name: "bob" }).click();
await expect(page.getByText("Review requested.")).toBeVisible();
// The requested reviewer appears in the reviewers row and the timeline.
await expect(page.getByText("Requested a review from bob")).toBeVisible({
timeout: 10_000,
});
await waitForAnimations(page);
await page.screenshot({
fullPage: false,
path: `${SHOTS}/01-review-requested.png`,
});
// Approve the PR: header flips to the approved chip and the discussion
// gains a compact approval timeline row.
await approve.click();
await expect(page.getByText("Pull request approved.")).toBeVisible();
await expect(page.getByText("approved these changes")).toBeVisible({
timeout: 10_000,
});
await expect(
page.getByRole("button", { name: "Approve", exact: true }),
).toHaveCount(0);
await waitForAnimations(page);
await page.screenshot({
fullPage: false,
path: `${SHOTS}/02-approved.png`,
});
// Convert to draft: badge flips to Draft and the ready button appears.
await convertToDraft.click();
await expect(page.getByText("Converted to draft.")).toBeVisible();
const readyForReview = page.getByRole("button", {
name: "Ready for review",
});
await expect(readyForReview).toBeVisible({ timeout: 10_000 });
await expect(convertToDraft).toHaveCount(0);
await waitForAnimations(page);
await page.screenshot({
fullPage: false,
path: `${SHOTS}/03-draft.png`,
});
// And back: Ready for review restores the Open state.
await readyForReview.click();
await expect(page.getByText("Marked as ready for review.")).toBeVisible();
await expect(
page.getByRole("button", { name: "Convert to draft" }),
).toBeVisible({ timeout: 10_000 });
});