fix(desktop): Cmd+W honors pinned / only-tab close guards

Address review: the Cmd/Ctrl+W path reused closeActiveTab(), an
unconditional force-close reserved for route-crash recovery, so the
shortcut could close a pinned tab or reseed the sole tab — both of which
the TabBar deliberately hides the close button for. Add a guarded
closeActiveTabIfClosable() that no-ops for pinned / only tabs and wire
the shortcut to it. (MUL-2987)

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Lambda
2026-06-04 16:22:29 +08:00
parent 37af0f769a
commit 780c12c798
3 changed files with 75 additions and 2 deletions

View File

@@ -71,11 +71,13 @@ function useNativeNavigationGestures() {
}
// Cmd/Ctrl+W closes the active tab. The main process owns the keystroke (it
// must swallow the OS "Close Window" accelerator) and forwards it here.
// must swallow the OS "Close Window" accelerator) and forwards it here. Uses
// the guarded close so the shortcut honors the same pinned / only-tab rules
// as the TabBar's close button — never the unconditional force-close.
function useCloseActiveTabShortcut() {
useEffect(() => {
return window.desktopAPI.onCloseActiveTab(() => {
useTabStore.getState().closeActiveTab();
useTabStore.getState().closeActiveTabIfClosable();
});
}, []);
}

View File

@@ -320,6 +320,54 @@ describe("useTabStore actions", () => {
});
});
describe("closeActiveTabIfClosable (Cmd/Ctrl+W guard — MUL-2987)", () => {
it("closes the active tab when it is unpinned and not the only tab", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
const closableId = store.addTab("/acme/projects", "Projects", "FolderKanban");
store.setActiveTab(closableId);
store.closeActiveTabIfClosable();
const s = useTabStore.getState();
expect(s.byWorkspace.acme.tabs.some((t) => t.id === closableId)).toBe(false);
expect(s.byWorkspace.acme.tabs).toHaveLength(1);
});
it("no-ops on the only tab (never reseeds a default the user didn't ask for)", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
const onlyTabId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
store.closeActiveTabIfClosable();
const s = useTabStore.getState();
expect(s.byWorkspace.acme.tabs).toHaveLength(1);
expect(s.byWorkspace.acme.tabs[0].id).toBe(onlyTabId); // untouched, not reseeded
});
it("no-ops when the active tab is pinned (requires explicit Unpin first)", () => {
const store = useTabStore.getState();
store.switchWorkspace("acme");
store.addTab("/acme/projects", "Projects", "FolderKanban");
const pinnedId = useTabStore.getState().byWorkspace.acme.tabs[0].id;
store.togglePin(pinnedId);
store.setActiveTab(pinnedId);
store.closeActiveTabIfClosable();
const s = useTabStore.getState();
expect(s.byWorkspace.acme.tabs.some((t) => t.id === pinnedId)).toBe(true);
expect(s.byWorkspace.acme.tabs).toHaveLength(2);
});
it("no-ops when no workspace is active", () => {
const store = useTabStore.getState();
expect(() => store.closeActiveTabIfClosable()).not.toThrow();
expect(useTabStore.getState().byWorkspace).toEqual({});
});
});
describe("togglePin", () => {
it("flips a tab's pinned state", () => {
const store = useTabStore.getState();

View File

@@ -96,6 +96,15 @@ interface TabStore {
* (or a reseeded default if it was the last tab).
*/
closeActiveTab: () => void;
/**
* Close the active tab in response to the user Cmd/Ctrl+W shortcut. Mirrors
* the TabBar's close-affordance rules (tab-bar.tsx `showCloseButton`):
* no-ops when the active tab is pinned or is the only tab in its workspace,
* so the shortcut can never destroy a tab the UI intentionally exposes no
* close button for. Distinct from closeActiveTab(), which is an
* unconditional force-close reserved for route-crash recovery.
*/
closeActiveTabIfClosable: () => void;
/**
* Reorder within the active workspace's group only. Clamped so a tab can
* never cross the pinned / unpinned boundary — a drag that would move a
@@ -517,6 +526,20 @@ export const useTabStore = create<TabStore>()(
closeTab(group.activeTabId);
},
closeActiveTabIfClosable() {
const { activeWorkspaceSlug, byWorkspace, closeTab } = get();
if (!activeWorkspaceSlug) return;
const group = byWorkspace[activeWorkspaceSlug];
if (!group) return;
// Match the TabBar close-button guard: the sole tab never closes
// (its X is hidden; closing would reseed a default the user didn't
// ask for) and pinned tabs require an explicit Unpin first.
if (group.tabs.length === 1) return;
const active = group.tabs.find((t) => t.id === group.activeTabId);
if (!active || active.pinned) return;
closeTab(active.id);
},
moveTab(fromIndex, toIndex) {
if (fromIndex === toIndex) return;
const { activeWorkspaceSlug, byWorkspace } = get();