Compare commits

...

2 Commits

Author SHA1 Message Date
Lambda
780c12c798 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>
2026-06-04 16:22:29 +08:00
Lambda
37af0f769a fix(desktop): Cmd+W closes the active tab instead of the window
Cmd/Ctrl+W was bound to the OS application-menu "Close Window" role,
tearing down the whole window and every tab. handleAppShortcut now
swallows Cmd/Ctrl+W and dispatches a close-active-tab action over a new
IPC channel; the renderer's DesktopShell calls the existing
tab-store closeActiveTab(). (MUL-2987)

Co-authored-by: multica-agent <github@multica.ai>
2026-06-04 14:31:19 +08:00
9 changed files with 168 additions and 1 deletions

View File

@@ -10,6 +10,7 @@ import { openExternalSafely, downloadURLSafely } from "./external-url";
import { installContextMenu } from "./context-menu";
import { handleAppShortcut } from "./keyboard-shortcuts";
import { installNavigationGestures } from "./navigation-gestures";
import { CLOSE_ACTIVE_TAB_CHANNEL } from "../shared/window-shortcuts";
import { getAppVersion } from "./app-version";
import { loadRuntimeConfig } from "./runtime-config-loader";
import type { RuntimeConfigResult } from "../shared/runtime-config";
@@ -202,7 +203,12 @@ function createWindow(): void {
// anything we own here (reload-block, zoom) is the sole handler for
// that combination — no double-fire with the macOS default View menu.
mainWindow.webContents.on("before-input-event", (event, input) => {
if (handleAppShortcut(input, mainWindow!.webContents)) {
if (
handleAppShortcut(input, mainWindow!.webContents, process.platform, {
closeActiveTab: () =>
mainWindow?.webContents.send(CLOSE_ACTIVE_TAB_CHANNEL),
})
) {
event.preventDefault();
}
});

View File

@@ -143,6 +143,44 @@ describe("handleAppShortcut — reset zoom", () => {
});
});
describe("handleAppShortcut — close active tab (MUL-2987)", () => {
it("closes the active tab on Cmd+W (macOS) and swallows the event", () => {
const wc = makeWc();
const closeActiveTab = vi.fn();
expect(
handleAppShortcut(key("w", { meta: true }), wc, "darwin", { closeActiveTab }),
).toBe(true);
expect(closeActiveTab).toHaveBeenCalledTimes(1);
expect(wc.setZoomLevel).not.toHaveBeenCalled();
});
it("closes the active tab on Ctrl+W (Linux/Windows)", () => {
const wc = makeWc();
const closeActiveTab = vi.fn();
expect(
handleAppShortcut(key("w", { control: true }), wc, "linux", { closeActiveTab }),
).toBe(true);
expect(
handleAppShortcut(key("W", { control: true }), wc, "win32", { closeActiveTab }),
).toBe(true);
expect(closeActiveTab).toHaveBeenCalledTimes(2);
});
it("still swallows Cmd+W with no action wired, so the window can't close", () => {
const wc = makeWc();
expect(handleAppShortcut(key("w", { meta: true }), wc, "darwin")).toBe(true);
});
it("ignores plain W without Cmd/Ctrl", () => {
const wc = makeWc();
const closeActiveTab = vi.fn();
expect(
handleAppShortcut(key("w"), wc, "darwin", { closeActiveTab }),
).toBe(false);
expect(closeActiveTab).not.toHaveBeenCalled();
});
});
describe("handleAppShortcut — unrelated keys pass through", () => {
it("does not capture plain letters", () => {
const wc = makeWc();

View File

@@ -13,6 +13,14 @@ export type ShortcutInput = {
// Subset of WebContents the zoom handler needs. Keeps the test mock tiny.
export type ZoomTarget = Pick<WebContents, "getZoomLevel" | "setZoomLevel">;
// Side effects the shortcut handler dispatches into the renderer. Passed in
// (rather than reached for via `webContents.send`) so the handler stays a
// pure, unit-testable function with no Electron dependency.
export type ShortcutActions = {
/** Cmd/Ctrl+W → close the active tab instead of the window. */
closeActiveTab: () => void;
};
// Match Electron's built-in zoomIn/zoomOut roles (Chromium default of 0.5
// per step). Clamp to a range that keeps the UI legible — values outside
// this band turn the workspace into either confetti or a microfiche.
@@ -33,11 +41,17 @@ const ZOOM_MAX = 4.5;
* layouts (issue MUL-2354 — Cmd+= zooms in but Cmd+- doesn't undo it).
* Handling the shortcuts here gives identical behavior on every platform
* and every layout.
*
* Cmd/Ctrl+W is handled here for the same reason: the OS application menu
* binds it to "Close Window" by default, which would tear down the whole
* window (and every tab in it). We swallow it and ask the renderer to close
* just the active tab instead (MUL-2987).
*/
export function handleAppShortcut(
input: ShortcutInput,
webContents: ZoomTarget,
platform: NodeJS.Platform = process.platform,
actions?: ShortcutActions,
): boolean {
if (input.type !== "keyDown") return false;
const cmdOrCtrl = platform === "darwin" ? input.meta : input.control;
@@ -50,6 +64,15 @@ export function handleAppShortcut(
if (!cmdOrCtrl) return false;
// Cmd/Ctrl + W → close the active tab, never the window. Swallow it even
// when no action is wired (the renderer hasn't mounted the tab shell yet,
// e.g. on the login screen) so the menu's Close Window accelerator can't
// fire and kill the only window.
if (input.key.toLowerCase() === "w") {
actions?.closeActiveTab();
return true;
}
// Cmd/Ctrl + "=" (unshifted) or "+" (Shift+=) → zoom in.
if (input.key === "=" || input.key === "+") {
const next = Math.min(webContents.getZoomLevel() + ZOOM_STEP, ZOOM_MAX);

View File

@@ -45,6 +45,8 @@ interface DesktopAPI {
) => () => void;
/** Listen for native macOS back/forward swipe gestures. Returns an unsubscribe function. */
onNavigationGesture: (callback: (gesture: NavigationGesture) => void) => () => void;
/** Listen for Cmd/Ctrl+W → close the active tab. Returns an unsubscribe function. */
onCloseActiveTab: (callback: () => void) => () => void;
/** Open the OS folder picker and return the chosen absolute path.
* Used by the Project settings "Add local directory" flow. */
pickDirectory: (

View File

@@ -6,6 +6,7 @@ import {
NAVIGATION_GESTURE_CHANNEL,
type NavigationGesture,
} from "../shared/navigation-gestures";
import { CLOSE_ACTIVE_TAB_CHANNEL } from "../shared/window-shortcuts";
// Synchronously fetch app metadata from main at preload time so the renderer
// can pass it into CoreProvider during the initial render — the alternative
@@ -156,6 +157,14 @@ const desktopAPI = {
ipcRenderer.removeListener(NAVIGATION_GESTURE_CHANNEL, handler);
};
},
/** Listen for Cmd/Ctrl+W → close the active tab. Returns an unsubscribe function. */
onCloseActiveTab: (callback: () => void) => {
const handler = () => callback();
ipcRenderer.on(CLOSE_ACTIVE_TAB_CHANNEL, handler);
return () => {
ipcRenderer.removeListener(CLOSE_ACTIVE_TAB_CHANNEL, handler);
};
},
/** Open the OS folder picker and return the chosen absolute path. */
pickDirectory: (defaultPath?: string) =>
ipcRenderer.invoke("local-directory:pick", defaultPath),

View File

@@ -70,6 +70,18 @@ function useNativeNavigationGestures() {
}, [goBack, goForward]);
}
// 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. 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().closeActiveTabIfClosable();
});
}, []);
}
// The main area's top bar doubles as a window drag region. When the sidebar
// is not occupying main-flow width — either user-collapsed (offcanvas) or
// auto-hidden in mobile mode (<768px, becomes a sheet drawer) — we pad the
@@ -149,6 +161,7 @@ export function DesktopShell() {
useInternalLinkHandler();
useActiveTitleSync();
useNativeNavigationGestures();
useCloseActiveTabShortcut();
// Reactive read of current workspace slug from the platform singleton.
// On first mount, slug is null until WorkspaceRouteLayout (inside the tab

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();

View File

@@ -0,0 +1,5 @@
// IPC channel main → renderer carries window-level keyboard shortcuts that
// the main process must own (it intercepts them in `before-input-event` to
// stop the application-menu accelerator from firing) but whose effect lives
// in the renderer's tab store.
export const CLOSE_ACTIVE_TAB_CHANNEL = "shortcut:close-active-tab";