diff --git a/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.test.tsx b/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.test.tsx new file mode 100644 index 000000000..7d3033721 --- /dev/null +++ b/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.test.tsx @@ -0,0 +1,146 @@ +/* @vitest-environment jsdom */ + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { LaneDeleteRisk, LaneSummary } from "../../../shared/types"; +import { ManageLaneDialog } from "./ManageLaneDialog"; + +afterEach(cleanup); + +const deleteRisk: LaneDeleteRisk = { + laneId: "lane-1", + branchRef: "feature/manage-tabs", + dirty: false, + hasUnpushedCommits: false, + unpushedCommitCount: 0, + remoteBranchExists: true, + runningProcessCount: 0, + activePtyCount: 0, + activeWatcherCount: 0, + envInitialized: true, +}; + +function makeLane(overrides: Partial = {}): LaneSummary { + return { + id: "lane-1", + name: "Manage tabs", + description: null, + laneType: "worktree", + baseRef: "main", + branchRef: "feature/manage-tabs", + worktreePath: "/tmp/ade/manage-tabs", + attachedRootPath: null, + parentLaneId: null, + childCount: 0, + stackDepth: 0, + parentStatus: null, + isEditProtected: false, + status: { dirty: false, ahead: 0, behind: 0, remoteBehind: -1, rebaseInProgress: false }, + color: null, + icon: null, + tags: [], + folder: null, + createdAt: "2026-05-27T12:00:00.000Z", + archivedAt: null, + ...overrides, + }; +} + +type DialogProps = Parameters[0]; + +function makeProps(overrides: Partial = {}): DialogProps { + const lane = makeLane(); + return { + open: true, + onOpenChange: vi.fn(), + managedLane: lane, + managedLanes: undefined, + allLanes: [lane], + deleteMode: "worktree", + setDeleteMode: vi.fn(), + deleteRemoteName: "origin", + setDeleteRemoteName: vi.fn(), + deleteForce: false, + setDeleteForce: vi.fn(), + deleteConfirmText: "delete Manage tabs", + setDeleteConfirmText: vi.fn(), + deletePhrase: "delete Manage tabs", + laneActionBusy: false, + laneActionStatus: null, + laneActionError: null, + laneActionKind: null, + onAdoptAttached: vi.fn(), + onArchive: vi.fn(), + onDelete: vi.fn(), + onAppearanceChanged: vi.fn(), + onStackReorganized: vi.fn(), + ...overrides, + }; +} + +function selectedTabLabel(): string | null { + return screen + .getAllByRole("tab") + .find((tab) => tab.getAttribute("aria-selected") === "true") + ?.textContent ?? null; +} + +describe("ManageLaneDialog tabs", () => { + const originalAde = (globalThis.window as any).ade; + + beforeEach(() => { + (globalThis.window as any).ade = { + lanes: { + getDeleteRisk: vi.fn().mockResolvedValue(deleteRisk), + onDeleteEvent: vi.fn(() => vi.fn()), + updateAppearance: vi.fn().mockResolvedValue(undefined), + reparent: vi.fn().mockResolvedValue(undefined), + }, + }; + }); + + afterEach(() => { + (globalThis.window as any).ade = originalAde; + vi.clearAllMocks(); + }); + + it("opens on the first non-destructive tab for a single lane", () => { + render(); + + expect(selectedTabLabel()).toBe("Appearance"); + }); + + it("opens on archive for batch lane management", () => { + const firstLane = makeLane({ id: "lane-1", name: "First lane" }); + const secondLane = makeLane({ id: "lane-2", name: "Second lane" }); + + render( + , + ); + + expect(selectedTabLabel()).toBe("Archive"); + }); + + it("does not reset the selected tab when the lane object refreshes", () => { + const lane = makeLane(); + const { rerender } = render( + , + ); + + fireEvent.click(screen.getByRole("tab", { name: "Archive" })); + expect(selectedTabLabel()).toBe("Archive"); + + const refreshedLane = { ...lane, color: "#5eead4" }; + rerender( + , + ); + + expect(selectedTabLabel()).toBe("Archive"); + }); +}); diff --git a/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.tsx b/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.tsx index 4395143fe..9c48e5ea1 100644 --- a/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.tsx +++ b/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.tsx @@ -241,6 +241,8 @@ export function ManageLaneDialog({ const isAttached = !isBatch && lanes[0]?.laneType === "attached"; const hasNonAttached = lanes.some((l) => l.laneType !== "attached" && l.laneType !== "primary"); const isMixed = hasAttached && hasNonAttached; + const singleLaneId = singleLane?.id ?? null; + const singleLaneType = singleLane?.laneType ?? null; let worktreeDeleteLabel: string; let localDeleteLabel: string; let remoteDeleteLabel: string; @@ -263,17 +265,19 @@ export function ManageLaneDialog({ const [activeTab, setActiveTab] = useState("delete"); const tabDefs = React.useMemo((): ManageLaneTabDef[] => { - const defs: ManageLaneTabDef[] = []; - defs.push({ id: "delete", label: "Delete", icon: Trash }); - if (singleLane) { - defs.push({ id: "appearance", label: "Appearance", icon: Palette }); - } - if (singleLane && singleLane.laneType !== "primary") { - defs.push({ id: "stack", label: "Restack", icon: TreeStructure }); - } - defs.push({ id: "archive", label: "Archive", icon: Archive }); - return defs; - }, [singleLane]); + return [ + { id: "delete" as const, label: "Delete", icon: Trash, show: true }, + { id: "appearance" as const, label: "Appearance", icon: Palette, show: !!singleLaneType }, + { id: "stack" as const, label: "Restack", icon: TreeStructure, show: !!singleLaneType && singleLaneType !== "primary" }, + { id: "archive" as const, label: "Archive", icon: Archive, show: true }, + ] + .filter((t) => t.show) + .map(({ show: _, ...tab }) => tab); + }, [singleLaneType]); + const defaultTab = React.useMemo((): ManageLaneTab => { + const preferredOrder: ManageLaneTab[] = ["appearance", "stack", "archive", "delete"]; + return preferredOrder.find((id) => tabDefs.some((tab) => tab.id === id)) ?? tabDefs[0]?.id ?? "archive"; + }, [tabDefs]); // Reset transient state when dialog closes or active lane changes. useEffect(() => { @@ -282,22 +286,18 @@ export function ManageLaneDialog({ setDeleteProgress(null); return; } - // Land users on a non-destructive tab by default. Explicit "delete"/"archive" - // intent is handled by the laneActionKind effect below. - const preferredOrder: ManageLaneTab[] = ["appearance", "stack", "archive", "delete"]; - const firstAvailable = preferredOrder.find((id) => tabDefs.some((tab) => tab.id === id)); - setActiveTab(firstAvailable ?? tabDefs[0]?.id ?? "archive"); - }, [open, singleLane?.id, isBatch, tabDefs]); + setActiveTab(defaultTab); + }, [open, singleLaneId, isBatch, defaultTab]); // Fetch pre-flight risk for the single-lane case. useEffect(() => { - if (!open || !singleLane || singleLane.laneType === "primary") { + if (!open || !singleLaneId || singleLaneType === "primary") { setDeleteRisk(null); return; } let cancelled = false; void window.ade.lanes - .getDeleteRisk({ laneId: singleLane.id }) + .getDeleteRisk({ laneId: singleLaneId }) .then((risk) => { if (!cancelled) setDeleteRisk(risk); }) @@ -307,19 +307,19 @@ export function ManageLaneDialog({ return () => { cancelled = true; }; - }, [open, singleLane?.id]); + }, [open, singleLaneId, singleLaneType]); // Stream live delete progress for the active lane. useEffect(() => { - if (!open || !singleLane) return; + if (!open || !singleLaneId) return; const unsubscribe = window.ade.lanes.onDeleteEvent((event) => { - if (event.progress.laneId !== singleLane.id) return; + if (event.progress.laneId !== singleLaneId) return; setDeleteProgress(event.progress); }); return () => { unsubscribe?.(); }; - }, [open, singleLane?.id]); + }, [open, singleLaneId]); const requiresTypeConfirm = isBatch || @@ -334,8 +334,8 @@ export function ManageLaneDialog({ useEffect(() => { if (tabDefs.some((tab) => tab.id === activeTab)) return; - setActiveTab(tabDefs[0]?.id ?? "archive"); - }, [activeTab, tabDefs]); + setActiveTab(defaultTab); + }, [activeTab, defaultTab, tabDefs]); useEffect(() => { if (laneActionKind === "delete") setActiveTab("delete");