mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
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:
@@ -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();
|
||||
});
|
||||
}, []);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user