diff --git a/apps/desktop/src/renderer/src/components/desktop-layout.tsx b/apps/desktop/src/renderer/src/components/desktop-layout.tsx index d69747e0c..79da48f1d 100644 --- a/apps/desktop/src/renderer/src/components/desktop-layout.tsx +++ b/apps/desktop/src/renderer/src/components/desktop-layout.tsx @@ -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(); }); }, []); } diff --git a/apps/desktop/src/renderer/src/stores/tab-store.test.ts b/apps/desktop/src/renderer/src/stores/tab-store.test.ts index 276637f45..4f5878eed 100644 --- a/apps/desktop/src/renderer/src/stores/tab-store.test.ts +++ b/apps/desktop/src/renderer/src/stores/tab-store.test.ts @@ -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(); diff --git a/apps/desktop/src/renderer/src/stores/tab-store.ts b/apps/desktop/src/renderer/src/stores/tab-store.ts index 33613429e..647fe2be9 100644 --- a/apps/desktop/src/renderer/src/stores/tab-store.ts +++ b/apps/desktop/src/renderer/src/stores/tab-store.ts @@ -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()( 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();