mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 19:59:20 +02:00
Compare commits
2 Commits
main
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
780c12c798 | ||
|
|
37af0f769a |
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
2
apps/desktop/src/preload/index.d.ts
vendored
2
apps/desktop/src/preload/index.d.ts
vendored
@@ -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: (
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
5
apps/desktop/src/shared/window-shortcuts.ts
Normal file
5
apps/desktop/src/shared/window-shortcuts.ts
Normal 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";
|
||||
Reference in New Issue
Block a user