mirror of
https://github.com/block/buzz.git
synced 2026-11-04 16:31:38 +00:00
feat(desktop): make project workspaces repository-aware
Unify project navigation, repository controls, contextual agent chat, and compact work-item views so repository state and access recovery stay visible in one workspace. Signed-off-by: Thomas Petersen <thomasp@squareup.com>
This commit is contained in:
@@ -38,6 +38,11 @@ pub fn repo_link(owner: &str, repo_id: &str) -> String {
|
||||
format!("buzz://repo?owner={owner}&d={repo_id}")
|
||||
}
|
||||
|
||||
/// Build a link to a specific git commit in a repository.
|
||||
pub fn commit_link(commit_hash: &str, owner: &str, repo_id: &str) -> String {
|
||||
format!("buzz://repo?owner={owner}&d={repo_id}&tab=commits&commit={commit_hash}")
|
||||
}
|
||||
|
||||
/// Build a `buzz://project` link for a project announcement (kind 30621).
|
||||
pub fn project_link(owner: &str, project_id: &str) -> String {
|
||||
format!("buzz://project?owner={owner}&d={project_id}")
|
||||
@@ -85,6 +90,10 @@ mod tests {
|
||||
project_link(owner, dtag),
|
||||
golden["links"]["project"].as_str().unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
commit_link(event_id, owner, dtag),
|
||||
golden["links"]["commit"].as_str().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -372,6 +372,10 @@ fn is_hex64(value: &str) -> bool {
|
||||
value.len() == 64 && value.chars().all(|c| c.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
fn is_git_object_id(value: &str) -> bool {
|
||||
matches!(value.len(), 40 | 64) && value.chars().all(|c| c.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
/// Mirrors `isValidDtag` in `entityLink.ts` — the link format addresses a
|
||||
/// narrower d-tag charset than Nostr allows.
|
||||
fn is_linkable_dtag(value: &str) -> bool {
|
||||
@@ -416,13 +420,14 @@ fn parse_entity_deep_link(url: &Url) -> Option<()> {
|
||||
|
||||
let needs_event_id = host == "pr" || host == "issue";
|
||||
let allows_tab = host == "repo" || host == "project";
|
||||
let (mut owner, mut dtag, mut id, mut tab) = (None, None, None, None);
|
||||
let (mut owner, mut dtag, mut id, mut tab, mut commit) = (None, None, None, None, None);
|
||||
for (key, value) in url.query_pairs() {
|
||||
let slot = match key.as_ref() {
|
||||
"owner" => &mut owner,
|
||||
"d" => &mut dtag,
|
||||
"id" if needs_event_id => &mut id,
|
||||
"tab" if allows_tab => &mut tab,
|
||||
"commit" if host == "repo" => &mut commit,
|
||||
_ => return None,
|
||||
};
|
||||
if slot.is_some() {
|
||||
@@ -440,8 +445,13 @@ fn parse_entity_deep_link(url: &Url) -> Option<()> {
|
||||
if needs_event_id && !id.is_some_and(|id| is_hex64(&id)) {
|
||||
return None;
|
||||
}
|
||||
if let Some(tab) = tab {
|
||||
if !ENTITY_LINK_TABS.contains(&tab.as_str()) {
|
||||
if let Some(tab) = tab.as_deref() {
|
||||
if !ENTITY_LINK_TABS.contains(&tab) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if let Some(commit) = commit {
|
||||
if tab.as_deref() != Some("commits") || !is_git_object_id(&commit) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,11 @@ fn parse_entity_deep_link_accepts_every_share_link_shape() {
|
||||
"{raw}"
|
||||
);
|
||||
}
|
||||
let commit_link = format!(
|
||||
"buzz://repo?owner={owner}&d={dtag}&tab=commits&commit={}",
|
||||
golden["eventId"].as_str().unwrap()
|
||||
);
|
||||
assert!(parse_entity_deep_link(&Url::parse(&commit_link).unwrap()).is_some());
|
||||
let expected_tabs = golden["tabs"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
@@ -64,6 +69,8 @@ fn parse_entity_deep_link_rejects_malformed_and_non_canonical_links() {
|
||||
// Unknown tab value, duplicate tab, and tab on an event link.
|
||||
format!("buzz://repo?owner={owner}&d=buzz-world&tab=overview"),
|
||||
format!("buzz://repo?owner={owner}&d=buzz-world&tab=prs&tab=prs"),
|
||||
format!("buzz://repo?owner={owner}&d=buzz-world&tab=files&commit={event_id}"),
|
||||
format!("buzz://repo?owner={owner}&d=buzz-world&tab=commits&commit=short"),
|
||||
format!("buzz://pr?id={event_id}&owner={owner}&d=buzz-world&tab=prs"),
|
||||
format!("buzz://repo/extra?owner={owner}&d=buzz-world"),
|
||||
format!("buzz://repo?owner={owner}&d=buzz-world#top"),
|
||||
|
||||
@@ -8,6 +8,10 @@ import { AppShellOverlays, TerminalBootstrap } from "@/app/AppShellOverlays";
|
||||
import { AppShellChannelSurface } from "@/app/AppShellChannelSurface";
|
||||
import { AppHuddleShell } from "@/app/AppHuddleShell";
|
||||
import { AppTopChrome } from "@/app/AppTopChrome";
|
||||
import {
|
||||
type TerminalContextOverride,
|
||||
TerminalContextOverrideProvider,
|
||||
} from "@/app/TerminalContextOverrideContext";
|
||||
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
|
||||
import { useBackForwardControls } from "@/app/navigation/useBackForwardControls";
|
||||
import { useCommunityNavigationTransitions } from "@/app/useCommunityNavigationTransitions";
|
||||
@@ -312,6 +316,8 @@ export function AppShell() {
|
||||
selectedView,
|
||||
sidebarChannels,
|
||||
]);
|
||||
const [terminalContextOverride, setTerminalContextOverride] =
|
||||
React.useState<TerminalContextOverride | null>(null);
|
||||
const { activeChannel, terminalContext } = useTerminalContext({
|
||||
channelId: selectedChannelId,
|
||||
channels,
|
||||
@@ -319,6 +325,14 @@ export function AppShell() {
|
||||
pubkey: identityQuery.data?.pubkey,
|
||||
relayUrl: communitiesHook.activeCommunity?.relayUrl,
|
||||
});
|
||||
const effectiveTerminalContext = terminalContextOverride
|
||||
? {
|
||||
...terminalContext,
|
||||
channelId: terminalContextOverride.channelId,
|
||||
channelName: terminalContextOverride.channelName,
|
||||
threadId: null,
|
||||
}
|
||||
: terminalContext;
|
||||
const managedChannel = React.useMemo(() => {
|
||||
const targetChannelId = managedChannelId ?? selectedChannelId;
|
||||
return targetChannelId
|
||||
@@ -893,14 +907,20 @@ export function AppShell() {
|
||||
onUnstarChannel={unstarChannel}
|
||||
/>
|
||||
) : null}
|
||||
<AppShellChannelSurface
|
||||
isHuddleRoom={isHuddleRoom}
|
||||
isHuddleRoomStarting={isHuddleRoomStarting}
|
||||
mainInsetRef={mainInsetRef}
|
||||
terminal={<TerminalBootstrap {...terminalContext} />}
|
||||
<TerminalContextOverrideProvider
|
||||
onChange={setTerminalContextOverride}
|
||||
>
|
||||
<Outlet />
|
||||
</AppShellChannelSurface>
|
||||
<AppShellChannelSurface
|
||||
isHuddleRoom={isHuddleRoom}
|
||||
isHuddleRoomStarting={isHuddleRoomStarting}
|
||||
mainInsetRef={mainInsetRef}
|
||||
terminal={
|
||||
<TerminalBootstrap {...effectiveTerminalContext} />
|
||||
}
|
||||
>
|
||||
<Outlet />
|
||||
</AppShellChannelSurface>
|
||||
</TerminalContextOverrideProvider>
|
||||
{!isHuddleRoom ? (
|
||||
<RelayConnectionOverlay
|
||||
card={relayConnectionCard}
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
} from "lucide-react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
import { isMacPlatform } from "@/shared/lib/platform";
|
||||
import { useIsFullscreen } from "@/shared/lib/useIsFullscreen";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { DrawerPanelIcon } from "@/shared/ui/DrawerPanelIcon";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { topChromeBackdrop } from "@/shared/layout/chromeLayout";
|
||||
import { useOptionalSidebar } from "@/shared/ui/sidebar";
|
||||
@@ -26,7 +22,7 @@ type AppTopChromeProps = {
|
||||
// the row must not grow or shrink with the rem scale. Deliberate exception
|
||||
// to the rem-first rule.
|
||||
const TOP_CHROME_ICON_BUTTON_CLASS =
|
||||
"h-[28px] w-[28px] rounded-[4px] text-sidebar-foreground/65 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [&_svg]:size-[16px]";
|
||||
"h-[28px] w-[28px] rounded-[4px] text-sidebar-foreground/65 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground";
|
||||
const HISTORY_ICON_BUTTON_CLASS =
|
||||
"h-[28px] w-[24px] rounded-[4px] text-sidebar-foreground/65 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [&_svg]:size-[16px]";
|
||||
|
||||
@@ -50,7 +46,7 @@ function TopChromeSidebarTrigger() {
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{sidebar?.open ? <PanelLeftClose /> : <PanelLeftOpen />}
|
||||
<DrawerPanelIcon side={sidebar?.open ? "left" : "right"} />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
);
|
||||
@@ -131,6 +127,11 @@ export function AppTopChrome({
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className={cn("flex min-w-0 flex-1 items-center", navRowAlignmentClass)}
|
||||
data-tauri-drag-region
|
||||
id="app-top-chrome-content"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
18
desktop/src/app/AppTopChromePortal.tsx
Normal file
18
desktop/src/app/AppTopChromePortal.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
const APP_TOP_CHROME_CONTENT_ID = "app-top-chrome-content";
|
||||
|
||||
export function AppTopChromePortal({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [target, setTarget] = React.useState<HTMLElement | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
setTarget(document.getElementById(APP_TOP_CHROME_CONTENT_ID));
|
||||
}, []);
|
||||
|
||||
return target ? createPortal(children, target) : null;
|
||||
}
|
||||
38
desktop/src/app/TerminalContextOverrideContext.tsx
Normal file
38
desktop/src/app/TerminalContextOverrideContext.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import * as React from "react";
|
||||
|
||||
export type TerminalContextOverride = {
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
};
|
||||
|
||||
const TerminalContextOverrideContext = React.createContext<React.Dispatch<
|
||||
React.SetStateAction<TerminalContextOverride | null>
|
||||
> | null>(null);
|
||||
|
||||
export function TerminalContextOverrideProvider({
|
||||
children,
|
||||
onChange,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onChange: React.Dispatch<
|
||||
React.SetStateAction<TerminalContextOverride | null>
|
||||
>;
|
||||
}) {
|
||||
return (
|
||||
<TerminalContextOverrideContext.Provider value={onChange}>
|
||||
{children}
|
||||
</TerminalContextOverrideContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTerminalContextOverride(
|
||||
context: TerminalContextOverride | null,
|
||||
) {
|
||||
const setOverride = React.useContext(TerminalContextOverrideContext);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!setOverride) return;
|
||||
setOverride(context);
|
||||
return () => setOverride(null);
|
||||
}, [context, setOverride]);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { beforeEach, test } from "node:test";
|
||||
|
||||
import {
|
||||
mergeProjectAgentConversationEvents,
|
||||
restoreProjectsAgentConversation,
|
||||
visibleConversationMessages,
|
||||
} from "./projectAgentConversation.ts";
|
||||
@@ -121,6 +122,25 @@ test("messages the DM held before the first Projects prompt never appear", () =>
|
||||
assert.deepEqual(visible, [opener, reply]);
|
||||
});
|
||||
|
||||
test("root questions and separately queried replies stay in conversation order", () => {
|
||||
const firstQuestion = message(PROMPT_AT);
|
||||
const firstAnswer = message(PROMPT_AT + 2, KIND_STREAM_MESSAGE_V2);
|
||||
const secondQuestion = message(PROMPT_AT + 4);
|
||||
const secondAnswer = message(PROMPT_AT + 6, KIND_STREAM_MESSAGE_V2);
|
||||
|
||||
const merged = mergeProjectAgentConversationEvents(
|
||||
[firstQuestion, secondQuestion],
|
||||
[firstAnswer, secondAnswer, firstAnswer],
|
||||
);
|
||||
|
||||
assert.deepEqual(merged, [
|
||||
firstQuestion,
|
||||
firstAnswer,
|
||||
secondQuestion,
|
||||
secondAnswer,
|
||||
]);
|
||||
});
|
||||
|
||||
test("storage read rejects legacy pointers with a zero cutoff", () => {
|
||||
globalThis.localStorage.setItem(
|
||||
`buzz.projects.agentConversation.${encodeURIComponent(WORKSPACE_ID)}`,
|
||||
|
||||
@@ -54,3 +54,18 @@ export function visibleConversationMessages<
|
||||
)
|
||||
.sort((left, right) => left.created_at - right.created_at);
|
||||
}
|
||||
|
||||
/**
|
||||
* Combines the backing DM's root events with separately queried thread
|
||||
* replies. Keep this chronological: appending the reply query after the root
|
||||
* query would otherwise render every question before every agent answer.
|
||||
*/
|
||||
export function mergeProjectAgentConversationEvents<
|
||||
Event extends { id: string; created_at: number },
|
||||
>(rootEvents: readonly Event[], replyEvents: readonly Event[]): Event[] {
|
||||
return [
|
||||
...new Map(
|
||||
[...rootEvents, ...replyEvents].map((event) => [event.id, event]),
|
||||
).values(),
|
||||
].sort((left, right) => left.created_at - right.created_at);
|
||||
}
|
||||
|
||||
@@ -3,8 +3,12 @@ import { test } from "node:test";
|
||||
|
||||
import {
|
||||
commitAuthorPubkeysFromPullRequests,
|
||||
gitContributorPubkeysFromCommits,
|
||||
profileForCommit,
|
||||
projectContributorActivityCounts,
|
||||
signedProjectContributorPubkeys,
|
||||
} from "./projectContributorMatching.ts";
|
||||
import { pubkeyToNpub } from "../../../shared/lib/nostrUtils.ts";
|
||||
|
||||
const AGENT_PUBKEY = "a".repeat(64);
|
||||
const USER_PUBKEY = "b".repeat(64);
|
||||
@@ -34,6 +38,71 @@ function makeCommit(overrides = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
test("collects declared humans and signed agent activity without assignments", () => {
|
||||
const reviewer = "c".repeat(64);
|
||||
const commenters = ["d".repeat(64), "e".repeat(64)];
|
||||
const contributors = signedProjectContributorPubkeys({
|
||||
owner: USER_PUBKEY,
|
||||
contributors: [AGENT_PUBKEY],
|
||||
pullRequests: [
|
||||
{
|
||||
author: AGENT_PUBKEY,
|
||||
approvals: [{ author: reviewer }],
|
||||
comments: [{ author: commenters[0] }],
|
||||
updates: [{ author: USER_PUBKEY, commit: null }],
|
||||
},
|
||||
],
|
||||
issues: [
|
||||
{
|
||||
author: USER_PUBKEY,
|
||||
comments: [{ author: commenters[1] }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(contributors, [
|
||||
USER_PUBKEY,
|
||||
AGENT_PUBKEY,
|
||||
commenters[0],
|
||||
reviewer,
|
||||
commenters[1],
|
||||
]);
|
||||
});
|
||||
|
||||
test("counts signed commits, reviews, and tasks by contributor", () => {
|
||||
const counts = projectContributorActivityCounts({
|
||||
owner: USER_PUBKEY,
|
||||
contributors: [AGENT_PUBKEY],
|
||||
pullRequests: [
|
||||
{
|
||||
author: AGENT_PUBKEY,
|
||||
initialCommit: "1".repeat(40),
|
||||
commit: "2".repeat(40),
|
||||
approvals: [],
|
||||
comments: [],
|
||||
updates: [{ author: USER_PUBKEY, commit: "2".repeat(40) }],
|
||||
},
|
||||
],
|
||||
issues: [
|
||||
{
|
||||
author: USER_PUBKEY,
|
||||
comments: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(counts[AGENT_PUBKEY], {
|
||||
commits: 1,
|
||||
reviews: 1,
|
||||
tasks: 0,
|
||||
});
|
||||
assert.deepEqual(counts[USER_PUBKEY], {
|
||||
commits: 1,
|
||||
reviews: 0,
|
||||
tasks: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("maps initial, latest, and update commits to their publishers", () => {
|
||||
const map = commitAuthorPubkeysFromPullRequests([
|
||||
{
|
||||
@@ -62,6 +131,55 @@ test("maps initial, latest, and update commits to their publishers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("links Git contributors through commit hashes from signed reviews", () => {
|
||||
const commit = makeCommit({ authorEmail: "wes@example.com" });
|
||||
const linked = gitContributorPubkeysFromCommits(
|
||||
[commit],
|
||||
[
|
||||
{
|
||||
author: USER_PUBKEY,
|
||||
initialCommit: commit.hash,
|
||||
commit: commit.hash,
|
||||
updates: [],
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
assert.equal(linked.get("wes@example.com"), USER_PUBKEY);
|
||||
});
|
||||
|
||||
test("does not link a Git identity claimed by multiple signed publishers", () => {
|
||||
const first = makeCommit({
|
||||
hash: "1".repeat(40),
|
||||
shortHash: "1".repeat(7),
|
||||
authorEmail: "shared@example.com",
|
||||
});
|
||||
const second = makeCommit({
|
||||
hash: "2".repeat(40),
|
||||
shortHash: "2".repeat(7),
|
||||
authorEmail: "shared@example.com",
|
||||
});
|
||||
const linked = gitContributorPubkeysFromCommits(
|
||||
[first, second],
|
||||
[
|
||||
{
|
||||
author: USER_PUBKEY,
|
||||
initialCommit: first.hash,
|
||||
commit: first.hash,
|
||||
updates: [],
|
||||
},
|
||||
{
|
||||
author: AGENT_PUBKEY,
|
||||
initialCommit: second.hash,
|
||||
commit: second.hash,
|
||||
updates: [],
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
assert.equal(linked.has("shared@example.com"), false);
|
||||
});
|
||||
|
||||
test("profileForCommit prefers the signed PR-event mapping", () => {
|
||||
const commit = makeCommit();
|
||||
const map = new Map([[commit.hash, AGENT_PUBKEY]]);
|
||||
@@ -78,6 +196,21 @@ test("profileForCommit falls back to exact git author matching", () => {
|
||||
assert.equal(matched?.pubkey, USER_PUBKEY);
|
||||
});
|
||||
|
||||
test("profileForCommit resolves an npub git author to its profile", () => {
|
||||
const commit = makeCommit({ authorName: pubkeyToNpub(USER_PUBKEY) });
|
||||
const matched = profileForCommit(commit, PROFILES, new Map());
|
||||
assert.equal(matched?.pubkey, USER_PUBKEY);
|
||||
assert.equal(matched?.profile.displayName, "Thomas P");
|
||||
});
|
||||
|
||||
test("profileForCommit ignores malformed npub git authors", () => {
|
||||
const commit = makeCommit({
|
||||
authorName: "npub1not-a-valid-key",
|
||||
authorEmail: "unknown@example.com",
|
||||
});
|
||||
assert.equal(profileForCommit(commit, PROFILES, new Map()), null);
|
||||
});
|
||||
|
||||
test("profileForCommit returns null when nothing matches", () => {
|
||||
const commit = makeCommit();
|
||||
assert.equal(profileForCommit(commit, PROFILES, new Map()), null);
|
||||
|
||||
@@ -3,6 +3,104 @@ import type {
|
||||
ProjectRepoCommit,
|
||||
ProjectRepoContributor,
|
||||
} from "@/shared/api/types";
|
||||
import { parsePubkeyInput } from "@/shared/lib/nostrUtils";
|
||||
|
||||
type SignedProjectContributorSources = {
|
||||
owner: string;
|
||||
contributors: readonly string[];
|
||||
pullRequests: readonly {
|
||||
author: string;
|
||||
initialCommit?: string | null;
|
||||
commit?: string | null;
|
||||
comments: readonly { author: string }[];
|
||||
approvals: readonly { author: string }[];
|
||||
updates: readonly { author: string; commit: string | null }[];
|
||||
}[];
|
||||
issues: readonly {
|
||||
author: string;
|
||||
comments: readonly { author: string }[];
|
||||
}[];
|
||||
};
|
||||
|
||||
export type ProjectContributorActivityCounts = {
|
||||
commits: number;
|
||||
reviews: number;
|
||||
tasks: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Collects identities tied to a repository by its announcement or by signed
|
||||
* project activity. Assignments and recipients are intentionally excluded:
|
||||
* being asked to review or own a task is not itself a contribution.
|
||||
*/
|
||||
export function signedProjectContributorPubkeys({
|
||||
owner,
|
||||
contributors,
|
||||
pullRequests,
|
||||
issues,
|
||||
}: SignedProjectContributorSources): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
[
|
||||
owner,
|
||||
...contributors,
|
||||
...pullRequests.flatMap((pullRequest) => [
|
||||
pullRequest.author,
|
||||
...pullRequest.comments.map((comment) => comment.author),
|
||||
...pullRequest.approvals.map((approval) => approval.author),
|
||||
...pullRequest.updates.map((update) => update.author),
|
||||
]),
|
||||
...issues.flatMap((issue) => [
|
||||
issue.author,
|
||||
...issue.comments.map((comment) => comment.author),
|
||||
]),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.map((pubkey) => pubkey.trim().toLowerCase()),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/** Counts signed repository activity by actor for contributor list columns. */
|
||||
export function projectContributorActivityCounts({
|
||||
owner,
|
||||
contributors,
|
||||
pullRequests,
|
||||
issues,
|
||||
}: SignedProjectContributorSources): Record<
|
||||
string,
|
||||
ProjectContributorActivityCounts
|
||||
> {
|
||||
const counts = Object.fromEntries(
|
||||
signedProjectContributorPubkeys({
|
||||
owner,
|
||||
contributors,
|
||||
pullRequests,
|
||||
issues,
|
||||
}).map((pubkey) => [pubkey, { commits: 0, reviews: 0, tasks: 0 }]),
|
||||
);
|
||||
const ensureCounts = (rawPubkey: string) => {
|
||||
const pubkey = rawPubkey.trim().toLowerCase();
|
||||
const existing = counts[pubkey];
|
||||
if (existing) return existing;
|
||||
const created = { commits: 0, reviews: 0, tasks: 0 };
|
||||
counts[pubkey] = created;
|
||||
return created;
|
||||
};
|
||||
|
||||
for (const pubkey of commitAuthorPubkeysFromPullRequests(
|
||||
pullRequests,
|
||||
).values()) {
|
||||
ensureCounts(pubkey).commits += 1;
|
||||
}
|
||||
for (const pullRequest of pullRequests) {
|
||||
ensureCounts(pullRequest.author).reviews += 1;
|
||||
}
|
||||
for (const issue of issues) {
|
||||
ensureCounts(issue.author).tasks += 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
export function contributorKey(contributor: ProjectRepoContributor) {
|
||||
return (contributor.email || contributor.name).trim().toLowerCase();
|
||||
@@ -55,6 +153,16 @@ export function profileForCommitAuthor(
|
||||
profiles: UserProfileLookup | undefined,
|
||||
) {
|
||||
if (!profiles) return null;
|
||||
// A Git author may use their Nostr key as the name/email. This is still an
|
||||
// unauthenticated display hint—the signed PR mapping remains authoritative.
|
||||
const claimedPubkey =
|
||||
parsePubkeyInput(commit.authorName) ?? parsePubkeyInput(commit.authorEmail);
|
||||
if (claimedPubkey) {
|
||||
const profile = profiles[claimedPubkey];
|
||||
if (profile) {
|
||||
return { pubkey: claimedPubkey, profile };
|
||||
}
|
||||
}
|
||||
const contributor = {
|
||||
name: commit.authorName,
|
||||
email: commit.authorEmail,
|
||||
@@ -100,6 +208,39 @@ export function commitAuthorPubkeysFromPullRequests(
|
||||
return byHash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Links aggregate Git author rows to Buzz identities through commit hashes
|
||||
* published in signed review events. Ambiguous author strings fail closed.
|
||||
*/
|
||||
export function gitContributorPubkeysFromCommits(
|
||||
commits: readonly ProjectRepoCommit[],
|
||||
pullRequests: Parameters<typeof commitAuthorPubkeysFromPullRequests>[0],
|
||||
): Map<string, string> {
|
||||
const pubkeysByHash = commitAuthorPubkeysFromPullRequests(pullRequests);
|
||||
const candidatesByContributor = new Map<string, Set<string>>();
|
||||
for (const commit of commits) {
|
||||
const pubkey =
|
||||
pubkeysByHash.get(commit.hash.toLowerCase()) ??
|
||||
pubkeysByHash.get(commit.shortHash.toLowerCase());
|
||||
if (!pubkey) continue;
|
||||
const key = contributorKey({
|
||||
commitCount: 0,
|
||||
email: commit.authorEmail,
|
||||
lastCommitAt: commit.timestamp,
|
||||
name: commit.authorName,
|
||||
});
|
||||
const candidates = candidatesByContributor.get(key) ?? new Set<string>();
|
||||
candidates.add(pubkey.toLowerCase());
|
||||
candidatesByContributor.set(key, candidates);
|
||||
}
|
||||
|
||||
return new Map(
|
||||
[...candidatesByContributor.entries()]
|
||||
.filter(([, pubkeys]) => pubkeys.size === 1)
|
||||
.map(([key, pubkeys]) => [key, [...pubkeys][0]]),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 —
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildProjectDetailAgentContext,
|
||||
projectDetailAgentContextBlock,
|
||||
stripProjectDetailAgentContext,
|
||||
} from "./projectDetailAgentContext.ts";
|
||||
|
||||
const base = {
|
||||
activeTab: "files",
|
||||
branch: "main",
|
||||
file: { kind: "file", path: "src/app.tsx" },
|
||||
project: { name: "Buzz Patrol" },
|
||||
repository: { name: "Buzz", repoAddress: "owner:buzz" },
|
||||
source: "local",
|
||||
workItems: [null, null, null],
|
||||
};
|
||||
|
||||
test("builds selected file context", () => {
|
||||
const context = buildProjectDetailAgentContext(base);
|
||||
assert.equal(context.view, "Files");
|
||||
assert.deepEqual(context.file, { kind: "file", path: "src/app.tsx" });
|
||||
assert.equal(context.workItem, null);
|
||||
});
|
||||
|
||||
test("review detail takes precedence over its workspace tab", () => {
|
||||
const context = buildProjectDetailAgentContext({
|
||||
...base,
|
||||
activeTab: "prs",
|
||||
workItems: [
|
||||
null,
|
||||
null,
|
||||
{ id: "review-42", status: "Open", title: "Ship the fix" },
|
||||
],
|
||||
});
|
||||
assert.equal(context.view, "Review detail");
|
||||
assert.deepEqual(context.workItem, {
|
||||
id: "review-42",
|
||||
kind: "review",
|
||||
status: "Open",
|
||||
title: "Ship the fix",
|
||||
});
|
||||
assert.equal(context.file, null);
|
||||
});
|
||||
|
||||
test("prompt footer contains current page details", () => {
|
||||
const footer = projectDetailAgentContextBlock(
|
||||
buildProjectDetailAgentContext(base),
|
||||
);
|
||||
assert.match(footer, /Current Buzz project page:/);
|
||||
assert.match(footer, /Repository: Buzz \(owner:buzz\)/);
|
||||
assert.match(footer, /View: Files/);
|
||||
assert.match(footer, /File: src\/app\.tsx/);
|
||||
assert.match(footer, /Branch: main/);
|
||||
});
|
||||
|
||||
test("strips hidden page context from the displayed user message", () => {
|
||||
const content = `Explain this file${projectDetailAgentContextBlock(
|
||||
buildProjectDetailAgentContext(base),
|
||||
)}`;
|
||||
assert.equal(stripProjectDetailAgentContext(content), "Explain this file");
|
||||
});
|
||||
121
desktop/src/features/projects/lib/projectDetailAgentContext.ts
Normal file
121
desktop/src/features/projects/lib/projectDetailAgentContext.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
const PROJECT_PAGE_CONTEXT_MARKER = "Current Buzz project page:";
|
||||
|
||||
export type ProjectDetailAgentContext = {
|
||||
branch?: string | null;
|
||||
file?: { kind: "file" | "folder"; path: string } | null;
|
||||
projectName: string;
|
||||
repoAddress: string;
|
||||
repositoryName: string;
|
||||
source: "local" | "remote";
|
||||
view: string;
|
||||
workItem?: {
|
||||
id: string;
|
||||
kind: "commit" | "review" | "task";
|
||||
status?: string;
|
||||
title: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export function buildProjectDetailAgentContext({
|
||||
activeTab,
|
||||
branch,
|
||||
file,
|
||||
project,
|
||||
repository,
|
||||
source,
|
||||
workItems,
|
||||
}: {
|
||||
activeTab: string;
|
||||
branch?: string | null;
|
||||
file?: ProjectDetailAgentContext["file"];
|
||||
project: { name: string };
|
||||
repository: { name: string; repoAddress: string };
|
||||
source: "local" | "remote";
|
||||
workItems: readonly [
|
||||
{ hash: string; subject?: string | null } | null,
|
||||
{ id: string; status?: string; title: string } | null,
|
||||
{ id: string; status?: string; title: string } | null,
|
||||
];
|
||||
}): ProjectDetailAgentContext {
|
||||
const [commit, issue, pullRequest] = workItems;
|
||||
const viewLabels: Record<string, string> = {
|
||||
activity: "Commits",
|
||||
channels: "Channels",
|
||||
contributors: "Contributors",
|
||||
files: "Files",
|
||||
issues: "Tasks",
|
||||
overview: "Overview",
|
||||
prs: "Reviews",
|
||||
};
|
||||
const workItem = pullRequest
|
||||
? {
|
||||
id: pullRequest.id,
|
||||
kind: "review" as const,
|
||||
status: pullRequest.status,
|
||||
title: pullRequest.title,
|
||||
}
|
||||
: issue
|
||||
? {
|
||||
id: issue.id,
|
||||
kind: "task" as const,
|
||||
status: issue.status,
|
||||
title: issue.title,
|
||||
}
|
||||
: commit
|
||||
? {
|
||||
id: commit.hash,
|
||||
kind: "commit" as const,
|
||||
title: commit.subject || commit.hash.slice(0, 7),
|
||||
}
|
||||
: null;
|
||||
return {
|
||||
branch,
|
||||
file: activeTab === "files" ? file : null,
|
||||
projectName: project.name,
|
||||
repoAddress: repository.repoAddress,
|
||||
repositoryName: repository.name,
|
||||
source,
|
||||
view: workItem
|
||||
? `${workItem.kind[0]?.toUpperCase()}${workItem.kind.slice(1)} detail`
|
||||
: (viewLabels[activeTab] ?? activeTab),
|
||||
workItem,
|
||||
};
|
||||
}
|
||||
|
||||
export function projectDetailAgentContextBlock(
|
||||
context: ProjectDetailAgentContext,
|
||||
) {
|
||||
const lines = [
|
||||
"",
|
||||
"---",
|
||||
PROJECT_PAGE_CONTEXT_MARKER,
|
||||
`- Project: ${context.projectName}`,
|
||||
`- Repository: ${context.repositoryName} (${context.repoAddress})`,
|
||||
`- View: ${context.view}`,
|
||||
`- Source: ${context.source}`,
|
||||
];
|
||||
if (context.branch) lines.push(`- Branch: ${context.branch}`);
|
||||
if (context.file) {
|
||||
lines.push(
|
||||
`- ${context.file.kind === "file" ? "File" : "Folder"}: ${context.file.path || "/"}`,
|
||||
);
|
||||
}
|
||||
if (context.workItem) {
|
||||
lines.push(
|
||||
`- ${context.workItem.kind}: ${context.workItem.title} (${context.workItem.id})`,
|
||||
);
|
||||
if (context.workItem.status) {
|
||||
lines.push(`- Status: ${context.workItem.status}`);
|
||||
}
|
||||
}
|
||||
lines.push(
|
||||
"Use this current UI context to interpret the user's request. Do not claim access to data not supplied here or available through your tools.",
|
||||
);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function stripProjectDetailAgentContext(content: string) {
|
||||
const markerIndex = content.indexOf(`---\n${PROJECT_PAGE_CONTEXT_MARKER}`);
|
||||
if (markerIndex === -1) return content;
|
||||
return content.slice(0, markerIndex).replace(/\n+$/, "");
|
||||
}
|
||||
@@ -24,6 +24,18 @@ test("presents missing and network failures clearly", () => {
|
||||
projectCloneErrorPresentation(new Error("Repository not found")).title,
|
||||
"Repository not found",
|
||||
);
|
||||
assert.deepEqual(
|
||||
projectCloneErrorPresentation(
|
||||
new Error("Repository not found"),
|
||||
"https://relay.example/git/owner/repo",
|
||||
"access",
|
||||
),
|
||||
{
|
||||
title: "Repository access restricted",
|
||||
description:
|
||||
"You need access to the repository’s channel before you can clone it.",
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
projectCloneErrorPresentation(new Error("Could not resolve host")).title,
|
||||
"Couldn’t reach the repository",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ProjectRepoUnavailableReason } from "./projectRepoAvailability";
|
||||
|
||||
export type ProjectGitErrorPresentation = {
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -19,10 +21,18 @@ function isGitHubUrl(cloneUrl: string | null | undefined) {
|
||||
export function projectCloneErrorPresentation(
|
||||
error: unknown,
|
||||
cloneUrl?: string | null,
|
||||
unavailableReason?: ProjectRepoUnavailableReason,
|
||||
): ProjectGitErrorPresentation {
|
||||
const message = errorText(error);
|
||||
const github = isGitHubUrl(cloneUrl);
|
||||
|
||||
if (unavailableReason === "access") {
|
||||
return {
|
||||
title: "Repository access restricted",
|
||||
description:
|
||||
"You need access to the repository’s channel before you can clone it.",
|
||||
};
|
||||
}
|
||||
if (
|
||||
/\b(?:401|403)\b|authenticat|authoriz|permission denied|access denied|ssh certificate/.test(
|
||||
message,
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import {
|
||||
projectRepoUnavailablePresentation,
|
||||
projectRepoUnavailableReason,
|
||||
refineRepoUnavailableReason,
|
||||
} from "./projectRepoAvailability.ts";
|
||||
@@ -105,3 +106,11 @@ test("never rewrites non-missing reasons", () => {
|
||||
"network",
|
||||
);
|
||||
});
|
||||
|
||||
test("presents access failures without exposing the relay's masked 404", () => {
|
||||
assert.deepEqual(projectRepoUnavailablePresentation("access"), {
|
||||
description:
|
||||
"Repository access is granted through its channel, and you’re not a member. Ask the repository owner for an invite.",
|
||||
title: "Repository access restricted",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,60 @@ export type ProjectRepoUnavailableReason =
|
||||
| "ref"
|
||||
| "unknown";
|
||||
|
||||
/** User-facing copy for a classified repository availability failure. */
|
||||
export type ProjectRepoUnavailablePresentation = {
|
||||
description: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
const PROJECT_REPO_UNAVAILABLE_PRESENTATIONS: Record<
|
||||
ProjectRepoUnavailableReason,
|
||||
ProjectRepoUnavailablePresentation
|
||||
> = {
|
||||
authentication: {
|
||||
description:
|
||||
"Buzz could not authenticate with this repository. Check your access and try again.",
|
||||
title: "Repository access failed",
|
||||
},
|
||||
missing: {
|
||||
description:
|
||||
"The project announcement exists, but its git repository was not found on the Buzz relay.",
|
||||
title: "Repository not initialized",
|
||||
},
|
||||
access: {
|
||||
description:
|
||||
"Repository access is granted through its channel, and you’re not a member. Ask the repository owner for an invite.",
|
||||
title: "Repository access restricted",
|
||||
},
|
||||
unbound: {
|
||||
description:
|
||||
"This repository has no access channel binding, so the relay cannot authorize anyone to read it. The repository owner can bind a channel from the Access menu.",
|
||||
title: "No access channel bound",
|
||||
},
|
||||
network: {
|
||||
description:
|
||||
"The Buzz git service could not be reached. Check your connection and try again.",
|
||||
title: "Couldn’t reach repository",
|
||||
},
|
||||
ref: {
|
||||
description:
|
||||
"The selected branch is advertised by the project but is missing from its git remote.",
|
||||
title: "Branch unavailable",
|
||||
},
|
||||
unknown: {
|
||||
description:
|
||||
"Buzz could not load this repository. Try again or contact the project owner.",
|
||||
title: "Repository unavailable",
|
||||
},
|
||||
};
|
||||
|
||||
/** Returns consistent, sanitized copy for repository availability UI. */
|
||||
export function projectRepoUnavailablePresentation(
|
||||
reason: ProjectRepoUnavailableReason,
|
||||
): ProjectRepoUnavailablePresentation {
|
||||
return PROJECT_REPO_UNAVAILABLE_PRESENTATIONS[reason];
|
||||
}
|
||||
|
||||
export function projectRepoUnavailableReason(
|
||||
error: unknown,
|
||||
): ProjectRepoUnavailableReason {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
KIND_REPO_ANNOUNCEMENT,
|
||||
} from "@/shared/constants/kinds";
|
||||
import {
|
||||
buildCommitLink,
|
||||
buildIssueLink,
|
||||
buildProjectLink,
|
||||
buildPullRequestLink,
|
||||
@@ -29,6 +30,7 @@ import type { ProjectPullRequest } from "../projectPullRequests.mjs";
|
||||
type Coordinate = { kind: number; owner: string; dtag: string };
|
||||
|
||||
const HEX64_RE = /^[a-fA-F0-9]{64}$/;
|
||||
const GIT_OBJECT_ID_RE = /^(?:[a-fA-F0-9]{40}|[a-fA-F0-9]{64})$/;
|
||||
|
||||
/**
|
||||
* Split an addressable coordinate (`<kind>:<owner>:<d>`). Only the first two
|
||||
@@ -118,6 +120,18 @@ export function repositoryShareLink(repository: Repository): string | null {
|
||||
: null;
|
||||
}
|
||||
|
||||
export function commitShareLink(
|
||||
repository: Repository,
|
||||
commitHash: string,
|
||||
): string | null {
|
||||
const coordinate = repositoryCoordinate(repository.repoAddress);
|
||||
return coordinate &&
|
||||
GIT_OBJECT_ID_RE.test(commitHash) &&
|
||||
isLinkableCoordinate(coordinate.owner, coordinate.dtag)
|
||||
? buildCommitLink({ ...coordinate, commitHash })
|
||||
: null;
|
||||
}
|
||||
|
||||
export function issueShareLink(issue: ProjectIssue): string | null {
|
||||
const coordinate = repositoryCoordinate(issue.repoAddress);
|
||||
return coordinate &&
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
const PROJECT_SIDEBAR_MEMBERSHIP_PREFIX = "buzz.sidebar.projects.membership.v1";
|
||||
export const PROJECT_SIDEBAR_MEMBERSHIP_EVENT =
|
||||
"buzz:project-sidebar-membership-change";
|
||||
|
||||
function membershipKey(relayOrigin: string, pubkey: string) {
|
||||
return `${PROJECT_SIDEBAR_MEMBERSHIP_PREFIX}.${encodeURIComponent(relayOrigin)}.${pubkey.toLowerCase()}`;
|
||||
}
|
||||
|
||||
export function readProjectSidebarMembership(
|
||||
relayOrigin: string | null | undefined,
|
||||
pubkey: string | null | undefined,
|
||||
): string[] {
|
||||
if (!relayOrigin || !pubkey) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
globalThis.localStorage?.getItem(membershipKey(relayOrigin, pubkey)) ??
|
||||
"[]",
|
||||
);
|
||||
return Array.isArray(parsed)
|
||||
? [
|
||||
...new Set(
|
||||
parsed.filter(
|
||||
(address): address is string =>
|
||||
typeof address === "string" && address.length > 0,
|
||||
),
|
||||
),
|
||||
]
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeProjectSidebarMembership(
|
||||
relayOrigin: string,
|
||||
pubkey: string,
|
||||
addresses: readonly string[],
|
||||
) {
|
||||
try {
|
||||
globalThis.localStorage?.setItem(
|
||||
membershipKey(relayOrigin, pubkey),
|
||||
JSON.stringify([...new Set(addresses)]),
|
||||
);
|
||||
globalThis.dispatchEvent?.(
|
||||
new CustomEvent(PROJECT_SIDEBAR_MEMBERSHIP_EVENT),
|
||||
);
|
||||
} catch {
|
||||
// Persistence is best-effort; callers still update their local view.
|
||||
}
|
||||
}
|
||||
|
||||
export function addProjectToSidebar(
|
||||
projectAddress: string,
|
||||
relayOrigin: string | null | undefined,
|
||||
pubkey: string | null | undefined,
|
||||
) {
|
||||
if (!relayOrigin || !pubkey) return;
|
||||
writeProjectSidebarMembership(relayOrigin, pubkey, [
|
||||
...readProjectSidebarMembership(relayOrigin, pubkey),
|
||||
projectAddress,
|
||||
]);
|
||||
}
|
||||
|
||||
export function removeProjectFromSidebar(
|
||||
projectAddress: string,
|
||||
relayOrigin: string | null | undefined,
|
||||
pubkey: string | null | undefined,
|
||||
) {
|
||||
if (!relayOrigin || !pubkey) return;
|
||||
writeProjectSidebarMembership(
|
||||
relayOrigin,
|
||||
pubkey,
|
||||
readProjectSidebarMembership(relayOrigin, pubkey).filter(
|
||||
(address) => address !== projectAddress,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,6 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { useChannelsQuery } from "@/features/channels/hooks";
|
||||
import type { CreateProjectInput } from "@/features/projects/useCreateProject";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content";
|
||||
import { Dialog } from "@/shared/ui/dialog";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
|
||||
const CREATE_FIELD_SHELL_CLASS =
|
||||
"rounded-xl border border-input bg-muted/40 transition-colors duration-150 ease-out hover:border-muted-foreground/40 focus-within:border-muted-foreground/50";
|
||||
const CREATE_FIELD_CONTROL_CLASS =
|
||||
"border-0 bg-transparent text-muted-foreground/55 shadow-none outline-none ring-0 transition-colors duration-150 ease-out placeholder:text-muted-foreground/55 focus:bg-transparent focus:text-foreground focus:outline-hidden focus-visible:ring-0";
|
||||
const CREATE_LABEL_OPTIONAL_CLASS =
|
||||
"ml-1 text-xs font-normal text-muted-foreground/50";
|
||||
import { CreateProjectFormContent } from "./CreateProjectFormContent";
|
||||
|
||||
type CreateProjectDialogProps = {
|
||||
isCreating: boolean;
|
||||
@@ -30,67 +16,6 @@ export function CreateProjectDialog({
|
||||
onOpenChange,
|
||||
open,
|
||||
}: CreateProjectDialogProps) {
|
||||
const [name, setName] = React.useState("");
|
||||
const [description, setDescription] = React.useState("");
|
||||
const [cloneUrl, setCloneUrl] = React.useState("");
|
||||
const [webUrl, setWebUrl] = React.useState("");
|
||||
const [accessChannelId, setAccessChannelId] = React.useState("");
|
||||
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
|
||||
const nameInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const channelsQuery = useChannelsQuery({ enabled: open });
|
||||
const accessChannels = React.useMemo(
|
||||
() =>
|
||||
(channelsQuery.data ?? []).filter(
|
||||
(channel) =>
|
||||
channel.isMember &&
|
||||
!channel.archivedAt &&
|
||||
channel.channelType !== "dm",
|
||||
),
|
||||
[channelsQuery.data],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
setName("");
|
||||
setDescription("");
|
||||
setCloneUrl("");
|
||||
setWebUrl("");
|
||||
setAccessChannelId(accessChannels[0]?.id ?? "");
|
||||
setErrorMessage(null);
|
||||
|
||||
// Small delay to let the dialog animation start before focusing.
|
||||
const timerId = globalThis.setTimeout(() => {
|
||||
nameInputRef.current?.focus();
|
||||
}, 50);
|
||||
return () => globalThis.clearTimeout(timerId);
|
||||
}, [accessChannels, open]);
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName || !accessChannelId) return;
|
||||
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
await onCreate({
|
||||
accessChannelId,
|
||||
name: trimmedName,
|
||||
description: description.trim() || undefined,
|
||||
cloneUrl: cloneUrl.trim() || undefined,
|
||||
webUrl: webUrl.trim() || undefined,
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
setErrorMessage(
|
||||
error instanceof Error ? error.message : "Failed to create project.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
onOpenChange={(nextOpen) => {
|
||||
@@ -99,218 +24,12 @@ export function CreateProjectDialog({
|
||||
}}
|
||||
open={open}
|
||||
>
|
||||
<ChooserDialogContent
|
||||
className="max-w-lg"
|
||||
contentClassName="pt-3"
|
||||
data-testid="create-project-dialog"
|
||||
description="Projects group one or more repositories published to this workspace's relay."
|
||||
footer={
|
||||
<div className="flex w-full items-center justify-end gap-3">
|
||||
<Button
|
||||
data-testid="create-project-submit"
|
||||
disabled={
|
||||
isCreating || name.trim().length === 0 || !accessChannelId
|
||||
}
|
||||
form="create-project-form"
|
||||
type="submit"
|
||||
>
|
||||
{isCreating ? "Creating..." : "Create project"}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
footerClassName="border-t-0 pt-0"
|
||||
headerClassName="pb-2"
|
||||
title="Create a new project"
|
||||
>
|
||||
<form
|
||||
className="space-y-5"
|
||||
id="create-project-form"
|
||||
onSubmit={(event) => {
|
||||
void handleSubmit(event);
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
className="text-sm font-medium text-foreground"
|
||||
htmlFor="create-project-name"
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-11 items-center px-3",
|
||||
CREATE_FIELD_SHELL_CLASS,
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
autoCapitalize="none"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
className={cn(
|
||||
"h-8 px-0 py-0 leading-6",
|
||||
CREATE_FIELD_CONTROL_CLASS,
|
||||
)}
|
||||
data-testid="create-project-name"
|
||||
disabled={isCreating}
|
||||
id="create-project-name"
|
||||
onChange={(event) => {
|
||||
setName(event.target.value);
|
||||
setErrorMessage(null);
|
||||
}}
|
||||
placeholder="bee-garden-game"
|
||||
ref={nameInputRef}
|
||||
spellCheck={false}
|
||||
value={name}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
className="text-sm font-medium text-foreground"
|
||||
htmlFor="create-project-access-channel"
|
||||
>
|
||||
Repository access channel
|
||||
</label>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-11 items-center px-3",
|
||||
CREATE_FIELD_SHELL_CLASS,
|
||||
)}
|
||||
>
|
||||
<select
|
||||
className={cn(
|
||||
"h-8 w-full px-0 py-0",
|
||||
CREATE_FIELD_CONTROL_CLASS,
|
||||
)}
|
||||
data-testid="create-project-access-channel"
|
||||
disabled={isCreating}
|
||||
id="create-project-access-channel"
|
||||
onChange={(event) => {
|
||||
setAccessChannelId(event.target.value);
|
||||
setErrorMessage(null);
|
||||
}}
|
||||
required
|
||||
value={accessChannelId}
|
||||
>
|
||||
<option value="">Select a channel</option>
|
||||
{accessChannels.map((channel) => (
|
||||
<option key={channel.id} value={channel.id}>
|
||||
{channel.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Members of this channel can access project repositories.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
className="text-sm font-medium text-foreground"
|
||||
htmlFor="create-project-description"
|
||||
>
|
||||
Description
|
||||
<span className={CREATE_LABEL_OPTIONAL_CLASS}>Optional</span>
|
||||
</label>
|
||||
<div className={CREATE_FIELD_SHELL_CLASS}>
|
||||
<Textarea
|
||||
className={cn(
|
||||
"min-h-20 resize-none px-3 py-3 leading-5",
|
||||
CREATE_FIELD_CONTROL_CLASS,
|
||||
)}
|
||||
data-testid="create-project-description"
|
||||
disabled={isCreating}
|
||||
id="create-project-description"
|
||||
onChange={(event) => {
|
||||
setDescription(event.target.value);
|
||||
setErrorMessage(null);
|
||||
}}
|
||||
placeholder="What this project is about"
|
||||
rows={2}
|
||||
value={description}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
className="text-sm font-medium text-foreground"
|
||||
htmlFor="create-project-clone-url"
|
||||
>
|
||||
Initial repository clone URL
|
||||
<span className={CREATE_LABEL_OPTIONAL_CLASS}>Optional</span>
|
||||
</label>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-11 items-center px-3",
|
||||
CREATE_FIELD_SHELL_CLASS,
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
autoCapitalize="none"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
className={cn(
|
||||
"h-8 px-0 py-0 leading-6",
|
||||
CREATE_FIELD_CONTROL_CLASS,
|
||||
)}
|
||||
data-testid="create-project-clone-url"
|
||||
disabled={isCreating}
|
||||
id="create-project-clone-url"
|
||||
onChange={(event) => {
|
||||
setCloneUrl(event.target.value);
|
||||
setErrorMessage(null);
|
||||
}}
|
||||
placeholder="https://relay.example.com/git/bee-garden-game.git"
|
||||
spellCheck={false}
|
||||
value={cloneUrl}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
className="text-sm font-medium text-foreground"
|
||||
htmlFor="create-project-web-url"
|
||||
>
|
||||
Initial repository web URL
|
||||
<span className={CREATE_LABEL_OPTIONAL_CLASS}>Optional</span>
|
||||
</label>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-11 items-center px-3",
|
||||
CREATE_FIELD_SHELL_CLASS,
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
autoCapitalize="none"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
className={cn(
|
||||
"h-8 px-0 py-0 leading-6",
|
||||
CREATE_FIELD_CONTROL_CLASS,
|
||||
)}
|
||||
data-testid="create-project-web-url"
|
||||
disabled={isCreating}
|
||||
id="create-project-web-url"
|
||||
onChange={(event) => {
|
||||
setWebUrl(event.target.value);
|
||||
setErrorMessage(null);
|
||||
}}
|
||||
placeholder="https://github.com/owner/repo"
|
||||
spellCheck={false}
|
||||
value={webUrl}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{errorMessage ? (
|
||||
<p className="text-sm text-destructive">{errorMessage}</p>
|
||||
) : null}
|
||||
</form>
|
||||
</ChooserDialogContent>
|
||||
<CreateProjectFormContent
|
||||
active={open}
|
||||
isCreating={isCreating}
|
||||
onCreate={onCreate}
|
||||
onCreated={() => onOpenChange(false)}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
314
desktop/src/features/projects/ui/CreateProjectFormContent.tsx
Normal file
314
desktop/src/features/projects/ui/CreateProjectFormContent.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { useChannelsQuery } from "@/features/channels/hooks";
|
||||
import type { CreateProjectInput } from "@/features/projects/useCreateProject";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
|
||||
const CREATE_FIELD_SHELL_CLASS =
|
||||
"rounded-xl border border-input bg-muted/40 transition-colors duration-150 ease-out hover:border-muted-foreground/40 focus-within:border-muted-foreground/50";
|
||||
const CREATE_FIELD_CONTROL_CLASS =
|
||||
"border-0 bg-transparent text-muted-foreground/55 shadow-none outline-none ring-0 transition-colors duration-150 ease-out placeholder:text-muted-foreground/55 focus:bg-transparent focus:text-foreground focus:outline-hidden focus-visible:ring-0";
|
||||
const CREATE_LABEL_OPTIONAL_CLASS =
|
||||
"ml-1 text-xs font-normal text-muted-foreground/50";
|
||||
|
||||
export function CreateProjectFormContent({
|
||||
active,
|
||||
initialName = "",
|
||||
isCreating,
|
||||
onBack,
|
||||
onCreate,
|
||||
onCreated,
|
||||
}: {
|
||||
active: boolean;
|
||||
initialName?: string;
|
||||
isCreating: boolean;
|
||||
onBack?: () => void;
|
||||
onCreate: (input: CreateProjectInput) => Promise<void>;
|
||||
onCreated: () => void;
|
||||
}) {
|
||||
const [name, setName] = React.useState("");
|
||||
const [description, setDescription] = React.useState("");
|
||||
const [cloneUrl, setCloneUrl] = React.useState("");
|
||||
const [webUrl, setWebUrl] = React.useState("");
|
||||
const [accessChannelId, setAccessChannelId] = React.useState("");
|
||||
const [errorMessage, setErrorMessage] = React.useState<string | null>(null);
|
||||
const nameInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const channelsQuery = useChannelsQuery({ enabled: active });
|
||||
const accessChannels = React.useMemo(
|
||||
() =>
|
||||
(channelsQuery.data ?? []).filter(
|
||||
(channel) =>
|
||||
channel.isMember &&
|
||||
!channel.archivedAt &&
|
||||
channel.channelType !== "dm",
|
||||
),
|
||||
[channelsQuery.data],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active) return;
|
||||
setName(initialName);
|
||||
setDescription("");
|
||||
setCloneUrl("");
|
||||
setWebUrl("");
|
||||
setAccessChannelId(accessChannels[0]?.id ?? "");
|
||||
setErrorMessage(null);
|
||||
|
||||
const timerId = globalThis.setTimeout(() => {
|
||||
nameInputRef.current?.focus();
|
||||
}, 50);
|
||||
return () => globalThis.clearTimeout(timerId);
|
||||
}, [accessChannels, active, initialName]);
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName || !accessChannelId) return;
|
||||
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
await onCreate({
|
||||
accessChannelId,
|
||||
name: trimmedName,
|
||||
description: description.trim() || undefined,
|
||||
cloneUrl: cloneUrl.trim() || undefined,
|
||||
webUrl: webUrl.trim() || undefined,
|
||||
});
|
||||
onCreated();
|
||||
} catch (error) {
|
||||
setErrorMessage(
|
||||
error instanceof Error ? error.message : "Failed to create project.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ChooserDialogContent
|
||||
className="max-w-lg"
|
||||
contentClassName="pt-3"
|
||||
data-testid="create-project-dialog"
|
||||
description="Projects group one or more repositories published to this workspace's relay."
|
||||
footer={
|
||||
<div className="flex w-full items-center justify-end gap-3">
|
||||
<Button
|
||||
data-testid="create-project-submit"
|
||||
disabled={
|
||||
isCreating || name.trim().length === 0 || !accessChannelId
|
||||
}
|
||||
form="create-project-form"
|
||||
type="submit"
|
||||
>
|
||||
{isCreating ? "Creating..." : "Create project"}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
footerClassName="border-t-0 pt-0"
|
||||
headerClassName="pb-2"
|
||||
title="Create a new project"
|
||||
>
|
||||
{onBack ? (
|
||||
<Button
|
||||
className="mb-3 h-8 gap-1.5 px-2 text-muted-foreground"
|
||||
disabled={isCreating}
|
||||
onClick={onBack}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to projects
|
||||
</Button>
|
||||
) : null}
|
||||
<form
|
||||
className="space-y-5"
|
||||
id="create-project-form"
|
||||
onSubmit={(event) => {
|
||||
void handleSubmit(event);
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
className="text-sm font-medium text-foreground"
|
||||
htmlFor="create-project-name"
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-11 items-center px-3",
|
||||
CREATE_FIELD_SHELL_CLASS,
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
autoCapitalize="none"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
className={cn(
|
||||
"h-8 px-0 py-0 leading-6",
|
||||
CREATE_FIELD_CONTROL_CLASS,
|
||||
)}
|
||||
data-testid="create-project-name"
|
||||
disabled={isCreating}
|
||||
id="create-project-name"
|
||||
onChange={(event) => {
|
||||
setName(event.target.value);
|
||||
setErrorMessage(null);
|
||||
}}
|
||||
placeholder="bee-garden-game"
|
||||
ref={nameInputRef}
|
||||
spellCheck={false}
|
||||
value={name}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
className="text-sm font-medium text-foreground"
|
||||
htmlFor="create-project-access-channel"
|
||||
>
|
||||
Repository access channel
|
||||
</label>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-11 items-center px-3",
|
||||
CREATE_FIELD_SHELL_CLASS,
|
||||
)}
|
||||
>
|
||||
<select
|
||||
className={cn("h-8 w-full px-0 py-0", CREATE_FIELD_CONTROL_CLASS)}
|
||||
data-testid="create-project-access-channel"
|
||||
disabled={isCreating}
|
||||
id="create-project-access-channel"
|
||||
onChange={(event) => {
|
||||
setAccessChannelId(event.target.value);
|
||||
setErrorMessage(null);
|
||||
}}
|
||||
required
|
||||
value={accessChannelId}
|
||||
>
|
||||
<option value="">Select a channel</option>
|
||||
{accessChannels.map((channel) => (
|
||||
<option key={channel.id} value={channel.id}>
|
||||
{channel.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Members of this channel can access project repositories.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
className="text-sm font-medium text-foreground"
|
||||
htmlFor="create-project-description"
|
||||
>
|
||||
Description
|
||||
<span className={CREATE_LABEL_OPTIONAL_CLASS}>Optional</span>
|
||||
</label>
|
||||
<div className={CREATE_FIELD_SHELL_CLASS}>
|
||||
<Textarea
|
||||
className={cn(
|
||||
"min-h-20 resize-none px-3 py-3 leading-5",
|
||||
CREATE_FIELD_CONTROL_CLASS,
|
||||
)}
|
||||
data-testid="create-project-description"
|
||||
disabled={isCreating}
|
||||
id="create-project-description"
|
||||
onChange={(event) => {
|
||||
setDescription(event.target.value);
|
||||
setErrorMessage(null);
|
||||
}}
|
||||
placeholder="What this project is about"
|
||||
rows={2}
|
||||
value={description}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
className="text-sm font-medium text-foreground"
|
||||
htmlFor="create-project-clone-url"
|
||||
>
|
||||
Initial repository clone URL
|
||||
<span className={CREATE_LABEL_OPTIONAL_CLASS}>Optional</span>
|
||||
</label>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-11 items-center px-3",
|
||||
CREATE_FIELD_SHELL_CLASS,
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
autoCapitalize="none"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
className={cn(
|
||||
"h-8 px-0 py-0 leading-6",
|
||||
CREATE_FIELD_CONTROL_CLASS,
|
||||
)}
|
||||
data-testid="create-project-clone-url"
|
||||
disabled={isCreating}
|
||||
id="create-project-clone-url"
|
||||
onChange={(event) => {
|
||||
setCloneUrl(event.target.value);
|
||||
setErrorMessage(null);
|
||||
}}
|
||||
placeholder="https://relay.example.com/git/bee-garden-game.git"
|
||||
spellCheck={false}
|
||||
value={cloneUrl}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
className="text-sm font-medium text-foreground"
|
||||
htmlFor="create-project-web-url"
|
||||
>
|
||||
Initial repository web URL
|
||||
<span className={CREATE_LABEL_OPTIONAL_CLASS}>Optional</span>
|
||||
</label>
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-11 items-center px-3",
|
||||
CREATE_FIELD_SHELL_CLASS,
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
autoCapitalize="none"
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
className={cn(
|
||||
"h-8 px-0 py-0 leading-6",
|
||||
CREATE_FIELD_CONTROL_CLASS,
|
||||
)}
|
||||
data-testid="create-project-web-url"
|
||||
disabled={isCreating}
|
||||
id="create-project-web-url"
|
||||
onChange={(event) => {
|
||||
setWebUrl(event.target.value);
|
||||
setErrorMessage(null);
|
||||
}}
|
||||
placeholder="https://github.com/owner/repo"
|
||||
spellCheck={false}
|
||||
value={webUrl}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{errorMessage ? (
|
||||
<p className="text-sm text-destructive">{errorMessage}</p>
|
||||
) : null}
|
||||
</form>
|
||||
</ChooserDialogContent>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Hash } from "lucide-react";
|
||||
import { Hash, MessageSquare } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
@@ -425,16 +425,18 @@ function ParticipantFacepile({
|
||||
const label = resolveUserLabel({ profiles, pubkey });
|
||||
if (!interactive) {
|
||||
return (
|
||||
<UserAvatar
|
||||
avatarUrl={profiles?.[pubkey]?.avatarUrl ?? null}
|
||||
className={cn(
|
||||
"rounded-full ring-2 ring-background",
|
||||
index > 0 && "-ml-1.5",
|
||||
)}
|
||||
displayName={label}
|
||||
<span
|
||||
className={cn(index > 0 && "-ml-1.5")}
|
||||
key={pubkey}
|
||||
size="xs"
|
||||
/>
|
||||
title={label}
|
||||
>
|
||||
<UserAvatar
|
||||
avatarUrl={profiles?.[pubkey]?.avatarUrl ?? null}
|
||||
className="rounded-full ring-2 ring-background"
|
||||
displayName={label}
|
||||
size="xs"
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
@@ -472,7 +474,13 @@ function ParticipantFacepile({
|
||||
* where the repository (or its PRs/issues) is linked in chat, with the
|
||||
* people who discussed it there.
|
||||
*/
|
||||
export function DiscussionChannelsPanel({ query }: { query: string }) {
|
||||
export function DiscussionChannelsPanel({
|
||||
query,
|
||||
repositoryName,
|
||||
}: {
|
||||
query: string;
|
||||
repositoryName: string;
|
||||
}) {
|
||||
const { channels, isLoading, isTruncated } = useDiscussionChannels(query);
|
||||
const { goChannel } = useAppNavigation();
|
||||
const channelName = useChannelNameLookup(channels.length > 0);
|
||||
@@ -500,46 +508,53 @@ export function DiscussionChannelsPanel({ query }: { query: string }) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ul
|
||||
className="divide-y divide-border/50"
|
||||
data-testid="discussion-channels"
|
||||
>
|
||||
<ul data-testid="discussion-channels">
|
||||
{channels.map((channel) => {
|
||||
const name = channelName(channel.id, channel.name);
|
||||
const speakers = channel.participants
|
||||
.slice(0, 2)
|
||||
.map((pubkey) => resolveUserLabel({ profiles, pubkey }));
|
||||
const others = channel.participants.length - speakers.length;
|
||||
return (
|
||||
<li className="relative" key={channel.id}>
|
||||
<button
|
||||
className="flex w-full min-w-0 items-center gap-2.5 px-4 py-3 text-left transition-colors hover:bg-muted/30"
|
||||
className="flex min-h-9 w-full min-w-0 items-center gap-2 px-4 py-1.5 text-left transition-colors hover:bg-muted/30"
|
||||
data-testid="project-channel-row"
|
||||
onClick={() => void goChannel(channel.id)}
|
||||
title={`Open #${name}`}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-muted/50">
|
||||
<Hash className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="flex h-4 w-4 shrink-0 items-center justify-center">
|
||||
<Hash className="h-3.5 w-3.5 text-muted-foreground/70" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 space-y-1">
|
||||
<span className="block truncate text-sm font-medium text-foreground">
|
||||
#{name}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{speakers.join(", ")}
|
||||
{others > 0
|
||||
? ` and ${others} ${others === 1 ? "other" : "others"}`
|
||||
: ""}{" "}
|
||||
· {channel.messageCount}
|
||||
{isTruncated ? "+" : ""}{" "}
|
||||
{channel.messageCount === 1 ? "message" : "messages"}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
|
||||
#{name}
|
||||
</span>
|
||||
<ParticipantFacepile
|
||||
participants={channel.participants}
|
||||
profiles={profiles}
|
||||
/>
|
||||
<span
|
||||
className="hidden w-20 shrink-0 text-right text-xs text-muted-foreground sm:block"
|
||||
className="hidden w-28 shrink-0 truncate text-right text-xs text-muted-foreground md:block"
|
||||
data-testid="project-channel-repository"
|
||||
title={repositoryName}
|
||||
>
|
||||
{repositoryName}
|
||||
</span>
|
||||
<span
|
||||
className="flex w-20 shrink-0 justify-end"
|
||||
data-testid="project-channel-participants"
|
||||
>
|
||||
<ParticipantFacepile
|
||||
participants={channel.participants}
|
||||
profiles={profiles}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className="flex w-12 shrink-0 items-center justify-end gap-1 text-xs text-muted-foreground"
|
||||
data-testid="project-channel-message-count"
|
||||
title={`${channel.messageCount}${isTruncated ? "+" : ""} ${
|
||||
channel.messageCount === 1 ? "message" : "messages"
|
||||
}`}
|
||||
>
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
{channel.messageCount}
|
||||
{isTruncated ? "+" : ""}
|
||||
</span>
|
||||
<span
|
||||
className="hidden w-20 shrink-0 whitespace-nowrap text-right text-xs text-muted-foreground/70 sm:block"
|
||||
data-testid="project-channel-row-date"
|
||||
title={new Date(
|
||||
channel.lastActivityAt * 1_000,
|
||||
|
||||
235
desktop/src/features/projects/ui/ProjectAgentChatPanel.tsx
Normal file
235
desktop/src/features/projects/ui/ProjectAgentChatPanel.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import { Trash2 } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useStartManagedAgentMutation } from "@/features/agents/hooks";
|
||||
import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane";
|
||||
import { useChannelsQuery, useOpenDmMutation } from "@/features/channels/hooks";
|
||||
import type { ProjectDetailAgentContext } from "@/features/projects/lib/projectDetailAgentContext";
|
||||
import {
|
||||
projectDetailAgentContextBlock,
|
||||
stripProjectDetailAgentContext,
|
||||
} from "@/features/projects/lib/projectDetailAgentContext";
|
||||
import { restoreProjectsAgentConversation } from "@/features/projects/lib/projectAgentConversation";
|
||||
import {
|
||||
clearStoredProjectsAgentConversation,
|
||||
readStoredProjectsAgentConversation,
|
||||
type StoredProjectsAgentConversation,
|
||||
writeStoredProjectsAgentConversation,
|
||||
} from "@/features/projects/lib/projectAgentConversationStorage";
|
||||
import { MessageComposer } from "@/features/messages/ui/MessageComposer";
|
||||
import { useProfileQuery, useUsersBatchQuery } from "@/features/profile/hooks";
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import { sendChannelMessage } from "@/shared/api/tauri";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
type AgentCandidate,
|
||||
ConversationThread,
|
||||
useAgentCandidates,
|
||||
} from "./ProjectsAgentPromptPage";
|
||||
import { ProjectAgentContextStrip } from "./ProjectAgentContextStrip";
|
||||
|
||||
type ProjectAgentConversation = {
|
||||
agent: AgentCandidate;
|
||||
channel: Channel;
|
||||
visibleAfter: number;
|
||||
};
|
||||
|
||||
export function ProjectAgentChatPanel({
|
||||
canResetWidth,
|
||||
context,
|
||||
onResetWidth,
|
||||
onResizeStart,
|
||||
sharedHeaderBackdrop,
|
||||
widthPx,
|
||||
}: {
|
||||
canResetWidth: boolean;
|
||||
context: ProjectDetailAgentContext;
|
||||
onResetWidth: () => void;
|
||||
onResizeStart: (event: React.PointerEvent<HTMLButtonElement>) => void;
|
||||
sharedHeaderBackdrop?: boolean;
|
||||
widthPx: number;
|
||||
}) {
|
||||
const storageScope = `detail:${context.repoAddress}`;
|
||||
const [isSending, setIsSending] = React.useState(false);
|
||||
const [storedConversation, setStoredConversation] =
|
||||
React.useState<StoredProjectsAgentConversation | null>(() =>
|
||||
readStoredProjectsAgentConversation(storageScope),
|
||||
);
|
||||
const [conversation, setConversation] =
|
||||
React.useState<ProjectAgentConversation | null>(null);
|
||||
const candidates = useAgentCandidates();
|
||||
const channelsQuery = useChannelsQuery();
|
||||
const identityQuery = useIdentityQuery();
|
||||
const profileQuery = useProfileQuery();
|
||||
const openDmMutation = useOpenDmMutation();
|
||||
const startAgentMutation = useStartManagedAgentMutation();
|
||||
const selectedAgent = conversation?.agent ?? candidates[0] ?? null;
|
||||
const candidateProfilesQuery = useUsersBatchQuery(
|
||||
selectedAgent ? [selectedAgent.pubkey] : [],
|
||||
);
|
||||
const selectedAgentAvatarUrl = selectedAgent
|
||||
? (candidateProfilesQuery.data?.profiles[
|
||||
normalizePubkey(selectedAgent.pubkey)
|
||||
]?.avatarUrl ?? null)
|
||||
: null;
|
||||
const restorableConversation = React.useMemo(
|
||||
() =>
|
||||
restoreProjectsAgentConversation({
|
||||
candidates,
|
||||
channels: channelsQuery.data ?? [],
|
||||
stored: storedConversation,
|
||||
}),
|
||||
[candidates, channelsQuery.data, storedConversation],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (conversation || !restorableConversation) return;
|
||||
setConversation(restorableConversation);
|
||||
}, [conversation, restorableConversation]);
|
||||
|
||||
const handleSubmit = React.useCallback(
|
||||
async (
|
||||
content: string,
|
||||
mentionPubkeys: string[],
|
||||
mediaTags?: string[][],
|
||||
) => {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed || !selectedAgent || isSending) return;
|
||||
setIsSending(true);
|
||||
const visibleAfter =
|
||||
conversation?.visibleAfter ?? Math.floor(Date.now() / 1_000);
|
||||
try {
|
||||
if (selectedAgent.isManaged && !selectedAgent.isActive) {
|
||||
await startAgentMutation.mutateAsync(selectedAgent.pubkey);
|
||||
}
|
||||
const channel =
|
||||
conversation?.channel ??
|
||||
(await openDmMutation.mutateAsync({
|
||||
pubkeys: [selectedAgent.pubkey],
|
||||
}));
|
||||
await sendChannelMessage(
|
||||
channel.id,
|
||||
`${trimmed}${projectDetailAgentContextBlock(context)}`,
|
||||
undefined,
|
||||
mediaTags,
|
||||
[...new Set([...mentionPubkeys, selectedAgent.pubkey])],
|
||||
);
|
||||
if (!conversation) {
|
||||
const nextConversation = {
|
||||
agent: selectedAgent,
|
||||
channel,
|
||||
visibleAfter,
|
||||
};
|
||||
const stored = {
|
||||
agentPubkey: selectedAgent.pubkey,
|
||||
channelId: channel.id,
|
||||
visibleAfter,
|
||||
};
|
||||
setConversation(nextConversation);
|
||||
setStoredConversation(stored);
|
||||
writeStoredProjectsAgentConversation(storageScope, stored);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to reach the agent",
|
||||
);
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
context,
|
||||
conversation,
|
||||
isSending,
|
||||
openDmMutation,
|
||||
selectedAgent,
|
||||
startAgentMutation,
|
||||
storageScope,
|
||||
],
|
||||
);
|
||||
|
||||
const handleClear = React.useCallback(() => {
|
||||
clearStoredProjectsAgentConversation(storageScope);
|
||||
setStoredConversation(null);
|
||||
setConversation(null);
|
||||
}, [storageScope]);
|
||||
|
||||
return (
|
||||
<RightAuxiliaryPane
|
||||
canResetWidth={canResetWidth}
|
||||
onResetWidth={onResetWidth}
|
||||
onResizeStart={onResizeStart}
|
||||
testId="project-agent-chat-panel"
|
||||
widthPx={widthPx}
|
||||
>
|
||||
<ProjectAgentContextStrip
|
||||
context={context}
|
||||
sharedBackdrop={sharedHeaderBackdrop}
|
||||
/>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-y-auto px-4 pb-4 pt-[4.25rem]"
|
||||
data-testid="project-agent-conversation-scroll"
|
||||
>
|
||||
{conversation ? (
|
||||
<ConversationThread
|
||||
agent={conversation.agent}
|
||||
agentAvatarUrl={selectedAgentAvatarUrl}
|
||||
channel={conversation.channel}
|
||||
currentPubkey={identityQuery.data?.pubkey ?? null}
|
||||
selfAvatarUrl={profileQuery.data?.avatarUrl ?? null}
|
||||
stripSelfContent={stripProjectDetailAgentContext}
|
||||
visibleAfter={conversation.visibleAfter}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full min-h-40 flex-col items-center justify-center gap-2 text-center">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Ask about this page
|
||||
</p>
|
||||
<p className="max-w-56 text-xs text-muted-foreground">
|
||||
Start a conversation with the project agent.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<MessageComposer
|
||||
channelId={conversation?.channel.id ?? null}
|
||||
channelName={selectedAgent?.name ?? "project agent"}
|
||||
channelType="dm"
|
||||
containerClassName="px-3 pb-3"
|
||||
disabled={!selectedAgent || isSending}
|
||||
draftKey={`project-agent:${storageScope}`}
|
||||
isSending={isSending}
|
||||
layoutMode="standalone"
|
||||
onSend={handleSubmit}
|
||||
placeholder={
|
||||
selectedAgent
|
||||
? `Message ${selectedAgent.name}`
|
||||
: "No agents available"
|
||||
}
|
||||
profiles={candidateProfilesQuery.data?.profiles}
|
||||
showBackgroundUploadProgress={false}
|
||||
showTopBorder={false}
|
||||
toolbarExtraActions={
|
||||
conversation ? (
|
||||
<Button
|
||||
aria-label="Clear project agent chat"
|
||||
className="h-7 w-7"
|
||||
onClick={handleClear}
|
||||
size="icon"
|
||||
title="Clear conversation"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</RightAuxiliaryPane>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { ProjectDetailAgentContext } from "@/features/projects/lib/projectDetailAgentContext";
|
||||
import { PROJECT_COLUMN_HEADER_BACKDROP_CLASS } from "./projectPanelStyles";
|
||||
|
||||
function contextLabel(context: ProjectDetailAgentContext) {
|
||||
if (context.workItem) {
|
||||
return context.workItem.title;
|
||||
}
|
||||
if (context.file?.path) {
|
||||
return context.file.path.split("/").filter(Boolean).at(-1) ?? context.view;
|
||||
}
|
||||
return context.view;
|
||||
}
|
||||
|
||||
export function ProjectAgentContextStrip({
|
||||
context,
|
||||
sharedBackdrop = false,
|
||||
}: {
|
||||
context: ProjectDetailAgentContext;
|
||||
sharedBackdrop?: boolean;
|
||||
}) {
|
||||
const label = contextLabel(context);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`absolute inset-x-0 top-0 z-30 flex h-13 shrink-0 items-center px-4 ${
|
||||
sharedBackdrop ? "" : PROJECT_COLUMN_HEADER_BACKDROP_CLASS
|
||||
}`}
|
||||
data-testid="project-agent-context"
|
||||
title={label}
|
||||
>
|
||||
<p className="truncate text-sm font-medium text-foreground">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
192
desktop/src/features/projects/ui/ProjectBrowserDialog.tsx
Normal file
192
desktop/src/features/projects/ui/ProjectBrowserDialog.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { Check, Folders, Plus, Search } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import type { Project } from "@/features/projects/hooks";
|
||||
import type { CreateProjectInput } from "@/features/projects/useCreateProject";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content";
|
||||
import { Dialog } from "@/shared/ui/dialog";
|
||||
import {
|
||||
MODAL_SEARCH_INPUT_CLASS,
|
||||
MODAL_SEARCH_SHELL_CLASS,
|
||||
} from "@/shared/ui/modalSearchStyles";
|
||||
import { CreateProjectFormContent } from "./CreateProjectFormContent";
|
||||
|
||||
function projectSearchText(project: Project) {
|
||||
return [
|
||||
project.name,
|
||||
project.description,
|
||||
...project.repositories.flatMap((repository) => [
|
||||
repository.name,
|
||||
repository.description,
|
||||
]),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLocaleLowerCase();
|
||||
}
|
||||
|
||||
export function ProjectBrowserDialog({
|
||||
isCreating,
|
||||
onCreate,
|
||||
onOpenChange,
|
||||
onSelectProject,
|
||||
open,
|
||||
projects,
|
||||
selectedProjectAddresses,
|
||||
}: {
|
||||
isCreating: boolean;
|
||||
onCreate: (input: CreateProjectInput) => Promise<void>;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSelectProject: (project: Project) => void;
|
||||
open: boolean;
|
||||
projects: readonly Project[];
|
||||
selectedProjectAddresses: ReadonlySet<string>;
|
||||
}) {
|
||||
const [mode, setMode] = React.useState<"browse" | "create">("browse");
|
||||
const [query, setQuery] = React.useState("");
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const deferredQuery = React.useDeferredValue(
|
||||
query.trim().toLocaleLowerCase(),
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
setMode("browse");
|
||||
setQuery("");
|
||||
const timerId = globalThis.setTimeout(() => inputRef.current?.focus(), 50);
|
||||
return () => globalThis.clearTimeout(timerId);
|
||||
}, [open]);
|
||||
|
||||
const visibleProjects = React.useMemo(() => {
|
||||
const sorted = [...projects].sort((left, right) =>
|
||||
left.name.localeCompare(right.name, undefined, { sensitivity: "base" }),
|
||||
);
|
||||
if (!deferredQuery) return sorted;
|
||||
return sorted.filter((project) =>
|
||||
projectSearchText(project).includes(deferredQuery),
|
||||
);
|
||||
}, [deferredQuery, projects]);
|
||||
const hasExactMatch = projects.some(
|
||||
(project) =>
|
||||
project.name.trim().toLocaleLowerCase() ===
|
||||
query.trim().toLocaleLowerCase(),
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen && isCreating) return;
|
||||
onOpenChange(nextOpen);
|
||||
}}
|
||||
open={open}
|
||||
>
|
||||
{mode === "create" ? (
|
||||
<CreateProjectFormContent
|
||||
active={open}
|
||||
initialName={query.trim()}
|
||||
isCreating={isCreating}
|
||||
onBack={() => setMode("browse")}
|
||||
onCreate={onCreate}
|
||||
onCreated={() => onOpenChange(false)}
|
||||
/>
|
||||
) : (
|
||||
<ChooserDialogContent
|
||||
className="max-w-lg"
|
||||
contentClassName="space-y-3 pt-1"
|
||||
data-testid="project-browser-dialog"
|
||||
headerClassName="pb-2"
|
||||
scrollAreaClassName="px-3"
|
||||
title="Add a project"
|
||||
>
|
||||
<div className={MODAL_SEARCH_SHELL_CLASS}>
|
||||
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
aria-label="Search projects"
|
||||
className={MODAL_SEARCH_INPUT_CLASS}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search or create a project"
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!hasExactMatch ? (
|
||||
<Button
|
||||
className="h-auto w-full justify-start gap-3 rounded-lg px-3 py-2.5 text-left"
|
||||
data-testid="project-browser-create"
|
||||
onClick={() => setMode("create")}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Plus className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-medium text-foreground">
|
||||
{query.trim()
|
||||
? `Create “${query.trim()}”`
|
||||
: "Create a new project"}
|
||||
</span>
|
||||
<span className="block text-xs font-normal text-muted-foreground">
|
||||
Configure its repository and access options
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<div className="border-t border-border/60 pt-2">
|
||||
{visibleProjects.length > 0 ? (
|
||||
<div className="space-y-0.5">
|
||||
{visibleProjects.map((project) => (
|
||||
<Button
|
||||
className="h-auto w-full justify-start gap-3 rounded-lg px-3 py-2.5 text-left"
|
||||
data-testid={`project-browser-result-${project.dtag}`}
|
||||
key={project.id}
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
onSelectProject(project);
|
||||
}}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Folders className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-foreground">
|
||||
{project.name}
|
||||
</span>
|
||||
<span className="block truncate text-xs font-normal text-muted-foreground">
|
||||
{project.description ||
|
||||
`${project.repositories.length} ${
|
||||
project.repositories.length === 1
|
||||
? "repository"
|
||||
: "repositories"
|
||||
}`}
|
||||
</span>
|
||||
</span>
|
||||
{selectedProjectAddresses.has(project.projectAddress) ? (
|
||||
<span className="flex shrink-0 items-center gap-1 text-xs text-muted-foreground">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
Added
|
||||
</span>
|
||||
) : null}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 py-10 text-center">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
No projects found
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Try another search or create a new project.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ChooserDialogContent>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
resolveUserLabel,
|
||||
type UserProfileLookup,
|
||||
} from "@/features/profile/lib/identity";
|
||||
import type { Repository } from "@/features/projects/hooks";
|
||||
import { commitDiscussionQuery } from "@/features/projects/lib/discussionChannels";
|
||||
import { commitShareLink } from "@/features/projects/lib/projectShareLinks";
|
||||
import type { ProjectRepoCommit, ProjectRepoDiff } from "@/shared/api/types";
|
||||
import { DiscussedInChannels } from "./DiscussionChannels";
|
||||
import { CopyCommitHashButton } from "./ProjectCommitCopyButton";
|
||||
@@ -22,6 +24,7 @@ import { ProfileIdentityButton } from "./ProjectProfileIdentity";
|
||||
import { ProjectDiffFilesPanel } from "./ProjectPullRequestFilesChangedPanel";
|
||||
import { ProjectOriginReference } from "./ProjectOriginReference";
|
||||
import { ProjectRichContent } from "./ProjectRichContent";
|
||||
import { ShareLinkButton } from "./ShareLinkButton";
|
||||
|
||||
function commitDateLabel(timestamp: number) {
|
||||
return new Date(timestamp * 1_000).toLocaleString(undefined, {
|
||||
@@ -44,6 +47,7 @@ export function ProjectCommitDetailPanel({
|
||||
originAgentName,
|
||||
originChannelId,
|
||||
profiles,
|
||||
project,
|
||||
viewerGitIdentity,
|
||||
}: {
|
||||
commit: ProjectRepoCommit | null;
|
||||
@@ -56,6 +60,7 @@ export function ProjectCommitDetailPanel({
|
||||
originAgentName?: string | null;
|
||||
originChannelId?: string | null;
|
||||
profiles?: UserProfileLookup;
|
||||
project: Repository;
|
||||
viewerGitIdentity?: ViewerGitIdentity | null;
|
||||
}) {
|
||||
const matchedProfile = commit
|
||||
@@ -71,7 +76,13 @@ export function ProjectCommitDetailPanel({
|
||||
<div className={PROJECT_DETAIL_PANEL_CLASS} data-project-detail-panel>
|
||||
<header className="space-y-1 px-4 pb-1 pt-3">
|
||||
<h3 className="line-clamp-2 text-xl font-semibold text-foreground">
|
||||
{commit?.subject ?? shortHash}
|
||||
{commit?.subject ?? shortHash}{" "}
|
||||
<ShareLinkButton
|
||||
className="ml-1 inline-flex h-7 w-7 align-text-bottom"
|
||||
label="Copy commit link"
|
||||
link={commitShareLink(project, commit?.hash ?? commitHash)}
|
||||
testId="project-commit-copy-link"
|
||||
/>
|
||||
</h3>
|
||||
<p className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs font-medium text-muted-foreground">
|
||||
<ProfileIdentityButton
|
||||
|
||||
@@ -2,6 +2,7 @@ import * as React from "react";
|
||||
|
||||
import type { SearchHit } from "@/shared/api/searchTypes";
|
||||
import { ProjectConversationPanel } from "./ProjectConversationPanel";
|
||||
import { PROJECT_COLUMN_HEADER_BACKDROP_CLASS } from "./projectPanelStyles";
|
||||
|
||||
type ProjectConversationPanelContextValue = {
|
||||
openConversation: (hit: SearchHit) => void;
|
||||
@@ -34,19 +35,23 @@ export function ProjectConversationPanelController({
|
||||
canResetWidth,
|
||||
children,
|
||||
closeWhen,
|
||||
fallbackPanel,
|
||||
onOpenConversation,
|
||||
onResetWidth,
|
||||
onResizeStart,
|
||||
resetKey,
|
||||
sharedHeaderBackdrop,
|
||||
widthPx,
|
||||
}: {
|
||||
canResetWidth: boolean;
|
||||
children: React.ReactNode;
|
||||
closeWhen: boolean;
|
||||
fallbackPanel?: React.ReactNode;
|
||||
onOpenConversation: () => void;
|
||||
onResetWidth: () => void;
|
||||
onResizeStart: (event: React.PointerEvent<HTMLButtonElement>) => void;
|
||||
resetKey: string;
|
||||
sharedHeaderBackdrop?: boolean;
|
||||
widthPx: number;
|
||||
}) {
|
||||
const [hit, setHit] = React.useState<SearchHit | null>(null);
|
||||
@@ -65,7 +70,14 @@ export function ProjectConversationPanelController({
|
||||
);
|
||||
return (
|
||||
<ProjectConversationPanelProvider onOpenConversation={openConversation}>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-row overflow-hidden">
|
||||
<div className="relative flex min-h-0 min-w-0 flex-1 flex-row overflow-hidden">
|
||||
{sharedHeaderBackdrop ? (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={`pointer-events-none absolute inset-x-0 top-0 z-20 h-13 ${PROJECT_COLUMN_HEADER_BACKDROP_CLASS}`}
|
||||
data-testid="project-shared-header-backdrop"
|
||||
/>
|
||||
) : null}
|
||||
{children}
|
||||
{hit ? (
|
||||
<ProjectConversationPanel
|
||||
@@ -76,7 +88,9 @@ export function ProjectConversationPanelController({
|
||||
onResizeStart={onResizeStart}
|
||||
widthPx={widthPx}
|
||||
/>
|
||||
) : null}
|
||||
) : (
|
||||
fallbackPanel
|
||||
)}
|
||||
</div>
|
||||
</ProjectConversationPanelProvider>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { ChevronRight, FolderGit2 } from "lucide-react";
|
||||
import { ChevronRight, Folders } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
|
||||
import type { Project } from "@/features/projects/hooks";
|
||||
import { AppTopChromePortal } from "@/app/AppTopChromePortal";
|
||||
import type { Project, Repository } from "@/features/projects/hooks";
|
||||
import { projectShareLink } from "@/features/projects/lib/projectShareLinks";
|
||||
import { channelChrome, topChromeInset } from "@/shared/layout/chromeLayout";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import type { EntityLinkTab } from "@/shared/lib/entityLink";
|
||||
import { ShareLinkButton } from "./ShareLinkButton";
|
||||
|
||||
@@ -18,73 +17,73 @@ export function ProjectDetailChrome({
|
||||
actions,
|
||||
activeTabCrumb,
|
||||
activeWorkItemCrumb,
|
||||
chromeRef,
|
||||
onGoProjectHome,
|
||||
onGoProjects,
|
||||
project,
|
||||
repository,
|
||||
shareTab,
|
||||
}: {
|
||||
/** Repository-scoped controls, rendered left of the project-wide ones. */
|
||||
actions?: React.ReactNode;
|
||||
activeTabCrumb: string | null;
|
||||
activeWorkItemCrumb: ProjectDetailWorkItemCrumb | null;
|
||||
chromeRef: React.Ref<HTMLDivElement>;
|
||||
onGoProjectHome: () => void;
|
||||
onGoProjects: () => void;
|
||||
project: Project;
|
||||
/**
|
||||
* Workspace tab the copied link should open (`undefined` = overview), so
|
||||
* sharing from the PR or issue list lands recipients on that same list.
|
||||
*/
|
||||
repository: Repository;
|
||||
shareTab?: EntityLinkTab;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"pointer-events-none relative z-30 overflow-hidden rounded-tl-xl bg-background/80 backdrop-blur-md supports-backdrop-filter:bg-background/70 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-backdrop-filter:bg-background/55",
|
||||
channelChrome.negativeMargin,
|
||||
topChromeInset.divider,
|
||||
)}
|
||||
ref={chromeRef}
|
||||
>
|
||||
<AppTopChromePortal>
|
||||
<div
|
||||
className="pointer-events-auto flex min-h-[2.75rem] items-center justify-between gap-3 px-4 py-1.5"
|
||||
className="flex min-w-0 flex-1 items-center justify-between gap-3 pl-2"
|
||||
data-tauri-drag-region
|
||||
data-testid="project-detail-chrome"
|
||||
>
|
||||
<nav
|
||||
aria-label="Project breadcrumb"
|
||||
className="-ml-1 flex min-w-0 items-center gap-0.5 text-xs text-muted-foreground"
|
||||
className="absolute left-1/2 flex max-w-[50%] min-w-0 -translate-x-1/2 -translate-y-px items-center gap-0.5 text-xs text-sidebar-foreground/65"
|
||||
>
|
||||
<button
|
||||
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"
|
||||
className="flex shrink-0 items-center gap-1.5 rounded-md px-1 py-1 font-medium transition-colors hover:text-sidebar-accent-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={onGoProjects}
|
||||
type="button"
|
||||
>
|
||||
<FolderGit2 className="h-3.5 w-3.5" />
|
||||
<Folders className="h-3.5 w-3.5" />
|
||||
Projects
|
||||
</button>
|
||||
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground/60" />
|
||||
<ChevronRight className="h-3 w-3 shrink-0 opacity-60" />
|
||||
<button
|
||||
className="min-w-0 truncate rounded-md px-0.5 py-1 font-medium transition-colors hover:text-sidebar-accent-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
||||
data-testid="project-breadcrumb-project"
|
||||
onClick={onGoProjectHome}
|
||||
type="button"
|
||||
>
|
||||
{project.name}
|
||||
</button>
|
||||
<ChevronRight className="h-3 w-3 shrink-0 opacity-60" />
|
||||
{activeWorkItemCrumb ? (
|
||||
<>
|
||||
<button
|
||||
className="min-w-0 truncate rounded-md px-0.5 py-1 font-medium transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="min-w-0 truncate rounded-md px-0.5 py-1 font-medium transition-colors hover:text-sidebar-accent-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
||||
data-testid="project-breadcrumb-repository"
|
||||
onClick={onGoProjectHome}
|
||||
type="button"
|
||||
>
|
||||
{project.name}
|
||||
{repository.name}
|
||||
</button>
|
||||
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground/60" />
|
||||
<ChevronRight className="h-3 w-3 shrink-0 opacity-60" />
|
||||
<button
|
||||
className="shrink-0 rounded-md px-0.5 py-1 font-medium transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="shrink-0 rounded-md px-0.5 py-1 font-medium transition-colors hover:text-sidebar-accent-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={activeWorkItemCrumb.clear}
|
||||
type="button"
|
||||
>
|
||||
{activeWorkItemCrumb.category}
|
||||
</button>
|
||||
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground/60" />
|
||||
<ChevronRight className="h-3 w-3 shrink-0 opacity-60" />
|
||||
<span
|
||||
aria-current="page"
|
||||
className="min-w-0 truncate px-0.5 font-medium text-muted-foreground/60"
|
||||
className="min-w-0 truncate px-0.5 font-medium opacity-60"
|
||||
>
|
||||
{activeWorkItemCrumb.title}
|
||||
</span>
|
||||
@@ -92,16 +91,17 @@ export function ProjectDetailChrome({
|
||||
) : 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"
|
||||
className="min-w-0 truncate rounded-md px-0.5 py-1 font-medium transition-colors hover:text-sidebar-accent-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
||||
data-testid="project-breadcrumb-repository"
|
||||
onClick={onGoProjectHome}
|
||||
type="button"
|
||||
>
|
||||
{project.name}
|
||||
{repository.name}
|
||||
</button>
|
||||
<ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground/60" />
|
||||
<ChevronRight className="h-3 w-3 shrink-0 opacity-60" />
|
||||
<span
|
||||
aria-current="page"
|
||||
className="min-w-0 truncate px-0.5 font-medium text-muted-foreground/60"
|
||||
className="min-w-0 truncate px-0.5 font-medium opacity-60"
|
||||
>
|
||||
{activeTabCrumb}
|
||||
</span>
|
||||
@@ -109,21 +109,23 @@ export function ProjectDetailChrome({
|
||||
) : (
|
||||
<span
|
||||
aria-current="page"
|
||||
className="min-w-0 truncate px-0.5 font-medium text-muted-foreground/60"
|
||||
className="min-w-0 truncate px-0.5 font-medium opacity-60"
|
||||
data-testid="project-breadcrumb-repository"
|
||||
>
|
||||
{project.name}
|
||||
{repository.name}
|
||||
</span>
|
||||
)}
|
||||
</nav>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{actions}
|
||||
<div className="ml-auto flex shrink-0 items-center gap-0.5">
|
||||
<ShareLinkButton
|
||||
className="text-sidebar-foreground/65 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
label="Copy project link"
|
||||
link={projectShareLink(project, shareTab)}
|
||||
testId="project-detail-copy-link"
|
||||
/>
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppTopChromePortal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { Project, Repository } from "@/features/projects/hooks";
|
||||
import { ProjectRepositoryManagement } from "./ProjectRepositoryManagement";
|
||||
|
||||
/**
|
||||
* Repository-scoped controls for the project detail chrome. Extracted from
|
||||
* `ProjectDetailScreen` to keep the screen under the file-size ratchet.
|
||||
*/
|
||||
export function ProjectDetailChromeActions({
|
||||
identityPubkey,
|
||||
onRepositoryChange,
|
||||
project,
|
||||
projects,
|
||||
repository,
|
||||
}: {
|
||||
identityPubkey?: string;
|
||||
onRepositoryChange: (repositoryId: string) => void;
|
||||
project: Project;
|
||||
projects: Project[];
|
||||
repository: Repository;
|
||||
}) {
|
||||
return (
|
||||
<ProjectRepositoryManagement
|
||||
identityPubkey={identityPubkey}
|
||||
onChange={onRepositoryChange}
|
||||
project={project}
|
||||
projects={projects}
|
||||
repository={repository}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
contributorKey,
|
||||
profileForCommit,
|
||||
profileForContributor,
|
||||
type ProjectContributorActivityCounts,
|
||||
type ViewerGitIdentity,
|
||||
} from "@/features/projects/lib/projectContributorMatching";
|
||||
import type {
|
||||
@@ -16,57 +17,117 @@ import {
|
||||
resolveUserLabel,
|
||||
type UserProfileLookup,
|
||||
} from "@/features/profile/lib/identity";
|
||||
import { GitBranch } from "lucide-react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { CopyCommitHashButton } from "./ProjectCommitCopyButton";
|
||||
import {
|
||||
ProjectFeedRow,
|
||||
ProjectFeedRowCluster,
|
||||
ProjectFeedRowMonoCell,
|
||||
} from "./ProjectFeedRow";
|
||||
CircleDot,
|
||||
GitBranch,
|
||||
GitCommitHorizontal,
|
||||
GitPullRequest,
|
||||
} from "lucide-react";
|
||||
|
||||
import { CopyCommitHashButton } from "./ProjectCommitCopyButton";
|
||||
import {
|
||||
PROJECT_DETAIL_PANEL_CLASS,
|
||||
PROJECT_DETAIL_PANEL_MESSAGE_CLASS,
|
||||
} from "./projectPanelStyles";
|
||||
import { ProfileIdentityButton } from "./ProjectProfileIdentity";
|
||||
import { ProjectWorkItemRow } from "./ProjectWorkItemRow";
|
||||
|
||||
function pluralize(count: number, singular: string, plural = `${singular}s`) {
|
||||
return `${count} ${count === 1 ? singular : plural}`;
|
||||
}
|
||||
|
||||
export function ContributorsPanel({
|
||||
activityCounts,
|
||||
contributorPubkeys,
|
||||
contributorPubkeysByGitIdentity,
|
||||
profiles,
|
||||
repoContributors,
|
||||
}: {
|
||||
activityCounts: Record<string, ProjectContributorActivityCounts>;
|
||||
contributorPubkeys: string[];
|
||||
contributorPubkeysByGitIdentity: ReadonlyMap<string, string>;
|
||||
profiles?: UserProfileLookup;
|
||||
repoContributors: ProjectRepoContributor[];
|
||||
}) {
|
||||
const rows = repoContributors.map((contributor) => {
|
||||
const matchedProfile = profileForContributor(contributor, profiles);
|
||||
const gitRows = repoContributors.map((contributor) => {
|
||||
const signedPubkey = contributorPubkeysByGitIdentity.get(
|
||||
contributorKey(contributor),
|
||||
);
|
||||
const signedProfile = signedPubkey ? profiles?.[signedPubkey] : undefined;
|
||||
const heuristicProfile = signedPubkey
|
||||
? null
|
||||
: profileForContributor(contributor, profiles);
|
||||
const matchedPubkey = signedPubkey ?? heuristicProfile?.pubkey ?? null;
|
||||
const matchedProfile = signedProfile ?? heuristicProfile?.profile;
|
||||
const label = matchedProfile
|
||||
? resolveUserLabel({ pubkey: matchedProfile.pubkey, profiles })
|
||||
? resolveUserLabel({ pubkey: matchedPubkey ?? "", profiles })
|
||||
: contributor.name || contributor.email || "Unknown contributor";
|
||||
const signedCounts = matchedPubkey
|
||||
? activityCounts[matchedPubkey]
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
avatarUrl: matchedProfile?.profile.avatarUrl ?? null,
|
||||
avatarUrl: matchedProfile?.avatarUrl ?? null,
|
||||
commitCount: contributor.commitCount,
|
||||
id: `git:${contributorKey(contributor)}`,
|
||||
isAgent: matchedProfile?.profile.isAgent === true,
|
||||
isAgent: matchedProfile?.isAgent === true,
|
||||
label,
|
||||
lastCommitAt: contributor.lastCommitAt,
|
||||
pubkey: matchedProfile?.pubkey ?? null,
|
||||
// Profile matches come from unauthenticated git author strings, so
|
||||
// they are surfaced as unverified rather than as a confirmed identity.
|
||||
role: matchedProfile
|
||||
? `${
|
||||
matchedProfile.profile.nip05Handle ||
|
||||
contributor.email ||
|
||||
"Git contributor"
|
||||
} · unverified match`
|
||||
: contributor.email || "Git contributor",
|
||||
pubkey: matchedPubkey,
|
||||
profileLinked: matchedPubkey !== null,
|
||||
reviewCount: signedCounts?.reviews ?? null,
|
||||
role: signedPubkey
|
||||
? matchedProfile?.nip05Handle || contributor.email || "Buzz contributor"
|
||||
: heuristicProfile
|
||||
? `${
|
||||
heuristicProfile.profile.nip05Handle ||
|
||||
contributor.email ||
|
||||
"Git contributor"
|
||||
} · unverified match`
|
||||
: contributor.email || "Git contributor",
|
||||
taskCount: signedCounts?.tasks ?? null,
|
||||
};
|
||||
});
|
||||
const matchedPubkeys = new Set(
|
||||
gitRows
|
||||
.map((row) => row.pubkey)
|
||||
.filter((pubkey): pubkey is string => pubkey !== null),
|
||||
);
|
||||
const linkedRows = contributorPubkeys
|
||||
.filter((pubkey) => !matchedPubkeys.has(pubkey))
|
||||
.map((pubkey) => {
|
||||
const profile = profiles?.[pubkey];
|
||||
const isAgent = profile?.isAgent === true;
|
||||
const signedCounts = activityCounts[pubkey] ?? {
|
||||
commits: 0,
|
||||
reviews: 0,
|
||||
tasks: 0,
|
||||
};
|
||||
return {
|
||||
avatarUrl: profile?.avatarUrl ?? null,
|
||||
commitCount: signedCounts.commits,
|
||||
id: `buzz:${pubkey}`,
|
||||
isAgent,
|
||||
label: profile
|
||||
? resolveUserLabel({ profiles, pubkey })
|
||||
: `${pubkey.slice(0, 8)}…`,
|
||||
profileLinked: true,
|
||||
pubkey,
|
||||
reviewCount: signedCounts.reviews,
|
||||
role:
|
||||
profile?.nip05Handle ||
|
||||
(isAgent ? "Agent contributor" : "Buzz contributor"),
|
||||
taskCount: signedCounts.tasks,
|
||||
};
|
||||
});
|
||||
const rows = [
|
||||
...gitRows.filter((row) => row.profileLinked),
|
||||
...linkedRows,
|
||||
...gitRows.filter((row) => !row.profileLinked),
|
||||
].sort(
|
||||
(left, right) =>
|
||||
(right.commitCount ?? -1) - (left.commitCount ?? -1) ||
|
||||
left.label.localeCompare(right.label),
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return (
|
||||
@@ -81,46 +142,71 @@ export function ContributorsPanel({
|
||||
|
||||
return (
|
||||
<div className={PROJECT_DETAIL_PANEL_CLASS} data-project-detail-panel>
|
||||
{rows.map((row, index) => (
|
||||
{rows.map((row) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 items-center gap-3 p-3 transition-colors hover:bg-muted/35",
|
||||
index !== rows.length - 1 && "border-border/50 border-b",
|
||||
)}
|
||||
className="flex min-h-9 min-w-0 items-center gap-2 px-4 py-1.5 transition-colors hover:bg-muted/35"
|
||||
data-project-contributor-kind={row.isAgent ? "agent" : "human"}
|
||||
data-testid="project-contributor-row"
|
||||
key={row.id}
|
||||
>
|
||||
<ProfileIdentityButton
|
||||
avatarClassName="shrink-0"
|
||||
avatarSize="md"
|
||||
avatarSize="xs"
|
||||
avatarUrl={row.avatarUrl}
|
||||
isAgent={row.isAgent}
|
||||
label={row.label}
|
||||
pubkey={row.pubkey}
|
||||
showLabel={false}
|
||||
/>
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
<p className="truncate text-sm font-semibold leading-5 text-foreground">
|
||||
{row.label}
|
||||
</p>
|
||||
<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">{row.role}</span>
|
||||
<span className="rounded-full border border-border/60 px-1.5 py-0.5 text-2xs">
|
||||
{row.commitCount === null
|
||||
? "No git commits"
|
||||
: `${row.commitCount} commit${row.commitCount === 1 ? "" : "s"}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{row.lastCommitAt ? (
|
||||
<span
|
||||
className="hidden w-20 shrink-0 text-right text-xs text-muted-foreground sm:block"
|
||||
data-testid="project-contributor-row-date"
|
||||
title={new Date(row.lastCommitAt * 1_000).toLocaleString()}
|
||||
>
|
||||
{relativeTime(row.lastCommitAt)}
|
||||
</span>
|
||||
) : null}
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate text-sm font-medium text-foreground"
|
||||
title={row.label}
|
||||
>
|
||||
{row.label}
|
||||
</span>
|
||||
<span
|
||||
className="hidden min-w-0 flex-1 truncate text-xs text-muted-foreground md:block"
|
||||
data-testid="project-contributor-identity"
|
||||
title={row.role}
|
||||
>
|
||||
{row.role}
|
||||
</span>
|
||||
<span
|
||||
className="flex w-14 shrink-0 items-center justify-end gap-1 text-xs tabular-nums text-muted-foreground"
|
||||
data-testid="project-contributor-commit-count"
|
||||
title={
|
||||
row.commitCount === null
|
||||
? "No git commits"
|
||||
: pluralize(row.commitCount, "commit")
|
||||
}
|
||||
>
|
||||
<GitCommitHorizontal className="h-3.5 w-3.5" />
|
||||
{row.commitCount ?? 0}
|
||||
</span>
|
||||
<span
|
||||
className="flex w-14 shrink-0 items-center justify-end gap-1 text-xs tabular-nums text-muted-foreground"
|
||||
data-testid="project-contributor-review-count"
|
||||
title={
|
||||
row.reviewCount === null
|
||||
? "No linked reviews"
|
||||
: pluralize(row.reviewCount, "review")
|
||||
}
|
||||
>
|
||||
<GitPullRequest className="h-3.5 w-3.5" />
|
||||
{row.reviewCount ?? 0}
|
||||
</span>
|
||||
<span
|
||||
className="flex w-14 shrink-0 items-center justify-end gap-1 text-xs tabular-nums text-muted-foreground"
|
||||
data-testid="project-contributor-task-count"
|
||||
title={
|
||||
row.taskCount === null
|
||||
? "No linked tasks"
|
||||
: pluralize(row.taskCount, "task")
|
||||
}
|
||||
>
|
||||
<CircleDot className="h-3.5 w-3.5" />
|
||||
{row.taskCount ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -179,7 +265,7 @@ export function ActivityPanel({
|
||||
|
||||
return (
|
||||
<section className={PROJECT_DETAIL_PANEL_CLASS} data-project-detail-panel>
|
||||
<div className="divide-y divide-border/50">
|
||||
<div>
|
||||
{commits.map((commit) => {
|
||||
const matchedProfile = profileForCommit(
|
||||
commit,
|
||||
@@ -202,57 +288,56 @@ export function ActivityPanel({
|
||||
);
|
||||
|
||||
return (
|
||||
<ProjectFeedRow
|
||||
<ProjectWorkItemRow
|
||||
eventId={commit.hash}
|
||||
identifier={commit.shortHash}
|
||||
identifierClassName="font-mono"
|
||||
identifierTitle={`View commit ${commit.shortHash}`}
|
||||
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">
|
||||
<span className="font-medium text-foreground/80">
|
||||
{authorLabel}
|
||||
</span>{" "}
|
||||
committed
|
||||
metadata={
|
||||
branch ? (
|
||||
<span className="inline-flex min-w-0 items-center gap-1">
|
||||
<GitBranch className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{branch}</span>
|
||||
</span>
|
||||
{branch ? (
|
||||
<span className="inline-flex min-w-0 items-center gap-1 rounded-full border border-border/60 px-1.5 py-0.5 font-mono text-2xs">
|
||||
<GitBranch className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{branch}</span>
|
||||
</span>
|
||||
) : null}
|
||||
{matchingContributor?.commitCount ? (
|
||||
<span className="rounded-full border border-border/60 px-1.5 py-0.5 text-2xs">
|
||||
{pluralize(matchingContributor.commitCount, "commit")}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
onOpen={onSelectCommit ? () => onSelectCommit(commit) : undefined}
|
||||
statusIcon={
|
||||
<GitCommitHorizontal className="h-3.5 w-3.5 text-muted-foreground/70" />
|
||||
}
|
||||
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>
|
||||
<span
|
||||
className="hidden w-20 shrink-0 text-right text-xs text-muted-foreground sm:block"
|
||||
className="flex h-5 w-5 shrink-0 items-center justify-center"
|
||||
data-testid="project-commit-author"
|
||||
title={`Committed by ${authorLabel}${
|
||||
matchingContributor?.commitCount
|
||||
? ` · ${pluralize(
|
||||
matchingContributor.commitCount,
|
||||
"commit",
|
||||
)}`
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
<CopyCommitHashButton
|
||||
className="h-5 w-5 shrink-0 text-muted-foreground/70"
|
||||
hash={commit.hash}
|
||||
/>
|
||||
<span
|
||||
className="hidden w-20 shrink-0 whitespace-nowrap text-right text-xs text-muted-foreground/70 sm:block"
|
||||
data-testid="project-commit-row-date"
|
||||
title={new Date(commit.timestamp * 1_000).toLocaleString()}
|
||||
>
|
||||
|
||||
@@ -54,14 +54,14 @@ export function ProjectDetailMetaRow({
|
||||
))}
|
||||
<span
|
||||
className={cn(
|
||||
"w-20 shrink-0 truncate whitespace-nowrap text-muted-foreground",
|
||||
"w-20 shrink-0 truncate whitespace-nowrap font-normal text-muted-foreground/75",
|
||||
labelClassName,
|
||||
)}
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex min-h-5 min-w-0 flex-1 items-center text-foreground">
|
||||
<div className="flex min-h-5 min-w-0 flex-1 items-center font-medium text-foreground">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
36
desktop/src/features/projects/ui/ProjectDetailRightPanel.tsx
Normal file
36
desktop/src/features/projects/ui/ProjectDetailRightPanel.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import type * as React from "react";
|
||||
|
||||
import type { ProjectDetailAgentContext } from "@/features/projects/lib/projectDetailAgentContext";
|
||||
import { ProjectAgentChatPanel } from "./ProjectAgentChatPanel";
|
||||
import { ProjectRepositoryActionsPanel } from "./ProjectRepositoryActionsPanel";
|
||||
import type { ProjectRightPanelMode } from "./ProjectRightPanelControls";
|
||||
|
||||
type RepositoryPanelProps = React.ComponentProps<
|
||||
typeof ProjectRepositoryActionsPanel
|
||||
>;
|
||||
|
||||
export function ProjectDetailRightPanel({
|
||||
context,
|
||||
mode,
|
||||
sharedHeaderBackdrop,
|
||||
...repositoryProps
|
||||
}: RepositoryPanelProps & {
|
||||
context: ProjectDetailAgentContext;
|
||||
mode: ProjectRightPanelMode;
|
||||
sharedHeaderBackdrop?: boolean;
|
||||
}) {
|
||||
if (mode === "chat") {
|
||||
return (
|
||||
<ProjectAgentChatPanel
|
||||
canResetWidth={repositoryProps.canResetWidth}
|
||||
context={context}
|
||||
key={context.repoAddress}
|
||||
onResetWidth={repositoryProps.onResetWidth}
|
||||
onResizeStart={repositoryProps.onResizeStart}
|
||||
sharedHeaderBackdrop={sharedHeaderBackdrop}
|
||||
widthPx={repositoryProps.widthPx}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <ProjectRepositoryActionsPanel {...repositoryProps} />;
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
|
||||
import { useOpenDmMutation } from "@/features/channels/hooks";
|
||||
import { useTerminalContextOverride } from "@/app/TerminalContextOverrideContext";
|
||||
import {
|
||||
type Project,
|
||||
type Repository,
|
||||
@@ -28,22 +28,8 @@ import { useOptimisticProjectBranches } from "@/features/projects/useOptimisticP
|
||||
import { useProjectRepositoryRefSelection } from "@/features/projects/useProjectRepositoryRefSelection";
|
||||
import { useUpdateProjectPullRequestMutation } from "@/features/projects/pullRequestMutations";
|
||||
import { useCreateProjectIssueMutation } from "@/features/projects/issueMutations";
|
||||
import { useProfileQuery, useUsersBatchQuery } from "@/features/profile/hooks";
|
||||
import { mergeCurrentProfileIntoLookup } from "@/features/profile/lib/identity";
|
||||
import {
|
||||
type ProfilePanelTab,
|
||||
type ProfilePanelView,
|
||||
UserProfilePanel,
|
||||
} from "@/features/profile/ui/UserProfilePanel";
|
||||
import {
|
||||
profilePanelTabFromSearch,
|
||||
profilePanelViewFromSearch,
|
||||
} from "@/features/profile/ui/UserProfilePanelUtils";
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel";
|
||||
import { openProjectMergeRecoveryTerminal } from "@/shared/api/projectGit";
|
||||
import { useMainInsetRef } from "@/shared/layout/MainInsetContext";
|
||||
import { channelContentTopPaddingMeasurement } from "@/shared/layout/chromeLayout";
|
||||
import { useMeasuredCssVariable } from "@/shared/layout/useMeasuredCssVariable";
|
||||
import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext";
|
||||
import { useHistorySearchState } from "@/shared/hooks/useHistorySearchState";
|
||||
import { useThreadPanelWidth } from "@/shared/hooks/useThreadPanelWidth";
|
||||
@@ -51,8 +37,6 @@ import { Button } from "@/shared/ui/button";
|
||||
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
|
||||
import { useCommunities } from "@/features/communities/useCommunities";
|
||||
import { useProjectCommitDiffQuery } from "@/features/projects/useProjectCommitDiff";
|
||||
import { useGitIdentityQuery } from "@/features/projects/useGitIdentity";
|
||||
import type { ViewerGitIdentity } from "@/features/projects/lib/projectContributorMatching";
|
||||
import {
|
||||
projectBranchCreationReason,
|
||||
projectBranchManagementState,
|
||||
@@ -64,7 +48,17 @@ import {
|
||||
shareTabForWorkspaceTab,
|
||||
workspaceTabForShareTab,
|
||||
} from "@/features/projects/lib/projectShareLinks";
|
||||
import {
|
||||
buildProjectDetailAgentContext,
|
||||
type ProjectDetailAgentContext,
|
||||
} from "@/features/projects/lib/projectDetailAgentContext";
|
||||
import {
|
||||
projectRepoUnavailablePresentation,
|
||||
projectRepoUnavailableReason,
|
||||
refineRepoUnavailableReason,
|
||||
} from "@/features/projects/lib/projectRepoAvailability";
|
||||
import { selectProjectRepository } from "@/features/projects/projectModels";
|
||||
import { useMemberChannelIds } from "@/features/projects/useRepositoryAccess";
|
||||
import { KIND_REPO_ANNOUNCEMENT } from "@/shared/constants/kinds";
|
||||
import type { EntityLinkTab } from "@/shared/lib/entityLink";
|
||||
import { useProjectRepoPresentation } from "@/features/projects/useProjectRepoHost";
|
||||
@@ -78,15 +72,15 @@ import {
|
||||
import type { CreateIssueDialogInput } from "./CreateIssueDialog";
|
||||
import { ProjectBranchActionDialogs } from "./ProjectBranchActionDialogs";
|
||||
import { ProjectDetailChrome } from "./ProjectDetailChrome";
|
||||
import { ProjectDetailChromeActions } from "./ProjectDetailChromeActions";
|
||||
import { ProjectConversationPanelController } from "./ProjectConversationPanelContext";
|
||||
import { ProjectDetailRightPanel } from "./ProjectDetailRightPanel";
|
||||
import { ProjectRightPanelControls } from "./ProjectRightPanelControls";
|
||||
import { UnavailableProjectRepositories } from "./UnavailableProjectRepositories";
|
||||
import {
|
||||
PROJECT_TAB_CRUMB_LABELS,
|
||||
projectPeople,
|
||||
pushPullTitle,
|
||||
snapshotHasContent,
|
||||
} from "./projectDetailHelpers";
|
||||
import { buildProjectDetailCrumbs } from "./useProjectDetailCrumbs";
|
||||
import { useProjectDetailPeople } from "./useProjectDetailPeople";
|
||||
import { useProjectProfilePanel } from "./useProjectProfilePanel";
|
||||
import { useProjectRepositoryPanel } from "./useProjectRepositoryPanel";
|
||||
import { pushPullTitle, snapshotHasContent } from "./projectDetailHelpers";
|
||||
|
||||
type ProjectDetailScreenProps = {
|
||||
commitHash?: string;
|
||||
@@ -99,11 +93,6 @@ type ProjectDetailScreenProps = {
|
||||
tab?: EntityLinkTab;
|
||||
};
|
||||
|
||||
const PROJECT_DETAIL_PANEL_SEARCH_KEYS = [
|
||||
"profile",
|
||||
"profileTab",
|
||||
"profileView",
|
||||
] as const;
|
||||
const PROJECT_REPOSITORY_SEARCH_KEYS = [
|
||||
"repositoryId",
|
||||
"issueId",
|
||||
@@ -121,28 +110,15 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
|
||||
repositoryId,
|
||||
tab,
|
||||
} = props;
|
||||
const { goChannel, goProject, goProjects } = useAppNavigation();
|
||||
const { goProject, goProjects } = useAppNavigation();
|
||||
const { activeCommunity } = useCommunities();
|
||||
const mainInsetRef = useMainInsetRef();
|
||||
const projectDetailHeaderChromeRef = useMeasuredCssVariable({
|
||||
targetRef: mainInsetRef,
|
||||
resetKey: projectId,
|
||||
...channelContentTopPaddingMeasurement,
|
||||
});
|
||||
const projectQuery = useProjectQuery(projectId);
|
||||
const projectsQuery = useProjectsQuery();
|
||||
const project = projectQuery.data;
|
||||
// When the projectId is a canonical 30617:<owner>:<d> coordinate (emitted by
|
||||
// entity links in #4695), derive the repository selection directly from the
|
||||
// <owner>:<d> portion rather than falling back to the project's primary
|
||||
// repository. Repository.id is "<owner>:<dtag>", so stripping the kind+colon
|
||||
// prefix gives the exact repository id. This ensures a linked PR/issue on a
|
||||
// non-primary member opens from the correct repository instead of the primary.
|
||||
const routeRepositoryId: string | undefined = React.useMemo(() => {
|
||||
if (repositoryId) return repositoryId;
|
||||
const kindStr = `${String(KIND_REPO_ANNOUNCEMENT)}:`;
|
||||
if (!projectId.startsWith(kindStr)) return undefined;
|
||||
// projectId is "30617:<owner>:<dtag>" — strip "30617:" to get "<owner>:<dtag>"
|
||||
return projectId.slice(kindStr.length);
|
||||
}, [projectId, repositoryId]);
|
||||
const repository = selectProjectRepository(project, routeRepositoryId);
|
||||
@@ -193,19 +169,15 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
|
||||
() => setSelectedCommitHash(commitHash ?? null),
|
||||
[commitHash],
|
||||
);
|
||||
// Remounts WorkspaceTabs when breadcrumb navigation should open Overview.
|
||||
const [tabsResetKey, setTabsResetKey] = React.useState(0);
|
||||
// Local state lets breadcrumb and repository resets drop a share-link tab.
|
||||
const [requestedTab, setRequestedTab] = React.useState<
|
||||
EntityLinkTab | undefined
|
||||
>(tab);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: the transient request ID deliberately reapplies an unchanged share-link tab.
|
||||
React.useEffect(() => setRequestedTab(tab), [entityNavigationId, tab]);
|
||||
// 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, PR, and issue details are mutually exclusive views, so opening
|
||||
// one clears the others.
|
||||
const [filesContext, setFilesContext] =
|
||||
React.useState<ProjectDetailAgentContext["file"]>(null);
|
||||
const handleSelectedPullRequestIdChange = React.useCallback(
|
||||
(id: string | null) => {
|
||||
setSelectedPullRequestId(id);
|
||||
@@ -253,6 +225,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
|
||||
const [repoSource, setRepoSource] = React.useState<"remote" | "local">(
|
||||
"remote",
|
||||
);
|
||||
const repositoryPanel = useProjectRepositoryPanel();
|
||||
const repoSnapshotQuery = useProjectRepoSnapshotQuery(
|
||||
repository,
|
||||
activeBranch,
|
||||
@@ -260,6 +233,17 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
|
||||
activeTag,
|
||||
repoRemote.host.kind === "buzz",
|
||||
);
|
||||
const memberChannelIds = useMemberChannelIds();
|
||||
const remoteUnavailableReason =
|
||||
repoRemote.host.kind === "buzz" &&
|
||||
!repoSnapshotQuery.isLoading &&
|
||||
(!repoSnapshotQuery.data || repoSnapshotQuery.error)
|
||||
? refineRepoUnavailableReason({
|
||||
reason: projectRepoUnavailableReason(repoSnapshotQuery.error),
|
||||
repositoryChannelId: repository?.channelId,
|
||||
memberChannelIds,
|
||||
})
|
||||
: undefined;
|
||||
const repoDiffQuery = useProjectRepoDiffQuery(
|
||||
repository,
|
||||
activeBranch,
|
||||
@@ -384,16 +368,29 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
|
||||
]);
|
||||
const error = results.find((result) => result.error)?.error;
|
||||
if (error) {
|
||||
toast.error("Could not fetch repository.", {
|
||||
description:
|
||||
error instanceof Error ? error.message : "The Git fetch failed.",
|
||||
const reason = refineRepoUnavailableReason({
|
||||
reason: projectRepoUnavailableReason(error),
|
||||
repositoryChannelId: repository?.channelId,
|
||||
memberChannelIds,
|
||||
});
|
||||
const presentation = projectRepoUnavailablePresentation(reason);
|
||||
toast.error(presentation.title, {
|
||||
description: presentation.description,
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast.success("Remote state refreshed.");
|
||||
}, [repoSnapshotQuery, repoStateQuery, repoSyncStatusQuery]);
|
||||
// Compact branch + remote/local controls shared by the readme and Files
|
||||
// tab headers.
|
||||
}, [
|
||||
memberChannelIds,
|
||||
repoSnapshotQuery,
|
||||
repoStateQuery,
|
||||
repoSyncStatusQuery,
|
||||
repository?.channelId,
|
||||
]);
|
||||
const cloneBlockedByRemote =
|
||||
remoteUnavailableReason !== undefined &&
|
||||
remoteUnavailableReason !== "ref" &&
|
||||
remoteUnavailableReason !== "unknown";
|
||||
const filesSourceControls: RepoSourceHeaderControls = {
|
||||
branch: activeBranch ?? "",
|
||||
branchOptions: branchOptionsWithLocal,
|
||||
@@ -421,8 +418,16 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
|
||||
? "Local"
|
||||
: "Local missing",
|
||||
...repoRemote.controls,
|
||||
remoteUnavailableReason,
|
||||
onAskForAccess: () => {
|
||||
repositoryPanel.setMode("chat");
|
||||
repositoryPanel.expand();
|
||||
},
|
||||
onCloneLocal:
|
||||
!selectedTag && repository?.cloneUrls[0] && repoRemote.canCloneLocally
|
||||
!selectedTag &&
|
||||
!cloneBlockedByRemote &&
|
||||
repository?.cloneUrls[0] &&
|
||||
repoRemote.canCloneLocally
|
||||
? () => {
|
||||
void handleCloneRepo();
|
||||
}
|
||||
@@ -467,9 +472,6 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
|
||||
const projectPending = projectQuery.isPending;
|
||||
React.useEffect(() => {
|
||||
if (!repository) {
|
||||
// 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;
|
||||
setSelectedPullRequestId(null);
|
||||
setSelectedIssueId(null);
|
||||
@@ -490,90 +492,28 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
|
||||
return currentSource;
|
||||
});
|
||||
}, [hasLocalCheckout, hasRemoteSnapshot, selectedTag]);
|
||||
const peoplePubkeys = React.useMemo(() => {
|
||||
if (!repository) 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),
|
||||
...pullRequest.comments.map((comment) => comment.author),
|
||||
...pullRequest.reviewers,
|
||||
...pullRequest.approvals.map((approval) => approval.author),
|
||||
],
|
||||
);
|
||||
const issuePubkeys = (issuesQuery.data ?? []).flatMap((issue) => [
|
||||
issue.author,
|
||||
...issue.recipients,
|
||||
...issue.assignees,
|
||||
...issue.comments.map((comment) => comment.author),
|
||||
]);
|
||||
return [
|
||||
...new Set([
|
||||
...projectPeople(repository),
|
||||
...pullRequestPubkeys,
|
||||
...issuePubkeys,
|
||||
]),
|
||||
];
|
||||
}, [issuesQuery.data, pullRequestsQuery.data, repository]);
|
||||
const profilesQuery = useUsersBatchQuery(peoplePubkeys, {
|
||||
enabled: peoplePubkeys.length > 0,
|
||||
const {
|
||||
contributorActivityCounts,
|
||||
contributorPubkeys,
|
||||
identityPubkey,
|
||||
profiles,
|
||||
viewerGitIdentity,
|
||||
} = useProjectDetailPeople({
|
||||
issues: issuesQuery.data ?? [],
|
||||
pullRequests: pullRequestsQuery.data ?? [],
|
||||
repository,
|
||||
});
|
||||
const currentProfileQuery = useProfileQuery();
|
||||
const profiles = React.useMemo(
|
||||
() =>
|
||||
mergeCurrentProfileIntoLookup(
|
||||
profilesQuery.data?.profiles,
|
||||
currentProfileQuery.data,
|
||||
),
|
||||
[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,
|
||||
);
|
||||
const profilePanelPubkey = values.profile;
|
||||
const profilePanelTab = profilePanelTabFromSearch(values.profileTab);
|
||||
const profilePanelView = profilePanelViewFromSearch(values.profileView);
|
||||
const handleOpenProfilePanel = React.useCallback(
|
||||
(pubkey: string) =>
|
||||
applyPatch({ profile: pubkey, profileTab: null, profileView: null }),
|
||||
[applyPatch],
|
||||
);
|
||||
const handleCloseProfilePanel = React.useCallback(
|
||||
() => applyPatch({ profile: null, profileTab: null, profileView: null }),
|
||||
[applyPatch],
|
||||
);
|
||||
const handleProfilePanelViewChange = React.useCallback(
|
||||
(view: ProfilePanelView, options?: { replace?: boolean }) =>
|
||||
applyPatch({ profileView: view === "summary" ? null : view }, options),
|
||||
[applyPatch],
|
||||
);
|
||||
const handleProfilePanelTabChange = React.useCallback(
|
||||
(tab: ProfilePanelTab, options?: { replace?: boolean }) =>
|
||||
applyPatch({ profileTab: tab === "info" ? null : tab }, options),
|
||||
[applyPatch],
|
||||
);
|
||||
const {
|
||||
handleCloseProfilePanel,
|
||||
handleOpenDm,
|
||||
handleOpenProfilePanel,
|
||||
handleProfilePanelTabChange,
|
||||
handleProfilePanelViewChange,
|
||||
profilePanelPubkey,
|
||||
profilePanelTab,
|
||||
profilePanelView,
|
||||
} = useProjectProfilePanel();
|
||||
const threadPanelWidth = useThreadPanelWidth();
|
||||
const openDmMutation = useOpenDmMutation();
|
||||
const handleOpenDm = React.useCallback(
|
||||
async (pubkeys: string[]) => {
|
||||
const dm = await openDmMutation.mutateAsync({ pubkeys });
|
||||
await goChannel(dm.id);
|
||||
},
|
||||
[goChannel, openDmMutation],
|
||||
);
|
||||
const handlePushLocalRepo = React.useCallback(async () => {
|
||||
try {
|
||||
const result = await pushLocalRepoMutation.mutateAsync();
|
||||
@@ -612,9 +552,23 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
|
||||
toast.success(result.message);
|
||||
setRepoSource("local");
|
||||
} catch (error) {
|
||||
showProjectCloneErrorToast(error, repository?.cloneUrls[0]);
|
||||
const unavailableReason = refineRepoUnavailableReason({
|
||||
reason: projectRepoUnavailableReason(error),
|
||||
repositoryChannelId: repository?.channelId,
|
||||
memberChannelIds,
|
||||
});
|
||||
showProjectCloneErrorToast(
|
||||
error,
|
||||
repository?.cloneUrls[0],
|
||||
unavailableReason,
|
||||
);
|
||||
}
|
||||
}, [cloneRepoMutation, repository?.cloneUrls]);
|
||||
}, [
|
||||
cloneRepoMutation,
|
||||
memberChannelIds,
|
||||
repository?.channelId,
|
||||
repository?.cloneUrls,
|
||||
]);
|
||||
const handlePullRequestCreated = React.useCallback(
|
||||
async (
|
||||
createdProject: Project,
|
||||
@@ -723,6 +677,20 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
|
||||
},
|
||||
[activeCommunity?.reposDir, repository],
|
||||
);
|
||||
const projectTerminalContext = React.useMemo(() => {
|
||||
const channelId = repository?.channelId ?? project?.projectChannelId;
|
||||
if (!channelId) return null;
|
||||
return {
|
||||
channelId,
|
||||
channelName: repository?.name ?? project?.name ?? "Project",
|
||||
};
|
||||
}, [
|
||||
project?.name,
|
||||
project?.projectChannelId,
|
||||
repository?.channelId,
|
||||
repository?.name,
|
||||
]);
|
||||
useTerminalContextOverride(projectTerminalContext);
|
||||
|
||||
if (projectQuery.isLoading) {
|
||||
return <ViewLoadingFallback kind="projects" />;
|
||||
@@ -788,6 +756,13 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
|
||||
}
|
||||
|
||||
const repoContributors = repoSnapshotQuery.data?.contributors ?? [];
|
||||
const displayedRepositorySnapshot =
|
||||
repoSource === "local"
|
||||
? (localRepoSnapshotQuery.data?.snapshot ?? null)
|
||||
: repoSnapshotQuery.data;
|
||||
const displayedRepositoryContributors =
|
||||
displayedRepositorySnapshot?.contributors ?? repoContributors;
|
||||
const displayedRepositoryFiles = displayedRepositorySnapshot?.files ?? [];
|
||||
const selectedPullRequest =
|
||||
pullRequestsQuery.data?.find((item) => item.id === selectedPullRequestId) ??
|
||||
null;
|
||||
@@ -802,42 +777,44 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
|
||||
(commit) => commit.hash === selectedCommitHash,
|
||||
) ?? null)
|
||||
: null;
|
||||
|
||||
// The active work item drives the breadcrumb trail: Projects › project ›
|
||||
// sub-tab › title. `clear` steps back to the item's list tab. Categories
|
||||
// match the workspace tab labels.
|
||||
const activeWorkItemCrumb = selectedPullRequest
|
||||
? {
|
||||
category: "Review",
|
||||
title: selectedPullRequest.title,
|
||||
clear: () => setSelectedPullRequestId(null),
|
||||
}
|
||||
: selectedIssue
|
||||
? {
|
||||
category: "Tasks",
|
||||
title: selectedIssue.title,
|
||||
clear: () => setSelectedIssueId(null),
|
||||
}
|
||||
: selectedCommitHash
|
||||
? {
|
||||
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 activeTabCrumb = activeWorkItemCrumb
|
||||
? null
|
||||
: (PROJECT_TAB_CRUMB_LABELS[activeTab] ?? null);
|
||||
const handleGoToProjectHome = () => {
|
||||
setSelectedPullRequestId(null);
|
||||
setSelectedIssueId(null);
|
||||
setSelectedCommitHash(null);
|
||||
setRequestedTab(undefined);
|
||||
// Remount the workspace tabs so the project page opens on Overview
|
||||
// instead of whatever tab the work item left behind.
|
||||
setTabsResetKey((key) => key + 1);
|
||||
};
|
||||
const { activeTabCrumb, activeWorkItemCrumb, handleGoToProjectHome } =
|
||||
buildProjectDetailCrumbs({
|
||||
activeTab,
|
||||
commit: selectedCommit,
|
||||
issue: selectedIssue,
|
||||
pullRequest: selectedPullRequest,
|
||||
setRequestedTab,
|
||||
setSelectedCommitHash,
|
||||
setSelectedIssueId,
|
||||
setSelectedPullRequestId,
|
||||
setTabsResetKey,
|
||||
});
|
||||
const agentPageContext = buildProjectDetailAgentContext({
|
||||
activeTab,
|
||||
branch: activeBranch,
|
||||
file: filesContext,
|
||||
project,
|
||||
repository,
|
||||
source: repoSource,
|
||||
workItems: [selectedCommit, selectedIssue, selectedPullRequest],
|
||||
});
|
||||
const repositoryPanelAction = (
|
||||
<ProjectRightPanelControls
|
||||
collapsed={repositoryPanel.collapsed}
|
||||
mode={repositoryPanel.mode}
|
||||
onCollapse={repositoryPanel.collapse}
|
||||
onExpand={repositoryPanel.expand}
|
||||
onModeChange={repositoryPanel.setMode}
|
||||
terminalAvailable={projectTerminalContext !== null}
|
||||
/>
|
||||
);
|
||||
const sharedHeaderBackdrop =
|
||||
repositoryPanel.mode === "chat" &&
|
||||
!repositoryPanel.collapsed &&
|
||||
!profilePanelPubkey &&
|
||||
!selectedPullRequestId &&
|
||||
!selectedIssueId &&
|
||||
!selectedCommitHash;
|
||||
const handleRepositoryChange = (nextRepositoryId: string) => {
|
||||
applyRepositorySearch({
|
||||
repositoryId: nextRepositoryId,
|
||||
@@ -865,24 +842,52 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
|
||||
<ProjectConversationPanelController
|
||||
canResetWidth={threadPanelWidth.canReset}
|
||||
closeWhen={Boolean(profilePanelPubkey)}
|
||||
onOpenConversation={() =>
|
||||
applyPatch({ profile: null, profileTab: null, profileView: null })
|
||||
fallbackPanel={
|
||||
profilePanelPubkey || repositoryPanel.collapsed ? null : (
|
||||
<ProjectDetailRightPanel
|
||||
canResetWidth={threadPanelWidth.canReset}
|
||||
contributors={displayedRepositoryContributors}
|
||||
context={agentPageContext}
|
||||
files={displayedRepositoryFiles}
|
||||
identityPubkey={identityPubkey}
|
||||
mode={repositoryPanel.mode}
|
||||
onOpenTerminal={() => {
|
||||
void handleOpenTerminal();
|
||||
}}
|
||||
onRepositoryChange={handleRepositoryChange}
|
||||
onResetWidth={threadPanelWidth.onResetWidth}
|
||||
onResizeStart={threadPanelWidth.onResizeStart}
|
||||
profiles={profiles}
|
||||
pullRequests={pullRequestsQuery.data ?? []}
|
||||
project={project}
|
||||
projects={projectsQuery.data ?? []}
|
||||
repository={repository}
|
||||
sharedHeaderBackdrop={sharedHeaderBackdrop}
|
||||
snapshot={displayedRepositorySnapshot}
|
||||
sourceControls={filesSourceControls}
|
||||
terminalTitle={projectTerminalLabel(hasLocalCheckout)}
|
||||
widthPx={threadPanelWidth.widthPx}
|
||||
/>
|
||||
)
|
||||
}
|
||||
onOpenConversation={handleCloseProfilePanel}
|
||||
onResetWidth={threadPanelWidth.onResetWidth}
|
||||
onResizeStart={threadPanelWidth.onResizeStart}
|
||||
resetKey={`${repository.id}:${selectedPullRequestId ?? ""}:${selectedIssueId ?? ""}:${selectedCommitHash ?? ""}`}
|
||||
sharedHeaderBackdrop={sharedHeaderBackdrop}
|
||||
widthPx={threadPanelWidth.widthPx}
|
||||
>
|
||||
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden [container-type:inline-size]">
|
||||
<ProjectDetailChrome
|
||||
actions={repositoryPanelAction}
|
||||
activeTabCrumb={activeTabCrumb}
|
||||
activeWorkItemCrumb={activeWorkItemCrumb}
|
||||
chromeRef={projectDetailHeaderChromeRef}
|
||||
onGoProjectHome={handleGoToProjectHome}
|
||||
onGoProjects={() => {
|
||||
void goProjects();
|
||||
}}
|
||||
project={project}
|
||||
repository={repository}
|
||||
shareTab={
|
||||
activeWorkItemCrumb
|
||||
? undefined
|
||||
@@ -890,8 +895,11 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden overflow-y-auto px-4 pb-4">
|
||||
<div className="w-full space-y-3 pt-[calc(var(--buzz-channel-content-top-padding,5.75rem)_+_1px)]">
|
||||
<div
|
||||
className="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-hidden overflow-y-auto overscroll-y-none px-4 pb-4"
|
||||
data-testid="project-detail-scroll"
|
||||
>
|
||||
<div className="w-full space-y-3">
|
||||
<WorkspaceTabs
|
||||
key={`${project.id}:${repository.id}:${tabsResetKey}`}
|
||||
initialTab={
|
||||
@@ -903,6 +911,8 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
|
||||
commitDiff={commitDiffQuery.data}
|
||||
commitDiffError={commitDiffQuery.error}
|
||||
commitDiffLoading={commitDiffQuery.isLoading}
|
||||
contributorActivityCounts={contributorActivityCounts}
|
||||
contributorPubkeys={contributorPubkeys}
|
||||
createIssueAction={{
|
||||
onCreate: handleCreateIssue,
|
||||
pending: createIssueMutation.isPending,
|
||||
@@ -929,11 +939,8 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
|
||||
localSnapshotError={localRepoSnapshotQuery.error}
|
||||
localSnapshotLoading={localRepoSnapshotQuery.isLoading}
|
||||
onBranchChange={handleBranchChange}
|
||||
onFilesContextChange={setFilesContext}
|
||||
onOpenMergeRecoveryTerminal={handleOpenMergeRecoveryTerminal}
|
||||
onOpenTerminal={() => {
|
||||
void handleOpenTerminal();
|
||||
}}
|
||||
terminalTitle={projectTerminalLabel(hasLocalCheckout)}
|
||||
onSelectedCommitHashChange={handleSelectedCommitHashChange}
|
||||
onSelectedIssueIdChange={handleSelectedIssueIdChange}
|
||||
onSelectedPullRequestIdChange={
|
||||
@@ -942,15 +949,6 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
|
||||
onSelectedTabChange={setActiveTab}
|
||||
profiles={profiles}
|
||||
project={repository}
|
||||
repositoryControls={
|
||||
<ProjectDetailChromeActions
|
||||
identityPubkey={identityQuery.data?.pubkey}
|
||||
onRepositoryChange={handleRepositoryChange}
|
||||
project={project}
|
||||
projects={projectsQuery.data ?? []}
|
||||
repository={repository}
|
||||
/>
|
||||
}
|
||||
projectId={project.id}
|
||||
repoDiff={displayedRepoDiff}
|
||||
repoDiffError={displayedRepoDiffError}
|
||||
@@ -964,6 +962,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
|
||||
selectedCommitHash={selectedCommitHash}
|
||||
selectedIssueId={selectedIssueId}
|
||||
selectedPullRequestId={selectedPullRequestId}
|
||||
sharedHeaderBackdrop={sharedHeaderBackdrop}
|
||||
snapshot={repoSnapshotQuery.data}
|
||||
snapshotError={repoSnapshotQuery.error}
|
||||
snapshotLoading={repoSnapshotQuery.isLoading}
|
||||
@@ -976,7 +975,7 @@ export function ProjectDetailScreen(props: ProjectDetailScreenProps) {
|
||||
{profilePanelPubkey ? (
|
||||
<UserProfilePanel
|
||||
canResetWidth={threadPanelWidth.canReset}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
currentPubkey={identityPubkey}
|
||||
onClose={handleCloseProfilePanel}
|
||||
onOpenDm={handleOpenDm}
|
||||
onOpenProfile={handleOpenProfilePanel}
|
||||
|
||||
@@ -40,7 +40,7 @@ export function ProjectDetailSection({
|
||||
>
|
||||
<button
|
||||
aria-expanded={open}
|
||||
className="flex min-h-8 w-full min-w-0 items-center gap-2 px-4 py-1.5 text-left text-sm font-semibold leading-5 text-foreground transition-colors hover:bg-muted/20"
|
||||
className="flex min-h-9 w-full min-w-0 items-center gap-2 px-4 py-1.5 text-left text-base font-normal leading-5 text-foreground transition-colors hover:bg-muted/20"
|
||||
onClick={() => setOpen(!open)}
|
||||
type="button"
|
||||
>
|
||||
@@ -52,7 +52,7 @@ export function ProjectDetailSection({
|
||||
!open && "-rotate-90",
|
||||
)}
|
||||
/>
|
||||
{count != null ? (
|
||||
{open && count != null ? (
|
||||
<span className="shrink-0 font-medium text-muted-foreground">
|
||||
{count}
|
||||
</span>
|
||||
|
||||
@@ -25,7 +25,7 @@ export function ProjectFeedRow({
|
||||
}) {
|
||||
return (
|
||||
<article
|
||||
className="group/feed-item flex min-w-0 items-center justify-between gap-3 p-3 transition-colors hover:bg-muted/35"
|
||||
className="group/feed-item flex min-w-0 items-center justify-between gap-3 px-4 py-3 transition-colors hover:bg-muted/35"
|
||||
data-project-event-id={eventId}
|
||||
data-testid={testId}
|
||||
>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import {
|
||||
Circle,
|
||||
CircleCheck,
|
||||
CircleDashed,
|
||||
CircleDot,
|
||||
CircleX,
|
||||
MessageSquare,
|
||||
Tag,
|
||||
type LucideIcon,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
@@ -32,11 +35,6 @@ import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import type { ChannelMember } from "@/shared/api/types";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { IssueAssigneeFacepile, IssueAssigneesRow } from "./IssueAssigneesRow";
|
||||
import {
|
||||
ProjectFeedRow,
|
||||
ProjectFeedRowCluster,
|
||||
ProjectFeedRowMonoCell,
|
||||
} from "./ProjectFeedRow";
|
||||
import { DiscussedInChannels } from "./DiscussionChannels";
|
||||
import { ProjectIssueCommentTimeline } from "./ProjectIssueCommentTimeline";
|
||||
import { ProjectOriginReference } from "./ProjectOriginReference";
|
||||
@@ -49,23 +47,71 @@ import { ProjectDetailSection } from "./ProjectDetailSection";
|
||||
import { ProfileIdentityButton } from "./ProjectProfileIdentity";
|
||||
import { ProjectRichContent } from "./ProjectRichContent";
|
||||
import { ShareLinkButton } from "./ShareLinkButton";
|
||||
import {
|
||||
ProjectStatusProgressIcon,
|
||||
type ProjectStatusProgressState,
|
||||
} from "./ProjectStatusProgressIcon";
|
||||
import { ProjectWorkItemGroup } from "./ProjectWorkItemGroup";
|
||||
import { ProjectWorkItemRow } from "./ProjectWorkItemRow";
|
||||
|
||||
export function issueStatusClassName(status: ProjectIssue["status"]) {
|
||||
if (status === "Triage" || status === "In Progress") return "text-amber-500";
|
||||
if (status === "Backlog") return "text-muted-foreground";
|
||||
if (status === "In Review") return "text-green-500";
|
||||
if (status === "Done") return "text-purple-400";
|
||||
if (status === "Closed") return "text-destructive";
|
||||
return "text-green-500";
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
|
||||
function issueStatusVisual(status: ProjectIssue["status"]) {
|
||||
function issueStatusVisual(status: ProjectIssue["status"]): {
|
||||
className: string;
|
||||
icon: LucideIcon;
|
||||
progress: ProjectStatusProgressState;
|
||||
} {
|
||||
if (status === "Done") {
|
||||
return { className: "text-purple-400", icon: CircleCheck };
|
||||
return {
|
||||
className: "text-purple-400",
|
||||
icon: CircleCheck,
|
||||
progress: "completed",
|
||||
};
|
||||
}
|
||||
if (status === "Closed") {
|
||||
return { className: "text-destructive", icon: CircleX };
|
||||
return {
|
||||
className: "text-destructive",
|
||||
icon: CircleX,
|
||||
progress: "canceled",
|
||||
};
|
||||
}
|
||||
return { className: "text-green-500", icon: CircleDot };
|
||||
if (status === "Backlog") {
|
||||
return {
|
||||
className: issueStatusClassName(status),
|
||||
icon: Circle,
|
||||
progress: "queued",
|
||||
};
|
||||
}
|
||||
if (status === "Triage") {
|
||||
return {
|
||||
className: issueStatusClassName(status),
|
||||
icon: CircleDashed,
|
||||
progress: "queued",
|
||||
};
|
||||
}
|
||||
return {
|
||||
className: issueStatusClassName(status),
|
||||
icon: CircleDot,
|
||||
progress: status === "In Review" ? "review" : "started",
|
||||
};
|
||||
}
|
||||
|
||||
const ISSUE_STATUS_ORDER: readonly ProjectIssue["status"][] = [
|
||||
"In Review",
|
||||
"In Progress",
|
||||
"Triage",
|
||||
"Backlog",
|
||||
"Done",
|
||||
"Closed",
|
||||
];
|
||||
|
||||
function issueMembers(
|
||||
project: Project,
|
||||
issue: ProjectIssue,
|
||||
@@ -103,71 +149,89 @@ function IssueRow({
|
||||
const authorProfile = profiles?.[normalizePubkey(issue.author)];
|
||||
const authorLabel = resolveUserLabel({ profiles, pubkey: issue.author });
|
||||
const status = issueStatusVisual(issue.status);
|
||||
const labels = projectTaskUserLabels(issue.labels);
|
||||
|
||||
return (
|
||||
<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 text-foreground/80">
|
||||
<span className="font-medium">{authorLabel}</span> created this task
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>{projectTaskCategoryLabel(issue.category)}</span>
|
||||
<span>·</span>
|
||||
<span>{issue.status}</span>
|
||||
{labels.map((label) => (
|
||||
<span
|
||||
className="rounded-full border border-border/60 px-1.5 py-0.5 text-2xs"
|
||||
key={label}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
<ProjectWorkItemRow
|
||||
eventId={issue.id}
|
||||
identifier={`#${issue.id.slice(0, 8)}`}
|
||||
identifierTitle="View task"
|
||||
onOpen={onOpen}
|
||||
statusIcon={
|
||||
<status.icon className={`h-3.5 w-3.5 shrink-0 ${status.className}`} />
|
||||
<ProjectStatusProgressIcon
|
||||
aria-label={issue.status}
|
||||
className={`h-3.5 w-3.5 shrink-0 ${status.className}`}
|
||||
state={status.progress}
|
||||
/>
|
||||
}
|
||||
testId="project-issue-row"
|
||||
title={issue.title}
|
||||
trailing={
|
||||
<>
|
||||
<IssueAssigneeFacepile
|
||||
assignees={issue.assignees}
|
||||
profiles={profiles}
|
||||
/>
|
||||
{issue.comments.length > 0 ? (
|
||||
<span
|
||||
className="hidden w-24 shrink-0 truncate text-right text-xs text-muted-foreground md:block"
|
||||
data-testid="project-issue-row-category"
|
||||
>
|
||||
{projectTaskCategoryLabel(issue.category)}
|
||||
</span>
|
||||
<span className="flex w-20 shrink-0 items-center justify-end gap-1">
|
||||
<span
|
||||
className="flex h-5 w-5 shrink-0 items-center justify-center"
|
||||
data-testid="project-issue-creator"
|
||||
title={`Created by ${authorLabel}`}
|
||||
>
|
||||
<ProfileIdentityButton
|
||||
avatarClassName="shrink-0"
|
||||
avatarSize="xs"
|
||||
avatarUrl={authorProfile?.avatarUrl ?? null}
|
||||
isAgent={authorProfile?.isAgent === true}
|
||||
label={authorLabel}
|
||||
pubkey={issue.author}
|
||||
showLabel={false}
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className="flex min-w-5 shrink-0 justify-end text-muted-foreground/45"
|
||||
data-testid="project-issue-assignee-cell"
|
||||
>
|
||||
{issue.assignees.length > 0 ? (
|
||||
<IssueAssigneeFacepile
|
||||
assignees={issue.assignees}
|
||||
profiles={profiles}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="flex h-5 w-5 items-center justify-center rounded-full border border-border/70 bg-muted/35"
|
||||
data-testid="project-issue-assignee-placeholder"
|
||||
title="Unassigned"
|
||||
>
|
||||
<User aria-hidden="true" className="h-3 w-3" />
|
||||
<span className="sr-only">Unassigned</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex w-8 shrink-0 justify-end">
|
||||
<button
|
||||
aria-label={`View ${issue.comments.length} comments`}
|
||||
className="flex items-center gap-1 rounded-md text-xs text-muted-foreground hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label={
|
||||
issue.comments.length > 0
|
||||
? `View ${issue.comments.length} comments`
|
||||
: "View comments"
|
||||
}
|
||||
className={`flex items-center gap-1 rounded-md text-xs hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring ${
|
||||
issue.comments.length > 0
|
||||
? "text-muted-foreground"
|
||||
: "text-muted-foreground/45"
|
||||
}`}
|
||||
data-testid="project-issue-comments"
|
||||
onClick={onOpen}
|
||||
type="button"
|
||||
>
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
{issue.comments.length}
|
||||
</button>
|
||||
) : null}
|
||||
<ProjectFeedRowCluster>
|
||||
<ProjectFeedRowMonoCell
|
||||
label={`#${issue.id.slice(0, 8)}`}
|
||||
onClick={onOpen}
|
||||
title="View task"
|
||||
/>
|
||||
</ProjectFeedRowCluster>
|
||||
</span>
|
||||
<span
|
||||
className="hidden w-20 shrink-0 text-right text-xs text-muted-foreground sm:block"
|
||||
className="hidden w-20 shrink-0 text-right text-xs text-muted-foreground/70 sm:block"
|
||||
data-testid="project-issue-row-date"
|
||||
title={new Date(issue.createdAt * 1_000).toLocaleString()}
|
||||
>
|
||||
@@ -315,19 +379,22 @@ export function ProjectIssueDetail({
|
||||
key={issue.id}
|
||||
profiles={profiles}
|
||||
/>
|
||||
<div data-testid="project-issue-comment-composer">
|
||||
<ForumComposer
|
||||
className="border border-border/60 bg-background/45"
|
||||
disabled={commentMutation.isPending}
|
||||
isSending={commentMutation.isPending}
|
||||
members={members}
|
||||
onSubmit={handleCommentSubmit}
|
||||
placeholder="Add a comment…"
|
||||
profiles={profiles}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ProjectDetailSection>
|
||||
<div
|
||||
className="px-4 pb-4 pt-2"
|
||||
data-testid="project-issue-comment-composer"
|
||||
>
|
||||
<ForumComposer
|
||||
className="border border-border/60 bg-background/45"
|
||||
disabled={commentMutation.isPending}
|
||||
isSending={commentMutation.isPending}
|
||||
members={members}
|
||||
onSubmit={handleCommentSubmit}
|
||||
placeholder="Add a comment…"
|
||||
profiles={profiles}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -372,16 +439,38 @@ export function ProjectIssuesPanel({
|
||||
);
|
||||
}
|
||||
|
||||
const groups = ISSUE_STATUS_ORDER.map((status) => ({
|
||||
items: issues.filter((issue) => issue.status === status),
|
||||
status,
|
||||
})).filter((group) => group.items.length > 0);
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-border/50">
|
||||
{issues.map((issue) => (
|
||||
<IssueRow
|
||||
issue={issue}
|
||||
key={issue.id}
|
||||
onOpen={() => onSelectedIssueIdChange(issue.id)}
|
||||
profiles={profiles}
|
||||
/>
|
||||
))}
|
||||
<div>
|
||||
{groups.map(({ items, status }) => {
|
||||
const visual = issueStatusVisual(status);
|
||||
return (
|
||||
<ProjectWorkItemGroup
|
||||
count={items.length}
|
||||
icon={
|
||||
<ProjectStatusProgressIcon
|
||||
className={`h-4 w-4 ${visual.className}`}
|
||||
state={visual.progress}
|
||||
/>
|
||||
}
|
||||
key={status}
|
||||
label={status}
|
||||
>
|
||||
{items.map((issue) => (
|
||||
<IssueRow
|
||||
issue={issue}
|
||||
key={issue.id}
|
||||
onOpen={() => onSelectedIssueIdChange(issue.id)}
|
||||
profiles={profiles}
|
||||
/>
|
||||
))}
|
||||
</ProjectWorkItemGroup>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,53 +1,22 @@
|
||||
import {
|
||||
FileCode2,
|
||||
GitBranch,
|
||||
GitCommitHorizontal,
|
||||
GitPullRequest,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import type {
|
||||
ProjectPullRequest,
|
||||
ProjectRepoContributor,
|
||||
ProjectRepoFile,
|
||||
ProjectRepoSnapshot,
|
||||
Repository as Project,
|
||||
} from "@/features/projects/hooks";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import {
|
||||
LANGUAGE_DOT_CLASSES,
|
||||
languageForPath,
|
||||
topLanguagesFromCounts,
|
||||
} from "@/features/projects/lib/projectLanguages";
|
||||
import type { ProjectRepoFile } from "@/features/projects/hooks";
|
||||
import type { ProjectRepoUnavailableReason } from "@/features/projects/lib/projectRepoAvailability";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import {
|
||||
PROJECT_DETAIL_META_GRID_WIDE_CLASS,
|
||||
PROJECT_DETAIL_META_RAIL_WIDE_BORDER_CLASS,
|
||||
PROJECT_DETAIL_PANEL_CLASS,
|
||||
} from "./projectPanelStyles";
|
||||
import type * as React from "react";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { ReadmePanel } from "./ProjectReadmePanel";
|
||||
import type { RepoSourceHeaderControls } from "./ProjectRepositorySource";
|
||||
|
||||
type ProjectOverviewPanelProps = {
|
||||
/** `buzz-channel` binding of the repository, for access-restricted copy. */
|
||||
accessChannelId?: string | null;
|
||||
contributors: ProjectRepoContributor[];
|
||||
externalHost?: string;
|
||||
externalUrl?: string | null;
|
||||
files: ProjectRepoFile[];
|
||||
gitDataState: GitDataState;
|
||||
/** Hide the readme header rows when the workspace renders them itself. */
|
||||
hideReadmeHeader?: boolean;
|
||||
project: Project;
|
||||
onViewContributors: () => void;
|
||||
profiles?: UserProfileLookup;
|
||||
pullRequests: ProjectPullRequest[];
|
||||
ownerAvatarUrl?: string | null;
|
||||
ownerIsAgent?: boolean;
|
||||
ownerName?: string;
|
||||
readmeFile: ProjectRepoFile | null;
|
||||
snapshot: ProjectRepoSnapshot | null | undefined;
|
||||
/** Branch picker + remote/local toggle for the readme header. */
|
||||
sourceControls?: RepoSourceHeaderControls;
|
||||
unavailableReason?: ProjectRepoUnavailableReason;
|
||||
@@ -55,78 +24,6 @@ type ProjectOverviewPanelProps = {
|
||||
|
||||
export type GitDataState = "checking" | "available" | "empty" | "unavailable";
|
||||
|
||||
function topLanguages(files: ProjectRepoFile[]) {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const file of files) {
|
||||
const language = languageForPath(file.path);
|
||||
if (language) counts[language] = (counts[language] ?? 0) + 1;
|
||||
}
|
||||
return topLanguagesFromCounts(counts);
|
||||
}
|
||||
|
||||
function projectPeople(project: Project) {
|
||||
return [
|
||||
...new Set(
|
||||
[project.owner, ...project.contributors]
|
||||
.filter(Boolean)
|
||||
.map(normalizePubkey),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function PeopleAvatars({
|
||||
people,
|
||||
profiles,
|
||||
}: {
|
||||
people: string[];
|
||||
profiles?: UserProfileLookup;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{people.slice(0, 18).map((person) => {
|
||||
const profile = profiles?.[normalizePubkey(person)];
|
||||
const label =
|
||||
profile?.displayName?.trim() ||
|
||||
profile?.nip05Handle?.trim() ||
|
||||
person;
|
||||
return (
|
||||
<UserAvatar
|
||||
accent={profile?.isAgent === true}
|
||||
avatarUrl={profile?.avatarUrl ?? null}
|
||||
displayName={label}
|
||||
key={person}
|
||||
size="sm"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LanguageChips({
|
||||
languages,
|
||||
}: {
|
||||
languages: Array<[string, number]>;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{languages.map(([language], index) => (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-border/60 bg-muted/40 px-2 py-1 text-xs text-muted-foreground"
|
||||
key={language}
|
||||
>
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${
|
||||
LANGUAGE_DOT_CLASSES[index % LANGUAGE_DOT_CLASSES.length]
|
||||
}`}
|
||||
/>
|
||||
{language}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OverviewRailSection({
|
||||
children,
|
||||
title,
|
||||
@@ -150,135 +47,34 @@ export function OverviewRailSection({
|
||||
|
||||
export function ProjectOverviewPanel({
|
||||
accessChannelId,
|
||||
contributors,
|
||||
externalHost,
|
||||
externalUrl,
|
||||
files,
|
||||
gitDataState,
|
||||
hideReadmeHeader,
|
||||
onViewContributors,
|
||||
project,
|
||||
profiles,
|
||||
pullRequests,
|
||||
ownerAvatarUrl,
|
||||
ownerIsAgent,
|
||||
ownerName,
|
||||
readmeFile,
|
||||
snapshot,
|
||||
sourceControls,
|
||||
unavailableReason,
|
||||
}: ProjectOverviewPanelProps) {
|
||||
const languages = topLanguages(files);
|
||||
const people = projectPeople(project);
|
||||
const latestCommit = snapshot?.latestCommit ?? null;
|
||||
const gitDataAvailable = gitDataState === "available";
|
||||
const unavailableSplash = gitDataState === "unavailable";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid",
|
||||
PROJECT_DETAIL_PANEL_CLASS,
|
||||
!unavailableSplash && PROJECT_DETAIL_META_GRID_WIDE_CLASS,
|
||||
)}
|
||||
data-project-detail-panel
|
||||
>
|
||||
<div className="min-w-0">
|
||||
{/* ReadmePanel renders its own "no README" fallback while keeping
|
||||
the branch + source controls reachable. */}
|
||||
<ReadmePanel
|
||||
accessChannelId={accessChannelId}
|
||||
externalHost={externalHost}
|
||||
externalUrl={externalUrl}
|
||||
file={readmeFile}
|
||||
gitDataState={gitDataState}
|
||||
hideHeader={hideReadmeHeader}
|
||||
sourceControls={sourceControls}
|
||||
unavailableReason={unavailableReason}
|
||||
/>
|
||||
</div>
|
||||
{!unavailableSplash ? (
|
||||
<aside
|
||||
className={cn(
|
||||
"space-y-6 border-t border-border/60 p-4",
|
||||
PROJECT_DETAIL_META_RAIL_WIDE_BORDER_CLASS,
|
||||
)}
|
||||
>
|
||||
<OverviewRailSection title="People">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<PeopleAvatars people={people} profiles={profiles} />
|
||||
<button
|
||||
className="shrink-0 rounded-md text-xs font-medium text-primary hover:underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={onViewContributors}
|
||||
type="button"
|
||||
>
|
||||
View all
|
||||
</button>
|
||||
</div>
|
||||
</OverviewRailSection>
|
||||
<OverviewRailSection title="Top Languages">
|
||||
{languages.length > 0 ? (
|
||||
<LanguageChips languages={languages} />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No language data is available yet.
|
||||
</p>
|
||||
)}
|
||||
</OverviewRailSection>
|
||||
<OverviewRailSection title="Buzz Activity">
|
||||
<dl className="space-y-2 text-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<dt className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<GitPullRequest className="h-3.5 w-3.5" />
|
||||
Reviews
|
||||
</dt>
|
||||
<dd className="font-medium text-foreground">
|
||||
{pullRequests.length}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</OverviewRailSection>
|
||||
<OverviewRailSection title="Git">
|
||||
<dl className="space-y-2 text-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<dt className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<GitBranch className="h-3.5 w-3.5" />
|
||||
Branch
|
||||
</dt>
|
||||
<dd className="font-medium text-foreground">
|
||||
{project.defaultBranch}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<dt className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<GitCommitHorizontal className="h-3.5 w-3.5" />
|
||||
Latest
|
||||
</dt>
|
||||
<dd className="font-mono text-xs text-foreground">
|
||||
{gitDataAvailable && latestCommit
|
||||
? latestCommit.hash.slice(0, 7)
|
||||
: "—"}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<dt className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<FileCode2 className="h-3.5 w-3.5" />
|
||||
Files
|
||||
</dt>
|
||||
<dd className="font-medium text-foreground">
|
||||
{gitDataAvailable ? files.length : "—"}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<dt className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
Contributors
|
||||
</dt>
|
||||
<dd className="font-medium text-foreground">
|
||||
{gitDataAvailable ? contributors.length : "—"}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</OverviewRailSection>
|
||||
</aside>
|
||||
) : null}
|
||||
<div className="min-w-0">
|
||||
{/* ReadmePanel renders its own "no README" fallback while keeping
|
||||
repository recovery actions reachable. */}
|
||||
<ReadmePanel
|
||||
accessChannelId={accessChannelId}
|
||||
externalHost={externalHost}
|
||||
externalUrl={externalUrl}
|
||||
file={readmeFile}
|
||||
gitDataState={gitDataState}
|
||||
hideHeader={hideReadmeHeader}
|
||||
ownerAvatarUrl={ownerAvatarUrl}
|
||||
ownerIsAgent={ownerIsAgent}
|
||||
ownerName={ownerName}
|
||||
sourceControls={sourceControls}
|
||||
unavailableReason={unavailableReason}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
MessageSquare,
|
||||
TriangleAlert,
|
||||
UserPlus,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -50,6 +49,12 @@ import { ProjectRichContent } from "./ProjectRichContent";
|
||||
import { PullRequestMetaHeader } from "./PullRequestMetaRail";
|
||||
import { PullRequestReviewCard } from "./PullRequestReviewCard";
|
||||
import { ShareLinkButton } from "./ShareLinkButton";
|
||||
import {
|
||||
ProjectStatusProgressIcon,
|
||||
type ProjectStatusProgressState,
|
||||
} from "./ProjectStatusProgressIcon";
|
||||
import { ProjectWorkItemGroup } from "./ProjectWorkItemGroup";
|
||||
import { ProjectWorkItemRow } from "./ProjectWorkItemRow";
|
||||
|
||||
export { PullRequestMetaHeader } from "./PullRequestMetaRail";
|
||||
|
||||
@@ -73,6 +78,22 @@ function pullRequestStatusClassName(status: ProjectPullRequest["status"]) {
|
||||
return "text-green-500";
|
||||
}
|
||||
|
||||
const PULL_REQUEST_STATUS_ORDER: readonly ProjectPullRequest["status"][] = [
|
||||
"Open",
|
||||
"Draft",
|
||||
"Merged",
|
||||
"Closed",
|
||||
];
|
||||
|
||||
function pullRequestProgressState(
|
||||
status: ProjectPullRequest["status"],
|
||||
): ProjectStatusProgressState {
|
||||
if (status === "Draft") return "queued";
|
||||
if (status === "Merged") return "completed";
|
||||
if (status === "Closed") return "canceled";
|
||||
return "review";
|
||||
}
|
||||
|
||||
function pullRequestMembers(
|
||||
project: Project,
|
||||
pullRequest: ProjectPullRequest,
|
||||
@@ -240,17 +261,53 @@ function PullRequestRow({
|
||||
}) {
|
||||
const authorProfile = profileForPubkey(pullRequest.author, profiles);
|
||||
const authorLabel = labelForPubkey(pullRequest.author, profiles);
|
||||
const StatusIcon =
|
||||
pullRequest.status === "Closed" || pullRequest.status === "Draft"
|
||||
? X
|
||||
: Check;
|
||||
const statusClassName = pullRequestStatusClassName(pullRequest.status);
|
||||
|
||||
return (
|
||||
<ProjectFeedRow
|
||||
<ProjectWorkItemRow
|
||||
eventId={pullRequest.id}
|
||||
meta={
|
||||
identifier={`#${pullRequest.id.slice(0, 8)}`}
|
||||
identifierTitle="View review"
|
||||
metadata={
|
||||
pullRequest.branchName ? (
|
||||
<span className="inline-flex min-w-0 items-center gap-1">
|
||||
<GitBranch className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{pullRequest.branchName}</span>
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
onOpen={onOpen}
|
||||
statusIcon={
|
||||
<ProjectStatusProgressIcon
|
||||
aria-label={pullRequest.status}
|
||||
className={`h-3.5 w-3.5 shrink-0 ${statusClassName}`}
|
||||
state={pullRequestProgressState(pullRequest.status)}
|
||||
/>
|
||||
}
|
||||
testId="project-pull-request-row"
|
||||
title={pullRequest.title}
|
||||
trailing={
|
||||
<>
|
||||
<span className="flex w-8 shrink-0 justify-end">
|
||||
<button
|
||||
aria-label={
|
||||
pullRequest.comments.length > 0
|
||||
? `View ${pullRequest.comments.length} comments`
|
||||
: "View comments"
|
||||
}
|
||||
className={`flex items-center gap-1 rounded-md text-xs hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring ${
|
||||
pullRequest.comments.length > 0
|
||||
? "text-muted-foreground"
|
||||
: "text-muted-foreground/45"
|
||||
}`}
|
||||
data-testid="project-pull-request-comments"
|
||||
onClick={onOpen}
|
||||
type="button"
|
||||
>
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
{pullRequest.comments.length}
|
||||
</button>
|
||||
</span>
|
||||
<ProfileIdentityButton
|
||||
avatarClassName="shrink-0"
|
||||
avatarSize="xs"
|
||||
@@ -260,53 +317,8 @@ function PullRequestRow({
|
||||
pubkey={pullRequest.author}
|
||||
showLabel={false}
|
||||
/>
|
||||
<span className="truncate">
|
||||
<ProfileAuthorName pubkey={pullRequest.author}>
|
||||
{authorLabel}
|
||||
</ProfileAuthorName>{" "}
|
||||
created this review
|
||||
</span>
|
||||
{pullRequest.branchName ? (
|
||||
<span className="inline-flex min-w-0 items-center gap-1 rounded-full border border-border/60 px-1.5 py-0.5 font-mono text-2xs">
|
||||
<GitBranch className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">{pullRequest.branchName}</span>
|
||||
</span>
|
||||
) : null}
|
||||
<span
|
||||
className={`rounded-full border border-border/60 px-1.5 py-0.5 text-2xs font-medium ${statusClassName}`}
|
||||
>
|
||||
{pullRequest.status}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
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 ? (
|
||||
<button
|
||||
aria-label={`View ${pullRequest.comments.length} comments`}
|
||||
className="flex items-center gap-1 rounded-md text-xs text-muted-foreground hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={onOpen}
|
||||
type="button"
|
||||
>
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
{pullRequest.comments.length}
|
||||
</button>
|
||||
) : null}
|
||||
<ProjectFeedRowCluster>
|
||||
<ProjectFeedRowMonoCell
|
||||
label={`#${pullRequest.id.slice(0, 8)}`}
|
||||
onClick={onOpen}
|
||||
title="View review"
|
||||
/>
|
||||
</ProjectFeedRowCluster>
|
||||
<span
|
||||
className="hidden w-20 shrink-0 text-right text-xs text-muted-foreground sm:block"
|
||||
className="hidden w-20 shrink-0 text-right text-xs text-muted-foreground/70 sm:block"
|
||||
data-testid="project-pull-request-row-date"
|
||||
title={formatExactTimestamp(pullRequest.createdAt)}
|
||||
>
|
||||
@@ -794,23 +806,26 @@ export function ProjectPullRequestDetail({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div data-testid="project-pull-request-comment-composer">
|
||||
<ForumComposer
|
||||
className="border border-border/60 bg-background/45"
|
||||
disabled={commentMutation.isPending}
|
||||
isSending={commentMutation.isPending}
|
||||
members={members}
|
||||
onSecondarySubmit={
|
||||
canRequestChanges ? handleChangeRequestSubmit : undefined
|
||||
}
|
||||
onSubmit={handleCommentSubmit}
|
||||
placeholder="Add a comment…"
|
||||
profiles={profiles}
|
||||
secondarySubmitLabel="Request changes"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ProjectDetailSection>
|
||||
<div
|
||||
className="px-4 pb-4 pt-2"
|
||||
data-testid="project-pull-request-comment-composer"
|
||||
>
|
||||
<ForumComposer
|
||||
className="border border-border/60 bg-background/45"
|
||||
disabled={commentMutation.isPending}
|
||||
isSending={commentMutation.isPending}
|
||||
members={members}
|
||||
onSecondarySubmit={
|
||||
canRequestChanges ? handleChangeRequestSubmit : undefined
|
||||
}
|
||||
onSubmit={handleCommentSubmit}
|
||||
placeholder="Add a comment…"
|
||||
profiles={profiles}
|
||||
secondarySubmitLabel="Request changes"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -891,16 +906,37 @@ export function PullRequestsPanel({
|
||||
);
|
||||
}
|
||||
|
||||
const groups = PULL_REQUEST_STATUS_ORDER.map((status) => ({
|
||||
items: pullRequests.filter((pullRequest) => pullRequest.status === status),
|
||||
status,
|
||||
})).filter((group) => group.items.length > 0);
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-border/50">
|
||||
{pullRequests.map((pullRequest) => (
|
||||
<PullRequestRow
|
||||
key={pullRequest.id}
|
||||
onOpen={() => onSelectedPullRequestIdChange(pullRequest.id)}
|
||||
profiles={profiles}
|
||||
pullRequest={pullRequest}
|
||||
/>
|
||||
))}
|
||||
<div>
|
||||
{groups.map(({ items, status }) => {
|
||||
return (
|
||||
<ProjectWorkItemGroup
|
||||
count={items.length}
|
||||
icon={
|
||||
<ProjectStatusProgressIcon
|
||||
className={`h-4 w-4 ${pullRequestStatusClassName(status)}`}
|
||||
state={pullRequestProgressState(status)}
|
||||
/>
|
||||
}
|
||||
key={status}
|
||||
label={status}
|
||||
>
|
||||
{items.map((pullRequest) => (
|
||||
<PullRequestRow
|
||||
key={pullRequest.id}
|
||||
onOpen={() => onSelectedPullRequestIdChange(pullRequest.id)}
|
||||
profiles={profiles}
|
||||
pullRequest={pullRequest}
|
||||
/>
|
||||
))}
|
||||
</ProjectWorkItemGroup>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,11 @@
|
||||
import {
|
||||
BookOpen,
|
||||
CircleAlert,
|
||||
CloudOff,
|
||||
DownloadCloud,
|
||||
ExternalLink,
|
||||
GitBranch,
|
||||
Globe,
|
||||
Loader2,
|
||||
LockKeyhole,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
|
||||
import { useChannelsQuery } from "@/features/channels/hooks";
|
||||
import type { ProjectRepoFile } from "@/features/projects/hooks";
|
||||
import type { ProjectRepoUnavailableReason } from "@/features/projects/lib/projectRepoAvailability";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
@@ -29,6 +22,7 @@ import {
|
||||
RepositoryBranchDropdown,
|
||||
} from "./ProjectRepositorySource";
|
||||
import { GitHubMark } from "./GitHubMark";
|
||||
import { ProjectRepositoryUnavailableState } from "./ProjectRepositoryUnavailableState";
|
||||
|
||||
export function findReadmeFile(files: ProjectRepoFile[]) {
|
||||
const readmes = files.filter((file) =>
|
||||
@@ -92,48 +86,6 @@ function normalizeReadmeMarkdown(content: string) {
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Description for the access-restricted state. Links to the bound channel
|
||||
* when it is visible to the viewer (public channels appear in the channel
|
||||
* list even before joining); private channels fall back to generic copy.
|
||||
*/
|
||||
function AccessRestrictedDescription({
|
||||
accessChannelId,
|
||||
}: {
|
||||
accessChannelId: string;
|
||||
}) {
|
||||
const { goChannel } = useAppNavigation();
|
||||
const channelsQuery = useChannelsQuery();
|
||||
const channel = channelsQuery.data?.find(
|
||||
(candidate) => candidate.id === accessChannelId,
|
||||
);
|
||||
|
||||
if (!channel) {
|
||||
return (
|
||||
<>
|
||||
Repository access is granted through a channel you can’t see. Ask the
|
||||
repository owner for an invite.
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
Repository access is granted through{" "}
|
||||
<button
|
||||
aria-label={`Open repository access channel #${channel.name}`}
|
||||
className="font-medium text-foreground underline-offset-2 hover:underline"
|
||||
onClick={() => void goChannel(channel.id)}
|
||||
type="button"
|
||||
>
|
||||
#{channel.name}
|
||||
</button>
|
||||
, and you’re not a member. Join the channel or ask the repository owner
|
||||
for an invite.
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReadmePanel({
|
||||
accessChannelId,
|
||||
file,
|
||||
@@ -141,6 +93,9 @@ export function ReadmePanel({
|
||||
externalHost,
|
||||
externalUrl,
|
||||
hideHeader,
|
||||
ownerAvatarUrl,
|
||||
ownerIsAgent,
|
||||
ownerName,
|
||||
sourceControls,
|
||||
unavailableReason,
|
||||
}: {
|
||||
@@ -155,6 +110,9 @@ export function ReadmePanel({
|
||||
* controls and last-changed timestamp itself.
|
||||
*/
|
||||
hideHeader?: boolean;
|
||||
ownerAvatarUrl?: string | null;
|
||||
ownerIsAgent?: boolean;
|
||||
ownerName?: string;
|
||||
unavailableReason?: ProjectRepoUnavailableReason;
|
||||
/** Branch picker + remote/local toggle rendered in the panel header. */
|
||||
sourceControls?: RepoSourceHeaderControls;
|
||||
@@ -212,60 +170,22 @@ export function ReadmePanel({
|
||||
}
|
||||
|
||||
if (gitDataState === "unavailable") {
|
||||
const reason = unavailableReason ?? "unknown";
|
||||
const unavailableContent = {
|
||||
authentication: {
|
||||
description:
|
||||
"Buzz could not authenticate with this repository. Check your access and try again.",
|
||||
icon: LockKeyhole,
|
||||
title: "Repository access failed",
|
||||
},
|
||||
missing: {
|
||||
description:
|
||||
"The project announcement exists, but its git repository was not found on the Buzz relay.",
|
||||
icon: CircleAlert,
|
||||
title: "Repository not initialized",
|
||||
},
|
||||
access: {
|
||||
description:
|
||||
"Repository access is granted through its channel, and you’re not a member of the channel bound to this repository. Ask the repository owner for an invite.",
|
||||
icon: LockKeyhole,
|
||||
title: "Repository access restricted",
|
||||
},
|
||||
unbound: {
|
||||
description:
|
||||
"This repository has no access channel binding, so the relay cannot authorize anyone to read it. The repository owner can bind a channel from the Access menu.",
|
||||
icon: LockKeyhole,
|
||||
title: "No access channel bound",
|
||||
},
|
||||
network: {
|
||||
description:
|
||||
"The Buzz git service could not be reached. Check your connection and try again.",
|
||||
icon: CloudOff,
|
||||
title: "Couldn’t reach repository",
|
||||
},
|
||||
ref: {
|
||||
description:
|
||||
"The selected branch is advertised by the project but is missing from its git remote.",
|
||||
icon: GitBranch,
|
||||
title: "Branch unavailable",
|
||||
},
|
||||
unknown: {
|
||||
description:
|
||||
"Buzz could not load this repository. Try again or contact the project owner.",
|
||||
icon: CircleAlert,
|
||||
title: "Repository unavailable",
|
||||
},
|
||||
} satisfies Record<
|
||||
ProjectRepoUnavailableReason,
|
||||
{
|
||||
description: string;
|
||||
icon: typeof CircleAlert;
|
||||
title: string;
|
||||
}
|
||||
>;
|
||||
const unavailable = unavailableContent[reason];
|
||||
const UnavailableIcon = unavailable.icon;
|
||||
if (!externalHost) {
|
||||
return (
|
||||
<section className="overflow-hidden">
|
||||
<ProjectRepositoryUnavailableState
|
||||
accessChannelId={accessChannelId}
|
||||
onAskForAccess={sourceControls?.onAskForAccess}
|
||||
onRetry={sourceControls?.onFetch}
|
||||
ownerAvatarUrl={ownerAvatarUrl}
|
||||
ownerIsAgent={ownerIsAgent}
|
||||
ownerName={ownerName}
|
||||
reason={unavailableReason}
|
||||
retryPending={sourceControls?.fetchPending}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="overflow-hidden">
|
||||
@@ -273,25 +193,16 @@ export function ReadmePanel({
|
||||
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-xl border border-border/60 bg-muted/40 text-muted-foreground">
|
||||
{externalHost === "github.com" ? (
|
||||
<GitHubMark className="h-6 w-6" />
|
||||
) : externalHost ? (
|
||||
<Globe className="h-6 w-6" />
|
||||
) : (
|
||||
<UnavailableIcon className="h-6 w-6" />
|
||||
<Globe className="h-6 w-6" />
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
{externalHost
|
||||
? `Code hosted on ${externalHost}`
|
||||
: unavailable.title}
|
||||
Code hosted on {externalHost}
|
||||
</h3>
|
||||
<p className="mt-1 max-w-lg text-sm text-muted-foreground">
|
||||
{externalHost ? (
|
||||
"Clone this repository locally to explore its files, commits, and contributors in Buzz."
|
||||
) : reason === "access" && accessChannelId ? (
|
||||
<AccessRestrictedDescription accessChannelId={accessChannelId} />
|
||||
) : (
|
||||
unavailable.description
|
||||
)}
|
||||
Clone this repository locally to explore its files, commits, and
|
||||
contributors in Buzz.
|
||||
</p>
|
||||
{externalUrl ? (
|
||||
<a
|
||||
@@ -304,22 +215,7 @@ export function ReadmePanel({
|
||||
</a>
|
||||
) : null}
|
||||
<div className="mt-4 flex flex-wrap items-center justify-center gap-2">
|
||||
{!externalHost && sourceControls?.onFetch ? (
|
||||
<Button
|
||||
disabled={sourceControls.fetchPending}
|
||||
onClick={sourceControls.onFetch}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{sourceControls.fetchPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
{sourceControls.fetchPending ? "Retrying…" : "Retry"}
|
||||
</Button>
|
||||
) : null}
|
||||
{externalHost && sourceControls?.onCloneLocal ? (
|
||||
{sourceControls?.onCloneLocal ? (
|
||||
<Button
|
||||
disabled={sourceControls.clonePending}
|
||||
onClick={sourceControls.onCloneLocal}
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
import {
|
||||
DownloadCloud,
|
||||
ExternalLink,
|
||||
FileCode2,
|
||||
GitCommitHorizontal,
|
||||
GitPullRequest,
|
||||
Loader2,
|
||||
MessageCircle,
|
||||
RefreshCw,
|
||||
SquareTerminal,
|
||||
UploadCloud,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import type * as React from "react";
|
||||
|
||||
import type {
|
||||
Project,
|
||||
ProjectPullRequest,
|
||||
ProjectRepoContributor,
|
||||
ProjectRepoFile,
|
||||
ProjectRepoSnapshot,
|
||||
Repository,
|
||||
} from "@/features/projects/hooks";
|
||||
import {
|
||||
LANGUAGE_DOT_CLASSES,
|
||||
languageForPath,
|
||||
topLanguagesFromCounts,
|
||||
} from "@/features/projects/lib/projectLanguages";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import {
|
||||
RepoSourceDropdown,
|
||||
type RepoSourceHeaderControls,
|
||||
RepositoryBranchDropdown,
|
||||
} from "./ProjectRepositorySource";
|
||||
import { ProjectRepositoryManagement } from "./ProjectRepositoryManagement";
|
||||
|
||||
type ProjectRepositoryActionsPanelProps = {
|
||||
canResetWidth: boolean;
|
||||
contributors: ProjectRepoContributor[];
|
||||
files: ProjectRepoFile[];
|
||||
identityPubkey?: string;
|
||||
onOpenTerminal: () => void;
|
||||
onRepositoryChange: (repositoryId: string) => void;
|
||||
onResetWidth: () => void;
|
||||
onResizeStart: (event: React.PointerEvent<HTMLButtonElement>) => void;
|
||||
profiles?: UserProfileLookup;
|
||||
pullRequests: ProjectPullRequest[];
|
||||
project: Project;
|
||||
projects: Project[];
|
||||
repository: Repository;
|
||||
snapshot: ProjectRepoSnapshot | null | undefined;
|
||||
sourceControls: RepoSourceHeaderControls;
|
||||
terminalTitle?: string;
|
||||
widthPx: number;
|
||||
};
|
||||
|
||||
function topLanguages(files: ProjectRepoFile[]) {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const file of files) {
|
||||
const language = languageForPath(file.path);
|
||||
if (language) counts[language] = (counts[language] ?? 0) + 1;
|
||||
}
|
||||
return topLanguagesFromCounts(counts);
|
||||
}
|
||||
|
||||
function RepositoryPanelSection({
|
||||
children,
|
||||
title,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
title: string;
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-2.5">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{title}
|
||||
</h3>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RepositoryActionButton({
|
||||
children,
|
||||
disabled,
|
||||
onClick,
|
||||
title,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
className="h-8 justify-start gap-2 text-sm"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
size="sm"
|
||||
title={title}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectRepositoryActionsPanel({
|
||||
canResetWidth,
|
||||
contributors,
|
||||
files,
|
||||
identityPubkey,
|
||||
onOpenTerminal,
|
||||
onRepositoryChange,
|
||||
onResetWidth,
|
||||
onResizeStart,
|
||||
profiles,
|
||||
pullRequests,
|
||||
project,
|
||||
projects,
|
||||
repository,
|
||||
snapshot,
|
||||
sourceControls,
|
||||
terminalTitle,
|
||||
widthPx,
|
||||
}: ProjectRepositoryActionsPanelProps) {
|
||||
const people = [
|
||||
...new Set(
|
||||
[repository.owner, ...repository.contributors]
|
||||
.filter(Boolean)
|
||||
.map(normalizePubkey),
|
||||
),
|
||||
];
|
||||
const languages = topLanguages(files);
|
||||
const latestCommit = snapshot?.latestCommit ?? null;
|
||||
const cloneAction = sourceControls.localDisabled
|
||||
? sourceControls.onCloneLocal
|
||||
: undefined;
|
||||
const fetchAction =
|
||||
sourceControls.source === "local" ||
|
||||
sourceControls.remoteKind !== "external"
|
||||
? sourceControls.onFetch
|
||||
: undefined;
|
||||
const pullAction = sourceControls.canPull ? sourceControls.onPull : undefined;
|
||||
const pushAction = sourceControls.canPush ? sourceControls.onPush : undefined;
|
||||
|
||||
return (
|
||||
<RightAuxiliaryPane
|
||||
canResetWidth={canResetWidth}
|
||||
onResetWidth={onResetWidth}
|
||||
onResizeStart={onResizeStart}
|
||||
testId="project-repository-actions-panel"
|
||||
widthPx={widthPx}
|
||||
>
|
||||
<div className="min-h-0 flex-1 space-y-6 overflow-y-auto p-5">
|
||||
<RepositoryPanelSection title="Working copy">
|
||||
<div className="grid gap-2 [&_button]:w-full [&_button]:justify-between [&_button]:text-sm">
|
||||
<RepoSourceDropdown controls={sourceControls} />
|
||||
<RepositoryBranchDropdown
|
||||
branch={sourceControls.branch}
|
||||
branchOptions={sourceControls.branchOptions}
|
||||
createBranchDisabled={sourceControls.createBranchDisabled}
|
||||
createBranchTitle={sourceControls.createBranchTitle}
|
||||
deleteBranchDisabled={sourceControls.deleteBranchDisabled}
|
||||
deleteBranchTitle={sourceControls.deleteBranchTitle}
|
||||
onBranchChange={sourceControls.onBranchChange}
|
||||
onCreateBranch={sourceControls.onCreateBranch}
|
||||
onDeleteBranch={sourceControls.onDeleteBranch}
|
||||
onTagChange={sourceControls.onTagChange}
|
||||
selectedTag={sourceControls.selectedTag}
|
||||
tagOptions={sourceControls.tagOptions}
|
||||
/>
|
||||
</div>
|
||||
</RepositoryPanelSection>
|
||||
|
||||
<RepositoryPanelSection title="Actions">
|
||||
<div className="grid gap-2">
|
||||
{cloneAction ? (
|
||||
<RepositoryActionButton
|
||||
disabled={sourceControls.clonePending}
|
||||
onClick={cloneAction}
|
||||
>
|
||||
{sourceControls.clonePending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<DownloadCloud className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{sourceControls.clonePending ? "Cloning…" : "Clone"}
|
||||
</RepositoryActionButton>
|
||||
) : null}
|
||||
{sourceControls.remoteUnavailableReason === "access" &&
|
||||
sourceControls.onAskForAccess ? (
|
||||
<RepositoryActionButton
|
||||
onClick={sourceControls.onAskForAccess}
|
||||
title="Open project chat to ask for repository access"
|
||||
>
|
||||
<MessageCircle className="h-3.5 w-3.5" />
|
||||
Ask for access
|
||||
</RepositoryActionButton>
|
||||
) : null}
|
||||
{fetchAction ? (
|
||||
<RepositoryActionButton
|
||||
disabled={sourceControls.fetchPending}
|
||||
onClick={fetchAction}
|
||||
title={sourceControls.fetchTitle}
|
||||
>
|
||||
{sourceControls.fetchPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Fetch
|
||||
</RepositoryActionButton>
|
||||
) : null}
|
||||
{pullAction ? (
|
||||
<RepositoryActionButton
|
||||
disabled={sourceControls.pullDisabled}
|
||||
onClick={pullAction}
|
||||
title={sourceControls.pullTitle}
|
||||
>
|
||||
{sourceControls.pullPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<DownloadCloud className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Pull
|
||||
{sourceControls.behindCount
|
||||
? ` ${sourceControls.behindCount}`
|
||||
: ""}
|
||||
</RepositoryActionButton>
|
||||
) : null}
|
||||
{pushAction ? (
|
||||
<RepositoryActionButton
|
||||
disabled={sourceControls.pushDisabled}
|
||||
onClick={pushAction}
|
||||
title={sourceControls.pushTitle}
|
||||
>
|
||||
{sourceControls.pushPending ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<UploadCloud className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Push
|
||||
{sourceControls.aheadCount
|
||||
? ` ${sourceControls.aheadCount}`
|
||||
: ""}
|
||||
</RepositoryActionButton>
|
||||
) : null}
|
||||
<RepositoryActionButton
|
||||
onClick={onOpenTerminal}
|
||||
title={terminalTitle ?? "Open terminal"}
|
||||
>
|
||||
<SquareTerminal className="h-3.5 w-3.5" />
|
||||
Terminal
|
||||
</RepositoryActionButton>
|
||||
{sourceControls.externalUrl ? (
|
||||
<Button
|
||||
asChild
|
||||
className="h-8 justify-start gap-2 text-sm"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<a
|
||||
href={sourceControls.externalUrl}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
Open
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid gap-2 [&_button]:h-8 [&_button]:w-full [&_button]:justify-start [&_button]:text-sm">
|
||||
<ProjectRepositoryManagement
|
||||
identityPubkey={identityPubkey}
|
||||
onChange={onRepositoryChange}
|
||||
project={project}
|
||||
projects={projects}
|
||||
repository={repository}
|
||||
/>
|
||||
</div>
|
||||
</RepositoryPanelSection>
|
||||
|
||||
<RepositoryPanelSection title="People">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{people.slice(0, 18).map((person) => {
|
||||
const profile = profiles?.[person];
|
||||
const label =
|
||||
profile?.displayName?.trim() ||
|
||||
profile?.nip05Handle?.trim() ||
|
||||
person;
|
||||
return (
|
||||
<UserAvatar
|
||||
accent={profile?.isAgent === true}
|
||||
avatarUrl={profile?.avatarUrl ?? null}
|
||||
displayName={label}
|
||||
key={person}
|
||||
size="sm"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</RepositoryPanelSection>
|
||||
|
||||
<RepositoryPanelSection title="Top languages">
|
||||
{languages.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{languages.map(([language], index) => (
|
||||
<span
|
||||
className="inline-flex h-5 items-center gap-1.5 rounded-full border border-border/60 bg-muted/40 px-2 text-xs text-muted-foreground"
|
||||
key={language}
|
||||
>
|
||||
<span
|
||||
className={`h-2 w-2 rounded-full ${
|
||||
LANGUAGE_DOT_CLASSES[index % LANGUAGE_DOT_CLASSES.length]
|
||||
}`}
|
||||
/>
|
||||
{language}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No language data available.
|
||||
</p>
|
||||
)}
|
||||
</RepositoryPanelSection>
|
||||
|
||||
<RepositoryPanelSection title="Repository details">
|
||||
<dl className="space-y-2 text-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<dt className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<GitPullRequest className="h-3.5 w-3.5" />
|
||||
Reviews
|
||||
</dt>
|
||||
<dd className="font-medium text-foreground">
|
||||
{pullRequests.length}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<dt className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<GitCommitHorizontal className="h-3.5 w-3.5" />
|
||||
Latest
|
||||
</dt>
|
||||
<dd className="font-mono text-xs text-foreground">
|
||||
{latestCommit ? latestCommit.hash.slice(0, 7) : "—"}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<dt className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<FileCode2 className="h-3.5 w-3.5" />
|
||||
Files
|
||||
</dt>
|
||||
<dd
|
||||
className="font-medium text-foreground"
|
||||
data-testid="project-repository-file-count"
|
||||
>
|
||||
{snapshot ? files.length : "—"}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<dt className="flex items-center gap-1.5 text-muted-foreground">
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
Contributors
|
||||
</dt>
|
||||
<dd className="font-medium text-foreground">
|
||||
{snapshot ? contributors.length : "—"}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</RepositoryPanelSection>
|
||||
</div>
|
||||
</RightAuxiliaryPane>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { Check, ShieldCheck } from "lucide-react";
|
||||
import { Check, FolderPlus, Link, Plus, ShieldCheck } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useIsManagedAgent } from "@/features/agent-memory/hooks";
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { AddProjectRepositoryDialog } from "./AddProjectRepositoryDialog";
|
||||
import { AttachProjectRepositoryDialog } from "./AttachProjectRepositoryDialog";
|
||||
import { ProjectRepositoryPicker } from "./ProjectRepositoryPicker";
|
||||
|
||||
export function ProjectRepositoryManagement({
|
||||
identityPubkey,
|
||||
@@ -131,13 +130,39 @@ export function ProjectRepositoryManagement({
|
||||
project={project}
|
||||
repositories={attachCandidates}
|
||||
/>
|
||||
<ProjectRepositoryPicker
|
||||
onAttach={canEdit ? () => setAttachOpen(true) : undefined}
|
||||
onChange={onChange}
|
||||
onCreate={canEdit ? () => setCreateOpen(true) : undefined}
|
||||
project={project}
|
||||
repository={repository}
|
||||
/>
|
||||
{canEdit ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label="Add repository"
|
||||
className="h-7 shrink-0 gap-1.5 rounded-md"
|
||||
data-testid="add-project-repository"
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem
|
||||
data-testid="create-project-repository"
|
||||
onSelect={() => setCreateOpen(true)}
|
||||
>
|
||||
<FolderPlus className="h-4 w-4" />
|
||||
Create new repository
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
data-testid="attach-project-repository"
|
||||
onSelect={() => setAttachOpen(true)}
|
||||
>
|
||||
<Link className="h-4 w-4" />
|
||||
Select existing repository
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
{canManageAccess ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
||||
@@ -555,7 +555,7 @@ function FileContentPanel({
|
||||
|
||||
return (
|
||||
<div className={PROJECT_DETAIL_PANEL_CLASS} data-project-detail-panel>
|
||||
<div className="flex min-h-14 items-center gap-1 border-border/50 border-b bg-muted/20 px-3 py-3">
|
||||
<div className="flex min-h-14 items-center gap-1 border-border/50 border-b bg-muted/20 px-4 py-3">
|
||||
<BreadcrumbButton onClick={() => onOpenPath("")}>
|
||||
Files
|
||||
</BreadcrumbButton>
|
||||
@@ -587,15 +587,15 @@ function FileContentPanel({
|
||||
Last changed {formatLastChangedAt(file.lastChangedAt)}
|
||||
</div>
|
||||
{file.previewContent ? (
|
||||
<pre className="max-h-[36rem] overflow-auto bg-background/60 p-4">
|
||||
<pre className="overflow-x-auto bg-background/60 p-4">
|
||||
{language ? (
|
||||
<SyntaxHighlightedCode
|
||||
className="text-xs leading-relaxed"
|
||||
className="whitespace-pre-wrap break-words text-xs leading-relaxed"
|
||||
code={file.previewContent}
|
||||
language={language}
|
||||
/>
|
||||
) : (
|
||||
<code className="block min-w-full whitespace-pre font-mono text-xs leading-relaxed text-foreground">
|
||||
<code className="block min-w-full whitespace-pre-wrap break-words font-mono text-xs leading-relaxed text-foreground">
|
||||
{file.previewContent}
|
||||
</code>
|
||||
)}
|
||||
@@ -617,6 +617,7 @@ export function RepositoryFilesPanel({
|
||||
error,
|
||||
profiles,
|
||||
fallbackAuthorPubkey,
|
||||
onContextChange,
|
||||
sourceControls,
|
||||
unavailableMessage,
|
||||
}: {
|
||||
@@ -626,6 +627,10 @@ export function RepositoryFilesPanel({
|
||||
error: unknown;
|
||||
profiles?: UserProfileLookup;
|
||||
fallbackAuthorPubkey?: string;
|
||||
onContextChange?: (context: {
|
||||
kind: "file" | "folder";
|
||||
path: string;
|
||||
}) => void;
|
||||
/** Branch picker + remote/local toggle rendered in the panel header. */
|
||||
sourceControls?: RepoSourceHeaderControls;
|
||||
unavailableMessage?: string;
|
||||
@@ -633,6 +638,12 @@ export function RepositoryFilesPanel({
|
||||
const [currentPath, setCurrentPath] = React.useState("");
|
||||
const [selectedFile, setSelectedFile] =
|
||||
React.useState<ProjectRepoFile | null>(null);
|
||||
React.useEffect(() => {
|
||||
onContextChange?.({
|
||||
kind: selectedFile ? "file" : "folder",
|
||||
path: selectedFile?.path ?? currentPath,
|
||||
});
|
||||
}, [currentPath, onContextChange, selectedFile]);
|
||||
const entries = React.useMemo(
|
||||
() => repositoryEntries(files, currentPath),
|
||||
[currentPath, files],
|
||||
@@ -705,7 +716,7 @@ export function RepositoryFilesPanel({
|
||||
}
|
||||
return (
|
||||
<div className={PROJECT_DETAIL_PANEL_CLASS} data-project-detail-panel>
|
||||
<div className="flex min-h-14 min-w-0 items-center gap-1 border-border/50 border-b px-3 py-3">
|
||||
<div className="flex min-h-14 min-w-0 items-center gap-1 border-border/50 border-b px-4 py-3">
|
||||
<RepoSourceDropdown controls={sourceControls} />
|
||||
<RepositoryBranchDropdown
|
||||
branch={sourceControls.branch}
|
||||
@@ -745,7 +756,7 @@ export function RepositoryFilesPanel({
|
||||
return (
|
||||
<div className={PROJECT_DETAIL_PANEL_CLASS} data-project-detail-panel>
|
||||
{sourceControls || pathSegments.length > 0 ? (
|
||||
<div className="flex min-h-14 min-w-0 items-center gap-1 border-border/50 border-b px-3 py-3">
|
||||
<div className="flex min-h-14 min-w-0 items-center gap-1 border-border/50 border-b px-4 py-3">
|
||||
{sourceControls ? (
|
||||
<>
|
||||
<RepoSourceDropdown controls={sourceControls} />
|
||||
@@ -871,7 +882,7 @@ export function RepositoryFilesPanel({
|
||||
}
|
||||
tabIndex={0}
|
||||
>
|
||||
<td className="min-w-52 p-3 align-middle">
|
||||
<td className="min-w-52 px-4 py-3 align-middle">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<RepositoryEntryIcon entry={entry} />
|
||||
<span className="truncate font-medium text-foreground">
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { DrawerPanelIcon } from "@/shared/ui/DrawerPanelIcon";
|
||||
|
||||
export function ProjectRepositoryPanelToggle({
|
||||
className,
|
||||
expanded = false,
|
||||
onClick,
|
||||
}: {
|
||||
className?: string;
|
||||
expanded?: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const action = expanded ? "Collapse" : "Expand";
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label={`${action} repository panel`}
|
||||
className={cn("h-7 w-7", className)}
|
||||
data-testid={`project-repository-actions-${expanded ? "collapse" : "expand"}`}
|
||||
onClick={onClick}
|
||||
size="icon"
|
||||
title={`${action} repository panel`}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<DrawerPanelIcon side={expanded ? "right" : "left"} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
FolderGit2,
|
||||
FolderPlus,
|
||||
Link,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
|
||||
import type { Project, Repository } from "@/features/projects/hooks";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { PROJECT_PICKER_TRIGGER_CLASS } from "./projectPanelStyles";
|
||||
|
||||
export function ProjectRepositoryPicker({
|
||||
onAttach,
|
||||
onChange,
|
||||
onCreate,
|
||||
project,
|
||||
repository,
|
||||
}: {
|
||||
onAttach?: () => void;
|
||||
onChange: (repositoryId: string) => void;
|
||||
onCreate?: () => void;
|
||||
project: Project;
|
||||
repository: Repository;
|
||||
}) {
|
||||
const repositoryLabel = (
|
||||
<>
|
||||
<FolderGit2 className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{repository.name}</span>
|
||||
</>
|
||||
);
|
||||
const unavailableRepositories = project.unavailableRepositoryAddresses ?? [];
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label="Select repository"
|
||||
className={cn("max-w-64", PROJECT_PICKER_TRIGGER_CLASS)}
|
||||
data-testid="project-repository-picker"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{repositoryLabel}
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="min-w-56">
|
||||
<DropdownMenuLabel>Repositories</DropdownMenuLabel>
|
||||
{project.repositories.map((candidate) => (
|
||||
<DropdownMenuItem
|
||||
className="justify-between gap-4"
|
||||
data-testid={`project-repository-${candidate.dtag}`}
|
||||
key={candidate.id}
|
||||
onSelect={() => onChange(candidate.id)}
|
||||
>
|
||||
<span className="min-w-0 truncate">{candidate.name}</span>
|
||||
{candidate.id === repository.id ? (
|
||||
<Check className="h-4 w-4 shrink-0" />
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{unavailableRepositories.map((address) => (
|
||||
<DropdownMenuItem
|
||||
className="justify-between gap-4"
|
||||
disabled
|
||||
key={address}
|
||||
>
|
||||
<span className="min-w-0 truncate">
|
||||
{address.slice(address.indexOf(":", 6) + 1)}
|
||||
</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
Unavailable
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{onCreate && onAttach ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger data-testid="add-project-repository">
|
||||
<Plus className="h-4 w-4" />
|
||||
Add repository
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem
|
||||
data-testid="create-project-repository"
|
||||
onSelect={onCreate}
|
||||
>
|
||||
<FolderPlus className="h-4 w-4" />
|
||||
Create new repository
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
data-testid="attach-project-repository"
|
||||
onSelect={onAttach}
|
||||
>
|
||||
<Link className="h-4 w-4" />
|
||||
Select existing repository
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import type { ProjectRepoUnavailableReason } from "@/features/projects/lib/projectRepoAvailability";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -65,9 +66,7 @@ export function RepositoryBranchDropdown({
|
||||
const RefIcon = selectedTag ? Tag : GitBranch;
|
||||
if (!branch) {
|
||||
return (
|
||||
<span className="truncate font-mono text-sm font-semibold text-foreground">
|
||||
—
|
||||
</span>
|
||||
<span className="truncate text-sm font-semibold text-foreground">—</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
@@ -80,7 +79,7 @@ export function RepositoryBranchDropdown({
|
||||
variant="outline"
|
||||
>
|
||||
<RefIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate font-mono">{selectedTag ?? branch}</span>
|
||||
<span className="truncate">{selectedTag ?? branch}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
@@ -99,7 +98,7 @@ export function RepositoryBranchDropdown({
|
||||
{selectableBranches.map((option) => (
|
||||
<DropdownMenuRadioItem key={option} value={`branch:${option}`}>
|
||||
<GitBranch className="mr-1.5 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="truncate font-mono">{option}</span>
|
||||
<span className="truncate">{option}</span>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
{tagOptions.length > 0 ? (
|
||||
@@ -112,7 +111,7 @@ export function RepositoryBranchDropdown({
|
||||
value={`tag:${option.name}`}
|
||||
>
|
||||
<Tag className="mr-1.5 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="truncate font-mono">{option.name}</span>
|
||||
<span className="truncate">{option.name}</span>
|
||||
<span className="ml-auto font-mono text-xs text-muted-foreground">
|
||||
{option.commit.slice(0, 7)}
|
||||
</span>
|
||||
@@ -181,7 +180,10 @@ export type RepoSourceHeaderControls = {
|
||||
localLabel: string;
|
||||
remoteLabel: string;
|
||||
remoteKind?: "buzz" | "external";
|
||||
remoteUnavailableReason?: ProjectRepoUnavailableReason;
|
||||
externalUrl?: string | null;
|
||||
/** Opens repository-scoped assistance when remote access is restricted. */
|
||||
onAskForAccess?: () => void;
|
||||
/** Clones the repository when no local checkout is available. */
|
||||
onCloneLocal?: () => void;
|
||||
clonePending?: boolean;
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import {
|
||||
CircleAlert,
|
||||
CloudOff,
|
||||
GitBranch,
|
||||
Loader2,
|
||||
LockKeyhole,
|
||||
MessageCircle,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
|
||||
import { useChannelsQuery } from "@/features/channels/hooks";
|
||||
import {
|
||||
type ProjectRepoUnavailableReason,
|
||||
projectRepoUnavailablePresentation,
|
||||
} from "@/features/projects/lib/projectRepoAvailability";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
|
||||
const UNAVAILABLE_ICONS = {
|
||||
authentication: LockKeyhole,
|
||||
missing: CircleAlert,
|
||||
access: LockKeyhole,
|
||||
unbound: LockKeyhole,
|
||||
network: CloudOff,
|
||||
ref: GitBranch,
|
||||
unknown: CircleAlert,
|
||||
} satisfies Record<ProjectRepoUnavailableReason, typeof CircleAlert>;
|
||||
|
||||
function RepositoryOwnerReference({
|
||||
ownerAvatarUrl,
|
||||
ownerIsAgent,
|
||||
ownerName,
|
||||
}: {
|
||||
ownerAvatarUrl?: string | null;
|
||||
ownerIsAgent?: boolean;
|
||||
ownerName?: string;
|
||||
}) {
|
||||
if (!ownerName) {
|
||||
return <>the repository owner</>;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<span className="inline-flex items-center gap-1 align-middle">
|
||||
<span aria-hidden="true">
|
||||
<UserAvatar
|
||||
accent={ownerIsAgent}
|
||||
avatarUrl={ownerAvatarUrl ?? null}
|
||||
displayName={ownerName}
|
||||
fallbackDelayMs={0}
|
||||
size="xs"
|
||||
testId="repository-owner-avatar"
|
||||
/>
|
||||
</span>
|
||||
<span
|
||||
className="font-medium text-foreground"
|
||||
data-testid="repository-owner-name"
|
||||
>
|
||||
{ownerName}
|
||||
</span>
|
||||
</span>
|
||||
, the repository owner,
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AccessRestrictedDescription({
|
||||
accessChannelId,
|
||||
ownerAvatarUrl,
|
||||
ownerIsAgent,
|
||||
ownerName,
|
||||
}: {
|
||||
accessChannelId?: string | null;
|
||||
ownerAvatarUrl?: string | null;
|
||||
ownerIsAgent?: boolean;
|
||||
ownerName?: string;
|
||||
}) {
|
||||
const { goChannel } = useAppNavigation();
|
||||
const channelsQuery = useChannelsQuery();
|
||||
const channel = channelsQuery.data?.find(
|
||||
(candidate) => candidate.id === accessChannelId,
|
||||
);
|
||||
|
||||
if (!channel) {
|
||||
return (
|
||||
<>
|
||||
Repository access is granted through a channel you can’t see. Ask{" "}
|
||||
<RepositoryOwnerReference
|
||||
ownerAvatarUrl={ownerAvatarUrl}
|
||||
ownerIsAgent={ownerIsAgent}
|
||||
ownerName={ownerName}
|
||||
/>{" "}
|
||||
for an invite.
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
Repository access is granted through{" "}
|
||||
<button
|
||||
aria-label={`Open repository access channel #${channel.name}`}
|
||||
className="font-medium text-foreground underline-offset-2 hover:underline"
|
||||
onClick={() => void goChannel(channel.id)}
|
||||
type="button"
|
||||
>
|
||||
#{channel.name}
|
||||
</button>
|
||||
, and you’re not a member. Join the channel or ask{" "}
|
||||
<RepositoryOwnerReference
|
||||
ownerAvatarUrl={ownerAvatarUrl}
|
||||
ownerIsAgent={ownerIsAgent}
|
||||
ownerName={ownerName}
|
||||
/>{" "}
|
||||
for an invite.
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectRepositoryUnavailableState({
|
||||
accessChannelId,
|
||||
onAskForAccess,
|
||||
onRetry,
|
||||
ownerAvatarUrl,
|
||||
ownerIsAgent,
|
||||
ownerName,
|
||||
reason = "unknown",
|
||||
retryPending = false,
|
||||
}: {
|
||||
accessChannelId?: string | null;
|
||||
onAskForAccess?: () => void;
|
||||
onRetry?: () => void;
|
||||
ownerAvatarUrl?: string | null;
|
||||
ownerIsAgent?: boolean;
|
||||
ownerName?: string;
|
||||
reason?: ProjectRepoUnavailableReason;
|
||||
retryPending?: boolean;
|
||||
}) {
|
||||
const unavailable = projectRepoUnavailablePresentation(reason);
|
||||
const UnavailableIcon = UNAVAILABLE_ICONS[reason];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-[calc(100dvh-7rem)] flex-col items-center justify-center p-8 text-center"
|
||||
data-testid="project-repository-unavailable"
|
||||
>
|
||||
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-xl border border-border/60 bg-muted/40 text-muted-foreground">
|
||||
<UnavailableIcon className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
{unavailable.title}
|
||||
</h3>
|
||||
<p className="mt-1 max-w-lg text-sm text-muted-foreground">
|
||||
{reason === "access" ? (
|
||||
<AccessRestrictedDescription
|
||||
accessChannelId={accessChannelId}
|
||||
ownerAvatarUrl={ownerAvatarUrl}
|
||||
ownerIsAgent={ownerIsAgent}
|
||||
ownerName={ownerName}
|
||||
/>
|
||||
) : (
|
||||
unavailable.description
|
||||
)}
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap items-center justify-center gap-2">
|
||||
{onRetry ? (
|
||||
<Button
|
||||
disabled={retryPending}
|
||||
onClick={onRetry}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{retryPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
{retryPending ? "Retrying…" : "Retry"}
|
||||
</Button>
|
||||
) : null}
|
||||
{reason === "access" && onAskForAccess ? (
|
||||
<Button onClick={onAskForAccess} size="sm">
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
Ask for access
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
desktop/src/features/projects/ui/ProjectRightPanelControls.tsx
Normal file
110
desktop/src/features/projects/ui/ProjectRightPanelControls.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Info, MessageCircle } from "lucide-react";
|
||||
|
||||
import {
|
||||
toggleTerminalPanel,
|
||||
useTerminalPanel,
|
||||
} from "@/features/terminal/terminalPanelStore";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import terminalIcon from "@/shared/ui/assets/terminal.svg";
|
||||
import { ProjectRepositoryPanelToggle } from "./ProjectRepositoryPanelToggle";
|
||||
|
||||
export type ProjectRightPanelMode = "chat" | "repository";
|
||||
|
||||
export function ProjectRightPanelControls({
|
||||
collapsed,
|
||||
mode,
|
||||
onCollapse,
|
||||
onExpand,
|
||||
onModeChange,
|
||||
terminalAvailable,
|
||||
}: {
|
||||
collapsed: boolean;
|
||||
mode: ProjectRightPanelMode;
|
||||
onCollapse: () => void;
|
||||
onExpand: () => void;
|
||||
onModeChange: (mode: ProjectRightPanelMode) => void;
|
||||
terminalAvailable: boolean;
|
||||
}) {
|
||||
const terminalPanel = useTerminalPanel();
|
||||
const terminalOpen = terminalPanel.mode !== "closed";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button
|
||||
aria-label="Show repository information"
|
||||
aria-pressed={!collapsed && mode === "repository"}
|
||||
className={cn(
|
||||
"h-7 w-7 text-sidebar-foreground/65 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
!collapsed &&
|
||||
mode === "repository" &&
|
||||
"bg-sidebar-accent text-sidebar-accent-foreground",
|
||||
)}
|
||||
data-testid="project-right-panel-repository-tab"
|
||||
onClick={() => {
|
||||
onModeChange("repository");
|
||||
onExpand();
|
||||
}}
|
||||
size="icon"
|
||||
title="Repository information"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Info className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Show project chat"
|
||||
aria-pressed={!collapsed && mode === "chat"}
|
||||
className={cn(
|
||||
"h-7 w-7 text-sidebar-foreground/65 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
!collapsed &&
|
||||
mode === "chat" &&
|
||||
"bg-sidebar-accent text-sidebar-accent-foreground",
|
||||
)}
|
||||
data-testid="project-right-panel-chat-tab"
|
||||
onClick={() => {
|
||||
onModeChange("chat");
|
||||
onExpand();
|
||||
}}
|
||||
size="icon"
|
||||
title="Project chat"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={terminalOpen ? "Hide Buzz Term" : "Open Buzz Term"}
|
||||
aria-pressed={terminalOpen}
|
||||
className={cn(
|
||||
"h-7 w-7 text-sidebar-foreground/65 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
terminalOpen && "bg-sidebar-accent text-sidebar-accent-foreground",
|
||||
)}
|
||||
data-testid="project-terminal-toggle"
|
||||
disabled={!terminalAvailable}
|
||||
onClick={toggleTerminalPanel}
|
||||
size="icon"
|
||||
title="Buzz Term (⌘J)"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="h-4 w-[1.1rem] bg-current [mask-position:center] [mask-repeat:no-repeat]"
|
||||
data-testid="project-terminal-icon"
|
||||
style={{
|
||||
maskImage: `url("${terminalIcon}")`,
|
||||
maskSize: "calc(100% - 2px) calc(100% - 2px)",
|
||||
WebkitMaskImage: `url("${terminalIcon}")`,
|
||||
WebkitMaskSize: "calc(100% - 2px) calc(100% - 2px)",
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
<ProjectRepositoryPanelToggle
|
||||
className="text-sidebar-foreground/65 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
||||
expanded={!collapsed}
|
||||
onClick={collapsed ? onExpand : onCollapse}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export function ProjectSectionHeader({
|
||||
}) {
|
||||
return (
|
||||
<header
|
||||
className="flex min-h-12 items-center gap-3 border-border/50 border-b px-4 py-2"
|
||||
className="flex min-h-12 items-center gap-3 px-4 py-2"
|
||||
data-testid="project-section-header"
|
||||
>
|
||||
<Icon
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
export type ProjectStatusProgressState =
|
||||
| "queued"
|
||||
| "started"
|
||||
| "review"
|
||||
| "completed"
|
||||
| "canceled";
|
||||
|
||||
/** Linear-style circular indicator showing a work item's workflow progress. */
|
||||
export function ProjectStatusProgressIcon({
|
||||
"aria-label": ariaLabel,
|
||||
className,
|
||||
state,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
state: ProjectStatusProgressState;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
aria-label={ariaLabel ?? `${state} status`}
|
||||
className={cn("shrink-0", className)}
|
||||
data-project-status-progress={state}
|
||||
fill="none"
|
||||
role="img"
|
||||
viewBox="0 0 16 16"
|
||||
{...props}
|
||||
>
|
||||
{state === "queued" ? (
|
||||
<circle
|
||||
cx="8"
|
||||
cy="8"
|
||||
r="6"
|
||||
stroke="currentColor"
|
||||
strokeDasharray="1.5 1.8"
|
||||
strokeLinecap="round"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
) : null}
|
||||
{state === "started" || state === "review" ? (
|
||||
<>
|
||||
<circle
|
||||
cx="8"
|
||||
cy="8"
|
||||
opacity="0.45"
|
||||
r="6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d={
|
||||
state === "started"
|
||||
? "M8 8V2A6 6 0 0 1 8 14Z"
|
||||
: "M8 8V2A6 6 0 1 1 2 8Z"
|
||||
}
|
||||
fill="currentColor"
|
||||
opacity="0.8"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
{state === "completed" || state === "canceled" ? (
|
||||
<>
|
||||
<circle className="fill-current" cx="8" cy="8" r="6.5" />
|
||||
<path
|
||||
className="stroke-background"
|
||||
d={
|
||||
state === "completed"
|
||||
? "m4.75 8.1 2 2 4.5-4.5"
|
||||
: "m5.5 5.5 5 5m0-5-5 5"
|
||||
}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
34
desktop/src/features/projects/ui/ProjectWorkItemGroup.tsx
Normal file
34
desktop/src/features/projects/ui/ProjectWorkItemGroup.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import type * as React from "react";
|
||||
|
||||
/** Groups related project work-item rows under a subtle status summary. */
|
||||
export function ProjectWorkItemGroup({
|
||||
children,
|
||||
count,
|
||||
icon,
|
||||
label,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
count: number;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className="pt-2 first:pt-0"
|
||||
data-project-work-item-status={label}
|
||||
data-testid="project-work-item-group"
|
||||
>
|
||||
<div
|
||||
className="mx-2 flex h-9 items-center gap-1.5 rounded-md bg-muted/40 px-2 text-xs text-muted-foreground"
|
||||
data-testid="project-work-item-group-header"
|
||||
>
|
||||
<span className="flex h-4 w-4 items-center justify-center opacity-80">
|
||||
{icon}
|
||||
</span>
|
||||
<span className="font-medium text-foreground/80">{label}</span>
|
||||
<span className="tabular-nums text-muted-foreground/65">{count}</span>
|
||||
</div>
|
||||
<div>{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
86
desktop/src/features/projects/ui/ProjectWorkItemRow.tsx
Normal file
86
desktop/src/features/projects/ui/ProjectWorkItemRow.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
export function ProjectWorkItemRow({
|
||||
eventId,
|
||||
identifier,
|
||||
identifierClassName,
|
||||
identifierTitle,
|
||||
metadata,
|
||||
onOpen,
|
||||
statusIcon,
|
||||
testId,
|
||||
title,
|
||||
trailing,
|
||||
}: {
|
||||
eventId: string;
|
||||
identifier: string;
|
||||
identifierClassName?: string;
|
||||
identifierTitle?: string;
|
||||
metadata?: React.ReactNode;
|
||||
onOpen?: () => void;
|
||||
statusIcon: React.ReactNode;
|
||||
testId: string;
|
||||
title: string;
|
||||
trailing?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<article
|
||||
className="group/work-item flex min-h-10 min-w-0 items-center gap-2 px-4 py-2 transition-colors hover:bg-muted/30"
|
||||
data-project-event-id={eventId}
|
||||
data-testid={testId}
|
||||
>
|
||||
<span className="flex h-4 w-4 shrink-0 items-center justify-center opacity-80">
|
||||
{statusIcon}
|
||||
</span>
|
||||
{onOpen ? (
|
||||
<button
|
||||
className={cn(
|
||||
"w-[4.75rem] shrink-0 truncate rounded-sm text-left text-xs font-medium tabular-nums text-muted-foreground/70 transition-colors hover:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring",
|
||||
identifierClassName,
|
||||
)}
|
||||
onClick={onOpen}
|
||||
title={identifierTitle}
|
||||
type="button"
|
||||
>
|
||||
{identifier}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
"w-[4.75rem] shrink-0 truncate text-xs font-medium tabular-nums text-muted-foreground/70",
|
||||
identifierClassName,
|
||||
)}
|
||||
>
|
||||
{identifier}
|
||||
</span>
|
||||
)}
|
||||
{onOpen ? (
|
||||
<button
|
||||
className="min-w-0 truncate rounded-sm text-left text-sm font-normal text-foreground transition-colors hover:text-primary focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={onOpen}
|
||||
title={title}
|
||||
type="button"
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
) : (
|
||||
<span className="min-w-0 truncate text-sm font-normal text-foreground">
|
||||
{title}
|
||||
</span>
|
||||
)}
|
||||
{metadata ? (
|
||||
<div className="hidden min-w-0 shrink items-center gap-1.5 overflow-hidden text-xs text-muted-foreground lg:flex">
|
||||
<span className="text-muted-foreground/45">›</span>
|
||||
{metadata}
|
||||
</div>
|
||||
) : null}
|
||||
{trailing ? (
|
||||
<div className="ml-auto flex shrink-0 items-center gap-2 pl-2">
|
||||
{trailing}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +1,29 @@
|
||||
import { BookOpen } from "lucide-react";
|
||||
import { Glasses } from "lucide-react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { TabsList, TabsTrigger } from "@/shared/ui/tabs";
|
||||
|
||||
const PROJECT_TAB_TRIGGER_CLASS =
|
||||
"relative h-full shrink-0 rounded-none px-2.5 text-sm leading-5 tracking-tight text-muted-foreground shadow-none after:absolute after:inset-x-2.5 after:bottom-0 after:h-0.5 after:bg-current after:opacity-0 after:transition-opacity after:content-[''] hover:bg-transparent hover:text-foreground hover:after:opacity-100 data-[state=active]:bg-transparent data-[state=active]:font-semibold data-[state=active]:text-foreground data-[state=active]:shadow-none data-[state=active]:after:opacity-100";
|
||||
"h-7 shrink-0 rounded-full bg-muted/30 px-3 text-xs font-medium leading-5 tracking-tight text-muted-foreground shadow-none transition-colors hover:bg-muted/55 hover:text-foreground data-[state=active]:bg-muted data-[state=active]:text-foreground data-[state=active]:shadow-none";
|
||||
|
||||
const PROJECT_TAB_SELECTED_CLASS =
|
||||
"font-semibold text-foreground after:opacity-100";
|
||||
const PROJECT_TAB_SELECTED_CLASS = "bg-muted text-foreground";
|
||||
const PROJECT_OVERVIEW_TAB_CLASS =
|
||||
"h-8 w-8 shrink-0 rounded-md p-2 text-muted-foreground shadow-none hover:bg-muted/50 hover:text-foreground data-[state=active]:bg-muted/50 data-[state=active]:text-foreground data-[state=active]:shadow-none";
|
||||
"h-7 w-7 shrink-0 rounded-full bg-muted/30 p-1.5 text-muted-foreground shadow-none transition-colors hover:bg-muted/55 hover:text-foreground data-[state=active]:bg-muted data-[state=active]:text-foreground data-[state=active]:shadow-none";
|
||||
|
||||
function ProjectTabLabel({ children }: { children: string }) {
|
||||
return (
|
||||
<span className="grid">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="invisible col-start-1 row-start-1 font-semibold"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
<span className="col-start-1 row-start-1">{children}</span>
|
||||
</span>
|
||||
);
|
||||
return <span>{children}</span>;
|
||||
}
|
||||
|
||||
export function ProjectTabsList({ prsActive }: { prsActive?: boolean }) {
|
||||
return (
|
||||
<TabsList className="h-full min-w-0 max-w-full flex-none justify-start gap-1 overflow-x-auto bg-transparent p-0 scrollbar-none">
|
||||
<TabsList className="h-full min-w-0 max-w-full flex-none justify-start gap-1.5 overflow-x-auto bg-transparent p-0 scrollbar-none">
|
||||
<TabsTrigger
|
||||
aria-label="Overview"
|
||||
className={PROJECT_OVERVIEW_TAB_CLASS}
|
||||
title="README"
|
||||
value="overview"
|
||||
>
|
||||
<BookOpen className="h-full w-full" strokeWidth={2} />
|
||||
<Glasses className="h-full w-full" strokeWidth={2} />
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className={PROJECT_TAB_TRIGGER_CLASS} value="files">
|
||||
<ProjectTabLabel>Files</ProjectTabLabel>
|
||||
@@ -58,9 +47,6 @@ export function ProjectTabsList({ prsActive }: { prsActive?: boolean }) {
|
||||
<TabsTrigger className={PROJECT_TAB_TRIGGER_CLASS} value="channels">
|
||||
<ProjectTabLabel>Channels</ProjectTabLabel>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className={PROJECT_TAB_TRIGGER_CLASS} value="channels">
|
||||
<ProjectTabLabel>Channels</ProjectTabLabel>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className={PROJECT_TAB_TRIGGER_CLASS} value="contributors">
|
||||
<ProjectTabLabel>Contributors</ProjectTabLabel>
|
||||
</TabsTrigger>
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
GitPullRequest,
|
||||
Hash,
|
||||
RefreshCw,
|
||||
SquareTerminal,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
@@ -23,27 +22,20 @@ import type {
|
||||
} from "@/features/projects/hooks";
|
||||
import {
|
||||
commitAuthorPubkeysFromPullRequests,
|
||||
gitContributorPubkeysFromCommits,
|
||||
type ProjectContributorActivityCounts,
|
||||
type ViewerGitIdentity,
|
||||
} from "@/features/projects/lib/projectContributorMatching";
|
||||
import { repositoryDiscussionQuery } from "@/features/projects/lib/discussionChannels";
|
||||
import type { ProjectRepoHost } from "@/features/projects/lib/projectRepoHost";
|
||||
import {
|
||||
projectRepoUnavailableReason,
|
||||
refineRepoUnavailableReason,
|
||||
} from "@/features/projects/lib/projectRepoAvailability";
|
||||
import { useMemberChannelIds } from "@/features/projects/useRepositoryAccess";
|
||||
import { projectRepoUnavailableReason } from "@/features/projects/lib/projectRepoAvailability";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Tabs, TabsContent } from "@/shared/ui/tabs";
|
||||
import { findReadmeFile } from "./ProjectReadmePanel";
|
||||
import { RepositoryFilesPanel } from "./ProjectRepositoryPanel";
|
||||
import {
|
||||
type RepoSourceHeaderControls,
|
||||
RepoSourceDropdown,
|
||||
RepoSyncActionButton,
|
||||
RepositoryBranchDropdown,
|
||||
} from "./ProjectRepositorySource";
|
||||
import type { RepoSourceHeaderControls } from "./ProjectRepositorySource";
|
||||
import { DiscussionChannelsPanel } from "./DiscussionChannels";
|
||||
import { ProjectCommitDetailPanel } from "./ProjectCommitDetailPanel";
|
||||
import { ActivityPanel, ContributorsPanel } from "./ProjectDetailFeedPanels";
|
||||
@@ -56,7 +48,9 @@ import {
|
||||
import { PullRequestsPanel } from "./ProjectPullRequestsPanel";
|
||||
import { ProjectTabsList } from "./ProjectWorkspaceTabList";
|
||||
import { ProjectPullRequestFilesChangedPanel } from "./ProjectPullRequestFilesChangedPanel";
|
||||
import { ProjectRepositoryUnavailableState } from "./ProjectRepositoryUnavailableState";
|
||||
import {
|
||||
PROJECT_COLUMN_HEADER_BACKDROP_CLASS,
|
||||
PROJECT_DETAIL_PANEL_CLASS,
|
||||
PROJECT_DETAIL_PANEL_MESSAGE_CLASS,
|
||||
} from "./projectPanelStyles";
|
||||
@@ -91,6 +85,8 @@ export function WorkspaceTabs({
|
||||
commitDiff,
|
||||
commitDiffError,
|
||||
commitDiffLoading,
|
||||
contributorActivityCounts,
|
||||
contributorPubkeys,
|
||||
createIssueAction,
|
||||
createPullRequestAction,
|
||||
updatePullRequestAction,
|
||||
@@ -107,16 +103,17 @@ export function WorkspaceTabs({
|
||||
selectedCommitHash,
|
||||
selectedIssueId,
|
||||
selectedPullRequestId,
|
||||
sharedHeaderBackdrop,
|
||||
pullRequests,
|
||||
pullRequestsError,
|
||||
pullRequestsLoading,
|
||||
onSelectedCommitHashChange,
|
||||
onFilesContextChange,
|
||||
onSelectedIssueIdChange,
|
||||
onSelectedPullRequestIdChange,
|
||||
onSelectedTabChange,
|
||||
onBranchChange,
|
||||
onOpenMergeRecoveryTerminal,
|
||||
onOpenTerminal,
|
||||
snapshot,
|
||||
snapshotError,
|
||||
snapshotLoading,
|
||||
@@ -124,14 +121,14 @@ export function WorkspaceTabs({
|
||||
repoContributors,
|
||||
repoSource,
|
||||
repoHost,
|
||||
repositoryControls,
|
||||
sourceControls,
|
||||
terminalTitle,
|
||||
viewerGitIdentity,
|
||||
}: {
|
||||
commitDiff: ProjectRepoDiff | null | undefined;
|
||||
commitDiffError: unknown;
|
||||
commitDiffLoading: boolean;
|
||||
contributorActivityCounts: Record<string, ProjectContributorActivityCounts>;
|
||||
contributorPubkeys: string[];
|
||||
createIssueAction: CreateIssueAction;
|
||||
createPullRequestAction?: CreatePullRequestAction;
|
||||
updatePullRequestAction?: UpdatePullRequestAction;
|
||||
@@ -150,17 +147,21 @@ export function WorkspaceTabs({
|
||||
selectedCommitHash: string | null;
|
||||
selectedIssueId: string | null;
|
||||
selectedPullRequestId: string | null;
|
||||
sharedHeaderBackdrop?: boolean;
|
||||
pullRequests: ProjectPullRequest[];
|
||||
pullRequestsError: unknown;
|
||||
pullRequestsLoading: boolean;
|
||||
onSelectedCommitHashChange: (hash: string | null) => void;
|
||||
onFilesContextChange?: (context: {
|
||||
kind: "file" | "folder";
|
||||
path: string;
|
||||
}) => 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;
|
||||
onOpenMergeRecoveryTerminal?: OpenMergeRecoveryTerminal;
|
||||
onOpenTerminal?: () => void;
|
||||
snapshot: ProjectRepoSnapshot | null | undefined;
|
||||
snapshotError: unknown;
|
||||
snapshotLoading: boolean;
|
||||
@@ -168,11 +169,8 @@ export function WorkspaceTabs({
|
||||
repoContributors: ProjectRepoContributor[];
|
||||
repoSource: "remote" | "local";
|
||||
repoHost: ProjectRepoHost;
|
||||
/** Repository picker (and related actions) for the selection header row. */
|
||||
repositoryControls?: React.ReactNode;
|
||||
/** Branch picker + remote/local toggle for the selection header row. */
|
||||
/** Branch and source state used by repository content and recovery actions. */
|
||||
sourceControls?: RepoSourceHeaderControls;
|
||||
terminalTitle?: string;
|
||||
viewerGitIdentity?: ViewerGitIdentity | null;
|
||||
}) {
|
||||
const localCheckoutSnapshot = localSnapshot?.snapshot ?? null;
|
||||
@@ -184,8 +182,21 @@ export function WorkspaceTabs({
|
||||
repoSource === "local" ? localSnapshotLoading : snapshotLoading;
|
||||
const displayedContributors =
|
||||
displayedSnapshot?.contributors ?? repoContributors;
|
||||
const contributorPubkeysByGitIdentity = React.useMemo(
|
||||
() =>
|
||||
gitContributorPubkeysFromCommits(
|
||||
displayedSnapshot?.commits ?? [],
|
||||
pullRequests,
|
||||
),
|
||||
[displayedSnapshot?.commits, pullRequests],
|
||||
);
|
||||
const files = displayedSnapshot?.files ?? [];
|
||||
const readmeFile = React.useMemo(() => findReadmeFile(files), [files]);
|
||||
const ownerProfile = profiles?.[normalizePubkey(project.owner)];
|
||||
const ownerName =
|
||||
ownerProfile?.displayName?.trim() ||
|
||||
ownerProfile?.nip05Handle?.trim() ||
|
||||
undefined;
|
||||
const externalHost =
|
||||
repoSource === "remote" && repoHost.kind === "external"
|
||||
? repoHost.host
|
||||
@@ -197,20 +208,28 @@ export function WorkspaceTabs({
|
||||
: files.length === 0
|
||||
? "empty"
|
||||
: "available";
|
||||
// The relay masks channel-ACL denials as 404 (anti-enumeration), so a
|
||||
// "missing" git result is re-classified with the repository's channel
|
||||
// binding and the viewer's memberships before it reaches the UI copy.
|
||||
const memberChannelIds = useMemberChannelIds();
|
||||
const unavailableReason =
|
||||
gitDataState === "unavailable" && !externalHost
|
||||
? refineRepoUnavailableReason({
|
||||
reason: projectRepoUnavailableReason(displayedSnapshotError),
|
||||
repositoryChannelId: project.channelId,
|
||||
memberChannelIds,
|
||||
})
|
||||
? repoSource === "remote"
|
||||
? (sourceControls?.remoteUnavailableReason ??
|
||||
projectRepoUnavailableReason(displayedSnapshotError))
|
||||
: displayedSnapshotError
|
||||
? projectRepoUnavailableReason(displayedSnapshotError)
|
||||
: undefined
|
||||
: undefined;
|
||||
const repositoryLoaded =
|
||||
gitDataState === "available" || gitDataState === "empty";
|
||||
const repositoryUnavailableState =
|
||||
unavailableReason && !externalHost ? (
|
||||
<ProjectRepositoryUnavailableState
|
||||
accessChannelId={project.channelId}
|
||||
onAskForAccess={sourceControls?.onAskForAccess}
|
||||
onRetry={sourceControls?.onFetch}
|
||||
ownerAvatarUrl={ownerProfile?.avatarUrl}
|
||||
ownerIsAgent={ownerProfile?.isAgent}
|
||||
ownerName={ownerName}
|
||||
reason={unavailableReason}
|
||||
retryPending={sourceControls?.fetchPending}
|
||||
/>
|
||||
) : null;
|
||||
const commitAuthorPubkeys = React.useMemo(
|
||||
() => commitAuthorPubkeysFromPullRequests(pullRequests),
|
||||
[pullRequests],
|
||||
@@ -304,12 +323,10 @@ export function WorkspaceTabs({
|
||||
},
|
||||
[selectedPullRequestId],
|
||||
);
|
||||
const showRepositorySelection =
|
||||
selectedTab === "overview" || selectedTab === "files";
|
||||
const sectionHeader =
|
||||
selectedTab === "overview" ? (
|
||||
selectedTab === "overview" && readmeFile?.previewContent ? (
|
||||
<ProjectSectionHeader icon={BookOpen} title="README" />
|
||||
) : selectedTab === "files" ? (
|
||||
) : selectedTab === "files" && files.length > 0 ? (
|
||||
<ProjectSectionHeader icon={FilesIcon} title="Files" />
|
||||
) : selectedTab === "activity" && !selectedCommitHash ? (
|
||||
<ProjectSectionHeader icon={GitCommitHorizontal} title="Commits" />
|
||||
@@ -348,24 +365,14 @@ export function WorkspaceTabs({
|
||||
onValueChange={handleTabChange}
|
||||
value={selectedTab}
|
||||
>
|
||||
{repositoryLoaded && !isDetailSelected ? (
|
||||
{!isDetailSelected ? (
|
||||
<div
|
||||
className="flex h-10 min-w-0 items-center gap-1"
|
||||
className={`sticky top-0 z-30 -mx-4 flex h-13 min-w-0 items-center gap-1 px-4 ${
|
||||
sharedHeaderBackdrop ? "" : PROJECT_COLUMN_HEADER_BACKDROP_CLASS
|
||||
}`}
|
||||
data-testid="project-workspace-tab-menu"
|
||||
>
|
||||
<ProjectTabsList prsActive={isPullRequestSelected} />
|
||||
{onOpenTerminal ? (
|
||||
<Button
|
||||
aria-label="Open terminal"
|
||||
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => onOpenTerminal()}
|
||||
size="icon"
|
||||
title={terminalTitle ?? "Open terminal"}
|
||||
variant="ghost"
|
||||
>
|
||||
<SquareTerminal className="h-[1.125rem] w-[1.125rem]" />
|
||||
</Button>
|
||||
) : null}
|
||||
{updatePullRequestAction ? (
|
||||
<Button
|
||||
className="h-8 shrink-0 gap-1.5"
|
||||
@@ -381,102 +388,65 @@ export function WorkspaceTabs({
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{/* The bordered workspace starts with repository / source / branch
|
||||
controls only for README and Files, then the active panel. Inner
|
||||
panels retain standalone chrome, neutralized here. */}
|
||||
{/* Project content follows the same borderless flow as work-item details.
|
||||
Inner panels retain standalone chrome, neutralized here. */}
|
||||
<div
|
||||
className={cn(
|
||||
PROJECT_DETAIL_PANEL_CLASS,
|
||||
"[&_[data-project-detail-panel]]:rounded-none [&_[data-project-detail-panel]]:border-0",
|
||||
)}
|
||||
data-project-detail-panel
|
||||
className="-mx-4 [&_[data-project-detail-panel]]:rounded-none [&_[data-project-detail-panel]]:border-0"
|
||||
data-testid="project-workspace-panel"
|
||||
>
|
||||
{showRepositorySelection && (repositoryControls || sourceControls) ? (
|
||||
<div
|
||||
className="flex min-h-12 min-w-0 flex-wrap items-center gap-1.5 border-border/50 border-b px-3 py-2"
|
||||
data-testid="project-repository-selection-row"
|
||||
>
|
||||
{repositoryControls}
|
||||
{sourceControls ? (
|
||||
<>
|
||||
<RepoSourceDropdown controls={sourceControls} />
|
||||
<RepositoryBranchDropdown
|
||||
branch={sourceControls.branch}
|
||||
branchOptions={sourceControls.branchOptions}
|
||||
createBranchDisabled={sourceControls.createBranchDisabled}
|
||||
createBranchTitle={sourceControls.createBranchTitle}
|
||||
deleteBranchDisabled={sourceControls.deleteBranchDisabled}
|
||||
deleteBranchTitle={sourceControls.deleteBranchTitle}
|
||||
onBranchChange={sourceControls.onBranchChange}
|
||||
onCreateBranch={sourceControls.onCreateBranch}
|
||||
onDeleteBranch={sourceControls.onDeleteBranch}
|
||||
onTagChange={sourceControls.onTagChange}
|
||||
selectedTag={sourceControls.selectedTag}
|
||||
tagOptions={sourceControls.tagOptions}
|
||||
/>
|
||||
<div className="ml-auto flex shrink-0 items-center">
|
||||
<RepoSyncActionButton controls={sourceControls} />
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{sectionHeader}
|
||||
|
||||
<TabsContent className="m-0" value="overview">
|
||||
<ProjectOverviewPanel
|
||||
accessChannelId={project.channelId}
|
||||
contributors={displayedContributors}
|
||||
externalHost={externalHost}
|
||||
externalUrl={externalHost ? sourceControls?.externalUrl : null}
|
||||
files={files}
|
||||
gitDataState={gitDataState}
|
||||
hideReadmeHeader
|
||||
onViewContributors={() => setSelectedTab("contributors")}
|
||||
profiles={profiles}
|
||||
project={project}
|
||||
pullRequests={pullRequests}
|
||||
ownerAvatarUrl={ownerProfile?.avatarUrl}
|
||||
ownerIsAgent={ownerProfile?.isAgent}
|
||||
ownerName={ownerName}
|
||||
readmeFile={readmeFile}
|
||||
snapshot={displayedSnapshot}
|
||||
sourceControls={sourceControls}
|
||||
unavailableReason={unavailableReason}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent className="m-0" value="activity">
|
||||
{selectedCommitHash ? (
|
||||
<ProjectCommitDetailPanel
|
||||
commit={
|
||||
displayedSnapshot?.commits.find(
|
||||
(commit) => commit.hash === selectedCommitHash,
|
||||
) ?? null
|
||||
}
|
||||
commitAuthorPubkeys={commitAuthorPubkeys}
|
||||
commitHash={selectedCommitHash}
|
||||
viewerGitIdentity={viewerGitIdentity}
|
||||
diff={commitDiff}
|
||||
diffError={commitDiffError}
|
||||
diffLoading={commitDiffLoading}
|
||||
originAgentName={selectedCommitPullRequest?.originAgentName}
|
||||
originChannelId={selectedCommitPullRequest?.channelId}
|
||||
profiles={profiles}
|
||||
/>
|
||||
) : (
|
||||
<ActivityPanel
|
||||
branch={sourceControls?.branch}
|
||||
error={displayedSnapshotError}
|
||||
isLoading={displayedSnapshotLoading}
|
||||
onSelectCommit={(commit) =>
|
||||
onSelectedCommitHashChange(commit.hash)
|
||||
}
|
||||
profiles={profiles}
|
||||
pullRequests={pullRequests}
|
||||
repoContributors={displayedContributors}
|
||||
snapshot={displayedSnapshot}
|
||||
viewerGitIdentity={viewerGitIdentity}
|
||||
/>
|
||||
)}
|
||||
{repositoryUnavailableState ??
|
||||
(selectedCommitHash ? (
|
||||
<ProjectCommitDetailPanel
|
||||
commit={
|
||||
displayedSnapshot?.commits.find(
|
||||
(commit) => commit.hash === selectedCommitHash,
|
||||
) ?? null
|
||||
}
|
||||
commitAuthorPubkeys={commitAuthorPubkeys}
|
||||
commitHash={selectedCommitHash}
|
||||
viewerGitIdentity={viewerGitIdentity}
|
||||
diff={commitDiff}
|
||||
diffError={commitDiffError}
|
||||
diffLoading={commitDiffLoading}
|
||||
originAgentName={selectedCommitPullRequest?.originAgentName}
|
||||
originChannelId={selectedCommitPullRequest?.channelId}
|
||||
profiles={profiles}
|
||||
project={project}
|
||||
/>
|
||||
) : (
|
||||
<ActivityPanel
|
||||
branch={sourceControls?.branch}
|
||||
error={displayedSnapshotError}
|
||||
isLoading={displayedSnapshotLoading}
|
||||
onSelectCommit={(commit) =>
|
||||
onSelectedCommitHashChange(commit.hash)
|
||||
}
|
||||
profiles={profiles}
|
||||
pullRequests={pullRequests}
|
||||
repoContributors={displayedContributors}
|
||||
snapshot={displayedSnapshot}
|
||||
viewerGitIdentity={viewerGitIdentity}
|
||||
/>
|
||||
))}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
@@ -495,7 +465,8 @@ export function WorkspaceTabs({
|
||||
}
|
||||
error={pullRequestsError}
|
||||
filesChanged={
|
||||
selectedPullRequest ? (
|
||||
repositoryUnavailableState ??
|
||||
(selectedPullRequest ? (
|
||||
<ProjectPullRequestFilesChangedPanel
|
||||
diff={repoDiff}
|
||||
error={repoDiffError}
|
||||
@@ -510,7 +481,7 @@ export function WorkspaceTabs({
|
||||
project={project}
|
||||
pullRequest={selectedPullRequest}
|
||||
/>
|
||||
) : undefined
|
||||
) : undefined)
|
||||
}
|
||||
filesCount={repoDiff?.files.length}
|
||||
forceOpenFiles={
|
||||
@@ -542,37 +513,48 @@ export function WorkspaceTabs({
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent className="m-0" value="files">
|
||||
{repoSource === "local" && !localSnapshot && !localSnapshotLoading ? (
|
||||
<div className="mb-3">
|
||||
<div
|
||||
className={PROJECT_DETAIL_PANEL_MESSAGE_CLASS}
|
||||
data-project-detail-panel
|
||||
>
|
||||
No local checkout found.
|
||||
{repositoryUnavailableState ??
|
||||
(repoSource === "local" &&
|
||||
!localSnapshot &&
|
||||
!localSnapshotLoading ? (
|
||||
<div className="mb-3">
|
||||
<div
|
||||
className={PROJECT_DETAIL_PANEL_MESSAGE_CLASS}
|
||||
data-project-detail-panel
|
||||
>
|
||||
No local checkout found.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<RepositoryFilesPanel
|
||||
error={displayedSnapshotError}
|
||||
fallbackAuthorPubkey={project.owner}
|
||||
files={files}
|
||||
isLoading={displayedSnapshotLoading}
|
||||
profiles={profiles}
|
||||
snapshot={displayedSnapshot}
|
||||
unavailableMessage={
|
||||
externalHost
|
||||
? `Not mirrored on Buzz. Repository files are hosted on ${externalHost}.`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<RepositoryFilesPanel
|
||||
error={displayedSnapshotError}
|
||||
fallbackAuthorPubkey={project.owner}
|
||||
files={files}
|
||||
isLoading={displayedSnapshotLoading}
|
||||
onContextChange={onFilesContextChange}
|
||||
profiles={profiles}
|
||||
snapshot={displayedSnapshot}
|
||||
unavailableMessage={
|
||||
externalHost
|
||||
? `Not mirrored on Buzz. Repository files are hosted on ${externalHost}.`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent className="m-0" value="channels">
|
||||
<DiscussionChannelsPanel query={repositoryDiscussionQuery(project)} />
|
||||
<DiscussionChannelsPanel
|
||||
query={repositoryDiscussionQuery(project)}
|
||||
repositoryName={project.name}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent className="m-0" value="contributors">
|
||||
<ContributorsPanel
|
||||
activityCounts={contributorActivityCounts}
|
||||
contributorPubkeys={contributorPubkeys}
|
||||
contributorPubkeysByGitIdentity={contributorPubkeysByGitIdentity}
|
||||
profiles={profiles}
|
||||
repoContributors={displayedContributors}
|
||||
/>
|
||||
|
||||
@@ -24,18 +24,24 @@ import { useChannelsQuery, useOpenDmMutation } from "@/features/channels/hooks";
|
||||
import {
|
||||
useChannelMessagesQuery,
|
||||
useChannelSubscription,
|
||||
useToggleReactionMutation,
|
||||
} from "@/features/messages/hooks";
|
||||
import { formatTimelineMessages } from "@/features/messages/lib/formatTimelineMessages";
|
||||
import { getThreadReference } from "@/features/messages/lib/threading";
|
||||
import { useLinkEditor } from "@/features/messages/lib/useLinkEditor";
|
||||
import {
|
||||
type LinkSelectionInfo,
|
||||
useRichTextEditor,
|
||||
} from "@/features/messages/lib/useRichTextEditor";
|
||||
import { FormattingToolbar } from "@/features/messages/ui/FormattingToolbar";
|
||||
import { TimelineMessageList } from "@/features/messages/ui/TimelineMessageList";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import { useThreadRepliesForRoots } from "@/features/messages/useThreadReplies";
|
||||
import { useProfileQuery, useUsersBatchQuery } from "@/features/profile/hooks";
|
||||
import type { Project } from "@/features/projects/hooks";
|
||||
import {
|
||||
mergeProjectAgentConversationEvents,
|
||||
restoreProjectsAgentConversation,
|
||||
visibleConversationMessages,
|
||||
} from "@/features/projects/lib/projectAgentConversation";
|
||||
import {
|
||||
clearStoredProjectsAgentConversation,
|
||||
@@ -46,6 +52,10 @@ import {
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import { sendChannelMessage } from "@/shared/api/tauri";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import {
|
||||
KIND_STREAM_MESSAGE,
|
||||
KIND_STREAM_MESSAGE_V2,
|
||||
} from "@/shared/constants/kinds";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
@@ -56,10 +66,9 @@ import {
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { Markdown } from "@/shared/ui/markdown";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
|
||||
type AgentCandidate = {
|
||||
export type AgentCandidate = {
|
||||
pubkey: string;
|
||||
name: string;
|
||||
/** Managed agents can be auto-started before the prompt is sent. */
|
||||
@@ -101,7 +110,7 @@ function repoContextBlock(projects: readonly Project[]) {
|
||||
|
||||
/** Hides the machine-readable repo footer when rendering the user's own
|
||||
* prompt back in the inline conversation. */
|
||||
function stripRepoContext(content: string) {
|
||||
export function stripRepoContext(content: string) {
|
||||
const markerIndex = content.indexOf(`---\n${REPO_CONTEXT_MARKER}`);
|
||||
if (markerIndex === -1) return content;
|
||||
return content.slice(0, markerIndex).replace(/\n+$/, "");
|
||||
@@ -134,7 +143,7 @@ function buildSuggestions(projects: readonly Project[]) {
|
||||
}
|
||||
|
||||
/** Sorts runnable agents first so the default pick can answer immediately. */
|
||||
function useAgentCandidates() {
|
||||
export function useAgentCandidates() {
|
||||
const identityQuery = useIdentityQuery();
|
||||
const managedAgentsQuery = useManagedAgentsQuery();
|
||||
const relayAgentsQuery = useRelayAgentsQuery();
|
||||
@@ -186,12 +195,13 @@ function useAgentCandidates() {
|
||||
|
||||
/** Live message feed for the conversation's backing DM channel, reduced to
|
||||
* plain chat rows (kind 9 / 40002 only). */
|
||||
function ConversationThread({
|
||||
export function ConversationThread({
|
||||
channel,
|
||||
agent,
|
||||
agentAvatarUrl,
|
||||
currentPubkey,
|
||||
selfAvatarUrl,
|
||||
stripSelfContent = stripRepoContext,
|
||||
visibleAfter,
|
||||
}: {
|
||||
channel: Channel;
|
||||
@@ -199,55 +209,132 @@ function ConversationThread({
|
||||
agentAvatarUrl: string | null;
|
||||
currentPubkey: string | null;
|
||||
selfAvatarUrl: string | null;
|
||||
stripSelfContent?: (content: string) => string;
|
||||
visibleAfter: number;
|
||||
}) {
|
||||
useChannelSubscription(channel);
|
||||
const messagesQuery = useChannelMessagesQuery(channel);
|
||||
const agentWorking = useAgentWorking(agent.pubkey, channel.id);
|
||||
const bottomRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
const messages = React.useMemo(
|
||||
() => visibleConversationMessages(messagesQuery.data ?? [], visibleAfter),
|
||||
const threadRootIds = React.useMemo(
|
||||
() =>
|
||||
(messagesQuery.data ?? [])
|
||||
.filter(
|
||||
(event) =>
|
||||
(event.kind === KIND_STREAM_MESSAGE ||
|
||||
event.kind === KIND_STREAM_MESSAGE_V2) &&
|
||||
event.created_at >= visibleAfter &&
|
||||
getThreadReference(event.tags).parentId === null,
|
||||
)
|
||||
.map((event) => event.id),
|
||||
[messagesQuery.data, visibleAfter],
|
||||
);
|
||||
|
||||
const threadReplies = useThreadRepliesForRoots(channel, threadRootIds);
|
||||
const toggleReactionMutation = useToggleReactionMutation();
|
||||
const agentWorking = useAgentWorking(agent.pubkey, channel.id);
|
||||
const bottomRef = React.useRef<HTMLDivElement>(null);
|
||||
const normalizedCurrent = currentPubkey
|
||||
? normalizePubkey(currentPubkey)
|
||||
: null;
|
||||
const profiles = React.useMemo(
|
||||
() => ({
|
||||
[normalizePubkey(agent.pubkey)]: {
|
||||
avatarUrl: agentAvatarUrl,
|
||||
displayName: agent.name,
|
||||
isAgent: true,
|
||||
name: agent.name,
|
||||
nip05Handle: null,
|
||||
ownerPubkey: null,
|
||||
},
|
||||
...(normalizedCurrent
|
||||
? {
|
||||
[normalizedCurrent]: {
|
||||
avatarUrl: selfAvatarUrl,
|
||||
displayName: "You",
|
||||
isAgent: false,
|
||||
name: null,
|
||||
nip05Handle: null,
|
||||
ownerPubkey: null,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
[
|
||||
agent.name,
|
||||
agent.pubkey,
|
||||
agentAvatarUrl,
|
||||
normalizedCurrent,
|
||||
selfAvatarUrl,
|
||||
],
|
||||
);
|
||||
const messages = React.useMemo(() => {
|
||||
const events = mergeProjectAgentConversationEvents(
|
||||
messagesQuery.data ?? [],
|
||||
threadReplies.events,
|
||||
);
|
||||
return formatTimelineMessages(
|
||||
events,
|
||||
channel,
|
||||
currentPubkey ?? undefined,
|
||||
selfAvatarUrl,
|
||||
profiles,
|
||||
)
|
||||
.filter(
|
||||
(message) =>
|
||||
(message.kind === KIND_STREAM_MESSAGE ||
|
||||
message.kind === KIND_STREAM_MESSAGE_V2) &&
|
||||
message.createdAt >= visibleAfter,
|
||||
)
|
||||
.map((message) =>
|
||||
normalizedCurrent &&
|
||||
message.pubkey &&
|
||||
normalizePubkey(message.pubkey) === normalizedCurrent
|
||||
? { ...message, body: stripSelfContent(message.body) }
|
||||
: message,
|
||||
);
|
||||
}, [
|
||||
channel,
|
||||
currentPubkey,
|
||||
messagesQuery.data,
|
||||
normalizedCurrent,
|
||||
profiles,
|
||||
selfAvatarUrl,
|
||||
stripSelfContent,
|
||||
threadReplies.events,
|
||||
visibleAfter,
|
||||
]);
|
||||
const conversationEntries = React.useMemo(
|
||||
() => messages.map((message) => ({ message, summary: null })),
|
||||
[messages],
|
||||
);
|
||||
const lastMessageId = messages[messages.length - 1]?.id ?? null;
|
||||
const handleToggleReaction = React.useCallback(
|
||||
async (message: TimelineMessage, emoji: string, remove: boolean) => {
|
||||
await toggleReactionMutation.mutateAsync({
|
||||
emoji,
|
||||
eventId: message.id,
|
||||
remove,
|
||||
});
|
||||
},
|
||||
[toggleReactionMutation.mutateAsync],
|
||||
);
|
||||
React.useEffect(() => {
|
||||
if (!lastMessageId && !agentWorking.working) return;
|
||||
bottomRef.current?.scrollIntoView({ block: "end" });
|
||||
}, [lastMessageId, agentWorking.working]);
|
||||
|
||||
const normalizedCurrent = currentPubkey
|
||||
? normalizePubkey(currentPubkey)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{messages.map((event) => {
|
||||
const isSelf = normalizePubkey(event.pubkey) === normalizedCurrent;
|
||||
return (
|
||||
<div className="flex gap-3" key={event.localKey ?? event.id}>
|
||||
<UserAvatar
|
||||
accent={!isSelf}
|
||||
avatarUrl={isSelf ? selfAvatarUrl : agentAvatarUrl}
|
||||
className="mt-0.5 shrink-0"
|
||||
displayName={isSelf ? "You" : agent.name}
|
||||
size="sm"
|
||||
/>
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
<span className="text-xs font-semibold text-muted-foreground">
|
||||
{isSelf ? "You" : agent.name}
|
||||
</span>
|
||||
<Markdown
|
||||
className="text-base text-foreground"
|
||||
content={
|
||||
isSelf ? stripRepoContext(event.content) : event.content
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div data-project-agent-channel-id={channel.id}>
|
||||
<TimelineMessageList
|
||||
channelId={channel.id}
|
||||
channelName={agent.name}
|
||||
channelType="dm"
|
||||
currentPubkey={currentPubkey ?? undefined}
|
||||
hideDayDividers
|
||||
mainEntries={conversationEntries}
|
||||
messages={messages}
|
||||
onToggleReaction={handleToggleReaction}
|
||||
profiles={profiles}
|
||||
useVirtualizer={false}
|
||||
/>
|
||||
{agentWorking.working ? (
|
||||
<div className="flex items-center gap-2 pl-11 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
|
||||
@@ -55,16 +55,12 @@ export function PullRequestMetaHeader({
|
||||
return (
|
||||
<ProjectDetailMetaList>
|
||||
<ProjectDetailMetaRow icon={GitBranch} label="Branch">
|
||||
<p className="flex min-w-0 flex-wrap items-center gap-1.5 text-xs">
|
||||
<code className="truncate font-mono text-foreground">
|
||||
{sourceBranch}
|
||||
</code>
|
||||
<p className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
<span className="truncate text-foreground">{sourceBranch}</span>
|
||||
<span aria-hidden className="text-muted-foreground">
|
||||
→
|
||||
</span>
|
||||
<code className="truncate font-mono text-foreground">
|
||||
{targetBranch}
|
||||
</code>
|
||||
<span className="truncate text-foreground">{targetBranch}</span>
|
||||
{diffStats ? (
|
||||
<>
|
||||
<span className="text-green-500">+{diffStats.additions}</span>
|
||||
@@ -73,7 +69,10 @@ export function PullRequestMetaHeader({
|
||||
) : null}
|
||||
{pullRequest.commit ? (
|
||||
<span className="inline-flex min-w-0 items-center gap-0.5 text-muted-foreground">
|
||||
<code className="truncate font-mono" title={pullRequest.commit}>
|
||||
<code
|
||||
className="truncate font-mono text-sm"
|
||||
title={pullRequest.commit}
|
||||
>
|
||||
{pullRequest.commit.slice(0, 7)}
|
||||
</code>
|
||||
<CopyCommitHashButton
|
||||
|
||||
@@ -202,7 +202,7 @@ export function PullRequestReviewersRow({
|
||||
<Dialog onOpenChange={setPickerOpen} open={pickerOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
className="h-5 px-0 text-xs text-muted-foreground hover:bg-transparent hover:text-foreground"
|
||||
className="h-5 px-0 text-sm text-muted-foreground hover:bg-transparent hover:text-foreground"
|
||||
disabled={requestReviewMutation.isPending}
|
||||
size="xs"
|
||||
type="button"
|
||||
@@ -301,7 +301,7 @@ export function PullRequestReviewersRow({
|
||||
<ProjectDetailMetaRow
|
||||
key={pubkey}
|
||||
label={label}
|
||||
labelClassName="text-foreground"
|
||||
labelClassName="font-medium text-foreground"
|
||||
leading={
|
||||
<UserAvatar
|
||||
accent={profile?.isAgent === true}
|
||||
@@ -313,7 +313,7 @@ export function PullRequestReviewersRow({
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-0.5 text-xs",
|
||||
"inline-flex items-center gap-0.5 text-sm",
|
||||
hasApproved && "text-green-600 dark:text-green-400",
|
||||
hasRequestedChanges && "text-amber-600 dark:text-amber-400",
|
||||
!hasApproved && !hasRequestedChanges && "text-muted-foreground",
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { projectCloneErrorPresentation } from "@/features/projects/lib/projectGitError";
|
||||
import type { ProjectRepoUnavailableReason } from "@/features/projects/lib/projectRepoAvailability";
|
||||
|
||||
export function showProjectCloneErrorToast(
|
||||
error: unknown,
|
||||
cloneUrl?: string | null,
|
||||
unavailableReason?: ProjectRepoUnavailableReason,
|
||||
) {
|
||||
const presentation = projectCloneErrorPresentation(error, cloneUrl);
|
||||
const presentation = projectCloneErrorPresentation(
|
||||
error,
|
||||
cloneUrl,
|
||||
unavailableReason,
|
||||
);
|
||||
toast.error(presentation.title, { description: presentation.description });
|
||||
}
|
||||
|
||||
@@ -13,18 +13,10 @@ export const PROJECT_PICKER_TRIGGER_CLASS =
|
||||
export const PROJECT_DETAIL_PANEL_CLASS =
|
||||
"overflow-hidden rounded-xl border border-border/60 bg-transparent";
|
||||
|
||||
/**
|
||||
* Detail + meta rail: two columns when the project pane itself is wide enough.
|
||||
* Uses a container query so opening the conversation panel reflows the rail
|
||||
* instead of clipping it against a viewport `xl` breakpoint.
|
||||
*/
|
||||
export const PROJECT_DETAIL_META_GRID_WIDE_CLASS =
|
||||
"[@container(min-width:48rem)]:grid-cols-[minmax(0,1fr)_18rem]";
|
||||
|
||||
/** Left border for the meta rail when it sits beside the detail column. */
|
||||
export const PROJECT_DETAIL_META_RAIL_WIDE_BORDER_CLASS =
|
||||
"[@container(min-width:48rem)]:border-l [@container(min-width:48rem)]:border-t-0";
|
||||
|
||||
/** Empty or loading state using the same transparent project panel shell. */
|
||||
export const PROJECT_DETAIL_PANEL_MESSAGE_CLASS =
|
||||
"rounded-xl border border-border/60 bg-transparent p-4 text-sm text-muted-foreground";
|
||||
|
||||
/** Shared translucent chrome for the paired project content/panel headers. */
|
||||
export const PROJECT_COLUMN_HEADER_BACKDROP_CLASS =
|
||||
"bg-background/80 backdrop-blur-md supports-backdrop-filter:bg-background/70 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-backdrop-filter:bg-background/55";
|
||||
|
||||
58
desktop/src/features/projects/ui/useProjectDetailCrumbs.ts
Normal file
58
desktop/src/features/projects/ui/useProjectDetailCrumbs.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type * as React from "react";
|
||||
|
||||
import type { EntityLinkTab } from "@/shared/lib/entityLink";
|
||||
import type { ProjectDetailWorkItemCrumb } from "./ProjectDetailChrome";
|
||||
import { PROJECT_TAB_CRUMB_LABELS } from "./projectDetailHelpers";
|
||||
|
||||
export function buildProjectDetailCrumbs({
|
||||
activeTab,
|
||||
commit,
|
||||
issue,
|
||||
pullRequest,
|
||||
setRequestedTab,
|
||||
setSelectedCommitHash,
|
||||
setSelectedIssueId,
|
||||
setSelectedPullRequestId,
|
||||
setTabsResetKey,
|
||||
}: {
|
||||
activeTab: string;
|
||||
commit: { hash: string; subject?: string | null } | null;
|
||||
issue: { id: string; title: string } | null;
|
||||
pullRequest: { id: string; title: string } | null;
|
||||
setRequestedTab: (tab: EntityLinkTab | undefined) => void;
|
||||
setSelectedCommitHash: (hash: string | null) => void;
|
||||
setSelectedIssueId: (id: string | null) => void;
|
||||
setSelectedPullRequestId: (id: string | null) => void;
|
||||
setTabsResetKey: React.Dispatch<React.SetStateAction<number>>;
|
||||
}) {
|
||||
const activeWorkItemCrumb: ProjectDetailWorkItemCrumb | null = pullRequest
|
||||
? {
|
||||
category: "Review",
|
||||
clear: () => setSelectedPullRequestId(null),
|
||||
title: pullRequest.title,
|
||||
}
|
||||
: issue
|
||||
? {
|
||||
category: "Tasks",
|
||||
clear: () => setSelectedIssueId(null),
|
||||
title: issue.title,
|
||||
}
|
||||
: commit
|
||||
? {
|
||||
category: "Commits",
|
||||
clear: () => setSelectedCommitHash(null),
|
||||
title: commit.subject || commit.hash.slice(0, 7),
|
||||
}
|
||||
: null;
|
||||
const activeTabCrumb = activeWorkItemCrumb
|
||||
? null
|
||||
: (PROJECT_TAB_CRUMB_LABELS[activeTab] ?? null);
|
||||
const handleGoToProjectHome = () => {
|
||||
setSelectedPullRequestId(null);
|
||||
setSelectedIssueId(null);
|
||||
setSelectedCommitHash(null);
|
||||
setRequestedTab(undefined);
|
||||
setTabsResetKey((key) => key + 1);
|
||||
};
|
||||
return { activeTabCrumb, activeWorkItemCrumb, handleGoToProjectHome };
|
||||
}
|
||||
101
desktop/src/features/projects/ui/useProjectDetailPeople.ts
Normal file
101
desktop/src/features/projects/ui/useProjectDetailPeople.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type {
|
||||
ProjectIssue,
|
||||
ProjectPullRequest,
|
||||
Repository,
|
||||
} from "@/features/projects/hooks";
|
||||
import {
|
||||
projectContributorActivityCounts,
|
||||
signedProjectContributorPubkeys,
|
||||
type ViewerGitIdentity,
|
||||
} from "@/features/projects/lib/projectContributorMatching";
|
||||
import { useGitIdentityQuery } from "@/features/projects/useGitIdentity";
|
||||
import { useProfileQuery, useUsersBatchQuery } from "@/features/profile/hooks";
|
||||
import { mergeCurrentProfileIntoLookup } from "@/features/profile/lib/identity";
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import { projectPeople } from "./projectDetailHelpers";
|
||||
|
||||
/** Resolves profiles and contributor identity data used across project panels. */
|
||||
export function useProjectDetailPeople({
|
||||
issues,
|
||||
pullRequests,
|
||||
repository,
|
||||
}: {
|
||||
issues: ProjectIssue[];
|
||||
pullRequests: ProjectPullRequest[];
|
||||
repository: Repository | null;
|
||||
}) {
|
||||
const peoplePubkeys = React.useMemo(() => {
|
||||
if (!repository) return [];
|
||||
const pullRequestPubkeys = pullRequests.flatMap((pullRequest) => [
|
||||
pullRequest.author,
|
||||
...pullRequest.updates.map((update) => update.author),
|
||||
...pullRequest.comments.map((comment) => comment.author),
|
||||
...pullRequest.reviewers,
|
||||
...pullRequest.approvals.map((approval) => approval.author),
|
||||
]);
|
||||
const issuePubkeys = issues.flatMap((issue) => [
|
||||
issue.author,
|
||||
...issue.recipients,
|
||||
...issue.assignees,
|
||||
...issue.comments.map((comment) => comment.author),
|
||||
]);
|
||||
return [
|
||||
...new Set([
|
||||
...projectPeople(repository),
|
||||
...pullRequestPubkeys,
|
||||
...issuePubkeys,
|
||||
]),
|
||||
];
|
||||
}, [issues, pullRequests, repository]);
|
||||
const profilesQuery = useUsersBatchQuery(peoplePubkeys, {
|
||||
enabled: peoplePubkeys.length > 0,
|
||||
});
|
||||
const currentProfileQuery = useProfileQuery();
|
||||
const profiles = React.useMemo(
|
||||
() =>
|
||||
mergeCurrentProfileIntoLookup(
|
||||
profilesQuery.data?.profiles,
|
||||
currentProfileQuery.data,
|
||||
),
|
||||
[currentProfileQuery.data, profilesQuery.data?.profiles],
|
||||
);
|
||||
const contributorPubkeys = React.useMemo(() => {
|
||||
if (!repository) return [];
|
||||
return signedProjectContributorPubkeys({
|
||||
contributors: repository.contributors,
|
||||
issues,
|
||||
owner: repository.owner,
|
||||
pullRequests,
|
||||
});
|
||||
}, [issues, pullRequests, repository]);
|
||||
const contributorActivityCounts = React.useMemo(() => {
|
||||
if (!repository) return {};
|
||||
return projectContributorActivityCounts({
|
||||
contributors: repository.contributors,
|
||||
issues,
|
||||
owner: repository.owner,
|
||||
pullRequests,
|
||||
});
|
||||
}, [issues, pullRequests, repository]);
|
||||
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]);
|
||||
|
||||
return {
|
||||
contributorActivityCounts,
|
||||
contributorPubkeys,
|
||||
identityPubkey: identityQuery.data?.pubkey,
|
||||
profiles,
|
||||
viewerGitIdentity,
|
||||
};
|
||||
}
|
||||
60
desktop/src/features/projects/ui/useProjectProfilePanel.ts
Normal file
60
desktop/src/features/projects/ui/useProjectProfilePanel.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
|
||||
import { useOpenDmMutation } from "@/features/channels/hooks";
|
||||
import type {
|
||||
ProfilePanelTab,
|
||||
ProfilePanelView,
|
||||
} from "@/features/profile/ui/UserProfilePanel";
|
||||
import {
|
||||
profilePanelTabFromSearch,
|
||||
profilePanelViewFromSearch,
|
||||
} from "@/features/profile/ui/UserProfilePanelUtils";
|
||||
import { useHistorySearchState } from "@/shared/hooks/useHistorySearchState";
|
||||
|
||||
const PROJECT_DETAIL_PANEL_SEARCH_KEYS = [
|
||||
"profile",
|
||||
"profileTab",
|
||||
"profileView",
|
||||
] as const;
|
||||
|
||||
/** Owns URL-backed profile panel state for the project workspace. */
|
||||
export function useProjectProfilePanel() {
|
||||
const { goChannel } = useAppNavigation();
|
||||
const { applyPatch, values } = useHistorySearchState(
|
||||
PROJECT_DETAIL_PANEL_SEARCH_KEYS,
|
||||
);
|
||||
const openDmMutation = useOpenDmMutation();
|
||||
|
||||
return {
|
||||
profilePanelPubkey: values.profile,
|
||||
profilePanelTab: profilePanelTabFromSearch(values.profileTab),
|
||||
profilePanelView: profilePanelViewFromSearch(values.profileView),
|
||||
handleOpenProfilePanel: React.useCallback(
|
||||
(pubkey: string) =>
|
||||
applyPatch({ profile: pubkey, profileTab: null, profileView: null }),
|
||||
[applyPatch],
|
||||
),
|
||||
handleCloseProfilePanel: React.useCallback(
|
||||
() => applyPatch({ profile: null, profileTab: null, profileView: null }),
|
||||
[applyPatch],
|
||||
),
|
||||
handleProfilePanelViewChange: React.useCallback(
|
||||
(view: ProfilePanelView, options?: { replace?: boolean }) =>
|
||||
applyPatch({ profileView: view === "summary" ? null : view }, options),
|
||||
[applyPatch],
|
||||
),
|
||||
handleProfilePanelTabChange: React.useCallback(
|
||||
(tab: ProfilePanelTab, options?: { replace?: boolean }) =>
|
||||
applyPatch({ profileTab: tab === "info" ? null : tab }, options),
|
||||
[applyPatch],
|
||||
),
|
||||
handleOpenDm: React.useCallback(
|
||||
async (pubkeys: string[]) => {
|
||||
const dm = await openDmMutation.mutateAsync({ pubkeys });
|
||||
await goChannel(dm.id);
|
||||
},
|
||||
[goChannel, openDmMutation],
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type { ProjectRightPanelMode } from "./ProjectRightPanelControls";
|
||||
|
||||
const REPOSITORY_PANEL_COLLAPSED_KEY =
|
||||
"buzz.desktop.project-repository-panel-collapsed";
|
||||
const PROJECT_RIGHT_PANEL_MODE_KEY = "buzz.desktop.project-right-panel-mode";
|
||||
|
||||
function initialCollapsed() {
|
||||
try {
|
||||
return (
|
||||
globalThis.sessionStorage?.getItem(REPOSITORY_PANEL_COLLAPSED_KEY) ===
|
||||
"true"
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function initialMode(): ProjectRightPanelMode {
|
||||
try {
|
||||
return globalThis.sessionStorage?.getItem(PROJECT_RIGHT_PANEL_MODE_KEY) ===
|
||||
"chat"
|
||||
? "chat"
|
||||
: "repository";
|
||||
} catch {
|
||||
return "repository";
|
||||
}
|
||||
}
|
||||
|
||||
export function useProjectRepositoryPanel() {
|
||||
const [collapsed, setCollapsedState] = React.useState(initialCollapsed);
|
||||
const [mode, setModeState] =
|
||||
React.useState<ProjectRightPanelMode>(initialMode);
|
||||
const setCollapsed = React.useCallback((nextCollapsed: boolean) => {
|
||||
setCollapsedState(nextCollapsed);
|
||||
try {
|
||||
globalThis.sessionStorage?.setItem(
|
||||
REPOSITORY_PANEL_COLLAPSED_KEY,
|
||||
String(nextCollapsed),
|
||||
);
|
||||
} catch {
|
||||
// Persistence is best-effort; the in-memory drawer state still works.
|
||||
}
|
||||
}, []);
|
||||
const setMode = React.useCallback((nextMode: ProjectRightPanelMode) => {
|
||||
setModeState(nextMode);
|
||||
try {
|
||||
globalThis.sessionStorage?.setItem(
|
||||
PROJECT_RIGHT_PANEL_MODE_KEY,
|
||||
nextMode,
|
||||
);
|
||||
} catch {
|
||||
// Persistence is best-effort; the in-memory panel mode still works.
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
collapse: React.useCallback(() => setCollapsed(true), [setCollapsed]),
|
||||
collapsed,
|
||||
expand: React.useCallback(() => setCollapsed(false), [setCollapsed]),
|
||||
mode,
|
||||
setMode,
|
||||
};
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
buildInitialProjectEventTemplates,
|
||||
isUnsupportedProjectKindError,
|
||||
} from "@/features/projects/projectCreation";
|
||||
import { addProjectToSidebar } from "@/features/projects/lib/projectSidebarMembership";
|
||||
import { buildProjectReadModels } from "@/features/projects/projectModels";
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import { getCachedRelayOrigin } from "@/shared/lib/mediaUrl";
|
||||
@@ -130,6 +131,11 @@ export function useCreateProjectMutation() {
|
||||
mutationFn: (input: CreateProjectInput) =>
|
||||
createProject(input, resumableProjectIdsRef.current),
|
||||
onSuccess: ({ project }) => {
|
||||
addProjectToSidebar(
|
||||
project.projectAddress,
|
||||
getCachedRelayOrigin(),
|
||||
project.owner,
|
||||
);
|
||||
queryClient.setQueryData<Project[]>(projectsQueryKey, (current = []) => [
|
||||
project,
|
||||
...current.filter(
|
||||
|
||||
@@ -30,12 +30,7 @@ export function useProjectRepoPresentation(
|
||||
controls: {
|
||||
externalUrl: host.kind === "external" ? webUrl : null,
|
||||
remoteKind: host.kind === "unresolved" ? undefined : host.kind,
|
||||
remoteLabel:
|
||||
host.kind === "external"
|
||||
? host.host
|
||||
: host.kind === "buzz"
|
||||
? "Buzz"
|
||||
: "Remote",
|
||||
remoteLabel: host.kind === "external" ? host.host : "Remote",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Activity, Bot, FolderGit2, Inbox, Zap } from "lucide-react";
|
||||
import { Activity, Bot, Folders, Inbox, Zap } from "lucide-react";
|
||||
|
||||
import { TopbarSearch } from "@/features/search/ui/TopbarSearch";
|
||||
import { SidebarProjectsSection } from "@/features/sidebar/ui/SidebarProjectsSection";
|
||||
@@ -155,7 +155,7 @@ export function AppSidebarPrimaryMenu({
|
||||
tooltip="Projects"
|
||||
type="button"
|
||||
>
|
||||
<FolderGit2 className="h-4 w-4" />
|
||||
<Folders className="h-4 w-4" />
|
||||
<SidebarMenuLabel>Projects</SidebarMenuLabel>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
|
||||
@@ -41,7 +41,7 @@ test("selectedProjectRouteId reads the project id from the detail route", () =>
|
||||
);
|
||||
});
|
||||
|
||||
test("listSidebarProjects keeps owned and contributed projects for Mine", () => {
|
||||
test("listSidebarProjects only includes explicitly added projects", () => {
|
||||
const owned = makeProject({
|
||||
dtag: "owned",
|
||||
id: `30621:${VIEWER}:owned`,
|
||||
@@ -72,14 +72,18 @@ test("listSidebarProjects keeps owned and contributed projects for Mine", () =>
|
||||
const other = makeProject({ name: "Other" });
|
||||
|
||||
const listed = listSidebarProjects({
|
||||
addedProjectAddresses: new Set([
|
||||
contributed.projectAddress,
|
||||
other.projectAddress,
|
||||
]),
|
||||
currentPubkey: VIEWER,
|
||||
filter: "mine",
|
||||
filter: "added",
|
||||
projects: [other, contributed, owned],
|
||||
sort: "name",
|
||||
});
|
||||
assert.deepEqual(
|
||||
listed.map((project) => project.name),
|
||||
["Contrib", "Owned"],
|
||||
["Contrib", "Other"],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -113,6 +117,10 @@ test("listSidebarProjects owned filter hides contributed projects", () => {
|
||||
});
|
||||
|
||||
const listed = listSidebarProjects({
|
||||
addedProjectAddresses: new Set([
|
||||
contributed.projectAddress,
|
||||
owned.projectAddress,
|
||||
]),
|
||||
currentPubkey: VIEWER,
|
||||
filter: "owned",
|
||||
projects: [contributed, owned],
|
||||
@@ -143,8 +151,12 @@ test("listSidebarProjects newest sort orders by createdAt then name", () => {
|
||||
});
|
||||
|
||||
const listed = listSidebarProjects({
|
||||
addedProjectAddresses: new Set([
|
||||
older.projectAddress,
|
||||
newer.projectAddress,
|
||||
]),
|
||||
currentPubkey: VIEWER,
|
||||
filter: "mine",
|
||||
filter: "added",
|
||||
projects: [older, newer],
|
||||
sort: "created",
|
||||
});
|
||||
|
||||
@@ -2,11 +2,14 @@ import { useLocation } from "@tanstack/react-router";
|
||||
import {
|
||||
ArrowUpDown,
|
||||
ChevronDown,
|
||||
EllipsisVertical,
|
||||
Folder,
|
||||
FolderGit2,
|
||||
FolderOpen,
|
||||
Folders,
|
||||
Link2,
|
||||
ListMinus,
|
||||
Plus,
|
||||
Settings2,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
@@ -20,13 +23,21 @@ import {
|
||||
} from "@/features/projects/hooks";
|
||||
import { isProjectOwnedByCurrentUser } from "@/features/projects/lib/projectsViewHelpers";
|
||||
import { projectShareLink } from "@/features/projects/lib/projectShareLinks";
|
||||
import {
|
||||
addProjectToSidebar,
|
||||
PROJECT_SIDEBAR_MEMBERSHIP_EVENT,
|
||||
readProjectSidebarMembership,
|
||||
removeProjectFromSidebar,
|
||||
} from "@/features/projects/lib/projectSidebarMembership";
|
||||
import { selectProjectRepository } from "@/features/projects/projectModels";
|
||||
import { projectMatchesRouteId } from "@/features/projects/projectRoutes";
|
||||
import { CreateProjectDialog } from "@/features/projects/ui/CreateProjectDialog";
|
||||
import { ProjectBrowserDialog } from "@/features/projects/ui/ProjectBrowserDialog";
|
||||
import { useCreateProjectMutation } from "@/features/projects/useCreateProject";
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import { FeatureGate } from "@/shared/features";
|
||||
import { copyTextToClipboard } from "@/shared/lib/clipboard";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { getCachedRelayOrigin } from "@/shared/lib/mediaUrl";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -113,10 +124,17 @@ function SidebarProjectsSectionContent() {
|
||||
const currentPubkey = identityQuery.data?.pubkey;
|
||||
const { goProject, goProjects } = useAppNavigation();
|
||||
const pathname = useLocation({ select: (location) => location.pathname });
|
||||
const routeRepositoryId = useLocation({
|
||||
select: (location) => {
|
||||
const value = (location.search as Record<string, unknown>).repositoryId;
|
||||
return typeof value === "string" ? value : undefined;
|
||||
},
|
||||
});
|
||||
const routeProjectId = selectedProjectRouteId(pathname);
|
||||
const relayOrigin = getCachedRelayOrigin();
|
||||
const [collapsed, setCollapsed] = React.useState(false);
|
||||
const [actionsOpen, setActionsOpen] = React.useState(false);
|
||||
const [createOpen, setCreateOpen] = React.useState(false);
|
||||
const [browserOpen, setBrowserOpen] = React.useState(false);
|
||||
const [projectToDelete, setProjectToDelete] = React.useState<Project | null>(
|
||||
null,
|
||||
);
|
||||
@@ -126,18 +144,36 @@ function SidebarProjectsSectionContent() {
|
||||
const [sort, setSort] = React.useState<SidebarProjectsSort>(
|
||||
readSidebarProjectsSort,
|
||||
);
|
||||
const [addedProjectAddresses, setAddedProjectAddresses] = React.useState<
|
||||
string[]
|
||||
>(() => readProjectSidebarMembership(relayOrigin, currentPubkey));
|
||||
const createProjectMutation = useCreateProjectMutation();
|
||||
const deleteProjectMutation = useDeleteProjectMutation();
|
||||
const isPending = projectsQuery.isPending || identityQuery.isPending;
|
||||
React.useEffect(() => {
|
||||
const refresh = () =>
|
||||
setAddedProjectAddresses(
|
||||
readProjectSidebarMembership(relayOrigin, currentPubkey),
|
||||
);
|
||||
refresh();
|
||||
globalThis.addEventListener(PROJECT_SIDEBAR_MEMBERSHIP_EVENT, refresh);
|
||||
return () =>
|
||||
globalThis.removeEventListener(PROJECT_SIDEBAR_MEMBERSHIP_EVENT, refresh);
|
||||
}, [currentPubkey, relayOrigin]);
|
||||
const addedProjectAddressSet = React.useMemo(
|
||||
() => new Set(addedProjectAddresses),
|
||||
[addedProjectAddresses],
|
||||
);
|
||||
const projects = React.useMemo(
|
||||
() =>
|
||||
listSidebarProjects({
|
||||
addedProjectAddresses: addedProjectAddressSet,
|
||||
currentPubkey,
|
||||
filter,
|
||||
projects: projectsQuery.data ?? [],
|
||||
sort,
|
||||
}),
|
||||
[currentPubkey, filter, projectsQuery.data, sort],
|
||||
[addedProjectAddressSet, currentPubkey, filter, projectsQuery.data, sort],
|
||||
);
|
||||
|
||||
const handleFilterChange = (next: SidebarProjectsFilter) => {
|
||||
@@ -148,11 +184,32 @@ function SidebarProjectsSectionContent() {
|
||||
setSort(next);
|
||||
writeSidebarProjectsSort(next);
|
||||
};
|
||||
const handleAdd = (project: Project) => {
|
||||
addProjectToSidebar(project.projectAddress, relayOrigin, currentPubkey);
|
||||
};
|
||||
const handleRemove = (project: Project) => {
|
||||
removeProjectFromSidebar(
|
||||
project.projectAddress,
|
||||
relayOrigin,
|
||||
currentPubkey,
|
||||
);
|
||||
if (
|
||||
routeProjectId != null &&
|
||||
projectMatchesRouteId(project, routeProjectId)
|
||||
) {
|
||||
void goProjects();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = React.useCallback(
|
||||
async (project: Project) => {
|
||||
try {
|
||||
await deleteProjectMutation.mutateAsync(project);
|
||||
removeProjectFromSidebar(
|
||||
project.projectAddress,
|
||||
relayOrigin,
|
||||
currentPubkey,
|
||||
);
|
||||
toast.success("Project deleted");
|
||||
if (
|
||||
routeProjectId != null &&
|
||||
@@ -168,7 +225,13 @@ function SidebarProjectsSectionContent() {
|
||||
setProjectToDelete(null);
|
||||
}
|
||||
},
|
||||
[deleteProjectMutation, goProjects, routeProjectId],
|
||||
[
|
||||
currentPubkey,
|
||||
deleteProjectMutation,
|
||||
goProjects,
|
||||
relayOrigin,
|
||||
routeProjectId,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -201,7 +264,7 @@ function SidebarProjectsSectionContent() {
|
||||
<SidebarProjectsHeaderActions
|
||||
filter={filter}
|
||||
onBrowseAll={() => void goProjects()}
|
||||
onCreate={() => setCreateOpen(true)}
|
||||
onCreate={() => setBrowserOpen(true)}
|
||||
onFilterChange={handleFilterChange}
|
||||
onOpenChange={setActionsOpen}
|
||||
onSortChange={handleSortChange}
|
||||
@@ -212,23 +275,56 @@ function SidebarProjectsSectionContent() {
|
||||
<SidebarGroupContent id="sidebar-projects">
|
||||
{projects.length > 0 ? (
|
||||
<SidebarMenu data-testid="sidebar-projects">
|
||||
{projects.map((project) => (
|
||||
<SidebarProjectRow
|
||||
canDelete={isProjectOwnedByCurrentUser(
|
||||
project,
|
||||
currentPubkey,
|
||||
)}
|
||||
deleteDisabled={deleteProjectMutation.isPending}
|
||||
isActive={
|
||||
routeProjectId != null &&
|
||||
projectMatchesRouteId(project, routeProjectId)
|
||||
}
|
||||
key={project.id}
|
||||
onDelete={() => setProjectToDelete(project)}
|
||||
onOpen={() => goProject(project.id)}
|
||||
project={project}
|
||||
/>
|
||||
))}
|
||||
{projects.map((project) => {
|
||||
const isActive =
|
||||
routeProjectId != null &&
|
||||
projectMatchesRouteId(project, routeProjectId);
|
||||
const selectedRepository = isActive
|
||||
? selectProjectRepository(project, routeRepositoryId)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<React.Fragment key={project.id}>
|
||||
<SidebarProjectRow
|
||||
canDelete={isProjectOwnedByCurrentUser(
|
||||
project,
|
||||
currentPubkey,
|
||||
)}
|
||||
deleteDisabled={deleteProjectMutation.isPending}
|
||||
isActive={isActive}
|
||||
onDelete={() => setProjectToDelete(project)}
|
||||
onOpen={() => goProject(project.id)}
|
||||
onRemove={() => handleRemove(project)}
|
||||
project={project}
|
||||
/>
|
||||
{isActive
|
||||
? project.repositories.map((repository) => (
|
||||
<SidebarMenuItem key={repository.id}>
|
||||
<SidebarMenuButton
|
||||
className="h-7 pl-7 text-sidebar-foreground/70 data-[active=true]:!bg-transparent data-[active=true]:font-semibold data-[active=true]:text-sidebar-foreground data-[active=true]:shadow-none data-[active=true]:hover:!bg-transparent data-[active=true]:hover:text-sidebar-foreground data-[active=true]:active:!bg-transparent"
|
||||
data-testid={`sidebar-project-repository-${repository.dtag}`}
|
||||
isActive={
|
||||
repository.id === selectedRepository?.id
|
||||
}
|
||||
onClick={() =>
|
||||
goProject(project.id, {
|
||||
repositoryId: repository.id,
|
||||
})
|
||||
}
|
||||
tooltip={repository.name}
|
||||
type="button"
|
||||
>
|
||||
<FolderGit2 className="h-3.5 w-3.5" />
|
||||
<SidebarMenuLabel>
|
||||
{repository.name}
|
||||
</SidebarMenuLabel>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))
|
||||
: null}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
) : isPending ? null : (
|
||||
<p className="px-2 py-1 text-xs text-sidebar-foreground/50">
|
||||
@@ -237,7 +333,7 @@ function SidebarProjectsSectionContent() {
|
||||
)}
|
||||
</SidebarGroupContent>
|
||||
) : null}
|
||||
<CreateProjectDialog
|
||||
<ProjectBrowserDialog
|
||||
isCreating={createProjectMutation.isPending}
|
||||
onCreate={async (input) => {
|
||||
const result = await createProjectMutation.mutateAsync(input);
|
||||
@@ -250,8 +346,14 @@ function SidebarProjectsSectionContent() {
|
||||
}
|
||||
await goProject(result.project.id);
|
||||
}}
|
||||
onOpenChange={setCreateOpen}
|
||||
open={createOpen}
|
||||
onOpenChange={setBrowserOpen}
|
||||
onSelectProject={(project) => {
|
||||
handleAdd(project);
|
||||
void goProject(project.id);
|
||||
}}
|
||||
open={browserOpen}
|
||||
projects={projectsQuery.data ?? []}
|
||||
selectedProjectAddresses={addedProjectAddressSet}
|
||||
/>
|
||||
<AlertDialog
|
||||
onOpenChange={(open) => {
|
||||
@@ -327,12 +429,12 @@ function SidebarProjectsHeaderActions({
|
||||
onSortChange: (sort: SidebarProjectsSort) => void;
|
||||
sort: SidebarProjectsSort;
|
||||
}) {
|
||||
const settingsTriggerRef = React.useRef<HTMLButtonElement>(null);
|
||||
const actionsTriggerRef = React.useRef<HTMLButtonElement>(null);
|
||||
|
||||
return (
|
||||
<div className="absolute right-1 top-1/2 z-10 flex -translate-y-1/2 items-center gap-0.5">
|
||||
<button
|
||||
aria-label="Create project"
|
||||
aria-label="Add project"
|
||||
className={cn(
|
||||
SECTION_ICON_BUTTON_CLASS,
|
||||
SECTION_ACTION_VISIBILITY_CLASS,
|
||||
@@ -343,7 +445,7 @@ function SidebarProjectsHeaderActions({
|
||||
onCreate();
|
||||
}}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
title="Create project"
|
||||
title="Add project"
|
||||
type="button"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
@@ -351,7 +453,7 @@ function SidebarProjectsHeaderActions({
|
||||
<DropdownMenu onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
aria-label="Project section settings"
|
||||
aria-label="More actions for Projects"
|
||||
className={cn(
|
||||
SECTION_ICON_BUTTON_CLASS,
|
||||
SECTION_ACTION_VISIBILITY_CLASS,
|
||||
@@ -359,17 +461,17 @@ function SidebarProjectsHeaderActions({
|
||||
data-testid="sidebar-projects-settings"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
ref={settingsTriggerRef}
|
||||
ref={actionsTriggerRef}
|
||||
type="button"
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
<EllipsisVertical className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
onCloseAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
settingsTriggerRef.current?.blur();
|
||||
actionsTriggerRef.current?.blur();
|
||||
}}
|
||||
>
|
||||
<DropdownMenuSub>
|
||||
@@ -384,7 +486,9 @@ function SidebarProjectsHeaderActions({
|
||||
}
|
||||
value={filter}
|
||||
>
|
||||
<DropdownMenuRadioItem value="mine">Mine</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="added">
|
||||
Added
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="owned">
|
||||
Owned by me
|
||||
</DropdownMenuRadioItem>
|
||||
@@ -412,7 +516,7 @@ function SidebarProjectsHeaderActions({
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => deferMenuAction(onBrowseAll)}>
|
||||
<FolderGit2 className="h-4 w-4" />
|
||||
<Folder className="h-4 w-4" />
|
||||
<span>Browse all projects</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
@@ -427,6 +531,7 @@ function SidebarProjectRow({
|
||||
isActive,
|
||||
onDelete,
|
||||
onOpen,
|
||||
onRemove,
|
||||
project,
|
||||
}: {
|
||||
canDelete: boolean;
|
||||
@@ -434,22 +539,25 @@ function SidebarProjectRow({
|
||||
isActive: boolean;
|
||||
onDelete: () => void;
|
||||
onOpen: () => void;
|
||||
onRemove: () => void;
|
||||
project: Project;
|
||||
}) {
|
||||
const shareLink = projectShareLink(project);
|
||||
const ProjectIcon = isActive ? FolderOpen : Folders;
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
className="data-[active=true]:!bg-transparent data-[active=true]:font-normal data-[active=true]:text-sidebar-foreground data-[active=true]:shadow-none data-[active=true]:hover:!bg-transparent data-[active=true]:hover:text-sidebar-foreground data-[active=true]:active:!bg-transparent"
|
||||
data-testid={`sidebar-project-${project.dtag}`}
|
||||
isActive={isActive}
|
||||
onClick={onOpen}
|
||||
tooltip={project.name}
|
||||
type="button"
|
||||
>
|
||||
<FolderGit2 className="h-4 w-4" />
|
||||
<ProjectIcon className="h-4 w-4" />
|
||||
<SidebarMenuLabel>{project.name}</SidebarMenuLabel>
|
||||
</SidebarMenuButton>
|
||||
{canDelete ? (
|
||||
@@ -470,23 +578,32 @@ function SidebarProjectRow({
|
||||
</SidebarMenuItem>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onSelect={() => deferMenuAction(onRemove)}>
|
||||
<ContextMenuIconSlot>
|
||||
<ListMinus className="h-4 w-4" />
|
||||
</ContextMenuIconSlot>
|
||||
<span>Remove from sidebar</span>
|
||||
</ContextMenuItem>
|
||||
{shareLink ? (
|
||||
<ContextMenuItem
|
||||
onSelect={() =>
|
||||
deferMenuAction(() =>
|
||||
copyTextToClipboard(shareLink, "Link copied to clipboard"),
|
||||
)
|
||||
}
|
||||
>
|
||||
<ContextMenuIconSlot>
|
||||
<Link2 className="h-4 w-4" />
|
||||
</ContextMenuIconSlot>
|
||||
<span>Copy link</span>
|
||||
</ContextMenuItem>
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
onSelect={() =>
|
||||
deferMenuAction(() =>
|
||||
copyTextToClipboard(shareLink, "Link copied to clipboard"),
|
||||
)
|
||||
}
|
||||
>
|
||||
<ContextMenuIconSlot>
|
||||
<Link2 className="h-4 w-4" />
|
||||
</ContextMenuIconSlot>
|
||||
<span>Copy link</span>
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
) : null}
|
||||
{canDelete ? (
|
||||
<>
|
||||
{shareLink ? <ContextMenuSeparator /> : null}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
data-testid={`sidebar-project-delete-menu-${project.dtag}`}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import type { Project } from "@/features/projects/projectModels";
|
||||
import {
|
||||
isProjectMine,
|
||||
isProjectOwnedByCurrentUser,
|
||||
} from "@/features/projects/lib/projectsViewHelpers";
|
||||
import { isProjectOwnedByCurrentUser } from "@/features/projects/lib/projectsViewHelpers";
|
||||
|
||||
const SIDEBAR_PROJECTS_FILTER_KEY = "buzz.sidebar.projects.filter";
|
||||
const SIDEBAR_PROJECTS_SORT_KEY = "buzz.sidebar.projects.sort";
|
||||
|
||||
export type SidebarProjectsFilter = "mine" | "owned";
|
||||
export type SidebarProjectsFilter = "added" | "owned";
|
||||
export type SidebarProjectsSort = "name" | "created";
|
||||
|
||||
export function selectedProjectRouteId(pathname: string): string | undefined {
|
||||
@@ -24,9 +21,9 @@ export function selectedProjectRouteId(pathname: string): string | undefined {
|
||||
export function readSidebarProjectsFilter(): SidebarProjectsFilter {
|
||||
try {
|
||||
const value = globalThis.localStorage?.getItem(SIDEBAR_PROJECTS_FILTER_KEY);
|
||||
return value === "owned" ? "owned" : "mine";
|
||||
return value === "owned" ? "owned" : "added";
|
||||
} catch {
|
||||
return "mine";
|
||||
return "added";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,21 +53,24 @@ export function writeSidebarProjectsSort(sort: SidebarProjectsSort) {
|
||||
}
|
||||
|
||||
export function listSidebarProjects({
|
||||
addedProjectAddresses,
|
||||
currentPubkey,
|
||||
filter,
|
||||
projects,
|
||||
sort,
|
||||
}: {
|
||||
addedProjectAddresses: ReadonlySet<string>;
|
||||
currentPubkey: string | undefined;
|
||||
filter: SidebarProjectsFilter;
|
||||
projects: readonly Project[];
|
||||
sort: SidebarProjectsSort;
|
||||
}): Project[] {
|
||||
return [...projects]
|
||||
.filter((project) =>
|
||||
filter === "owned"
|
||||
? isProjectOwnedByCurrentUser(project, currentPubkey)
|
||||
: isProjectMine(project, currentPubkey),
|
||||
.filter(
|
||||
(project) =>
|
||||
addedProjectAddresses.has(project.projectAddress) &&
|
||||
(filter !== "owned" ||
|
||||
isProjectOwnedByCurrentUser(project, currentPubkey)),
|
||||
)
|
||||
.sort((left, right) => {
|
||||
if (sort === "created") {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildCommitLink,
|
||||
buildIssueLink,
|
||||
buildProjectLink,
|
||||
buildPullRequestLink,
|
||||
@@ -41,6 +42,14 @@ test("builders emit the canonical cross-language link format", () => {
|
||||
buildProjectLink({ owner: OWNER, dtag: GOLDEN.dtag }),
|
||||
GOLDEN.links.project,
|
||||
);
|
||||
assert.equal(
|
||||
buildCommitLink({
|
||||
commitHash: EVENT_ID,
|
||||
owner: OWNER,
|
||||
dtag: GOLDEN.dtag,
|
||||
}),
|
||||
GOLDEN.links.commit,
|
||||
);
|
||||
assert.deepEqual(ENTITY_LINK_TABS, GOLDEN.tabs);
|
||||
for (const dtag of GOLDEN.validDtags) {
|
||||
assert.equal(isLinkableCoordinate(OWNER, dtag), true);
|
||||
@@ -85,6 +94,34 @@ test("parseEntityLink round-trips built links", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("commit links select an exact repository commit", () => {
|
||||
const link = buildCommitLink({
|
||||
commitHash: EVENT_ID,
|
||||
owner: OWNER,
|
||||
dtag: "buzz-world",
|
||||
});
|
||||
assert.equal(
|
||||
link,
|
||||
`buzz://repo?owner=${OWNER}&d=buzz-world&tab=commits&commit=${EVENT_ID}`,
|
||||
);
|
||||
assert.deepEqual(parseEntityLink(link), {
|
||||
ok: true,
|
||||
value: {
|
||||
type: "repo",
|
||||
owner: OWNER,
|
||||
dtag: "buzz-world",
|
||||
tab: "commits",
|
||||
commitHash: EVENT_ID,
|
||||
},
|
||||
});
|
||||
assert.deepEqual(
|
||||
parseEntityLink(
|
||||
`buzz://repo?owner=${OWNER}&d=buzz-world&tab=files&commit=${EVENT_ID}`,
|
||||
),
|
||||
{ ok: false, reason: "invalid-commit" },
|
||||
);
|
||||
});
|
||||
|
||||
test("parseEntityLink lowercase-normalizes hex identifiers", () => {
|
||||
const parsed = parseEntityLink(
|
||||
`buzz://issue?id=${EVENT_ID.toUpperCase()}&owner=${OWNER.toUpperCase()}&d=buzz-world`,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* `features/messages/lib/messageLink.ts` for `buzz://message`.
|
||||
*
|
||||
* Formats:
|
||||
* buzz://repo?owner=<owner-pubkey>&d=<repo-dtag>[&tab=<tab>]
|
||||
* buzz://repo?owner=<owner-pubkey>&d=<repo-dtag>[&tab=<tab>][&commit=<git-hash>]
|
||||
* buzz://project?owner=<owner-pubkey>&d=<project-dtag>[&tab=<tab>]
|
||||
* buzz://pr?id=<event-id>&owner=<owner-pubkey>&d=<repo-dtag>
|
||||
* buzz://issue?id=<event-id>&owner=<owner-pubkey>&d=<repo-dtag>
|
||||
@@ -45,7 +45,13 @@ export function isEntityLinkTab(value: unknown): value is EntityLinkTab {
|
||||
export type ParsedEntityLink =
|
||||
| { type: "pr"; id: string; owner: string; dtag: string }
|
||||
| { type: "issue"; id: string; owner: string; dtag: string }
|
||||
| { type: "repo"; owner: string; dtag: string; tab?: EntityLinkTab }
|
||||
| {
|
||||
type: "repo";
|
||||
owner: string;
|
||||
dtag: string;
|
||||
tab?: EntityLinkTab;
|
||||
commitHash?: string;
|
||||
}
|
||||
| { type: "project"; owner: string; dtag: string; tab?: EntityLinkTab };
|
||||
|
||||
export type EntityLinkParseResult =
|
||||
@@ -53,6 +59,7 @@ export type EntityLinkParseResult =
|
||||
| { ok: false; reason: string };
|
||||
|
||||
const HEX64_RE = /^[a-fA-F0-9]{64}$/;
|
||||
const GIT_OBJECT_ID_RE = /^(?:[a-fA-F0-9]{40}|[a-fA-F0-9]{64})$/;
|
||||
const DTAG_RE = /^[a-zA-Z0-9._-]{1,64}$/;
|
||||
|
||||
function isValidDtag(dtag: string): boolean {
|
||||
@@ -102,6 +109,19 @@ export function buildRepoLink(input: {
|
||||
return `buzz://repo?owner=${input.owner.toLowerCase()}&d=${input.dtag}${tabSuffix(input.tab)}`;
|
||||
}
|
||||
|
||||
/** Build a link to a specific commit in a repository. */
|
||||
export function buildCommitLink(input: {
|
||||
commitHash: string;
|
||||
owner: string;
|
||||
dtag: string;
|
||||
}): string {
|
||||
checkCoordinate(input.owner, input.dtag);
|
||||
if (!GIT_OBJECT_ID_RE.test(input.commitHash)) {
|
||||
throw new Error("entityLink: commit must be a 40- or 64-char hex hash");
|
||||
}
|
||||
return `buzz://repo?owner=${input.owner.toLowerCase()}&d=${input.dtag}&tab=commits&commit=${input.commitHash.toLowerCase()}`;
|
||||
}
|
||||
|
||||
/** Build a `buzz://project` link for a project announcement (kind 30621). */
|
||||
export function buildProjectLink(input: {
|
||||
owner: string;
|
||||
@@ -198,10 +218,13 @@ export function parseEntityLink(url: string): EntityLinkParseResult {
|
||||
}
|
||||
|
||||
// Validate known params and reject unknown ones, and enforce single-instance.
|
||||
const KNOWN_COORDINATE_PARAMS = new Set(["owner", "d", "tab"]);
|
||||
const KNOWN_REPO_PARAMS = new Set(["owner", "d", "tab", "commit"]);
|
||||
const KNOWN_PROJECT_PARAMS = new Set(["owner", "d", "tab"]);
|
||||
const KNOWN_EVENT_PARAMS = new Set(["id", "owner", "d"]);
|
||||
const knownParams = isCoordinateHost
|
||||
? KNOWN_COORDINATE_PARAMS
|
||||
? host === "repo"
|
||||
? KNOWN_REPO_PARAMS
|
||||
: KNOWN_PROJECT_PARAMS
|
||||
: KNOWN_EVENT_PARAMS;
|
||||
|
||||
for (const key of parsed.searchParams.keys()) {
|
||||
@@ -230,6 +253,14 @@ export function parseEntityLink(url: string): EntityLinkParseResult {
|
||||
if (tab !== null && !isEntityLinkTab(tab)) {
|
||||
return { ok: false, reason: "invalid-tab" };
|
||||
}
|
||||
const commitHash =
|
||||
host === "repo" ? parsed.searchParams.get("commit") : null;
|
||||
if (
|
||||
commitHash !== null &&
|
||||
(tab !== "commits" || !GIT_OBJECT_ID_RE.test(commitHash))
|
||||
) {
|
||||
return { ok: false, reason: "invalid-commit" };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
@@ -237,6 +268,9 @@ export function parseEntityLink(url: string): EntityLinkParseResult {
|
||||
owner: owner.toLowerCase(),
|
||||
dtag,
|
||||
...(tab !== null && isEntityLinkTab(tab) ? { tab } : {}),
|
||||
...(commitHash !== null
|
||||
? { commitHash: commitHash.toLowerCase() }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
43
desktop/src/shared/ui/DrawerPanelIcon.tsx
Normal file
43
desktop/src/shared/ui/DrawerPanelIcon.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
export function DrawerPanelIcon({
|
||||
className,
|
||||
side,
|
||||
}: {
|
||||
className?: string;
|
||||
side: "left" | "right";
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={cn("h-4 w-auto shrink-0", className)}
|
||||
fill="none"
|
||||
height="22"
|
||||
viewBox="0 0 24 22"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
height="20"
|
||||
rx="5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
width="22"
|
||||
x="1"
|
||||
y="1"
|
||||
/>
|
||||
<rect
|
||||
className="transition-transform duration-200 ease-out motion-reduce:transition-none"
|
||||
fill="currentColor"
|
||||
height="14"
|
||||
rx="3"
|
||||
style={{
|
||||
transform: side === "right" ? "translateX(10px)" : "translateX(0)",
|
||||
}}
|
||||
width="6"
|
||||
x="4"
|
||||
y="4"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
4
desktop/src/shared/ui/assets/terminal.svg
Normal file
4
desktop/src/shared/ui/assets/terminal.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="24" height="22" viewBox="0 0 24 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="1" y="1" width="22" height="20" rx="5" stroke="#F5EFF7" stroke-width="2"/>
|
||||
<rect x="4" y="14" width="16" height="4" rx="2" fill="#F5EFF7"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 252 B |
@@ -12,6 +12,7 @@ import {
|
||||
import { useTheme } from "@/shared/theme/ThemeProvider";
|
||||
import { resolveShikiThemeName } from "@/shared/theme/theme-loader";
|
||||
import { copyCodeBlockToClipboard } from "@/shared/lib/codeBlockClipboard";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { useSmoothCorners } from "@/shared/ui/smoothCorners";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
@@ -131,6 +132,7 @@ export function MarkdownCodeBlock({
|
||||
}
|
||||
|
||||
export function SyntaxHighlightedCode({
|
||||
className,
|
||||
code,
|
||||
language,
|
||||
...props
|
||||
@@ -212,7 +214,7 @@ export function SyntaxHighlightedCode({
|
||||
}
|
||||
}, [code, language, shikiTheme, loadedKey]);
|
||||
|
||||
const codeClassName = CODE_BLOCK_CLASS;
|
||||
const codeClassName = cn(CODE_BLOCK_CLASS, className);
|
||||
|
||||
if (!tokens) {
|
||||
const lines = code.split("\n");
|
||||
|
||||
@@ -18,9 +18,13 @@ function entityLinkPresentation(link: ParsedEntityLink) {
|
||||
switch (link.type) {
|
||||
case "repo":
|
||||
return {
|
||||
ariaLabel: `Open repository ${link.dtag}`,
|
||||
ariaLabel: link.commitHash
|
||||
? `Open commit ${link.commitHash.slice(0, 8)} in repository ${link.dtag}`
|
||||
: `Open repository ${link.dtag}`,
|
||||
icon: "repo" as const,
|
||||
label: link.dtag,
|
||||
label: link.commitHash
|
||||
? `${link.dtag} · ${link.commitHash.slice(0, 8)}`
|
||||
: link.dtag,
|
||||
};
|
||||
case "pr":
|
||||
return {
|
||||
@@ -65,6 +69,9 @@ export function useOpenEntityLink(): (link: ParsedEntityLink) => void {
|
||||
: {}),
|
||||
...(link.type === "pr" ? { pullRequestId: link.id } : {}),
|
||||
...(link.type === "issue" ? { issueId: link.id } : {}),
|
||||
...(link.type === "repo" && link.commitHash
|
||||
? { commitHash: link.commitHash }
|
||||
: {}),
|
||||
});
|
||||
},
|
||||
[goProject],
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { performSidebarDefaultHaptic } from "@/shared/lib/haptics";
|
||||
import { hasPrimaryShortcutModifier } from "@/shared/lib/platform";
|
||||
import { useIsMobile } from "@/shared/hooks/use-mobile";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { DrawerPanelIcon } from "@/shared/ui/DrawerPanelIcon";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Separator } from "@/shared/ui/separator";
|
||||
import {
|
||||
@@ -394,7 +394,7 @@ const SidebarTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof Button>,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, onClick, ...props }, ref) => {
|
||||
const { toggleSidebar, open } = useSidebar();
|
||||
const { open, toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<Button
|
||||
@@ -409,7 +409,7 @@ const SidebarTrigger = React.forwardRef<
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{open ? <PanelLeftClose /> : <PanelLeftOpen />}
|
||||
<DrawerPanelIcon side={open ? "left" : "right"} />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -188,6 +188,10 @@ type E2eConfig = {
|
||||
pocketVoiceImportResult?: "success" | "cancel" | "invalid";
|
||||
/** Advertised HEAD for the first mock project without adding that branch. */
|
||||
projectHeadBranch?: string;
|
||||
/** Override the repository access channel for project authorization states. */
|
||||
projectAccessChannelId?: string;
|
||||
/** Make remote project snapshots fail with this git-facing message. */
|
||||
projectRepoSnapshotError?: string;
|
||||
/** Builderlab account returned by hosted-community onboarding. Null/omitted = signed out. */
|
||||
builderlabAuth?: {
|
||||
email?: string;
|
||||
@@ -5475,7 +5479,11 @@ function buildMockProjectEvents(): RelayEvent[] {
|
||||
["d", seed.dtag],
|
||||
["name", seed.name],
|
||||
["description", seed.description],
|
||||
["buzz-channel", "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"],
|
||||
[
|
||||
"buzz-channel",
|
||||
getConfig()?.mock?.projectAccessChannelId ??
|
||||
"9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
|
||||
],
|
||||
["clone", seed.cloneUrl],
|
||||
...seed.contributors.map((pubkey) => ["p", pubkey]),
|
||||
],
|
||||
@@ -11535,6 +11543,9 @@ export function maybeInstallE2eTauriMocks() {
|
||||
// viewer-identity avatar attribution is exercised in e2e.
|
||||
return { name: "Thomas P", email: "thomasp@example.com" };
|
||||
case "get_project_repo_snapshot":
|
||||
if (activeConfig?.mock?.projectRepoSnapshotError) {
|
||||
throw new Error(activeConfig.mock.projectRepoSnapshotError);
|
||||
}
|
||||
return {
|
||||
latest_commit: {
|
||||
hash: "0123456789abcdef0123456789abcdef01234567",
|
||||
|
||||
@@ -18,6 +18,17 @@ async function enableProjectsFeature(page: import("@playwright/test").Page) {
|
||||
});
|
||||
}
|
||||
|
||||
async function addProjectToSidebar(
|
||||
page: import("@playwright/test").Page,
|
||||
dtag: string,
|
||||
) {
|
||||
await page.getByTestId("sidebar-projects-section-label").hover();
|
||||
await page.getByTestId("sidebar-projects-create").click();
|
||||
const browser = page.getByTestId("project-browser-dialog");
|
||||
await browser.getByRole("searchbox", { name: "Search projects" }).fill(dtag);
|
||||
await browser.getByTestId(`project-browser-result-${dtag}`).click();
|
||||
}
|
||||
|
||||
async function waitForMockLiveSubscription(
|
||||
page: import("@playwright/test").Page,
|
||||
channelName: string,
|
||||
@@ -320,9 +331,10 @@ test("unsupported relays keep the initial repository accessible", async ({
|
||||
await projectEntry
|
||||
.getByRole("button", { name: "View legacy-fallback" })
|
||||
.click();
|
||||
const repositoryPicker = page.getByTestId("project-repository-picker");
|
||||
await expect(repositoryPicker).toBeVisible();
|
||||
await expect(repositoryPicker).toContainText("legacy-fallback");
|
||||
const repositoryRow = page.getByTestId(
|
||||
"sidebar-project-repository-legacy-fallback",
|
||||
);
|
||||
await expect(repositoryRow).toBeVisible();
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({
|
||||
path: `${SHOTS}/06-single-repository-add.png`,
|
||||
@@ -419,41 +431,34 @@ test("multi-repository projects switch the active repository", async ({
|
||||
await enableProjectsFeature(page);
|
||||
await installMockBridge(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByTestId("open-projects-view").click();
|
||||
await page.getByTestId("projects-section-projects").click();
|
||||
await page
|
||||
.locator(
|
||||
'[data-testid="project-card-buzz"], [data-testid="project-row-buzz"]',
|
||||
)
|
||||
.first()
|
||||
.click();
|
||||
await addProjectToSidebar(page, "buzz");
|
||||
|
||||
const picker = page.getByTestId("project-repository-picker");
|
||||
await expect(picker).toContainText("buzz");
|
||||
await picker.click();
|
||||
await expect(
|
||||
page.getByTestId("project-repository-relay-tools"),
|
||||
).toBeVisible();
|
||||
const primaryRepository = page.getByTestId("sidebar-project-repository-buzz");
|
||||
const relayToolsRepository = page.getByTestId(
|
||||
"sidebar-project-repository-relay-tools",
|
||||
);
|
||||
await expect(primaryRepository).toHaveAttribute("data-active", "true");
|
||||
await expect(relayToolsRepository).toBeVisible();
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({
|
||||
path: `${SHOTS}/04-multi-repository-picker.png`,
|
||||
});
|
||||
|
||||
await page.getByTestId("project-repository-relay-tools").click();
|
||||
await expect(picker).toContainText("relay-tools");
|
||||
await relayToolsRepository.click();
|
||||
await expect(relayToolsRepository).toHaveAttribute("data-active", "true");
|
||||
await expect(page).toHaveURL(
|
||||
new RegExp(`repositoryId=${TEST_IDENTITIES.alice.pubkey}%3Arelay-tools`),
|
||||
);
|
||||
|
||||
await picker.click();
|
||||
await page.getByTestId("add-project-repository").click();
|
||||
await expect(page.getByTestId("create-project-repository")).toBeVisible();
|
||||
await expect(page.getByTestId("attach-project-repository")).toBeVisible();
|
||||
await page.getByTestId("create-project-repository").click();
|
||||
await page.getByTestId("add-project-repository-name").fill("mobile-app");
|
||||
await page.getByTestId("add-project-repository-submit").click();
|
||||
await expect(page.getByTestId("add-project-repository-dialog")).toBeHidden();
|
||||
await expect(picker).toContainText("mobile-app");
|
||||
await expect(
|
||||
page.getByTestId("sidebar-project-repository-mobile-app"),
|
||||
).toBeVisible();
|
||||
const addedEvents = await page.evaluate(
|
||||
() =>
|
||||
window.__BUZZ_E2E_ACCEPTED_PROJECT_EVENTS__?.filter(
|
||||
@@ -472,7 +477,6 @@ test("multi-repository projects switch the active repository", async ({
|
||||
addedEvents.find((event) => event.kind === 30617)?.tags,
|
||||
).toContainEqual(["buzz-channel", "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"]);
|
||||
|
||||
await picker.click();
|
||||
await page.getByTestId("add-project-repository").click();
|
||||
await page.getByTestId("attach-project-repository").click();
|
||||
await expect(
|
||||
@@ -482,7 +486,9 @@ test("multi-repository projects switch the active repository", async ({
|
||||
await expect(
|
||||
page.getByTestId("attach-project-repository-dialog"),
|
||||
).toBeHidden();
|
||||
await expect(picker).toContainText("design-system");
|
||||
await expect(
|
||||
page.getByTestId("sidebar-project-repository-design-system"),
|
||||
).toBeVisible();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
@@ -526,10 +532,22 @@ 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 });
|
||||
|
||||
// Commits share the rounded list structure used by issues and pull requests.
|
||||
// Commits use the same compact, aligned row structure as work items.
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Commits", exact: true }),
|
||||
).toBeVisible();
|
||||
const firstCommitRow = commitRows.first();
|
||||
expect((await firstCommitRow.boundingBox())?.height).toBeLessThanOrEqual(40);
|
||||
await expect(firstCommitRow.getByTitle(/^View commit /)).toBeVisible();
|
||||
await expect(
|
||||
firstCommitRow.getByTestId("project-commit-author"),
|
||||
).toHaveAttribute("title", /^Committed by .+ · \d+ commits?$/);
|
||||
await expect(
|
||||
firstCommitRow.getByRole("button", { name: "Copy commit hash" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
firstCommitRow.getByTestId("project-commit-row-date"),
|
||||
).toHaveClass(/text-muted-foreground\/70/);
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({
|
||||
fullPage: false,
|
||||
@@ -542,14 +560,17 @@ test("commit detail opens from the commits feed with a diff", async ({
|
||||
.getByRole("button", { name: /Add Trello board workflow details/ })
|
||||
.click();
|
||||
|
||||
// Detail header: author line, subject, and hash.
|
||||
await expect(page.getByText("Commit from")).toBeVisible();
|
||||
// Detail header: resolved author, subject, and hash.
|
||||
await expect(page.getByText("Brain", { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "Add Trello board workflow details" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Copy commit hash" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Copy commit link" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("project-workspace-tab-menu")).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("link", { name: "project guide", exact: true }),
|
||||
@@ -594,10 +615,10 @@ test("commit detail opens from the commits feed with a diff", async ({
|
||||
.first()
|
||||
.getByRole("button", { name: /Add Trello board workflow details/ })
|
||||
.click();
|
||||
await expect(page.getByText("Commit from")).toBeVisible();
|
||||
await expect(page.getByText("Brain", { exact: true })).toBeVisible();
|
||||
await page
|
||||
.getByRole("navigation", { name: "Project breadcrumb" })
|
||||
.getByRole("button", { name: "buzz", exact: true })
|
||||
.getByTestId("project-breadcrumb-repository")
|
||||
.click();
|
||||
await expect(page.getByRole("tab", { name: "Overview" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
@@ -674,7 +695,7 @@ test("project discussion row opens its channel thread in context", async ({
|
||||
await expect(panel).toBeHidden();
|
||||
});
|
||||
|
||||
test("pull request and issue feeds share the commit row structure", async ({
|
||||
test("pull request and issue feeds use compact work item rows", async ({
|
||||
page,
|
||||
}) => {
|
||||
await enableProjectsFeature(page);
|
||||
@@ -694,17 +715,30 @@ test("pull request and issue feeds share the commit row structure", async ({
|
||||
await expect(projectEntry).toBeVisible({ timeout: 10_000 });
|
||||
await projectEntry.click();
|
||||
|
||||
// PR rows use the shared feed row: title button + #id cluster cell.
|
||||
// Reviews use the compact single-line work-item row.
|
||||
await page.getByRole("tab", { name: "Review" }).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();
|
||||
expect((await prRows.first().boundingBox())?.height).toBeLessThanOrEqual(40);
|
||||
await expect(
|
||||
prRows.first().getByTestId("project-pull-request-comments"),
|
||||
).toHaveText("0");
|
||||
await expect(
|
||||
prRows.first().getByTestId("project-pull-request-row-date"),
|
||||
).toHaveClass(/text-muted-foreground\/70/);
|
||||
await expect(
|
||||
page.getByTestId("project-work-item-group-header").first(),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
prRows.first().locator("button").filter({ hasText: /.+/ }).nth(1),
|
||||
).toHaveCSS("font-weight", "400");
|
||||
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.
|
||||
// The inline #id opens the review detail, same as clicking the title.
|
||||
await prRows.first().getByRole("button", { name: /^#/ }).click();
|
||||
await expect(
|
||||
page.getByRole("navigation", { name: "Project breadcrumb" }),
|
||||
@@ -717,13 +751,47 @@ test("pull request and issue feeds share the commit row structure", async ({
|
||||
.click();
|
||||
await expect(prRows.first()).toBeVisible();
|
||||
|
||||
// Issue rows share the same structure.
|
||||
// Tasks share the same compact structure.
|
||||
await page.getByRole("tab", { name: "Tasks" }).click();
|
||||
const issueRows = page.getByTestId("project-issue-row");
|
||||
await expect(issueRows.first()).toBeVisible({ timeout: 10_000 });
|
||||
await expect(
|
||||
issueRows.first().getByRole("button", { name: /^#/ }),
|
||||
).toBeVisible();
|
||||
expect((await issueRows.first().boundingBox())?.height).toBeLessThanOrEqual(
|
||||
40,
|
||||
);
|
||||
const taskCategoryCells = issueRows.getByTestId("project-issue-row-category");
|
||||
await expect(taskCategoryCells.first()).toHaveText(
|
||||
/^(Issue|Change request|Improvement)$/,
|
||||
);
|
||||
const taskCreator = issueRows.first().getByTestId("project-issue-creator");
|
||||
const emptyAssignee = issueRows
|
||||
.first()
|
||||
.getByTestId("project-issue-assignee-placeholder");
|
||||
await expect(taskCreator).toHaveAttribute("title", /^Created by /);
|
||||
await expect(emptyAssignee).toHaveAttribute("title", "Unassigned");
|
||||
const [taskCreatorBox, emptyAssigneeBox] = await Promise.all([
|
||||
taskCreator.boundingBox(),
|
||||
emptyAssignee.boundingBox(),
|
||||
]);
|
||||
expect(
|
||||
(emptyAssigneeBox?.x ?? 0) -
|
||||
((taskCreatorBox?.x ?? 0) + (taskCreatorBox?.width ?? 0)),
|
||||
).toBeLessThanOrEqual(4);
|
||||
await expect(
|
||||
issueRows.first().getByTestId("project-issue-comments"),
|
||||
).toHaveText("0");
|
||||
await expect(
|
||||
issueRows.first().getByTestId("project-issue-row-date"),
|
||||
).toHaveClass(/text-muted-foreground\/70/);
|
||||
const taskCategoryBoxes = await taskCategoryCells.evaluateAll((cells) =>
|
||||
cells.slice(0, 5).map((cell) => {
|
||||
const box = cell.getBoundingClientRect();
|
||||
return box.x + box.width;
|
||||
}),
|
||||
);
|
||||
expect(new Set(taskCategoryBoxes).size).toBe(1);
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({
|
||||
fullPage: false,
|
||||
@@ -743,17 +811,8 @@ test("adding a repository retries and reports an error when the 30617 publicatio
|
||||
});
|
||||
await installMockBridge(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByTestId("open-projects-view").click();
|
||||
await page.getByTestId("projects-section-projects").click();
|
||||
await page
|
||||
.locator(
|
||||
'[data-testid="project-card-buzz"], [data-testid="project-row-buzz"]',
|
||||
)
|
||||
.first()
|
||||
.click();
|
||||
await addProjectToSidebar(page, "buzz");
|
||||
|
||||
const picker = page.getByTestId("project-repository-picker");
|
||||
await picker.click();
|
||||
await page.getByTestId("add-project-repository").click();
|
||||
await page.getByTestId("create-project-repository").click();
|
||||
await page.getByTestId("add-project-repository-name").fill("rejected-repo");
|
||||
@@ -810,18 +869,8 @@ test("adding a repository treats a lost 30617 acknowledgement as success", async
|
||||
});
|
||||
await installMockBridge(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByTestId("open-projects-view").click();
|
||||
await page.getByTestId("projects-section-projects").click();
|
||||
await page
|
||||
.locator(
|
||||
'[data-testid="project-card-buzz"], [data-testid="project-row-buzz"]',
|
||||
)
|
||||
.first()
|
||||
.click();
|
||||
await addProjectToSidebar(page, "buzz");
|
||||
|
||||
const picker = page.getByTestId("project-repository-picker");
|
||||
|
||||
await picker.click();
|
||||
await page.getByTestId("add-project-repository").click();
|
||||
await page.getByTestId("create-project-repository").click();
|
||||
await page.getByTestId("add-project-repository-name").fill("lost-ack-repo");
|
||||
@@ -829,8 +878,10 @@ test("adding a repository treats a lost 30617 acknowledgement as success", async
|
||||
|
||||
// The dialog should close — the operation recovered from the lost ACK.
|
||||
await expect(page.getByTestId("add-project-repository-dialog")).toBeHidden();
|
||||
// The repository picker must reflect the newly added repository.
|
||||
await expect(picker).toContainText("lost-ack-repo");
|
||||
// The sidebar must reflect the newly added repository.
|
||||
await expect(
|
||||
page.getByTestId("sidebar-project-repository-lost-ack-repo"),
|
||||
).toBeVisible();
|
||||
|
||||
// Both events must have been accepted: the 30621 (project update) and the
|
||||
// 30617 (repository — accepted by relay even though ACK was lost).
|
||||
@@ -887,8 +938,6 @@ test("adding a repository blocks when a standalone 30617 already exists at that
|
||||
.first()
|
||||
.click();
|
||||
|
||||
const picker = page.getByTestId("project-repository-picker");
|
||||
await picker.click();
|
||||
await page.getByTestId("add-project-repository").click();
|
||||
await page.getByTestId("create-project-repository").click();
|
||||
// Use the same name — the dtag will match the seeded standalone 30617.
|
||||
@@ -975,10 +1024,10 @@ test("navigating via a 30617 entity-link route opens the correct non-primary rep
|
||||
{ waitUntil: "domcontentloaded" },
|
||||
);
|
||||
|
||||
// Repository controls intentionally render only on README and Files. The
|
||||
// seeded PR and branch prove that this detail route resolved relay-tools
|
||||
// rather than falling back to the project's primary repository.
|
||||
await expect(page.getByTestId("project-repository-picker")).toHaveCount(0);
|
||||
// Direct navigation must not implicitly add the project to the sidebar.
|
||||
await expect(page.getByTestId("sidebar-project-buzz")).toHaveCount(0);
|
||||
// The seeded PR proves that this detail route resolved relay-tools rather
|
||||
// than falling back to the project's primary repository.
|
||||
// Use `first()` to avoid Playwright strict-mode violations: the text appears
|
||||
// in both the breadcrumb and the PR title heading once the detail panel opens.
|
||||
await expect(
|
||||
|
||||
@@ -1119,7 +1119,7 @@ test("project detail content areas do not paint background fills", async ({
|
||||
await expectVisiblePanelsToBeTransparent();
|
||||
});
|
||||
|
||||
test("project without a checkout offers fetch feedback and dropdown cloning", async ({
|
||||
test("project without a checkout offers fetch feedback and cloning", async ({
|
||||
page,
|
||||
}) => {
|
||||
await enableProjectsFeature(page);
|
||||
@@ -1127,21 +1127,21 @@ test("project without a checkout offers fetch feedback and dropdown cloning", as
|
||||
await openBuzzProject(page);
|
||||
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Buzz", exact: true }),
|
||||
page.getByRole("button", { name: "Remote", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Buzz", exact: true }),
|
||||
page.getByRole("button", { name: "Remote", exact: true }),
|
||||
).toHaveClass(/\bborder-input\/40\b/);
|
||||
await expect(page.getByRole("button", { name: /main/ })).toHaveClass(
|
||||
/\bborder-input\/40\b/,
|
||||
);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Clone", exact: true }),
|
||||
).toHaveCount(0);
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "Fetch", exact: true }).click();
|
||||
await expect(page.getByText("Remote state refreshed.")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "Buzz", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Remote", exact: true }).click();
|
||||
const cloneItem = page.getByRole("menuitem", {
|
||||
name: "Local missing Clone",
|
||||
});
|
||||
@@ -1223,7 +1223,7 @@ test("repository tags can be browsed as immutable remote snapshots", async ({
|
||||
|
||||
await expect(page.getByRole("button", { name: /v1\.0\.0/ })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Buzz", exact: true }),
|
||||
page.getByRole("button", { name: "Remote", exact: true }),
|
||||
).toBeVisible();
|
||||
await expect
|
||||
.poll(() =>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { waitForAnimations } from "../helpers/animations";
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
|
||||
|
||||
const SHOTS = "test-results/projects-v3-screenshots";
|
||||
|
||||
@@ -18,30 +18,325 @@ async function openBuzzProject(page: import("@playwright/test").Page) {
|
||||
await projectEntry.click();
|
||||
}
|
||||
|
||||
test("sidebar project add flow browses before creating", async ({ page }) => {
|
||||
await installMockBridge(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await expect(page.getByTestId("sidebar-project-buzz")).toHaveCount(0);
|
||||
await page.getByTestId("sidebar-projects-section-label").hover();
|
||||
await page.getByTestId("sidebar-projects-create").click();
|
||||
|
||||
const browser = page.getByTestId("project-browser-dialog");
|
||||
await expect(browser).toBeVisible();
|
||||
const search = browser.getByRole("searchbox", { name: "Search projects" });
|
||||
await search.fill("new workspace");
|
||||
await browser.getByTestId("project-browser-create").click();
|
||||
|
||||
await expect(page.getByTestId("create-project-dialog")).toBeVisible();
|
||||
await expect(page.getByTestId("create-project-name")).toHaveValue(
|
||||
"new workspace",
|
||||
);
|
||||
await page.getByRole("button", { name: "Back to projects" }).click();
|
||||
await expect(browser).toBeVisible();
|
||||
|
||||
await search.fill("buzz");
|
||||
await browser.getByTestId("project-browser-result-buzz").click();
|
||||
await expect(browser).toBeHidden();
|
||||
await expect(
|
||||
page.getByRole("navigation", { name: "Project breadcrumb" }),
|
||||
).toContainText("buzz");
|
||||
const addedProject = page.getByTestId("sidebar-project-buzz");
|
||||
await expect(addedProject).toBeVisible();
|
||||
await addedProject.click({ button: "right" });
|
||||
await page.getByRole("menuitem", { name: "Remove from sidebar" }).click();
|
||||
await expect(addedProject).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("restricted repositories keep event work visible and offer access help", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript((owner) => {
|
||||
window.__BUZZ_E2E_PROJECT_OWNER_OVERRIDE__ = owner;
|
||||
}, TEST_IDENTITIES.alice.pubkey);
|
||||
await installMockBridge(page, {
|
||||
projectAccessChannelId: "11111111-1111-4111-8111-111111111111",
|
||||
projectRepoSnapshotError: "remote: repository not found",
|
||||
});
|
||||
await openBuzzProject(page);
|
||||
|
||||
const unavailableState = page
|
||||
.getByTestId("project-repository-unavailable")
|
||||
.first();
|
||||
await expect(unavailableState).toContainText("Repository access restricted");
|
||||
await expect(
|
||||
unavailableState.getByTestId("repository-owner-name"),
|
||||
).toHaveText("alice");
|
||||
await expect(unavailableState).toContainText(/the repository owner/i);
|
||||
await expect(
|
||||
unavailableState
|
||||
.locator(
|
||||
'[data-testid="repository-owner-avatar-image"], [data-testid="repository-owner-avatar-fallback"]',
|
||||
)
|
||||
.first(),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("project-section-header")).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Ask for access" }),
|
||||
).toHaveCount(2);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Clone", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expect(page.getByTestId("project-repository-file-count")).toHaveText(
|
||||
"—",
|
||||
);
|
||||
|
||||
await page.getByRole("tab", { name: "Tasks", exact: true }).click();
|
||||
await expect(page.getByTestId("project-issue-row").first()).toBeVisible();
|
||||
|
||||
await page.getByRole("tab", { name: "Review", exact: true }).click();
|
||||
await expect(
|
||||
page.getByTestId("project-pull-request-row").first(),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole("tab", { name: "Files", exact: true }).click();
|
||||
await expect(page.getByText("Repository access restricted")).toBeVisible();
|
||||
await expect(page.getByTestId("project-section-header")).toHaveCount(0);
|
||||
|
||||
await page.getByRole("tab", { name: "Commits", exact: true }).click();
|
||||
await expect(page.getByText("Repository access restricted")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Ask for access" }).first().click();
|
||||
|
||||
const chatPanel = page.getByTestId("project-agent-chat-panel");
|
||||
await expect(chatPanel).toBeVisible();
|
||||
await expect(chatPanel.getByTestId("project-agent-context")).toContainText(
|
||||
"Commits",
|
||||
);
|
||||
await expect(chatPanel.getByTestId("message-composer")).toBeVisible();
|
||||
});
|
||||
|
||||
// Walks the Projects v3 workspace through its headline states so PR
|
||||
// screenshots capture distinct pixels per feature (overview box, tab-strip
|
||||
// plus, issue detail with inline copy link + avatar timeline, PR detail).
|
||||
test("projects v3 workspace screenshot states", async ({ page }) => {
|
||||
await installMockBridge(page);
|
||||
await openBuzzProject(page);
|
||||
const initialProjectBreadcrumb = page.getByRole("navigation", {
|
||||
name: "Project breadcrumb",
|
||||
});
|
||||
await expect(
|
||||
initialProjectBreadcrumb.getByTestId("project-breadcrumb-project"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
initialProjectBreadcrumb.getByTestId("project-breadcrumb-repository"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Open Discussion" }),
|
||||
).toHaveCount(0);
|
||||
|
||||
// Single-box workspace: tabs sit above the pickers, which only appear for
|
||||
// the README overview and Files.
|
||||
// Borderless workspace: repository controls live in the persistent,
|
||||
// resizable auxiliary panel instead of a header row.
|
||||
const overviewTab = page.getByRole("tab", { name: "Overview" });
|
||||
const filesTab = page.getByRole("tab", { name: "Files" });
|
||||
const channelsTab = page.getByRole("tab", { name: "Channels" });
|
||||
const contributorsTab = page.getByRole("tab", { name: "Contributors" });
|
||||
const tabMenu = page.getByTestId("project-workspace-tab-menu");
|
||||
const workspacePanel = page.getByTestId("project-workspace-panel");
|
||||
const selectionRow = page.getByTestId("project-repository-selection-row");
|
||||
const projectDetailScroll = page.getByTestId("project-detail-scroll");
|
||||
const repositoryActionsPanel = page.getByTestId(
|
||||
"project-repository-actions-panel",
|
||||
);
|
||||
const expectFullWidthSection = async () => {
|
||||
const sectionHeader = workspacePanel.getByTestId("project-section-header");
|
||||
const [workspaceBox, headerBox, menuBox] = await Promise.all([
|
||||
workspacePanel.boundingBox(),
|
||||
sectionHeader.boundingBox(),
|
||||
tabMenu.boundingBox(),
|
||||
]);
|
||||
expect(workspaceBox).not.toBeNull();
|
||||
expect(headerBox).not.toBeNull();
|
||||
expect(menuBox).not.toBeNull();
|
||||
expect(headerBox?.x).toBe(workspaceBox?.x);
|
||||
expect(headerBox?.width).toBe(workspaceBox?.width);
|
||||
expect(workspaceBox?.x).toBe(menuBox?.x);
|
||||
expect((workspaceBox?.x ?? 0) + (workspaceBox?.width ?? 0)).toBe(
|
||||
(menuBox?.x ?? 0) + (menuBox?.width ?? 0),
|
||||
);
|
||||
};
|
||||
await expect(filesTab).toBeVisible();
|
||||
await expect(selectionRow).toBeVisible();
|
||||
await expect(projectDetailScroll).toHaveCSS("overscroll-behavior-y", "none");
|
||||
await expect(overviewTab).toHaveAttribute("data-state", "active");
|
||||
const [projectDetailScrollBounds, initialTabMenuBounds] = await Promise.all([
|
||||
projectDetailScroll.boundingBox(),
|
||||
tabMenu.boundingBox(),
|
||||
]);
|
||||
expect(projectDetailScrollBounds).not.toBeNull();
|
||||
expect(initialTabMenuBounds?.y).toBe(projectDetailScrollBounds?.y);
|
||||
expect(
|
||||
await filesTab.evaluate((element) =>
|
||||
Number.parseFloat(getComputedStyle(element).borderRadius),
|
||||
),
|
||||
).toBeGreaterThan(13);
|
||||
expect((await filesTab.boundingBox())?.height).toBe(28);
|
||||
await expect(repositoryActionsPanel).toBeVisible();
|
||||
const repositoryPanelTab = page.getByTestId(
|
||||
"project-right-panel-repository-tab",
|
||||
);
|
||||
const chatPanelTab = page.getByTestId("project-right-panel-chat-tab");
|
||||
const terminalButton = page.getByTestId("project-terminal-toggle");
|
||||
const terminalIcon = page.getByTestId("project-terminal-icon");
|
||||
await expect(repositoryPanelTab).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(terminalIcon).toBeVisible();
|
||||
expect(
|
||||
await terminalIcon.evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
return {
|
||||
backgroundColor: style.backgroundColor,
|
||||
maskImage: style.maskImage || style.webkitMaskImage,
|
||||
parentColor: getComputedStyle(element.parentElement ?? element).color,
|
||||
};
|
||||
}),
|
||||
).toMatchObject({
|
||||
backgroundColor: await terminalButton.evaluate(
|
||||
(element) => getComputedStyle(element).color,
|
||||
),
|
||||
maskImage: expect.not.stringMatching(/^none$/),
|
||||
});
|
||||
const [repositoryTabBounds, chatTabBounds] = await Promise.all([
|
||||
repositoryPanelTab.boundingBox(),
|
||||
chatPanelTab.boundingBox(),
|
||||
]);
|
||||
expect(repositoryTabBounds?.width).toBe(chatTabBounds?.width);
|
||||
await chatPanelTab.click();
|
||||
const agentChatPanel = page.getByTestId("project-agent-chat-panel");
|
||||
await expect(agentChatPanel).toBeVisible();
|
||||
await expect(agentChatPanel.getByTestId("message-composer")).toBeVisible();
|
||||
const agentContext = agentChatPanel.getByTestId("project-agent-context");
|
||||
await expect(agentContext).toBeVisible();
|
||||
await expect(agentContext).toContainText("Overview");
|
||||
await expect(agentContext).not.toContainText("Buzz /");
|
||||
const [tabMenuHeaderBounds, agentContextBounds] = await Promise.all([
|
||||
tabMenu.boundingBox(),
|
||||
agentContext.boundingBox(),
|
||||
]);
|
||||
expect(tabMenuHeaderBounds).not.toBeNull();
|
||||
expect(agentContextBounds).not.toBeNull();
|
||||
expect(tabMenuHeaderBounds?.height).toBe(52);
|
||||
expect(
|
||||
Math.abs((tabMenuHeaderBounds?.y ?? 0) - (agentContextBounds?.y ?? 0)),
|
||||
).toBeLessThanOrEqual(1);
|
||||
expect(
|
||||
Math.abs(
|
||||
(tabMenuHeaderBounds?.height ?? 0) - (agentContextBounds?.height ?? 0),
|
||||
),
|
||||
).toBeLessThanOrEqual(1);
|
||||
const sharedHeaderBackdrop = page.getByTestId(
|
||||
"project-shared-header-backdrop",
|
||||
);
|
||||
await expect(sharedHeaderBackdrop).toBeVisible();
|
||||
await expect
|
||||
.poll(() =>
|
||||
sharedHeaderBackdrop.evaluate(
|
||||
(element) => getComputedStyle(element).backdropFilter,
|
||||
),
|
||||
)
|
||||
.not.toBe("none");
|
||||
const sharedHeaderBackdropBounds = await sharedHeaderBackdrop.boundingBox();
|
||||
expect(sharedHeaderBackdropBounds).not.toBeNull();
|
||||
expect(sharedHeaderBackdropBounds?.x).toBeLessThanOrEqual(
|
||||
tabMenuHeaderBounds?.x ?? 0,
|
||||
);
|
||||
expect(
|
||||
(sharedHeaderBackdropBounds?.x ?? 0) +
|
||||
(sharedHeaderBackdropBounds?.width ?? 0),
|
||||
).toBeGreaterThanOrEqual(
|
||||
(agentContextBounds?.x ?? 0) + (agentContextBounds?.width ?? 0),
|
||||
);
|
||||
await expect
|
||||
.poll(() =>
|
||||
agentContext.evaluate((element) => getComputedStyle(element).position),
|
||||
)
|
||||
.toBe("absolute");
|
||||
const agentConversationScroll = agentChatPanel.getByTestId(
|
||||
"project-agent-conversation-scroll",
|
||||
);
|
||||
await expect
|
||||
.poll(() =>
|
||||
agentConversationScroll.evaluate((element) =>
|
||||
Number.parseFloat(getComputedStyle(element).paddingTop),
|
||||
),
|
||||
)
|
||||
.toBeGreaterThanOrEqual(52);
|
||||
await agentChatPanel
|
||||
.getByTestId("message-input")
|
||||
.fill("Summarize this repository.");
|
||||
await agentChatPanel.getByTestId("send-message").click();
|
||||
const projectAgentMessage = agentChatPanel
|
||||
.locator("[data-message-id]")
|
||||
.filter({ hasText: "Summarize this repository." });
|
||||
await expect(projectAgentMessage).toBeVisible();
|
||||
const projectAgentMessageId =
|
||||
await projectAgentMessage.getAttribute("data-message-id");
|
||||
expect(projectAgentMessageId).not.toBeNull();
|
||||
const projectAgentChannelId = await agentChatPanel
|
||||
.locator("[data-project-agent-channel-id]")
|
||||
.getAttribute("data-project-agent-channel-id");
|
||||
expect(projectAgentChannelId).not.toBeNull();
|
||||
await page.evaluate(
|
||||
async ({ channelId, parentEventId }) => {
|
||||
if (!channelId)
|
||||
throw new Error("Project agent DM channel was not recorded.");
|
||||
await window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__?.("send_channel_message", {
|
||||
channelId,
|
||||
content: "A persisted threaded agent response.",
|
||||
parentEventId: parentEventId ?? undefined,
|
||||
});
|
||||
},
|
||||
{ channelId: projectAgentChannelId, parentEventId: projectAgentMessageId },
|
||||
);
|
||||
await expect(
|
||||
workspacePanel.getByTestId("project-repository-selection-row"),
|
||||
agentChatPanel.getByText("A persisted threaded agent response."),
|
||||
).toBeVisible();
|
||||
await expect(repositoryActionsPanel).toHaveCount(0);
|
||||
await page.getByTestId("project-right-panel-repository-tab").click();
|
||||
await expect(repositoryActionsPanel).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("project-repository-selection-row"),
|
||||
).toHaveCount(0);
|
||||
await page.getByTestId("project-repository-actions-collapse").click();
|
||||
await expect(repositoryActionsPanel).toHaveCount(0);
|
||||
const collapsedMainPaneBounds = await workspacePanel.boundingBox();
|
||||
const viewportSize = page.viewportSize();
|
||||
expect(collapsedMainPaneBounds).not.toBeNull();
|
||||
expect(viewportSize).not.toBeNull();
|
||||
expect(
|
||||
Math.abs(
|
||||
(collapsedMainPaneBounds?.x ?? 0) +
|
||||
(collapsedMainPaneBounds?.width ?? 0) -
|
||||
(viewportSize?.width ?? 0),
|
||||
),
|
||||
).toBeLessThanOrEqual(8);
|
||||
const expandRepositoryPanel = page.getByTestId(
|
||||
"project-repository-actions-expand",
|
||||
);
|
||||
const projectBreadcrumb = page.getByRole("navigation", {
|
||||
name: "Project breadcrumb",
|
||||
});
|
||||
const expandBounds = await expandRepositoryPanel.boundingBox();
|
||||
const breadcrumbBounds = await projectBreadcrumb.boundingBox();
|
||||
expect(expandBounds).not.toBeNull();
|
||||
expect(breadcrumbBounds).not.toBeNull();
|
||||
expect(
|
||||
Math.abs(
|
||||
(expandBounds?.y ?? 0) +
|
||||
(expandBounds?.height ?? 0) / 2 -
|
||||
((breadcrumbBounds?.y ?? 0) + (breadcrumbBounds?.height ?? 0) / 2),
|
||||
),
|
||||
).toBeLessThanOrEqual(1);
|
||||
await expandRepositoryPanel.click();
|
||||
await expect(repositoryActionsPanel).toBeVisible();
|
||||
await expect(page.getByTestId("project-detail-copy-link")).toBeVisible();
|
||||
await expect(
|
||||
repositoryActionsPanel.getByTestId("project-detail-copy-link"),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
workspacePanel.getByTestId("project-workspace-tab-menu"),
|
||||
).toHaveCount(0);
|
||||
@@ -50,36 +345,45 @@ test("projects v3 workspace screenshot states", async ({ page }) => {
|
||||
const contributorsBounds = await contributorsTab.boundingBox();
|
||||
const tabMenuBounds = await tabMenu.boundingBox();
|
||||
const workspaceBounds = await workspacePanel.boundingBox();
|
||||
const selectionBounds = await selectionRow.boundingBox();
|
||||
const repositoryActionsBounds = await repositoryActionsPanel.boundingBox();
|
||||
expect(overviewBounds).not.toBeNull();
|
||||
expect(channelsBounds).not.toBeNull();
|
||||
expect(contributorsBounds).not.toBeNull();
|
||||
expect(tabMenuBounds).not.toBeNull();
|
||||
expect(workspaceBounds).not.toBeNull();
|
||||
expect(selectionBounds).not.toBeNull();
|
||||
expect(repositoryActionsBounds).not.toBeNull();
|
||||
expect(channelsBounds?.x).toBeLessThan(contributorsBounds?.x ?? 0);
|
||||
expect(overviewBounds?.x).toBe(workspaceBounds?.x);
|
||||
expect(overviewBounds?.x).toBeGreaterThan(workspaceBounds?.x ?? 0);
|
||||
expect(tabMenuBounds?.y).toBeLessThan(workspaceBounds?.y ?? 0);
|
||||
expect(overviewBounds?.y).toBeLessThan(selectionBounds?.y ?? 0);
|
||||
expect(repositoryActionsBounds?.x).toBeGreaterThan(workspaceBounds?.x ?? 0);
|
||||
await expect(
|
||||
workspacePanel.getByRole("heading", { name: "README", exact: true }),
|
||||
).toBeVisible();
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
workspacePanel.getByTestId("project-section-header-icon"),
|
||||
).toBeVisible();
|
||||
).toHaveCount(0);
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({ path: `${SHOTS}/01-workspace-overview.png` });
|
||||
|
||||
await filesTab.click();
|
||||
await expect(selectionRow).toBeVisible();
|
||||
await expect(repositoryActionsPanel).toBeVisible();
|
||||
await expect(
|
||||
workspacePanel.getByRole("heading", { name: "Files", exact: true }),
|
||||
).toBeVisible();
|
||||
await chatPanelTab.click();
|
||||
await expect(agentContext).toContainText("Files");
|
||||
await expect(
|
||||
agentChatPanel.getByText("A persisted threaded agent response."),
|
||||
).toBeVisible();
|
||||
await repositoryPanelTab.click();
|
||||
await expect(repositoryActionsPanel).toBeVisible();
|
||||
await expectFullWidthSection();
|
||||
await page.getByRole("tab", { name: "Commits" }).click();
|
||||
await expect(selectionRow).toHaveCount(0);
|
||||
await expect(repositoryActionsPanel).toBeVisible();
|
||||
await expect(
|
||||
workspacePanel.getByRole("heading", { name: "Commits", exact: true }),
|
||||
).toBeVisible();
|
||||
await expectFullWidthSection();
|
||||
const commitRow = page.getByTestId("project-activity-feed-item").first();
|
||||
const commitDate = commitRow.getByTestId("project-commit-row-date");
|
||||
const commitHash = commitRow.getByTitle(/^View commit /);
|
||||
@@ -90,16 +394,52 @@ test("projects v3 workspace screenshot states", async ({ page }) => {
|
||||
expect(commitHashBounds).not.toBeNull();
|
||||
expect(commitDateBounds?.x).toBeGreaterThan(commitHashBounds?.x ?? 0);
|
||||
await contributorsTab.click();
|
||||
await expectFullWidthSection();
|
||||
const contributorRow = page.getByTestId("project-contributor-row").first();
|
||||
await expect(
|
||||
page.getByTestId("project-contributor-row-date").first(),
|
||||
contributorRow.getByTestId("project-contributor-commit-count"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
contributorRow.getByTestId("project-contributor-review-count"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
contributorRow.getByTestId("project-contributor-task-count"),
|
||||
).toBeVisible();
|
||||
const contributorBounds = await contributorRow.boundingBox();
|
||||
const contributorIdentityBounds = await contributorRow
|
||||
.getByTestId("project-contributor-identity")
|
||||
.boundingBox();
|
||||
const contributorCommitCountBounds = await contributorRow
|
||||
.getByTestId("project-contributor-commit-count")
|
||||
.boundingBox();
|
||||
expect(contributorBounds).not.toBeNull();
|
||||
expect(contributorBounds?.height).toBeLessThanOrEqual(40);
|
||||
expect(contributorIdentityBounds).not.toBeNull();
|
||||
expect(contributorCommitCountBounds?.x).toBeGreaterThan(
|
||||
contributorIdentityBounds?.x ?? 0,
|
||||
);
|
||||
const contributorCommitCounts = await page
|
||||
.getByTestId("project-contributor-commit-count")
|
||||
.evaluateAll((nodes) =>
|
||||
nodes.map((node) => {
|
||||
const value = Number.parseInt(node.textContent?.trim() ?? "", 10);
|
||||
return Number.isNaN(value) ? -1 : value;
|
||||
}),
|
||||
);
|
||||
expect(contributorCommitCounts).toEqual(
|
||||
[...contributorCommitCounts].sort((left, right) => right - left),
|
||||
);
|
||||
|
||||
await channelsTab.click();
|
||||
await expect(
|
||||
workspacePanel.getByRole("heading", { name: "Channels", exact: true }),
|
||||
).toBeVisible();
|
||||
await expectFullWidthSection();
|
||||
|
||||
// Issues tab: the create action lives in the section header.
|
||||
await page.getByRole("tab", { name: "Tasks", exact: true }).click();
|
||||
await expect(selectionRow).toHaveCount(0);
|
||||
await expect(repositoryActionsPanel).toBeVisible();
|
||||
await expectFullWidthSection();
|
||||
const newIssueButton = workspacePanel.getByRole("button", {
|
||||
name: "Create task",
|
||||
});
|
||||
@@ -127,7 +467,7 @@ test("projects v3 workspace screenshot states", async ({ page }) => {
|
||||
expect(issueDateBounds).not.toBeNull();
|
||||
expect(issueIdBounds).not.toBeNull();
|
||||
expect(issueRowBounds).not.toBeNull();
|
||||
expect(contributorBounds?.height).toBe(issueRowBounds?.height);
|
||||
expect(issueRowBounds?.height).toBeLessThanOrEqual(40);
|
||||
expect(issueDateBounds?.x).toBeGreaterThan(issueIdBounds?.x ?? 0);
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({ path: `${SHOTS}/02-issues-tab.png` });
|
||||
@@ -152,6 +492,16 @@ test("projects v3 workspace screenshot states", async ({ page }) => {
|
||||
await expect(
|
||||
page.getByTestId("project-issue-comment-timeline-row").first(),
|
||||
).toBeVisible();
|
||||
const issueActivity = workspacePanel.getByRole("button", {
|
||||
name: "Activity",
|
||||
exact: true,
|
||||
});
|
||||
await issueActivity.click();
|
||||
await expect(composer).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("project-issue-comment-timeline-row"),
|
||||
).toHaveCount(0);
|
||||
await issueActivity.click();
|
||||
// Let the "Comment posted." toast dismiss so it doesn't photobomb.
|
||||
await expect(page.getByText("Comment posted.")).toHaveCount(0, {
|
||||
timeout: 10_000,
|
||||
@@ -175,6 +525,7 @@ test("projects v3 workspace screenshot states", async ({ page }) => {
|
||||
await expect(
|
||||
workspacePanel.getByRole("button", { name: "Create review" }),
|
||||
).toBeVisible();
|
||||
await expectFullWidthSection();
|
||||
await expect(
|
||||
tabMenu.getByRole("button", { name: "Create review" }),
|
||||
).toHaveCount(0);
|
||||
@@ -192,6 +543,29 @@ test("projects v3 workspace screenshot states", async ({ page }) => {
|
||||
await expect(
|
||||
page.getByTestId("project-pull-request-copy-link"),
|
||||
).toBeVisible();
|
||||
const reviewCommits = workspacePanel.getByRole("button", {
|
||||
name: "Commits",
|
||||
exact: true,
|
||||
});
|
||||
await expect(reviewCommits).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(reviewCommits).toHaveCSS("font-size", "16px");
|
||||
await expect(reviewCommits).toHaveCSS("font-weight", "400");
|
||||
await reviewCommits.click();
|
||||
const openedReviewCommits = workspacePanel.getByRole("button", {
|
||||
name: /^Commits \d+$/,
|
||||
});
|
||||
await expect(openedReviewCommits).toHaveAttribute("aria-expanded", "true");
|
||||
await openedReviewCommits.click();
|
||||
const reviewComposer = page.getByTestId(
|
||||
"project-pull-request-comment-composer",
|
||||
);
|
||||
await expect(reviewComposer).toBeVisible();
|
||||
const reviewActivity = workspacePanel.getByRole("button", {
|
||||
name: "Activity",
|
||||
exact: true,
|
||||
});
|
||||
await reviewActivity.click();
|
||||
await expect(reviewComposer).toBeVisible();
|
||||
await expect(tabMenu).toHaveCount(0);
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({ path: `${SHOTS}/04-pr-detail.png` });
|
||||
|
||||
@@ -169,6 +169,33 @@ async function reveal(page: Page) {
|
||||
.toBeGreaterThanOrEqual(180);
|
||||
}
|
||||
|
||||
test("project terminal button opens Buzz Term for the repository", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
await installTerminalBackend(page);
|
||||
await installMockBridge(page);
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByTestId("open-projects-view").click();
|
||||
await page.getByTestId("projects-section-projects").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();
|
||||
|
||||
const terminalButton = page.getByTestId("project-terminal-toggle");
|
||||
await expect(terminalButton).toBeEnabled();
|
||||
await terminalButton.click();
|
||||
await expect(page.locator(TERM)).toHaveAttribute(
|
||||
"data-terminal-mode",
|
||||
"docked",
|
||||
);
|
||||
await expect(page.locator(TERM)).toBeVisible();
|
||||
});
|
||||
|
||||
test("scrollback: wheel over Buzz Term reaches terminal_scroll", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"links": {
|
||||
"pullRequest": "buzz://pr?id=c3b589fa5713ba25bad6dc095e2de00a4ac8f50050fdea00fc6444e603be1dd1&owner=71d67180ba17e749ee825fc8819c9c6ee7003617e1c126504f9b658070ab9224&d=buzz-world",
|
||||
"issue": "buzz://issue?id=c3b589fa5713ba25bad6dc095e2de00a4ac8f50050fdea00fc6444e603be1dd1&owner=71d67180ba17e749ee825fc8819c9c6ee7003617e1c126504f9b658070ab9224&d=buzz-world",
|
||||
"commit": "buzz://repo?owner=71d67180ba17e749ee825fc8819c9c6ee7003617e1c126504f9b658070ab9224&d=buzz-world&tab=commits&commit=c3b589fa5713ba25bad6dc095e2de00a4ac8f50050fdea00fc6444e603be1dd1",
|
||||
"repository": "buzz://repo?owner=71d67180ba17e749ee825fc8819c9c6ee7003617e1c126504f9b658070ab9224&d=buzz-world",
|
||||
"project": "buzz://project?owner=71d67180ba17e749ee825fc8819c9c6ee7003617e1c126504f9b658070ab9224&d=buzz-world"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user