mirror of
https://github.com/block/buzz.git
synced 2026-11-05 00:41:28 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [
|
||||
|
||||
208
desktop/src/features/projects/pullRequestReviews.ts
Normal file
208
desktop/src/features/projects/pullRequestReviews.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
39
desktop/src/features/projects/ui/ProjectCommitCopyButton.tsx
Normal file
39
desktop/src/features/projects/ui/ProjectCommitCopyButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 ? (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
98
desktop/src/features/projects/ui/ProjectFeedRow.tsx
Normal file
98
desktop/src/features/projects/ui/ProjectFeedRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
163
desktop/src/features/projects/ui/ProjectReadmePanel.tsx
Normal file
163
desktop/src/features/projects/ui/ProjectReadmePanel.tsx
Normal 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(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/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 ? `` : "";
|
||||
})
|
||||
.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>
|
||||
);
|
||||
}
|
||||
@@ -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(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/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 ? `` : "";
|
||||
})
|
||||
.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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
17
desktop/src/features/projects/useGitIdentity.ts
Normal file
17
desktop/src/features/projects/useGitIdentity.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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`,
|
||||
});
|
||||
});
|
||||
|
||||
111
desktop/tests/e2e/project-pr-review.spec.ts
Normal file
111
desktop/tests/e2e/project-pr-review.spec.ts
Normal 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 });
|
||||
});
|
||||
Reference in New Issue
Block a user