Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions apps/desktop/src/renderer/components/lanes/ManageLaneDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -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> = {}): 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<typeof ManageLaneDialog>[0];

function makeProps(overrides: Partial<DialogProps> = {}): 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(<ManageLaneDialog {...makeProps()} />);

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(
<ManageLaneDialog
{...makeProps({
managedLane: null,
managedLanes: [firstLane, secondLane],
allLanes: [firstLane, secondLane],
})}
/>,
);

expect(selectedTabLabel()).toBe("Archive");
});

it("does not reset the selected tab when the lane object refreshes", () => {
const lane = makeLane();
const { rerender } = render(
<ManageLaneDialog {...makeProps({ managedLane: lane, allLanes: [lane] })} />,
);

fireEvent.click(screen.getByRole("tab", { name: "Archive" }));
expect(selectedTabLabel()).toBe("Archive");

const refreshedLane = { ...lane, color: "#5eead4" };
rerender(
<ManageLaneDialog {...makeProps({ managedLane: refreshedLane, allLanes: [refreshedLane] })} />,
);

expect(selectedTabLabel()).toBe("Archive");
});
});
50 changes: 25 additions & 25 deletions apps/desktop/src/renderer/components/lanes/ManageLaneDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -263,17 +265,19 @@ export function ManageLaneDialog({
const [activeTab, setActiveTab] = useState<ManageLaneTab>("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(() => {
Expand All @@ -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);
})
Expand All @@ -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 ||
Expand All @@ -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");
Expand Down
Loading