diff --git a/FEATURES.md b/FEATURES.md index 35b4d16f..4ee7e602 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -349,6 +349,12 @@ Open **Worktree Lifecycle** from a workspace menu or the command menu to: worktrees. The default branch is queried from the remote, not cached `origin/HEAD`; an unavailable remote or unresolved default branch stops creation. - Inspect paths, open/closed state, branch status, and uncommitted counts. +- See GitHub PR / GitLab MR merge badges in **Worktree Lifecycle** and **Open + Worktree**, including checkouts without an open workspace. Expand a badge for + details and its last check time; use the list's Refresh button to query again. + No match, ambiguous matches, unsupported hosts, and unavailable CLI/auth or + requests are explicit states. Status uses host-side `gh` / `glab` authentication + and refreshes when the connection, repository, or checkout branch changes. - Focus, pull, configure branch updates, or remove with confirmation, hooks, process cleanup, and preservation of residual files when safe removal fails. diff --git a/server/src/workspace/files.test.ts b/server/src/workspace/files.test.ts index 2e877641..10a42a9a 100644 --- a/server/src/workspace/files.test.ts +++ b/server/src/workspace/files.test.ts @@ -15,6 +15,71 @@ async function withTempDir(fn: (dir: string) => Promise) { } describe("workspace file handlers", () => { + test("PR/MR reads accept only available worktrees listed for the source repository, locally and over SSH", async () => { + for (const host of [undefined, "user@server"]) { + const processes: string[][] = []; + const target = "/repo with 'quote/topic"; + const handlers = createFileHandlers({ + herdr: { + call: async (method: string, params: Record) => { + expect(params.workspace_id).toBe("w1"); + if (method === "workspace.get") + return { workspace: { worktree: { checkout_path: "/repo" } } }; + if (method === "worktree.list") + return { + worktrees: [ + { path: target }, + { path: "/repo/bare", is_bare: true }, + { path: "/repo/gone", is_prunable: true }, + ], + }; + throw new Error(`unexpected call ${method}`); + }, + } as any, + sshHost: () => host, + shQuote, + runProcessWithCodeTimeout: async (argv, _timeout, options) => { + processes.push(argv); + if (argv.join(" ").includes("rev-parse")) + return { code: 0, stdout: "/repo\n", stderr: "" }; + if (host) { + expect(argv[0]).toBe("ssh"); + expect(argv.at(-1)).toContain(`cd ${shQuote(target)} &&`); + } else { + expect(options?.cwd).toBe(target); + } + return { code: 1, stdout: "", stderr: "" }; + }, + }); + const status = await handlers.readWorkspacePullRequest({ + workspace_id: "w1", + worktree_path: target, + summary: true, + }); + expect(status.root).toBe(target); + expect(status.state).toBe("detached"); + for (const worktree_path of ["/other/repo", 42]) { + processes.length = 0; + await expect( + handlers.readWorkspacePullRequest({ + workspace_id: "w1", + worktree_path, + }), + ).rejects.toThrow("Worktree is unavailable"); + expect(processes).toHaveLength(1); + } + for (const worktree_path of ["/repo/bare", "/repo/gone"]) { + processes.length = 0; + const result = await handlers.readWorkspacePullRequest({ + workspace_id: "w1", + worktree_path, + }); + expect(result.state).toBe("unsupported"); + expect(result.message).toContain("Bare or prunable"); + expect(processes).toHaveLength(1); + } + } + }); test("falls back to focused pane cwd when workspace has no checkout path", async () => { await withTempDir(async (root) => { await mkdir(join(root, "src")); diff --git a/server/src/workspace/files.ts b/server/src/workspace/files.ts index 614764b0..634a916b 100644 --- a/server/src/workspace/files.ts +++ b/server/src/workspace/files.ts @@ -601,7 +601,33 @@ export function createFileHandlers({ } async function readWorkspacePullRequest(params: Record) { - const { root } = await workspaceAndGitRoot(params, "git.pull_request"); + let { root } = await workspaceAndGitRoot(params, "git.pull_request"); + if (params.worktree_path !== undefined) { + const listed = (await herdr.call("worktree.list", { + workspace_id: params.workspace_id, + })) as { + worktrees?: { + path: string; + is_bare?: boolean; + is_prunable?: boolean; + }[]; + }; + const worktree = listed.worktrees?.find( + (entry) => entry.path === params.worktree_path, + ); + if (!worktree) + throw new Error("Worktree is unavailable in this repository."); + if (worktree.is_bare || worktree.is_prunable) + return { + state: "unsupported" as const, + root: worktree.path, + branch: "", + remotes: [], + refreshedAt: new Date().toISOString(), + message: "Bare or prunable worktrees cannot resolve PR/MR status.", + }; + root = worktree.path; + } return readPullRequestStatus( { root, host: sshHost(), shQuote, runProcessWithCodeTimeout }, params, diff --git a/server/src/workspace/pull-request.test.ts b/server/src/workspace/pull-request.test.ts index 28c1aa88..730c6682 100644 --- a/server/src/workspace/pull-request.test.ts +++ b/server/src/workspace/pull-request.test.ts @@ -180,6 +180,67 @@ describe("PR/MR status", () => { }); for (const provider of ["github", "gitlab"] as const) { + test(`${provider} summarizes open, closed and merged requests without CI/review calls`, async () => { + for (const state of ["open", "closed", "merged"]) { + const f = fixture(provider, { + override: (command) => { + if (command.includes("'repos/team/project/pulls?")) + return ok([ + { + ...ghPR, + state: state === "merged" ? "closed" : state, + merged_at: state === "merged" ? "2026-10-01T00:00:00Z" : null, + }, + ]); + if (command.includes("'projects/2/merge_requests?")) + return ok([ + { ...glMR, state: state === "open" ? "opened" : state }, + ]); + }, + }); + const result = await f.read({ summary: true }); + expect(result.state).toBe("ready"); + expect(result.request?.state).toBe( + provider === "gitlab" && state === "open" ? "opened" : state, + ); + expect(result.ci).toBeUndefined(); + expect(result.review).toBeUndefined(); + expect( + f.commands + .flat() + .some((arg) => arg === "view" || arg.includes("/merge_requests/7")), + ).toBe(false); + } + }); + test(`${provider} summaries reject branch changes and ambiguous merged matches`, async () => { + let reads = 0; + const changed = await fixture(provider, { + override: (command) => + command.includes("'symbolic-ref'") && ++reads > 1 + ? ok("other") + : undefined, + }).read({ summary: true }); + expect(changed.state).toBe("error"); + expect(changed.request).toBeUndefined(); + const ambiguous = await fixture(provider, { + override: (command) => { + if (command.includes("'repos/team/project/pulls?")) + return ok([ + ghPR, + { + ...ghPR, + number: 13, + state: "closed", + merged_at: "2026-10-01T00:00:00Z", + }, + ]); + if (command.includes("'projects/2/merge_requests?")) + return ok([glMR, { ...glMR, iid: 8, state: "merged" }]); + }, + }).read({ summary: true }); + expect(ambiguous.state).toBe("select_match"); + expect(ambiguous.request).toBeUndefined(); + }); test(`${provider} resolves a renamed local branch through its upstream over local and SSH`, async () => { for (const ssh of [false, true]) { const f = fixture(provider, { diff --git a/server/src/workspace/pull-request.ts b/server/src/workspace/pull-request.ts index 39fdc1b6..8715dba3 100644 --- a/server/src/workspace/pull-request.ts +++ b/server/src/workspace/pull-request.ts @@ -367,7 +367,7 @@ export async function readPullRequestStatus( ); result.request = request; const [repository, id] = request.id.split("#"); - if (provider === "github") { + if (params.summary !== true && provider === "github") { const readField = (field: string) => json([ "gh", @@ -395,7 +395,7 @@ export async function readPullRequestStatus( review.status === "fulfilled" ? review.value : "Unavailable (review request failed)"; - } else { + } else if (params.summary !== true) { const path = `projects/${repository}/merge_requests/${id}`; const [ci, review] = await Promise.allSettled([ api(path).then((data) => { diff --git a/shared/pullRequest.ts b/shared/pullRequest.ts index e4a93c51..daa1c51a 100644 --- a/shared/pullRequest.ts +++ b/shared/pullRequest.ts @@ -41,3 +41,8 @@ export type PullRequestStatus = { ci?: string; review?: string; }; + +export type PullRequestSummary = Pick< + PullRequestStatus, + "state" | "message" | "refreshedAt" | "provider" | "request" +>; diff --git a/web/src/components/ModalDialogs.test.ts b/web/src/components/ModalDialogs.test.ts index 99afe46d..86217261 100644 --- a/web/src/components/ModalDialogs.test.ts +++ b/web/src/components/ModalDialogs.test.ts @@ -3,6 +3,8 @@ import { Window } from "happy-dom"; import type { ReactElement } from "react"; import type { Root } from "react-dom/client"; import type { ConnectionClient } from "../api"; +import type { Workspace, WorktreeList } from "../types"; +import type { PullRequestStatus } from "../../../shared/pullRequest"; if (process.env.ROAMGATE_DIALOG_EFFECT_DOM_TEST !== "1") { test("dialog effect regressions in an isolated runtime", async () => { @@ -60,6 +62,10 @@ async function registerDomTests() { ); const { CreateWorkspaceDialog } = await import("./CreateWorkspaceDialog"); const { WorktreeOpenDialog } = await import("./WorktreeOpenDialog"); + const { WorktreeLifecycleDialog } = await import("./WorktreeLifecycleDialog"); + const { useWorktreePullRequests } = await import( + "../useWorktreePullRequests" + ); const { bridge } = await import("../api"); const { __storeTesting, store } = await import("../store"); const { WorkspaceTree } = await import("./WorkspaceTree"); @@ -150,6 +156,372 @@ async function registerDomTests() { }, ]; + const reviewList: WorktreeList = { + type: "worktree_list", + source: { + repo_key: "repo", + repo_name: "Repo", + repo_root: "/repo", + source_checkout_path: "/repo", + }, + worktrees: Array.from({ length: 6 }, (_, index) => ({ + path: `/repo/topic-${index}`, + branch: `topic-${index}`, + label: `Topic ${index}`, + is_bare: false, + is_detached: false, + is_prunable: false, + is_linked_worktree: true, + })), + }; + const mergedStatus: PullRequestStatus = { + state: "ready", + root: "/repo/topic-0", + branch: "topic-0", + remotes: [], + refreshedAt: "2026-10-01T00:00:00Z", + provider: "github", + request: { + id: "r#1", + number: 1, + title: "Merged feature", + author: "alice", + source: "topic-0", + target: "main", + state: "merged", + draft: false, + url: "https://github.com/a/r/pull/1", + }, + }; + + test("worktree reviews bound concurrency, keep ordinary list polling local, and discard obsolete replies", async () => { + const pending: Array<(value: PullRequestStatus) => void> = []; + let calls = 0; + let current = true; + const reviewClient: ConnectionClient = { + ...client, + isCurrent: () => current, + call: async (method, params, timeout) => { + expect(method).toBe("git.pull_request"); + expect(params?.summary).toBe(true); + expect(timeout).toBe(65_000); + calls += 1; + return new Promise((resolve) => pending.push(resolve)); + }, + }; + function Reviews({ + list, + client, + refresh, + }: { + list: WorktreeList | null; + client: ConnectionClient; + refresh: number; + }) { + const statuses = useWorktreePullRequests( + client, + "workspace", + list, + refresh, + ); + return h("pre", null, JSON.stringify(statuses)); + } + const container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + const render = (list = reviewList, refresh = 0, client = reviewClient) => + root!.render(h(Reviews, { list, refresh, client })); + await act(async () => render()); + expect(calls).toBe(4); + await act(async () => pending.shift()!(mergedStatus)); + expect(calls).toBe(5); + expect(container.textContent).toContain("merged"); + await act(async () => + render({ + ...reviewList, + worktrees: reviewList.worktrees.map((worktree) => ({ + ...worktree, + label: "Renamed", + open_workspace_id: "opened", + })), + }), + ); + expect(calls).toBe(5); + + // Explicit refresh clears even a previously merged result. + const oldReplies = pending.splice(0); + await act(async () => render(reviewList, 1)); + expect(container.textContent).toBe("{}"); + expect(calls).toBe(5); + await act(async () => render(reviewList, 2)); + expect(calls).toBe(5); + await act(async () => + oldReplies.slice(0, -1).forEach((resolve) => resolve(mergedStatus)), + ); + expect(calls).toBe(5); + await act(async () => oldReplies[oldReplies.length - 1](mergedStatus)); + expect(container.textContent).toBe("{}"); + expect(calls).toBe(9); + + const previousConnection = pending.splice(0); + current = false; + const nextClient = { + ...reviewClient, + connectionId: "other", + generation: 2, + isCurrent: () => true, + }; + await act(async () => render(reviewList, 2, nextClient)); + await act(async () => + previousConnection.forEach((resolve) => resolve(mergedStatus)), + ); + expect(container.textContent).toBe("{}"); + + const previousRepo = pending.splice(0); + const nextRepo = { + ...reviewList, + source: { ...reviewList.source, repo_key: "other-repo" }, + }; + await act(async () => render(nextRepo, 2, nextClient)); + await act(async () => + previousRepo.forEach((resolve) => resolve(mergedStatus)), + ); + expect(container.textContent).toBe("{}"); + + const previousBranch = pending.splice(0); + await act(async () => + render( + { + ...nextRepo, + worktrees: [{ ...reviewList.worktrees[0], branch: "new-topic" }], + }, + 2, + nextClient, + ), + ); + await act(async () => + previousBranch.forEach((resolve) => resolve(mergedStatus)), + ); + expect(container.textContent).toBe("{}"); + await act(async () => + pending.shift()!({ + ...mergedStatus, + state: "no_match", + request: undefined, + }), + ); + expect(container.textContent).toContain("no_match"); + }); + + for (const lifecycle of [false, true]) { + test(`${lifecycle ? "lifecycle" : "open"} worktree list exposes merge badges and explicitly refreshes them`, async () => { + let reviews = 0; + let delayInventory = false; + let failInventory = false; + let finishInventory!: (list: WorktreeList) => void; + const call = spyOn(client, "call").mockImplementation(async (method) => { + if (method === "worktree.list") { + if (failInventory) throw new Error("Cannot load inventory"); + if (delayInventory) + return new Promise((resolve) => { + finishInventory = resolve; + }); + return { ...reviewList, worktrees: [reviewList.worktrees[0]] }; + } + if (method === "git.pull_request") { + reviews += 1; + if (reviews > 1) throw new Error("Connection failed"); + return mergedStatus; + } + if (method === "settings.worktree_hooks.get") return { enabled: true }; + throw new Error(`Unexpected call: ${method}`); + }); + const container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + let poll: () => void = () => {}; + const interval = spyOn(window, "setInterval").mockImplementation((( + callback: TimerHandler, + ) => { + poll = callback as () => void; + return 0; + }) as typeof window.setInterval); + try { + await act(async () => + root!.render( + h(lifecycle ? WorktreeLifecycleDialog : WorktreeOpenDialog, { + open: true, + workspaceId: "workspace", + onClose: () => {}, + }), + ), + ); + expect(document.body.textContent).toContain("PR #1: Merged"); + const badge = document.querySelector(".worktree-review summary")!; + expect(badge.closest("button")).toBeNull(); + expect(badge.parentElement?.tagName).toBe("DETAILS"); + (badge as HTMLElement).focus(); + expect(document.activeElement).toBe(badge); + expect( + document + .querySelector(".worktree-review time") + ?.getAttribute("datetime"), + ).toBe(mergedStatus.refreshedAt); + if (lifecycle) { + await act(async () => poll()); + expect(reviews).toBe(1); + } + const refresh = lifecycle + ? document.querySelector( + '[aria-label="Refresh lifecycle status"]', + )! + : document.querySelector( + ".modal-head button.ghost", + )!; + delayInventory = true; + await act(async () => refresh.click()); + expect(reviews).toBe(1); + expect(document.body.textContent).not.toContain("PR #1: Merged"); + await act(async () => + finishInventory({ + ...reviewList, + worktrees: [reviewList.worktrees[0]], + }), + ); + expect(reviews).toBe(2); + expect(document.body.textContent).toContain("PR/MR: Unavailable"); + expect(document.body.textContent).not.toContain("PR #1: Merged"); + failInventory = true; + await act(async () => refresh.click()); + expect(reviews).toBe(2); + expect(document.body.textContent).toContain("Cannot load inventory"); + expect(document.body.textContent).not.toContain( + "Loading repository lifecycle...", + ); + expect(refresh.disabled).toBe(false); + } finally { + await act(async () => root!.unmount()); + root = null; + interval.mockRestore(); + call.mockRestore(); + } + }); + } + + test("open worktree dialog clears merged badges immediately when its checkout changes branch", async () => { + const previousState = store.get(); + const workspace: Workspace = { + workspace_id: "workspace", + number: 1, + label: "Repo", + focused: true, + pane_count: 0, + tab_count: 0, + agent_status: "unknown", + worktree: { + repo_key: "repo", + repo_name: "Repo", + repo_root: "/repo", + checkout_path: "/repo/topic-0", + is_linked_worktree: true, + git_status: { + branch: "topic-0", + ahead: 0, + behind: 0, + staged: 0, + unstaged: 0, + untracked: 0, + conflicted: 0, + dirty: false, + }, + }, + }; + const branches: unknown[] = []; + let finishInventory!: (list: WorktreeList) => void; + const call = spyOn(client, "call").mockImplementation( + async (method, params) => { + if (method === "worktree.list") { + if (!branches.length) + return { ...reviewList, worktrees: [reviewList.worktrees[0]] }; + return new Promise((resolve) => { + finishInventory = resolve; + }); + } + expect(method).toBe("git.pull_request"); + branches.push(params?.branch); + return params?.branch === "topic-0" + ? mergedStatus + : { + ...mergedStatus, + branch: params?.branch, + state: params?.branch ? "ready" : "detached", + request: params?.branch + ? { ...mergedStatus.request!, state: "open" } + : undefined, + }; + }, + ); + const container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + const render = () => + root!.render( + h(WorktreeOpenDialog, { + open: true, + workspaceId: "workspace", + onClose: () => {}, + }), + ); + try { + await act(async () => { + __storeTesting.replaceState({ + ...previousState, + workspaces: [workspace], + }); + render(); + }); + expect(container.textContent).toContain("PR #1: Merged"); + for (const branch of ["new-topic", undefined]) { + await act(async () => { + __storeTesting.replaceState({ + ...store.get(), + workspaces: [ + { + ...workspace, + worktree: { + ...workspace.worktree!, + git_status: { ...workspace.worktree!.git_status!, branch }, + }, + }, + ], + }); + render(); + }); + expect(container.textContent).not.toContain("PR #1: Merged"); + expect(container.textContent).not.toContain("PR #1: Open"); + expect(container.textContent).toContain("Loading worktrees..."); + await act(async () => + finishInventory({ + ...reviewList, + worktrees: [ + { ...reviewList.worktrees[0], branch, is_detached: !branch }, + ], + }), + ); + expect(branches[branches.length - 1]).toBe(branch); + expect(container.textContent).toContain( + branch ? "PR #1: Open" : "PR: Detached HEAD", + ); + } + expect(branches).toEqual(["topic-0", "new-topic", undefined]); + } finally { + await act(async () => root!.unmount()); + root = null; + __storeTesting.replaceState(previousState); + call.mockRestore(); + } + }); + test("first workspace creation stays pending across an early topology refresh", async () => { const previousState = store.get(); let finish!: (result: unknown) => void; diff --git a/web/src/components/WorktreeLifecycleDialog.tsx b/web/src/components/WorktreeLifecycleDialog.tsx index 492b9661..061218d3 100644 --- a/web/src/components/WorktreeLifecycleDialog.tsx +++ b/web/src/components/WorktreeLifecycleDialog.tsx @@ -2,7 +2,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { FolderOpen, GitBranch, RefreshCw, Settings } from "lucide-react"; import { luckyWorktreeBranchName } from "../luckyName"; -import { useConnectionClient } from "../useConnectionClient"; +import { + connectionClientScopeKey, + useConnectionClient, +} from "../useConnectionClient"; +import { useWorktreePullRequests } from "../useWorktreePullRequests"; import { store, useStoreSelector } from "../store"; import type { Workspace, WorktreeList } from "../types"; import { resolveWorktreeOpenSource, worktreeCreationSource } from "../worktree"; @@ -98,9 +102,16 @@ export function WorktreeLifecycleDialog({ // Listing and repository settings accept linked workspaces. Creating or // opening a worktree does not, so keep those two contexts independent. const repositoryWorkspaceId = selectedWorkspace?.workspace_id ?? workspaceId; + const scope = connectionClientScopeKey( + connectionClient, + repositoryWorkspaceId, + selectedWorkspace?.worktree?.repo_key, + selectedWorkspace?.cwd, + selectedWorkspace?.worktree?.git_status?.branch, + ); const actionSourceWorkspaceId = actionSourceWorkspace?.workspace_id; const [listResult, setListResult] = useState<{ - workspaceId: string; + scope: string; list: WorktreeList; } | null>(null); const [hooks, setHooks] = useState(null); @@ -108,6 +119,7 @@ export function WorktreeLifecycleDialog({ Record >({}); const [loading, setLoading] = useState(false); + const [reviewRefresh, setReviewRefresh] = useState(0); const [error, setError] = useState(""); const [operation, setOperation] = useState(null); const [newWorktreeOpen, setNewWorktreeOpen] = useState(false); @@ -178,7 +190,7 @@ export function WorktreeLifecycleDialog({ result as WorkspaceAutoSyncInfo; } setListResult({ - workspaceId: repositoryWorkspaceId, + scope, list: worktreeList, }); setHooks(hookResult as WorktreeHookInfo); @@ -212,7 +224,7 @@ export function WorktreeLifecycleDialog({ } } }, - [connectionClient, repositoryWorkspaceId], + [connectionClient, repositoryWorkspaceId, scope], ); useEffect(() => { @@ -236,11 +248,21 @@ export function WorktreeLifecycleDialog({ }, [load, open, repositoryWorkspaceId]); const list = - listResult && listResult.workspaceId === repositoryWorkspaceId - ? listResult.list - : null; + listResult && listResult.scope === scope ? listResult.list : null; + const rows = useMemo( + () => (list ? buildWorktreeLifecycleRows(list, workspaces) : []), + [list, workspaces], + ); + const reviews = useWorktreePullRequests( + connectionClient, + repositoryWorkspaceId, + open && list + ? { ...list, worktrees: rows.map((row) => row.worktree) } + : null, + reviewRefresh, + ); const repositoryLoading = - loading || (!!open && !!repositoryWorkspaceId && !list); + loading || (!!open && !!repositoryWorkspaceId && !list && !error); useEffect(() => { if (!open) return; @@ -259,10 +281,6 @@ export function WorktreeLifecycleDialog({ }; }, [hooksOpen, newWorktreeOpen, onClose, open, openWorktreeOpen, removeRow]); - const rows = useMemo( - () => (list ? buildWorktreeLifecycleRows(list, workspaces) : []), - [list, workspaces], - ); const configuredHooks = hooks?.hooks ? Object.values(hooks.hooks).filter(Boolean).length : 0; @@ -469,7 +487,11 @@ export function WorktreeLifecycleDialog({ aria-label="Refresh lifecycle status" title="Refresh" disabled={repositoryLoading || operationRunning} - onClick={() => void load(true, true)} + onClick={() => { + setListResult(null); + setReviewRefresh((value) => value + 1); + void load(true, true); + }} > {row.worktree.path} +
{lifecycleGitSummary(row.gitStatus)} diff --git a/web/src/components/WorktreeOpenDialog.css b/web/src/components/WorktreeOpenDialog.css index 1613bc25..718f5fe2 100644 --- a/web/src/components/WorktreeOpenDialog.css +++ b/web/src/components/WorktreeOpenDialog.css @@ -15,17 +15,23 @@ } .worktree-option { + padding: 9px 10px; + background: var(--input-bg); + border: 1px solid var(--border); + border-radius: 8px; +} + +.worktree-option-open { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; min-height: 56px; - padding: 9px 10px; + padding: 0; text-align: left; background: var(--input-bg); - border: 1px solid var(--border); - border-radius: 8px; + border: 0; } .worktree-option:hover { @@ -62,3 +68,13 @@ flex: 0 0 auto; gap: 6px; } + +@media (max-width: 520px) { + .worktree-open-modal .modal-head { + flex-wrap: wrap; + } + + .worktree-option-open { + flex-wrap: wrap; + } +} diff --git a/web/src/components/WorktreeOpenDialog.tsx b/web/src/components/WorktreeOpenDialog.tsx index 4da46872..18850080 100644 --- a/web/src/components/WorktreeOpenDialog.tsx +++ b/web/src/components/WorktreeOpenDialog.tsx @@ -1,9 +1,14 @@ import { useEffect, useEffectEvent, useRef, useState } from "react"; -import { store } from "../store"; -import { useConnectionClient } from "../useConnectionClient"; +import { store, useStoreSelector } from "../store"; +import { + connectionClientScopeKey, + useConnectionClient, +} from "../useConnectionClient"; +import { useWorktreePullRequests } from "../useWorktreePullRequests"; import type { ExistingWorktree, WorktreeList } from "../types"; import { resolveWorktreeOpenSource } from "../worktree"; import { CloseButton } from "./CloseButton"; +import { WorktreePullRequestBadge } from "./WorktreePullRequestBadge"; import { focusDialogElement } from "./dialogFocus"; import "./WorktreeOpenDialog.css"; @@ -22,8 +27,7 @@ function sortWorktrees(a: ExistingWorktree, b: ExistingWorktree) { } type WorktreeListState = { - workspaceId: string; - loading: boolean; + scope: string; list: WorktreeList | null; error: string; }; @@ -42,11 +46,23 @@ export function WorktreeOpenDialog({ onClose: () => void; }) { const connectionClient = useConnectionClient(); + const workspace = useStoreSelector((state) => + state.workspaces.find((entry) => entry.workspace_id === workspaceId), + ); + const [refresh, setRefresh] = useState(0); + const scope = connectionClientScopeKey( + connectionClient, + workspaceId, + workspace?.worktree?.repo_key, + workspace?.cwd, + workspace?.worktree?.git_status?.branch, + refresh, + ); const [manualTarget, setManualTarget] = useState(""); const [query, setQuery] = useState(""); const [listState, setListState] = useState(null); const [actionError, setActionError] = useState<{ - workspaceId: string; + scope: string; message: string; } | null>(null); const onCloseEvent = useEffectEvent(onClose); @@ -59,14 +75,13 @@ export function WorktreeOpenDialog({ setManualTarget(""); setQuery(""); setActionError(null); - setListState({ workspaceId, loading: true, list: null, error: "" }); + setListState({ scope, list: null, error: "" }); connectionClient.call("worktree.list", { workspace_id: workspaceId }).then( (result) => { if (cancelled || !connectionClient.isCurrent()) return; setListState({ - workspaceId, - loading: false, + scope, list: result as WorktreeList, error: "", }); @@ -74,8 +89,7 @@ export function WorktreeOpenDialog({ (err) => { if (cancelled || !connectionClient.isCurrent()) return; setListState({ - workspaceId, - loading: false, + scope, list: null, error: (err as Error).message, }); @@ -90,18 +104,19 @@ export function WorktreeOpenDialog({ cancelled = true; window.removeEventListener("keydown", onKey); }; - }, [connectionClient, open, workspaceId]); + }, [connectionClient, open, workspaceId, scope]); - const currentListState = - listState?.workspaceId === workspaceId ? listState : null; + const currentListState = listState?.scope === scope ? listState : null; const list = currentListState?.list ?? null; - const loading = - !!open && - !!workspaceId && - !currentListState?.list && - (currentListState?.loading ?? true); + const reviews = useWorktreePullRequests( + connectionClient, + workspaceId, + open ? list : null, + refresh, + ); + const loading = !!open && !!workspaceId && !list && !currentListState?.error; const error = - (actionError?.workspaceId === workspaceId ? actionError.message : "") || + (actionError?.scope === scope ? actionError.message : "") || currentListState?.error || ""; @@ -128,8 +143,8 @@ export function WorktreeOpenDialog({ return store.openWorktreeFromCwd(actionSource.cwd, target); } setActionError({ - workspaceId, - message: "The repository source is unavailable.", + scope, + message: "Repository source unavailable.", }); return undefined; }; @@ -176,6 +191,14 @@ export function WorktreeOpenDialog({ >

Open Worktree

+
@@ -206,32 +229,40 @@ export function WorktreeOpenDialog({ {worktrees.length ? (
{worktrees.map((worktree) => ( - + + +
))}
) : !loading && !error ? ( diff --git a/web/src/components/WorktreePullRequestBadge.css b/web/src/components/WorktreePullRequestBadge.css new file mode 100644 index 00000000..fca951a9 --- /dev/null +++ b/web/src/components/WorktreePullRequestBadge.css @@ -0,0 +1,45 @@ +.worktree-review { + min-width: 0; + color: var(--muted); + font-size: 11px; +} + +.worktree-review-badge { + display: inline-flex; + align-items: center; + min-height: 32px; + cursor: pointer; + white-space: normal; +} + +.worktree-review-badge::before { + content: "+"; + margin-right: 5px; +} + +.worktree-review[open] > .worktree-review-badge::before { + content: "-"; +} + +.worktree-review-badge[data-state="merged"] { + color: var(--accent); + background: var(--accent-soft); +} + +.worktree-review-badge[data-state="open"], +.worktree-review-badge[data-state="opened"] { + color: var(--green); + background: var(--success-soft); +} + +.worktree-review-badge[data-state="closed"] { + color: var(--red); + background: var(--danger-soft); +} + +.worktree-review-detail { + display: grid; + gap: 4px; + padding: 6px 0; + overflow-wrap: anywhere; +} diff --git a/web/src/components/WorktreePullRequestBadge.test.ts b/web/src/components/WorktreePullRequestBadge.test.ts new file mode 100644 index 00000000..5285319d --- /dev/null +++ b/web/src/components/WorktreePullRequestBadge.test.ts @@ -0,0 +1,86 @@ +import { expect, test } from "bun:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { PullRequestStatus } from "../../../shared/pullRequest"; +import { WorktreePullRequestBadge } from "./WorktreePullRequestBadge"; + +const base: PullRequestStatus = { + state: "ready", + root: "/repo", + branch: "topic", + remotes: [], + refreshedAt: "2026-10-01T00:00:00Z", +}; +const render = (status?: PullRequestStatus) => + renderToStaticMarkup(createElement(WorktreePullRequestBadge, { status })); + +test("worktree badges distinguish GitHub/GitLab merge states and expose details with native disclosure controls", () => { + for (const provider of ["github", "gitlab"] as const) { + for (const [state, draft, label] of [ + ["merged", true, "Merged"], + ["closed", false, "Closed without merge"], + ["open", false, "Open"], + ["opened", false, "Open"], + ["open", true, "Draft"], + ["unexpected", false, "Unknown"], + ] as const) { + const html = render({ + ...base, + provider, + request: { + id: "team/project#12", + number: 12, + title: "Ship feature", + author: "alice", + source: "topic", + target: "main", + state, + draft, + url: `https://${provider}.com/team/project`, + }, + }); + expect(html).toContain(` { + const merged = { + id: "r#1", + number: 1, + title: "Old", + author: "a", + source: "t", + target: "m", + state: "merged", + draft: false, + url: "https://github.com/a/r/pull/1", + }; + for (const state of [ + "select_remote", + "select_match", + "unsupported", + "missing_cli", + "unauthenticated", + "no_match", + "detached", + "error", + ] as const) { + const html = render({ + ...base, + state, + message: "Explicit diagnostic", + request: merged, + }); + expect(html).not.toContain("Merged"); + expect(html).not.toContain("href="); + expect(html).toContain("Explicit diagnostic"); + } + expect(render()).toContain("PR/MR: Loading..."); +}); diff --git a/web/src/components/WorktreePullRequestBadge.tsx b/web/src/components/WorktreePullRequestBadge.tsx new file mode 100644 index 00000000..69b77e8a --- /dev/null +++ b/web/src/components/WorktreePullRequestBadge.tsx @@ -0,0 +1,61 @@ +import type { PullRequestSummary } from "../../../shared/pullRequest"; +import "./WorktreePullRequestBadge.css"; + +const labels: Record = { + ready: "Unknown", + select_remote: "Select remote", + select_match: "Multiple matches", + unsupported: "Unsupported", + missing_cli: "Missing CLI", + unauthenticated: "Auth unavailable", + no_match: "No match", + detached: "Detached HEAD", + error: "Unavailable", +}; + +export function WorktreePullRequestBadge({ + status, +}: { + status?: PullRequestSummary; +}) { + const request = status?.state === "ready" ? status.request : undefined; + const state = request?.state; + const label = + state === "merged" + ? "Merged" + : state === "closed" + ? "Closed without merge" + : state === "open" || state === "opened" + ? request?.draft + ? "Draft" + : "Open" + : status + ? labels[status.state] + : "Loading..."; + const provider = status?.provider + ? status.provider === "github" + ? "PR" + : "MR" + : "PR/MR"; + const text = `${provider}${request ? ` #${request.number}` : ""}: ${label}`; + return ( +
+ + {text} + +
+ {request?.title ?? status?.message ?? label} + {request ? ( + + Open {provider} #{request.number} + + ) : null} + {status ? ( + + ) : null} +
+
+ ); +} diff --git a/web/src/useWorktreePullRequests.ts b/web/src/useWorktreePullRequests.ts new file mode 100644 index 00000000..96336fd4 --- /dev/null +++ b/web/src/useWorktreePullRequests.ts @@ -0,0 +1,70 @@ +import { useEffect, useEffectEvent, useRef, useState } from "react"; +import type { PullRequestSummary } from "../../shared/pullRequest"; +import type { ConnectionClient } from "./api"; +import type { WorktreeList } from "./types"; +import { connectionClientScopeKey } from "./useConnectionClient"; + +export function useWorktreePullRequests( + client: ConnectionClient, + workspaceId: string | null | undefined, + list: WorktreeList | null, + refresh: number, +) { + const key = connectionClientScopeKey( + client, + workspaceId, + list?.source.repo_key, + list?.worktrees.map(({ label, open_workspace_id, ...checkout }) => { + void label; + void open_workspace_id; + return checkout; + }), + refresh, + ); + const [result, setResult] = useState<{ + key: string; + statuses: Record; + } | null>(null); + const readWorktrees = useEffectEvent(() => list?.worktrees ?? []); + const pending = useRef>(Promise.resolve()); + useEffect(() => { + if (!workspaceId) return; + let cancelled = false; + const queue = readWorktrees().values(); + setResult({ key, statuses: {} }); + const worker = async () => { + for (const worktree of queue) { + if (cancelled || !client.isCurrent()) return; + const status: PullRequestSummary = await client + .call( + "git.pull_request", + { + workspace_id: workspaceId, + worktree_path: worktree.path, + branch: worktree.branch, + summary: true, + }, + 65_000, + ) + .catch(() => ({ + state: "error", + refreshedAt: new Date().toISOString(), + })); + if (cancelled || !client.isCurrent()) return; + setResult((current) => ({ + key, + statuses: { ...current?.statuses, [worktree.path]: status }, + })); + } + }; + // Obsolete replies still occupy host-side CLI slots until they settle. + pending.current = pending.current.then(() => + Promise.all([worker(), worker(), worker(), worker()]), + ); + return () => { + cancelled = true; + }; + }, [client, workspaceId, key]); + + return client.isCurrent() && result?.key === key ? result.statuses : {}; +}