diff --git a/src/renderer/app.test.tsx b/src/renderer/app.test.tsx index d525daa0..bb453684 100644 --- a/src/renderer/app.test.tsx +++ b/src/renderer/app.test.tsx @@ -912,6 +912,8 @@ describe("App", () => { createBranch: true, startPoint: "main", copyIgnoredPatterns: [".env", ".env.*"], + transferUncommitted: false, + keepChangesInSource: false, }); }); diff --git a/src/renderer/components/common/BranchSelector/BranchSelector.test.tsx b/src/renderer/components/common/BranchSelector/BranchSelector.test.tsx new file mode 100644 index 00000000..002b7b1e --- /dev/null +++ b/src/renderer/components/common/BranchSelector/BranchSelector.test.tsx @@ -0,0 +1,128 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { BranchSelector } from "./BranchSelector"; + +const bridge = vi.hoisted(() => ({ + gitAddWorktree: + vi.fn<(payload: unknown) => Promise<{ path: string; changesTransferred?: boolean }>>(), +})); + +const refreshGitProject = vi.hoisted(() => vi.fn<(...args: unknown[]) => Promise>()); +const prefetchBranchPrData = vi.hoisted(() => vi.fn<(...args: unknown[]) => Promise>()); +const openNewThreadInWorktree = vi.hoisted(() => vi.fn<(input: unknown) => void>()); +const deleteWorktreeGroup = vi.hoisted(() => vi.fn<(...args: unknown[]) => void>()); +const useBranchListMock = vi.hoisted(() => + vi.fn<(params: { projectId: string; search: string }) => unknown>(), +); + +vi.mock("@/renderer/bridge", () => ({ + readBridge: () => bridge, +})); + +vi.mock("@/renderer/state/gitRefresh", () => ({ + refreshGitProject, + prefetchBranchPrData, +})); + +vi.mock("@/renderer/actions/threadActions", () => ({ + openNewThreadInWorktree, +})); + +vi.mock("@/renderer/actions/worktreeActions", () => ({ + deleteWorktreeGroup, +})); + +vi.mock("./parts/useBranchList", () => ({ + useBranchList: useBranchListMock, +})); + +const emptyBranchList = { + items: [], + hasLocal: false, + hasRemote: false, + worktreeBranches: new Set(), + branchWorktreePath: new Map(), + threadsByBranch: new Map(), + projectLocation: { kind: "windows", path: "C:\\repo" }, +}; + +describe("BranchSelector", () => { + beforeEach(() => { + bridge.gitAddWorktree.mockReset(); + bridge.gitAddWorktree.mockResolvedValue({ + path: "C:\\Users\\demo\\.lightcode\\worktrees\\repo\\feature-x", + changesTransferred: true, + }); + refreshGitProject.mockReset(); + refreshGitProject.mockResolvedValue(undefined); + prefetchBranchPrData.mockReset(); + prefetchBranchPrData.mockResolvedValue(undefined); + openNewThreadInWorktree.mockReset(); + deleteWorktreeGroup.mockReset(); + useBranchListMock.mockReset(); + useBranchListMock.mockReturnValue(emptyBranchList); + }); + + it("moves the current changes into a new worktree, leaving the current branch clean", async () => { + render( + , + ); + + fireEvent.click(screen.getByLabelText("Select branch")); + fireEvent.click(await screen.findByText("Move changes to a new worktree")); + + await waitFor(() => { + expect(bridge.gitAddWorktree).toHaveBeenCalledWith( + expect.objectContaining({ + projectLocation: { kind: "windows", path: "C:\\repo" }, + // A new branch is forked from the current branch and the work is moved + // into it (transferUncommitted), leaving the current branch clean. + branch: expect.any(String), + createBranch: true, + startPoint: "feature/x", + transferUncommitted: true, + copyIgnoredPatterns: [".env", ".env.*"], + }), + ); + }); + }); + + it("confirms before removing a worktree branch, then reuses deleteWorktreeGroup", async () => { + useBranchListMock.mockReturnValue({ + ...emptyBranchList, + hasLocal: true, + items: [ + { type: "header", id: "header-local", name: "Local" }, + { + type: "branch", + id: "feature/x", + branch: { name: "feature/x", current: false, commit: "abc123", isRemote: false }, + }, + ], + worktreeBranches: new Set(["feature/x"]), + branchWorktreePath: new Map([["feature/x", "/wt/feature-x"]]), + threadsByBranch: new Map([ + ["feature/x", [{ id: "t1", projectId: "project-1", status: "idle", done: false }]], + ]), + }); + + render(); + + fireEvent.click(screen.getByLabelText("Select branch")); + fireEvent.click(await screen.findByRole("button", { name: "Delete feature/x" })); + + // Removal does not run until the confirmation is accepted. + expect(deleteWorktreeGroup).not.toHaveBeenCalled(); + fireEvent.click(await screen.findByRole("button", { name: "Remove" })); + + await waitFor(() => { + expect(deleteWorktreeGroup).toHaveBeenCalledWith("project-1", "/wt/feature-x", ["t1"]); + }); + }); +}); diff --git a/src/renderer/components/common/BranchSelector/BranchSelector.tsx b/src/renderer/components/common/BranchSelector/BranchSelector.tsx index c37c9f4f..6cbc19bb 100644 --- a/src/renderer/components/common/BranchSelector/BranchSelector.tsx +++ b/src/renderer/components/common/BranchSelector/BranchSelector.tsx @@ -5,14 +5,28 @@ import type { GitBranchInfo } from "@/shared/contracts"; import { friendlyError } from "@/shared/messages"; import { readBridge } from "@/renderer/bridge"; import { useGitStore } from "@/renderer/state/gitStore"; +import { prefetchBranchPrData, refreshGitProject } from "@/renderer/state/gitRefresh"; +import { buildBranchNamePrKey } from "@/renderer/state/gitSelectors"; +import { usePanelStore } from "@/renderer/state/panelStore"; +import { openNewThreadInWorktree } from "@/renderer/actions/threadActions"; +import { deleteWorktreeGroup } from "@/renderer/actions/worktreeActions"; import { Button } from "../Button"; +import { ConfirmDialog } from "../ConfirmDialog"; import { useBranchList } from "./parts/useBranchList"; -import { BranchListBox } from "./parts/BranchListBox"; +import { BranchListBox, type OpenPrReviewArgs } from "./parts/BranchListBox"; import { BranchFooterActions } from "./parts/BranchFooterActions"; +import { generateWorktreeBranch } from "./parts/generateWorktreeBranch"; import type { BranchSelection } from "./parts/types"; export type { BranchSelection }; +interface PendingDelete { + branch: GitBranchInfo; + worktreePath?: string; + threadIds: string[]; + threadCount: number; +} + export interface BranchSelectorProps { projectId: string; currentBranch: string; @@ -26,9 +40,17 @@ export interface BranchSelectorProps { isDisabled?: boolean; trigger?: ReactNode; hideWorktreeToggle?: boolean; + /** Show the "Move changes to a new worktree" action in the popover footer. */ + showMoveBranchAction?: boolean; + /** Project copy patterns to preserve when moving the current branch. */ + moveBranchCopyIgnoredPatterns?: string[]; popoverPlacement?: "top" | "bottom"; forceHideLabel?: boolean; iconOnly?: boolean; + /** Hide the leading branch/fork glyph on the trigger (e.g. when a sibling control already shows it). */ + hideTriggerIcon?: boolean; + /** Render a shorter trigger for secondary control rows. */ + compact?: boolean; className?: string; } @@ -46,16 +68,23 @@ export function BranchSelector(props: BranchSelectorProps) { isDisabled, trigger, hideWorktreeToggle, + showMoveBranchAction = false, + moveBranchCopyIgnoredPatterns, popoverPlacement = "top", forceHideLabel = false, iconOnly = false, + hideTriggerIcon = false, + compact = false, } = props; + const triggerIconSize = compact ? "size-3" : "size-3.5"; const [isOpen, setIsOpen] = useState(false); const [search, setSearch] = useState(""); const [isCreating, setIsCreating] = useState(false); const [newBranchName, setNewBranchName] = useState(""); const [deletingBranch, setDeletingBranch] = useState(null); + const [isMovingBranch, setIsMovingBranch] = useState(false); + const [pendingDelete, setPendingDelete] = useState(null); const searchRef = useRef(null); const createRef = useRef(null); @@ -63,9 +92,9 @@ export function BranchSelector(props: BranchSelectorProps) { items, hasLocal, hasRemote, - activeWorktreeBranches, worktreeBranches, branchWorktreePath, + threadsByBranch, projectLocation, } = useBranchList({ projectId, search }); @@ -75,6 +104,11 @@ export function BranchSelector(props: BranchSelectorProps) { setIsCreating(false); setNewBranchName(""); setTimeout(() => searchRef.current?.focus(), 50); + // Refresh PR status for all branches in the background; cached icons show + // immediately (prefetch self-throttles + dedupes). + if (projectLocation) { + void prefetchBranchPrData({ id: projectId, location: projectLocation }); + } } }, [isOpen]); // eslint-disable-line react-hooks/exhaustive-deps -- only on open/close @@ -125,21 +159,43 @@ export function BranchSelector(props: BranchSelectorProps) { setNewBranchName(""); } - async function handleDeleteBranch(branch: GitBranchInfo) { - if (!projectLocation) return; + function handleRequestDelete(branch: GitBranchInfo) { + const worktreePath = branch.isRemote ? undefined : branchWorktreePath.get(branch.name); + const threads = threadsByBranch.get(branch.name) ?? []; + setIsOpen(false); + setPendingDelete({ + branch, + ...(worktreePath ? { worktreePath } : {}), + threadIds: threads.map((t) => t.id), + threadCount: threads.length, + }); + } + + function handleOpenPrReview(args: OpenPrReviewArgs) { + setIsOpen(false); + usePanelStore.getState().setPrReviewContext({ + projectId, + prNumber: args.prNumber, + ...(args.worktreePath + ? { worktreePath: args.worktreePath } + : { prKey: buildBranchNamePrKey(projectId, args.branch) }), + }); + } + + async function confirmDelete() { + const target = pendingDelete; + setPendingDelete(null); + if (!target || !projectLocation) return; + const { branch, worktreePath, threadIds } = target; + // Worktree branches reuse the sidebar's removal path (closes linked threads, + // runs the cleanup script, removes the worktree, then deletes the branch). + if (worktreePath) { + deleteWorktreeGroup(projectId, worktreePath, threadIds); + return; + } + // Plain local or remote branch with no worktree — delete the ref directly. setDeletingBranch(branch.name); try { - if (!branch.isRemote) { - const wtPath = branchWorktreePath.get(branch.name); - if (wtPath) { - await readBridge().gitRemoveWorktree({ - projectLocation, - path: wtPath, - force: true, - deleteBranch: false, - }); - } - } await readBridge().gitDeleteBranch({ projectLocation, branch: branch.name, @@ -164,6 +220,46 @@ export function BranchSelector(props: BranchSelectorProps) { } } + async function handleMoveBranchToWorktree() { + if (!projectLocation || isMovingBranch) return; + setIsMovingBranch(true); + setIsOpen(false); + try { + const newBranch = generateWorktreeBranch(); + const result = await readBridge().gitAddWorktree({ + projectLocation, + branch: newBranch, + createBranch: true, + startPoint: currentBranch, + transferUncommitted: true, + // This action MOVES the changes — leave the current branch clean. + keepChangesInSource: false, + ...(moveBranchCopyIgnoredPatterns?.length + ? { copyIgnoredPatterns: moveBranchCopyIgnoredPatterns } + : {}), + }); + await refreshGitProject({ id: projectId, location: projectLocation }, "manual", "full"); + openNewThreadInWorktree({ + projectId, + worktreePath: result.path, + worktreeBranch: newBranch, + }); + if (result.changesTransferred === false) { + toast.danger( + `Created a worktree on "${newBranch}", but the changes conflicted and remain in a git stash — resolve them in the worktree.`, + ); + } else { + toast.success( + `Moved your changes into a new worktree on "${newBranch}". "${currentBranch}" is now clean.`, + ); + } + } catch (error) { + toast.danger(friendlyError(error)); + } finally { + setIsMovingBranch(false); + } + } + return (
{worktreeMode && from} @@ -176,13 +272,17 @@ export function BranchSelector(props: BranchSelectorProps) { isDisabled={isDisabled ?? false} size="sm" variant="ghost" - className="lightcode-composer-menu min-w-0 max-w-48 px-2.5" + className={`lightcode-composer-menu min-w-0 max-w-48 ${ + compact ? "lightcode-composer-menu--compact px-2" : "px-2.5" + }`} > - {isWorktree || worktreeMode ? ( - - ) : ( - - )} + {!hideTriggerIcon || iconOnly ? ( + isWorktree || worktreeMode ? ( + + ) : ( + + ) + ) : null} {!iconOnly && ( )} @@ -235,6 +335,7 @@ export function BranchSelector(props: BranchSelectorProps) {
void handleDeleteBranch(b as GitBranchInfo)} + onDelete={(b) => handleRequestDelete(b as GitBranchInfo)} + onOpenPrReview={handleOpenPrReview} />
@@ -267,10 +370,33 @@ export function BranchSelector(props: BranchSelectorProps) { isWorktree={isWorktree} branchWorktreePath={branchWorktreePath} onSelect={onSelect} + showMoveBranch={showMoveBranchAction} + isMovingBranch={isMovingBranch} + onMoveBranchToWorktree={() => void handleMoveBranchToWorktree()} /> + 0 + ? ` and closes ${pendingDelete.threadCount} linked thread${ + pendingDelete.threadCount === 1 ? "" : "s" + }` + : "" + }, then deletes the branch.` + : `This permanently deletes the branch "${pendingDelete?.branch.name ?? ""}"${ + pendingDelete?.branch.isRemote ? " from its remote" : "" + }.` + } + confirmLabel={pendingDelete?.worktreePath ? "Remove" : "Delete"} + onConfirm={() => void confirmDelete()} + onClose={() => setPendingDelete(null)} + />
); } diff --git a/src/renderer/components/common/BranchSelector/parts/BranchFooterActions.test.tsx b/src/renderer/components/common/BranchSelector/parts/BranchFooterActions.test.tsx index be244d5d..eb033db0 100644 --- a/src/renderer/components/common/BranchSelector/parts/BranchFooterActions.test.tsx +++ b/src/renderer/components/common/BranchSelector/parts/BranchFooterActions.test.tsx @@ -27,6 +27,9 @@ describe("BranchFooterActions", () => { new Map([["feature/x", "C:\\Users\\demo\\.lightcode\\worktrees\\repo\\feature-x"]]) } onSelect={onSelect} + showMoveBranch={false} + isMovingBranch={false} + onMoveBranchToWorktree={vi.fn<() => void>()} />, ); diff --git a/src/renderer/components/common/BranchSelector/parts/BranchFooterActions.tsx b/src/renderer/components/common/BranchSelector/parts/BranchFooterActions.tsx index ecf15ed4..8c626817 100644 --- a/src/renderer/components/common/BranchSelector/parts/BranchFooterActions.tsx +++ b/src/renderer/components/common/BranchSelector/parts/BranchFooterActions.tsx @@ -1,5 +1,5 @@ import type { RefObject } from "react"; -import { GitFork, Plus } from "lucide-react"; +import { FolderInput, GitFork, Plus } from "lucide-react"; import { Checkbox, Label, ListBox } from "@heroui/react"; import type { BranchSelection } from "./types"; @@ -19,6 +19,9 @@ export function BranchFooterActions(props: { isWorktree: boolean | undefined; branchWorktreePath: Map; onSelect: ((selection: BranchSelection) => void) | undefined; + showMoveBranch: boolean; + isMovingBranch: boolean; + onMoveBranchToWorktree: () => void; }) { const { isCreating, @@ -36,6 +39,9 @@ export function BranchFooterActions(props: { isWorktree, branchWorktreePath, onSelect, + showMoveBranch, + isMovingBranch, + onMoveBranchToWorktree, } = props; return ( @@ -157,6 +163,28 @@ export function BranchFooterActions(props: { )} + + {/* Move the current uncommitted changes into a new worktree */} + {showMoveBranch && ( + onMoveBranchToWorktree()} + > + + + + + + )} ); } diff --git a/src/renderer/components/common/BranchSelector/parts/BranchListBox.test.tsx b/src/renderer/components/common/BranchSelector/parts/BranchListBox.test.tsx index 306d8500..0877e5f2 100644 --- a/src/renderer/components/common/BranchSelector/parts/BranchListBox.test.tsx +++ b/src/renderer/components/common/BranchSelector/parts/BranchListBox.test.tsx @@ -1,12 +1,65 @@ import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; -import { BranchListBox } from "./BranchListBox"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { PrData, Thread } from "@/shared/contracts"; +import { buildBranchNamePrKey } from "@/renderer/state/gitSelectors"; +import { useGitStore } from "@/renderer/state/gitStore"; +import { BranchListBox, type OpenPrReviewArgs } from "./BranchListBox"; + +function makePr(overrides: Partial & Pick): PrData { + return { + title: "Some PR", + url: "https://example.com/pr", + baseBranch: "main", + isDraft: false, + updatedAt: "2026-01-01T00:00:00.000Z", + ...overrides, + }; +} + +function makeThread(overrides: Partial & Pick): Thread { + return { + title: "Thread", + agentKind: "codex", + config: { model: "gpt-5.4" }, + status: "idle", + attention: "none", + canResumeWithConfig: false, + archived: false, + done: false, + starred: false, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + ...overrides, + }; +} + +function baseProps() { + return { + projectId: "p1", + hasLocal: false, + hasRemote: true, + currentBranch: "main", + value: "main", + baseBranch: undefined, + isWorktree: false, + worktreeMode: false, + deletingBranch: null, + worktreeBranches: new Set(), + branchWorktreePath: new Map(), + threadsByBranch: new Map(), + onSelect: vi.fn<(branchName: string) => void>(), + onDelete: vi.fn<(branch: { name: string; remote?: string; isRemote?: boolean }) => void>(), + onOpenPrReview: vi.fn<(args: OpenPrReviewArgs) => void>(), + }; +} + +afterEach(() => { + useGitStore.setState({ prData: {} }); +}); describe("BranchListBox", () => { it("fires delete for a remote branch row", () => { - const onDelete = - vi.fn<(branch: { name: string; remote?: string; isRemote?: boolean }) => void>(); - const onSelect = vi.fn<(branchName: string) => void>(); + const props = baseProps(); const branch = { name: "feature/x", current: false, @@ -17,28 +70,76 @@ describe("BranchListBox", () => { render( , ); fireEvent.click(screen.getByRole("button", { name: "Delete feature/x" })); - expect(onDelete).toHaveBeenCalledWith(branch); - expect(onSelect).not.toHaveBeenCalled(); + expect(props.onDelete).toHaveBeenCalledWith(branch); + expect(props.onSelect).not.toHaveBeenCalled(); + }); + + it("renders a PR icon and opens review on click for a branch without a worktree", () => { + const props = baseProps(); + const branch = { + name: "feature/x", + current: false, + commit: "abc123", + isRemote: true, + remote: "origin", + }; + useGitStore + .getState() + .setPrData(buildBranchNamePrKey("p1", "feature/x"), makePr({ number: 42, state: "open" })); + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Review PR #42 for feature/x" })); + + expect(props.onOpenPrReview).toHaveBeenCalledWith({ branch: "feature/x", prNumber: 42 }); + expect(props.onSelect).not.toHaveBeenCalled(); + }); + + it("marks worktree branches with a fork icon and a thread-count badge", () => { + const props = baseProps(); + const branch = { + name: "feature/x", + current: false, + commit: "abc123", + isRemote: false, + }; + + const { container } = render( + , + ); + + // Worktree kind is conveyed by the leading fork glyph, not a "worktree" label. + expect(container.querySelector(".lucide-git-fork")).not.toBeNull(); + expect(screen.queryByText("worktree")).not.toBeInTheDocument(); + expect(screen.getByText("1")).toBeInTheDocument(); }); }); diff --git a/src/renderer/components/common/BranchSelector/parts/BranchListBox.tsx b/src/renderer/components/common/BranchSelector/parts/BranchListBox.tsx index 865a7662..2ca383d9 100644 --- a/src/renderer/components/common/BranchSelector/parts/BranchListBox.tsx +++ b/src/renderer/components/common/BranchSelector/parts/BranchListBox.tsx @@ -1,5 +1,23 @@ -import { Check, GitBranch, Globe, Trash2 } from "lucide-react"; -import { Header, Label, ListBox, ListLayout, Virtualizer } from "@heroui/react"; +import { + Check, + GitBranch, + GitFork, + GitPullRequest, + Globe, + MessageSquare, + Trash2, +} from "lucide-react"; +import { Header, Label, ListBox, ListLayout, Tooltip, Virtualizer } from "@heroui/react"; +import type { Thread } from "@/shared/contracts"; +import { + buildBranchNamePrKey, + usePrChecksStatus, + usePrNumber, + usePrState, + usePrTitle, +} from "@/renderer/state/gitSelectors"; +import { getPrStatusTone, PR_TONE_TEXT_CLASS } from "@/renderer/utils/prStatus"; +import { getStatusTone, type StatusTone } from "@/renderer/components/providers/statusTone"; import { COMPACT_DROPDOWN_ROW_HEIGHT, VIRTUALIZED_COMPACT_DROPDOWN_ITEM_CLASS, @@ -7,7 +25,24 @@ import { import { PixelLoader } from "../../PixelLoader"; import type { BranchListItem } from "./useBranchList"; +const STATUS_DOT_CLASS: Record = { + inactive: "bg-muted/40", + active: "bg-success", + working: "bg-warning", + finished: "bg-success", + error: "bg-danger", + attention: "bg-warning", + done: "bg-muted/40", +}; + +export interface OpenPrReviewArgs { + branch: string; + prNumber: number; + worktreePath?: string; +} + export function BranchListBox(props: { + projectId: string; items: BranchListItem[]; hasLocal: boolean; hasRemote: boolean; @@ -17,12 +52,15 @@ export function BranchListBox(props: { isWorktree: boolean | undefined; worktreeMode: boolean; deletingBranch: string | null; - activeWorktreeBranches: Set; worktreeBranches: Set; + branchWorktreePath: Map; + threadsByBranch: Map; onSelect: (branchName: string) => void; onDelete: (branch: { name: string; remote?: string; isRemote?: boolean }) => void; + onOpenPrReview: (args: OpenPrReviewArgs) => void; }) { const { + projectId, items, hasLocal, hasRemote, @@ -32,16 +70,20 @@ export function BranchListBox(props: { isWorktree, worktreeMode, deletingBranch, - activeWorktreeBranches, worktreeBranches, + branchWorktreePath, + threadsByBranch, onSelect, onDelete, + onOpenPrReview, } = props; if (!hasLocal && !hasRemote) { return
No branches found
; } + const selectedKey = isWorktree || worktreeMode ? (baseBranch ?? value) : value; + return ( - - {({ isSelected }) => { - if (isDeleting) { - return ; - } - return isSelected ? : null; - }} - - {branch.isRemote ? ( - - ) : ( - - )} - - {branch.name === currentBranch && ( - current - )} - {worktreeBranches.has(branch.name) && branch.name !== currentBranch && ( - worktree - )} - {canDelete && !isDeleting && ( - - )} + ); }} @@ -135,3 +157,130 @@ export function BranchListBox(props: { ); } + +function BranchRowBody(props: { + branch: { name: string; isRemote: boolean; remote?: string }; + projectId: string; + isCurrent: boolean; + isSelected: boolean; + isWorktreeBranch: boolean; + worktreePath?: string; + threads: Thread[]; + isDeleting: boolean; + onDelete: (branch: { name: string; remote?: string; isRemote?: boolean }) => void; + onOpenPrReview: (args: OpenPrReviewArgs) => void; +}) { + const { + branch, + projectId, + isCurrent, + isSelected, + isWorktreeBranch, + worktreePath, + threads, + isDeleting, + onDelete, + onOpenPrReview, + } = props; + const canDelete = !isCurrent; + + const prKey = worktreePath ?? buildBranchNamePrKey(projectId, branch.name); + const prState = usePrState(prKey); + const prChecksStatus = usePrChecksStatus(prKey); + const prNumber = usePrNumber(prKey); + const prTitle = usePrTitle(prKey); + const showPr = prState !== undefined && prState !== "closed" && prNumber !== undefined; + + // The leading glyph carries the row's kind: a fork for worktree branches, a + // globe for remote ones, a plain branch otherwise. The current branch keeps + // its glyph but brightens to foreground (white) instead of a "current" label. + const LeadingIcon = branch.isRemote ? Globe : isWorktreeBranch ? GitFork : GitBranch; + + return ( + <> + + +
+ {threads.length > 0 && ( + + + + + {threads.length} + + + +
+ {threads.map((t) => ( +
+ + {t.title} +
+ ))} +
+
+
+ )} + {showPr && ( + + + + + +
+ {prTitle || `PR #${prNumber}`} + + #{prNumber} · {prState} + +
+
+
+ )} + {isSelected && !isDeleting && } +
+ {isDeleting ? ( + + ) : canDelete ? ( + + ) : null} +
+
+ + ); +} diff --git a/src/renderer/components/common/BranchSelector/parts/types.ts b/src/renderer/components/common/BranchSelector/parts/types.ts index dc26ca78..0749d08a 100644 --- a/src/renderer/components/common/BranchSelector/parts/types.ts +++ b/src/renderer/components/common/BranchSelector/parts/types.ts @@ -3,4 +3,6 @@ export interface BranchSelection { baseBranch?: string; isWorktree: boolean; worktreePath?: string; + /** Carry the main checkout's uncommitted changes into the new worktree. */ + transferUncommitted?: boolean; } diff --git a/src/renderer/components/common/BranchSelector/parts/useBranchList.ts b/src/renderer/components/common/BranchSelector/parts/useBranchList.ts index bc0be53b..33a9ec2e 100644 --- a/src/renderer/components/common/BranchSelector/parts/useBranchList.ts +++ b/src/renderer/components/common/BranchSelector/parts/useBranchList.ts @@ -22,15 +22,24 @@ export function useBranchList(params: { projectId: string; search: string }) { const { projectId, search } = params; const branchData = useGitStore((s) => s.branches[projectId]); const worktrees = useGitStore((s) => s.worktrees[projectId]); - const activeWorktreeBranchNames = useAppStore( - useShallow((s) => getActiveWorktreeBranchNames(s.threads, projectId)), + const projectThreads = useAppStore( + useShallow((s) => + s.threads.filter((t) => t.projectId === projectId && !t.archived && t.worktreeBranch), + ), ); const projectLocation = useProject(projectId)?.location; - const activeWorktreeBranches = useMemo( - () => new Set(activeWorktreeBranchNames), - [activeWorktreeBranchNames], - ); + const threadsByBranch = useMemo(() => { + const map = new Map(); + for (const t of projectThreads) { + const branch = t.worktreeBranch; + if (!branch) continue; + const list = map.get(branch); + if (list) list.push(t); + else map.set(branch, [t]); + } + return map; + }, [projectThreads]); const worktreeBranches = useMemo( () => new Set(worktrees?.filter((w) => !w.isMain).map((w) => w.branch) ?? []), [worktrees], @@ -90,9 +99,9 @@ export function useBranchList(params: { projectId: string; search: string }) { items, hasLocal, hasRemote, - activeWorktreeBranches, worktreeBranches, branchWorktreePath, + threadsByBranch, projectLocation, }; } diff --git a/src/renderer/components/thread/ThreadComposerSection.tsx b/src/renderer/components/thread/ThreadComposerSection.tsx index 4ecaa25b..29bf52cc 100644 --- a/src/renderer/components/thread/ThreadComposerSection.tsx +++ b/src/renderer/components/thread/ThreadComposerSection.tsx @@ -898,6 +898,13 @@ function ThreadComposerSectionInner(props: ThreadComposerSectionProps & { thread onSelect={handleBranchSelect} onSwitchBranch={handleSwitchBranch} hideWorktreeToggle + showMoveBranchAction + {...(project?.scripts?.worktreeCopyPatterns + ? { + moveBranchCopyIgnoredPatterns: + project.scripts.worktreeCopyPatterns, + } + : {})} forceHideLabel={level >= 3} iconOnly={level >= 3} /> diff --git a/src/renderer/components/thread/ThreadDraftComposerArea.tsx b/src/renderer/components/thread/ThreadDraftComposerArea.tsx index edf32775..97140ab3 100644 --- a/src/renderer/components/thread/ThreadDraftComposerArea.tsx +++ b/src/renderer/components/thread/ThreadDraftComposerArea.tsx @@ -34,6 +34,7 @@ import { type BranchSelection, } from "@/renderer/components/common"; import { useAppStore } from "@/renderer/state/appStore"; +import { useGitStore } from "@/renderer/state/gitStore"; import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; import { ThreadCommandPanel } from "./ThreadCommandPanel"; import { ThreadAgentUpdateDock } from "./ThreadAgentUpdateDock"; @@ -48,6 +49,7 @@ import { resolveLocalSlashCommandAction, } from "./threadSlashCommands"; import { handleComposerControlShortcut } from "./threadComposerShortcuts"; +import { WorktreeModeSelect, type WorktreeMode } from "./WorktreeModeSelect"; export type DraftStartInput = { agentKind: AgentStatus["kind"]; @@ -58,6 +60,7 @@ export type DraftStartInput = { worktreeBranch?: string; worktreeBaseBranch?: string; worktreeIsNewBranch?: boolean; + worktreeTransferUncommitted?: boolean; presentationMode?: ThreadPresentationMode; }; @@ -265,6 +268,47 @@ export function ThreadDraftComposerArea(props: { const authRequired = props.selectedAgent.authState === "missing"; const isHomeScope = isHomeProjectId(props.project.id); const browserMcpScope = getBrowserMcpScope(props.selectedAgent.kind, props.presentationMode); + + // Worktree creation lives in the composer toolbar. The "bring over uncommitted + // changes" affordance only appears when the new worktree forks from the + // current (dirty) checkout — the only case where transferring is meaningful. + const projectStatus = useGitStore((s) => s.statuses[props.project.id]); + const hasUncommittedChanges = + !!projectStatus && projectStatus.staged.length + projectStatus.unstaged.length > 0; + const worktreeBase = branchSelection?.baseBranch ?? branchSelection?.branch ?? props.gitBranch; + // The worktree dropdown's "+ changes" choice is offered whenever the current + // (dirty) checkout would be the worktree's fork point — independent of whether + // worktree mode is already on, since selecting it also turns worktree mode on. + const canBringChanges = hasUncommittedChanges && worktreeBase === props.gitBranch; + // Transferring is only meaningful once worktree mode is actually on. + const canTransferUncommitted = props.worktreeMode && canBringChanges; + const shouldTransferUncommitted = + canTransferUncommitted && branchSelection?.transferUncommitted === true; + + const worktreeSelected = branchSelection?.isWorktree ?? props.worktreeMode; + const worktreeMode: WorktreeMode = !worktreeSelected + ? "none" + : shouldTransferUncommitted + ? "new-with-changes" + : "new"; + + function selectNewWorktree(overrides?: Partial) { + const base = worktreeBase ?? props.gitBranch ?? ""; + setBranchSelection({ branch: base, baseBranch: base, isWorktree: true, ...overrides }); + } + + function handleWorktreeModeChange(mode: WorktreeMode) { + if (mode === "none") { + props.onWorktreeModeChange(false); + setBranchSelection(null); + return; + } + props.onWorktreeModeChange(true); + // Keep an existing worktree selection (e.g. a worktreePath from "New thread + // in worktree") intact rather than rebuilding it into a brand-new branch. + if (branchSelection?.worktreePath) return; + selectNewWorktree({ transferUncommitted: mode === "new-with-changes" }); + } const controls: ComposerControl[] = controlOpenRequest ? props.controls.map((control) => { if (controlOpenRequest.target === "model" && control.kind === "provider-model") { @@ -292,6 +336,7 @@ export function ThreadDraftComposerArea(props: { branchSelection?.branch ?? "", branchSelection?.baseBranch ?? "", branchSelection?.isWorktree ? "selection-worktree" : "selection-branch", + canTransferUncommitted ? "can-transfer" : "no-transfer", controlKinds, ].join("|"); function resetDraftRefs() { @@ -366,6 +411,7 @@ export function ThreadDraftComposerArea(props: { ? { worktreeBaseBranch: branchSelection.baseBranch } : {}), worktreeIsNewBranch: true, + ...(shouldTransferUncommitted ? { worktreeTransferUncommitted: true } : {}), } : {}), }); @@ -564,7 +610,7 @@ export function ThreadDraftComposerArea(props: { const segments = mentionRef.current?.serializeSegments() ?? []; submitSegments([...attachments.toSegments(), ...segments], prompt); }} - afterControls={(level) => ( + afterControls={() => ( <> props.onConfigChange({ browserMcp: next })} /> - {props.gitBranch ? ( - = 3} - iconOnly={level >= 3} - /> - ) : null} {showVoiceInputButton ? ( )} /> + {props.gitBranch ? ( +
+ + +
+ ) : null} {lightboxIndex !== null && imageAttachments.length > 0 ? ( void; + isDisabled?: boolean; + /** Render a shorter trigger for secondary control rows. */ + compact?: boolean; +}) { + const [isOpen, setIsOpen] = useState(false); + const iconSize = props.compact ? "size-3" : "size-3.5"; + + const options: WorktreeModeOption[] = [ + { + id: "none", + label: "No worktree", + description: "Work in the current checkout", + icon: GitBranch, + }, + { + id: "new", + label: "Worktree", + description: "Run in a separate worktree", + icon: GitFork, + }, + ...(props.canBringChanges + ? [ + { + id: "new-with-changes" as const, + label: "Worktree + changes", + description: "Copy uncommitted changes here (keeps them on this branch)", + icon: GitFork, + }, + ] + : []), + ]; + + const selected = options.find((option) => option.id === props.mode) ?? options[0]!; + const SelectedIcon = selected.icon; + + return ( + + + + + + + { + props.onChange(key as WorktreeMode); + setIsOpen(false); + }} + > + {options.map((option) => { + const OptionIcon = option.icon; + return ( + + +
+ + {option.description} +
+ {option.id === props.mode ? ( + + ) : null} +
+ ); + })} +
+
+
+
+ ); +} diff --git a/src/renderer/state/gitRefresh.ts b/src/renderer/state/gitRefresh.ts index bd58573d..939fccaa 100644 --- a/src/renderer/state/gitRefresh.ts +++ b/src/renderer/state/gitRefresh.ts @@ -3,7 +3,7 @@ import { readBridge } from "@/renderer/bridge"; import { useAppStore } from "@/renderer/state/appStore"; import { useDevTerminalStore } from "@/renderer/state/devTerminalStore"; import { useGitStore } from "@/renderer/state/gitStore"; -import { buildBranchPrKey } from "@/renderer/state/gitSelectors"; +import { buildBranchNamePrKey, buildBranchPrKey } from "@/renderer/state/gitSelectors"; import { usePanelStore } from "@/renderer/state/panelStore"; import { useSidebarUiStore } from "@/renderer/state/sidebarUiStore"; import { useSharedSettings } from "@/renderer/state/sharedSettingsStore"; @@ -34,6 +34,49 @@ export const PR_POST_PUSH_STATUS_POLL_MS = 5_000; const alwaysActive = () => true; +const BRANCH_PR_PREFETCH_THROTTLE_MS = 10_000; +const branchPrPrefetchLastRun = new Map(); +const branchPrPrefetchInFlight = new Set(); + +/** + * Bulk-fetch PR status for every branch of a project in one `gh pr list` call and + * cache it under branch-name keys (see {@link buildBranchNamePrKey}) so the branch + * selector can show PR-status icons for remote/local branches that aren't checked + * out as a worktree. Dedupes in-flight calls, throttles (so dropdown opens + the + * refresh cycle don't spam `gh`), and self-gates on gh availability + a GitHub + * remote. Results persist for free via the gitStore prData snapshot, so cached + * icons show instantly and refresh in place. + */ +export async function prefetchBranchPrData(project: { + id: string; + location: ProjectLocation; +}): Promise { + if (branchPrPrefetchInFlight.has(project.id)) return; + const last = branchPrPrefetchLastRun.get(project.id) ?? 0; + if (Date.now() - last < BRANCH_PR_PREFETCH_THROTTLE_MS) return; + const state = useGitStore.getState(); + if (!state.ghAvailable[project.id]) return; + const platform = state.statuses[project.id]?.remoteInfo?.platform; + if (platform !== undefined && platform !== "github" && platform !== "unknown") return; + + branchPrPrefetchInFlight.add(project.id); + branchPrPrefetchLastRun.set(project.id, Date.now()); + try { + const { prs } = await readBridge().ghListPrs({ projectLocation: project.location }); + const updates: Record = {}; + for (const [branch, pr] of Object.entries(prs)) { + updates[buildBranchNamePrKey(project.id, branch)] = pr; + } + if (Object.keys(updates).length > 0) { + useGitStore.getState().setPrDataBatch(updates); + } + } catch (err) { + console.warn(`[git-refresh] ghListPrs failed project=${project.id}`, err); + } finally { + branchPrPrefetchInFlight.delete(project.id); + } +} + function getWorktreeStatusDetail(reason: GitRefreshReason): GitStatusDetail { return reason === "fetch" || reason === "poll" ? "summary" : "full"; } @@ -518,6 +561,14 @@ export async function refreshGitProject( const statusPromise = snapshotPromise.then((snap) => snap?.status ?? undefined); const worktreesPromise = snapshotPromise.then((snap) => snap?.worktrees ?? undefined); + // Once the snapshot has set gh availability + remote platform, bulk-prefetch + // PR status for every branch (one gh call) for the branch-selector icons. + // Fire-and-forget: it writes branch-keyed prData independently of this refresh. + void snapshotPromise.then(() => { + if (!isRefreshCurrent(project.id, refreshToken, isActive)) return; + void prefetchBranchPrData(project); + }); + const ghAvailablePromise: Promise = cachedGhAvailable ? Promise.resolve(true) : snapshotPromise.then((snap) => { diff --git a/src/renderer/state/gitSelectors.ts b/src/renderer/state/gitSelectors.ts index 43ad7b24..b584222d 100644 --- a/src/renderer/state/gitSelectors.ts +++ b/src/renderer/state/gitSelectors.ts @@ -40,6 +40,16 @@ export function resolvePrKey(projectId: string, worktreePath: string | undefined return worktreePath ?? buildBranchPrKey(projectId); } +/** + * Key for a PR discovered by the bulk branch-PR prefetch (`ghListPrs`), addressed + * by branch name. Distinct from {@link buildBranchPrKey} (the checked-out main + * branch) and worktree-path keys, so branch-selector rows can show PR status for + * remote/local branches that aren't checked out as a worktree. + */ +export function buildBranchNamePrKey(projectId: string, branch: string): string { + return `__branchname:${projectId}:${branch}`; +} + type PrField = PrData[K] | undefined; function makePrFieldSelector(field: K) { diff --git a/src/renderer/state/panelStore.ts b/src/renderer/state/panelStore.ts index f56f481b..94017a1c 100644 --- a/src/renderer/state/panelStore.ts +++ b/src/renderer/state/panelStore.ts @@ -11,6 +11,13 @@ export interface PrReviewContext { projectId: string; worktreePath?: string; prNumber: number; + /** + * Explicit prData key override for selectors (title/url/checks). Set when + * opening a PR for a branch that has no worktree, so the overlay reads the + * branch-keyed prefetch entry instead of the main-branch key. Defaults to + * `resolvePrKey(projectId, worktreePath)` when omitted. + */ + prKey?: string; } export interface FilesPanelContext { @@ -154,7 +161,8 @@ export const usePanelStore = create((set) => ({ ctx !== null && prev.projectId === ctx.projectId && prev.worktreePath === ctx.worktreePath && - prev.prNumber === ctx.prNumber) + prev.prNumber === ctx.prNumber && + prev.prKey === ctx.prKey) ) { return {}; } diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 58e97b5f..56a122a2 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -1740,6 +1740,13 @@ html[data-platform="darwin"] .lightcode-content-over-drag-region--drag { gap: 0.35rem; } +/* Compact variant for secondary control rows (e.g. the worktree row under the composer). */ +.lightcode-composer-menu--compact { + height: 1.625rem; + gap: 0.25rem; + font-size: 0.75rem; +} + .lightcode-composer-static { display: inline-flex; height: 2.25rem; diff --git a/src/renderer/views/GitReviewOverlay/GitReviewOverlay.tsx b/src/renderer/views/GitReviewOverlay/GitReviewOverlay.tsx index 83b3f9fa..b55a37eb 100644 --- a/src/renderer/views/GitReviewOverlay/GitReviewOverlay.tsx +++ b/src/renderer/views/GitReviewOverlay/GitReviewOverlay.tsx @@ -176,6 +176,10 @@ export function GitReviewOverlay(props: { value={gitStatus.branch} onSwitchBranch={handleSwitchBranch} hideWorktreeToggle + showMoveBranchAction + {...(project.scripts?.worktreeCopyPatterns + ? { moveBranchCopyIgnoredPatterns: project.scripts.worktreeCopyPatterns } + : {})} popoverPlacement="bottom" trigger={