Compare commits

...
15 changed files with 587 additions and 38 deletions

No files matched your search

@@ -35,6 +35,19 @@ class FakeHTMLElement {
}
}
class FakePageHTMLElement extends FakeHTMLElement {
tagName = 'DIV';
classList = { contains: () => false };
closest(): FakePageHTMLElement | null {
return null;
}
hasAttribute(): boolean {
return false;
}
}
const previousHTMLElement = globalThis.HTMLElement;
globalThis.HTMLElement = FakeHTMLElement as unknown as typeof HTMLElement;
@@ -83,6 +96,242 @@ test('global hotkey handler lets terminal font size shortcuts reach xterm', () =
assert.equal(stopped, false);
});
test('terminal close-tab shortcut follows disabled, default, and rebound settings', () => {
const target = new FakeHTMLElement();
const handledActions: string[] = [];
const previousDocument = globalThis.document;
globalThis.document = { querySelectorAll: () => [] } as unknown as Document;
const dispatch = (key: string, keyBindings = DEFAULT_KEY_BINDINGS) => {
let prevented = false;
let stopped = false;
const event = {
key,
code: `Key${key.toUpperCase()}`,
ctrlKey: false,
metaKey: true,
altKey: false,
shiftKey: false,
target,
composedPath: () => [target],
preventDefault: () => { prevented = true; },
stopPropagation: () => { stopped = true; },
} as unknown as KeyboardEvent;
handleGlobalHotkeyKeyDownImpl(
() => ({
HOTKEY_DEBUG: false,
closeTabKeyStr: keyBindings.find((binding) => binding.action === 'closeTab')?.mac ?? null,
executeHotkeyAction: (action: string) => { handledActions.push(action); },
hotkeyScheme: 'mac',
keyBindings,
matchesKeyBinding,
}),
event,
);
return { prevented, stopped };
};
try {
const disabledBindings = DEFAULT_KEY_BINDINGS.map((binding) =>
binding.action === 'closeTab' ? { ...binding, mac: 'Disabled' } : binding,
);
assert.deepEqual(dispatch('w', disabledBindings), { prevented: false, stopped: false });
assert.deepEqual(handledActions, []);
assert.deepEqual(dispatch('w'), { prevented: true, stopped: true });
assert.deepEqual(handledActions, ['closeTab']);
handledActions.length = 0;
const reboundBindings = DEFAULT_KEY_BINDINGS.map((binding) =>
binding.action === 'closeTab' ? { ...binding, mac: '⌘ + E' } : binding,
);
assert.deepEqual(dispatch('w', reboundBindings), { prevented: false, stopped: false });
assert.deepEqual(dispatch('e', reboundBindings), { prevented: true, stopped: true });
assert.deepEqual(handledActions, ['closeTab']);
} finally {
globalThis.document = previousDocument;
}
});
test('matched close-tab shortcut delegates to the complete close path', () => {
const target = new FakePageHTMLElement();
const handledActions: string[] = [];
let completeCloseRequests = 0;
let prevented = false;
let stopped = false;
const previousDocument = globalThis.document;
globalThis.document = { querySelectorAll: () => [] } as unknown as Document;
const event = {
key: 'w',
code: 'KeyW',
ctrlKey: false,
metaKey: true,
altKey: false,
shiftKey: false,
target,
composedPath: () => [target],
preventDefault: () => { prevented = true; },
stopPropagation: () => { stopped = true; },
} as unknown as KeyboardEvent;
try {
handleGlobalHotkeyKeyDownImpl(
() => ({
HOTKEY_DEBUG: false,
closeTabKeyStr: '⌘ + W',
executeCloseTabHotkey: () => { completeCloseRequests += 1; },
executeHotkeyAction: (action: string) => { handledActions.push(action); },
hotkeyScheme: 'mac',
keyBindings: DEFAULT_KEY_BINDINGS,
matchesKeyBinding,
}),
event,
);
} finally {
globalThis.document = previousDocument;
}
assert.equal(prevented, true);
assert.equal(stopped, true);
assert.equal(completeCloseRequests, 1);
assert.deepEqual(handledActions, []);
});
test('close-tab shortcut follows disabled, default, and rebound settings in focused inputs', () => {
const target = new FakeInputHTMLElement();
const handledActions: string[] = [];
const previousDocument = globalThis.document;
globalThis.document = { querySelectorAll: () => [] } as unknown as Document;
const dispatch = (key: string, keyBindings = DEFAULT_KEY_BINDINGS) => {
let prevented = false;
let stopped = false;
const event = {
key,
code: `Key${key.toUpperCase()}`,
ctrlKey: false,
metaKey: true,
altKey: false,
shiftKey: false,
target,
composedPath: () => [target],
preventDefault: () => { prevented = true; },
stopPropagation: () => { stopped = true; },
} as unknown as KeyboardEvent;
handleGlobalHotkeyKeyDownImpl(
() => ({
HOTKEY_DEBUG: false,
closeTabKeyStr: keyBindings.find((binding) => binding.action === 'closeTab')?.mac ?? null,
executeHotkeyAction: (action: string) => { handledActions.push(action); },
hotkeyScheme: 'mac',
keyBindings,
matchesKeyBinding,
}),
event,
);
return { prevented, stopped };
};
try {
const disabledBindings = DEFAULT_KEY_BINDINGS.map((binding) =>
binding.action === 'closeTab' ? { ...binding, mac: 'Disabled' } : binding,
);
assert.deepEqual(dispatch('w', disabledBindings), { prevented: false, stopped: false });
assert.deepEqual(handledActions, []);
assert.deepEqual(dispatch('w'), { prevented: true, stopped: true });
assert.deepEqual(handledActions, ['closeTab']);
handledActions.length = 0;
const reboundBindings = DEFAULT_KEY_BINDINGS.map((binding) =>
binding.action === 'closeTab' ? { ...binding, mac: '⌘ + E' } : binding,
);
assert.deepEqual(dispatch('w', reboundBindings), { prevented: false, stopped: false });
assert.deepEqual(dispatch('e', reboundBindings), { prevented: true, stopped: true });
assert.deepEqual(handledActions, ['closeTab']);
} finally {
globalThis.document = previousDocument;
}
});
test('reassigning Command+W routes it to the newly configured action', () => {
const target = new FakePageHTMLElement();
const handledActions: string[] = [];
const keyBindings = DEFAULT_KEY_BINDINGS.map((binding) => {
if (binding.action === 'closeTab') return { ...binding, mac: '⌘ + E' };
if (binding.action === 'newTab') return { ...binding, mac: '⌘ + W' };
return binding;
});
const event = {
key: 'w',
code: 'KeyW',
ctrlKey: false,
metaKey: true,
altKey: false,
shiftKey: false,
target,
composedPath: () => [target],
preventDefault: () => {},
stopPropagation: () => {},
} as unknown as KeyboardEvent;
handleGlobalHotkeyKeyDownImpl(
() => ({
HOTKEY_DEBUG: false,
closeTabKeyStr: '⌘ + E',
executeHotkeyAction: (action: string) => { handledActions.push(action); },
hotkeyScheme: 'mac',
keyBindings,
matchesKeyBinding,
}),
event,
);
assert.deepEqual(handledActions, ['newTab']);
});
test('reassigning Command+W to a terminal action leaves the real event for xterm', () => {
const target = new FakeHTMLElement();
const handledActions: string[] = [];
let prevented = false;
let stopped = false;
const keyBindings = DEFAULT_KEY_BINDINGS.map((binding) => {
if (binding.action === 'closeTab') return { ...binding, mac: '⌘ + E' };
if (binding.action === 'searchTerminal') return { ...binding, mac: '⌘ + W' };
return binding;
});
const event = {
key: 'w',
code: 'KeyW',
ctrlKey: false,
metaKey: true,
altKey: false,
shiftKey: false,
target,
composedPath: () => [target],
preventDefault: () => { prevented = true; },
stopPropagation: () => { stopped = true; },
} as unknown as KeyboardEvent;
handleGlobalHotkeyKeyDownImpl(
() => ({
HOTKEY_DEBUG: false,
closeTabKeyStr: '⌘ + E',
executeHotkeyAction: (action: string) => { handledActions.push(action); },
hotkeyScheme: 'mac',
keyBindings,
matchesKeyBinding,
}),
event,
);
assert.equal(prevented, false);
assert.equal(stopped, false);
assert.deepEqual(handledActions, []);
});
test('global hotkey handler routes quick switch through focused search inputs', () => {
const target = new FakeInputHTMLElement();
const handledActions: string[] = [];
+6 -1
View File
@@ -226,7 +226,7 @@ export function flushQueuedTrayPanelConnectHostsImpl(getCtx: AppContextGetter) {
}
export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: KeyboardEvent) {
const { HOTKEY_DEBUG, closeTabKeyStr, executeHotkeyAction, hotkeyScheme, keyBindings, matchesKeyBinding } = getCtx();
const { HOTKEY_DEBUG, closeTabKeyStr, executeCloseTabHotkey, executeHotkeyAction, hotkeyScheme, keyBindings, matchesKeyBinding } = getCtx();
{
const isMac = hotkeyScheme === 'mac';
const target = e.target as HTMLElement;
@@ -268,6 +268,7 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
(isFormElement || isMonacoElement)
&& !isXtermInput
&& e.key !== 'Escape'
&& !isCloseTabHotkey
&& !isQuickSwitchHotkey
&& !isPaneZoomHotkey
) {
@@ -318,6 +319,10 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
isTerminalInPath,
});
}
if (binding.action === 'closeTab' && executeCloseTabHotkey) {
executeCloseTabHotkey(e);
return;
}
executeHotkeyAction(binding.action, e);
return;
}
+43 -15
View File
@@ -55,7 +55,10 @@ import { collectSessionIds } from '../../domain/workspace';
import type { PaneMagnificationController } from '../../domain/paneMagnification';
import { resolveCloseIntent } from '../state/resolveCloseIntent';
import { resolveSnippetsShortcutIntent } from '../state/resolveSnippetsShortcutIntent';
import { resolveWindowCommandCloseIntent } from '../state/windowCommandClose';
import {
resolveWindowCommandCloseRequestIntent,
type WindowCommandCloseRequest,
} from '../state/windowCommandClose';
import type { SyncPayload } from '../../domain/sync';
import { applySyncPayload, buildLocalVaultPayloadAsync, hasMeaningfulSyncData } from '../syncPayload';
import {
@@ -596,7 +599,28 @@ export function AppSideEffects() {
const _handleTrayTogglePortForward = useEffectEvent((ruleId: string, start: boolean) => { return handleTrayTogglePortForwardImpl(() => ({ hasRuntimeTunnel, hosts, identities, keys, knownHosts: effectiveKnownHosts, portForwardingRules, resolveEffectiveHost, ruleId, start, startTunnel, stopTunnel, t, terminalSettings, toast, undefined }), ruleId, start); });
const _handleTrayPanelConnect = useEffectEvent((hostId: string) => { return handleTrayPanelConnectImpl(() => ({ addConnectionLog, connectToHost, hostId, hosts, identities, keys, resolveEffectiveHost, resolveHostAuth, systemInfoRef, t, toast }), hostId); });
const _handleTrayPanelConnectRequest = useEffectEvent((hostId: string) => { return handleTrayPanelConnectRequestImpl(() => ({ connectNow: _handleTrayPanelConnect, hostId, isVaultInitialized, queueConnect: (queuedHostId: string) => setPendingTrayPanelConnectHostIds((prev) => [...prev, queuedHostId]) }), hostId); });
const _handleGlobalHotkeyKeyDown = useEffectEvent((e: KeyboardEvent) => { return handleGlobalHotkeyKeyDownImpl(() => ({ HOTKEY_DEBUG, closeTabKeyStr, e, executeHotkeyAction, hotkeyScheme, keyBindings, matchesKeyBinding }), e); });
const _handleGlobalHotkeyKeyDown = useEffectEvent((e: KeyboardEvent) => { return handleGlobalHotkeyKeyDownImpl(() => ({
HOTKEY_DEBUG,
closeTabKeyStr,
e,
executeCloseTabHotkey: (event: KeyboardEvent) => {
void handleWindowCommandCloseRequest({
source: 'keyboard',
input: {
key: event.key,
code: event.code,
metaKey: event.metaKey,
ctrlKey: event.ctrlKey,
altKey: event.altKey,
shiftKey: event.shiftKey,
},
});
},
executeHotkeyAction,
hotkeyScheme,
keyBindings,
matchesKeyBinding,
}), e); });
const _handleEscapeKeyDown = useEffectEvent((e: KeyboardEvent) => { return handleEscapeKeyDownImpl(() => ({ e, isQuickSwitcherOpen, setIsQuickSwitcherOpen, sftpPaneMagnificationRef, terminalPaneMagnificationRef }), e); });
// Vault hosts for tray / auto-start; terminalHosts (vault + ephemeral) only for
@@ -1092,16 +1116,11 @@ export function AppSideEffects() {
confirmIfBusyLocalTerminal,
]);
const handleWindowCommandCloseRequest = useCallback(async () => {
const openDialogs = Array.from(document.querySelectorAll<HTMLElement>('[role="dialog"][data-state="open"]'));
const topmostOpenDialog = openDialogs[openDialogs.length - 1] ?? null;
const topmostDialogClose = topmostOpenDialog?.querySelector<HTMLElement>('[data-dialog-close="true"]');
if (topmostDialogClose) {
topmostDialogClose.click();
return;
}
const intent = resolveWindowCommandCloseIntent({
const handleWindowCommandCloseRequest = useCallback(async (request?: WindowCommandCloseRequest) => {
const intent = resolveWindowCommandCloseRequestIntent({
request,
closeTabKeyStr,
isMac: hotkeyScheme === 'mac',
activeTabId: activeTabStore.getActiveTabId(),
editorTabIds: editorTabs.map((tab) => toEditorTabId(tab.id)),
sessionIds: sessions.map((session) => session.id),
@@ -1109,6 +1128,15 @@ export function AppSideEffects() {
logViewIds: logViews.map((logView) => logView.id),
pluginViewTabIds: pluginViewTabs.map((tab) => tab.id),
});
if (!intent) return;
const openDialogs = Array.from(document.querySelectorAll<HTMLElement>('[role="dialog"][data-state="open"]'));
const topmostOpenDialog = openDialogs[openDialogs.length - 1] ?? null;
const topmostDialogClose = topmostOpenDialog?.querySelector<HTMLElement>('[data-dialog-close="true"]');
if (topmostDialogClose) {
topmostDialogClose.click();
return;
}
if (intent.kind === 'closeTab') {
executeHotkeyAction('closeTab', new KeyboardEvent('keydown', { key: 'w', metaKey: true }));
@@ -1121,14 +1149,14 @@ export function AppSideEffects() {
}
await netcattyBridge.get()?.windowClose?.();
}, [closeLogView, editorTabs, executeHotkeyAction, logViews, pluginViewTabs, sessions, workspaces]);
}, [closeLogView, closeTabKeyStr, editorTabs, executeHotkeyAction, hotkeyScheme, logViews, pluginViewTabs, sessions, workspaces]);
useEffect(() => {
// Cmd/Ctrl+W from the app menu arrives via IPC, not the keydown listener.
// Gate it while locked so sessions/tabs cannot close behind the overlay.
const unsubscribe = netcattyBridge.get()?.onWindowCommandCloseRequested?.(() => {
const unsubscribe = netcattyBridge.get()?.onWindowCommandCloseRequested?.((request) => {
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) return;
void handleWindowCommandCloseRequest();
void handleWindowCommandCloseRequest(request);
});
return () => unsubscribe?.();
}, [appLockLocked, handleWindowCommandCloseRequest]);
+3 -1
View File
@@ -57,7 +57,9 @@ export const useWindowControls = () => {
const onFullscreenChanged = useCallback(subscribeWindowFullscreenChanged, []);
const onWindowCommandCloseRequested = useCallback((cb: () => void) => {
const onWindowCommandCloseRequested = useCallback((
cb: (request?: import('./windowCommandClose').WindowCommandCloseRequest) => void,
) => {
const bridge = netcattyBridge.get();
return bridge?.onWindowCommandCloseRequested?.(cb) ?? (() => {});
}, []);
+105 -1
View File
@@ -1,7 +1,111 @@
import test from "node:test";
import assert from "node:assert/strict";
import { resolveWindowCommandCloseIntent } from "./windowCommandClose.ts";
import {
resolveWindowCommandCloseIntent,
resolveWindowCommandCloseRequestIntent,
shouldHandleWindowCommandCloseRequest,
} from "./windowCommandClose.ts";
const commandWRequest = {
source: "keyboard" as const,
input: {
key: "w",
code: "KeyW",
metaKey: true,
ctrlKey: false,
altKey: false,
shiftKey: false,
},
};
test("keyboard close requests follow disabled, default, and rebound settings", () => {
assert.equal(shouldHandleWindowCommandCloseRequest({
request: commandWRequest,
closeTabKeyStr: null,
isMac: true,
}), false);
assert.equal(shouldHandleWindowCommandCloseRequest({
request: commandWRequest,
closeTabKeyStr: "Disabled",
isMac: true,
}), false);
assert.equal(shouldHandleWindowCommandCloseRequest({
request: commandWRequest,
closeTabKeyStr: "⌘ + W",
isMac: true,
}), true);
assert.equal(shouldHandleWindowCommandCloseRequest({
request: commandWRequest,
closeTabKeyStr: "⌘ + E",
isMac: true,
}), false);
});
test("menu close requests remain available when the keyboard shortcut is disabled", () => {
assert.equal(shouldHandleWindowCommandCloseRequest({
closeTabKeyStr: null,
isMac: true,
}), true);
});
test("disabled and rebound keyboard requests cannot close the Vault or a log view", () => {
assert.equal(resolveWindowCommandCloseRequestIntent({
request: commandWRequest,
closeTabKeyStr: null,
isMac: true,
activeTabId: "vault",
editorTabIds: [],
sessionIds: [],
workspaceIds: [],
logViewIds: [],
}), null);
assert.equal(resolveWindowCommandCloseRequestIntent({
request: commandWRequest,
closeTabKeyStr: "⌘ + E",
isMac: true,
activeTabId: "log-1",
editorTabIds: [],
sessionIds: [],
workspaceIds: [],
logViewIds: ["log-1"],
}), null);
});
test("enabled keyboard requests preserve Vault and log-view close behavior", () => {
assert.deepEqual(resolveWindowCommandCloseRequestIntent({
request: commandWRequest,
closeTabKeyStr: "⌘ + W",
isMac: true,
activeTabId: "vault",
editorTabIds: [],
sessionIds: [],
workspaceIds: [],
logViewIds: [],
}), { kind: "closeWindow" });
assert.deepEqual(resolveWindowCommandCloseRequestIntent({
request: commandWRequest,
closeTabKeyStr: "⌘ + W",
isMac: true,
activeTabId: "log-1",
editorTabIds: [],
sessionIds: [],
workspaceIds: [],
logViewIds: ["log-1"],
}), { kind: "closeLogView", tabId: "log-1" });
});
test("menu requests preserve Vault close behavior with the shortcut disabled", () => {
assert.deepEqual(resolveWindowCommandCloseRequestIntent({
closeTabKeyStr: null,
isMac: true,
activeTabId: "vault",
editorTabIds: [],
sessionIds: [],
workspaceIds: [],
logViewIds: [],
}), { kind: "closeWindow" });
});
test("Cmd+W closes the active closable tab first", () => {
assert.deepEqual(
+44
View File
@@ -1,3 +1,17 @@
import { matchesKeyBinding } from '../../domain/models/keyBindings';
export interface WindowCommandCloseRequest {
source?: 'keyboard';
input?: {
key: string;
code: string;
metaKey: boolean;
ctrlKey: boolean;
altKey: boolean;
shiftKey: boolean;
};
}
export type WindowCommandCloseIntent =
| { kind: 'closeTab' }
| { kind: 'closeLogView'; tabId: string }
@@ -12,6 +26,20 @@ interface ResolveWindowCommandCloseIntentInput {
pluginViewTabIds?: string[];
}
export function shouldHandleWindowCommandCloseRequest({
request,
closeTabKeyStr,
isMac,
}: {
request?: WindowCommandCloseRequest;
closeTabKeyStr: string | null;
isMac: boolean;
}): boolean {
if (request?.source !== 'keyboard') return true;
if (!request.input || !closeTabKeyStr) return false;
return matchesKeyBinding(request.input as KeyboardEvent, closeTabKeyStr, isMac);
}
export function resolveWindowCommandCloseIntent({
activeTabId,
editorTabIds,
@@ -42,3 +70,19 @@ export function resolveWindowCommandCloseIntent({
return { kind: 'closeWindow' };
}
export function resolveWindowCommandCloseRequestIntent({
request,
closeTabKeyStr,
isMac,
...intentInput
}: ResolveWindowCommandCloseIntentInput & {
request?: WindowCommandCloseRequest;
closeTabKeyStr: string | null;
isMac: boolean;
}): WindowCommandCloseIntent | null {
if (!shouldHandleWindowCommandCloseRequest({ request, closeTabKeyStr, isMac })) {
return null;
}
return resolveWindowCommandCloseIntent(intentInput);
}
+28
View File
@@ -7,8 +7,10 @@ import {
canPromoteTextEditor,
getTextEditorContentStats,
isTextEditorReadOnly,
shouldHandleMonacoCloseCommand,
TextEditorPromoteButton,
} from "./TextEditorPane.tsx";
import { DEFAULT_KEY_BINDINGS } from "../../domain/models/keyBindings.ts";
import { TooltipProvider } from "../ui/tooltip.tsx";
const wrap = (child: React.ReactElement) =>
@@ -49,3 +51,29 @@ test("counts editor content without allocating line arrays", () => {
assert.deepEqual(getTextEditorContentStats(""), { lineCount: 1, charCount: 0 });
assert.deepEqual(getTextEditorContentStats("one\ntwo\n"), { lineCount: 3, charCount: 8 });
});
test("Monaco close command follows disabled, default, and rebound close-tab settings", () => {
const closeTabBinding = DEFAULT_KEY_BINDINGS.find((binding) => binding.action === "closeTab");
assert.ok(closeTabBinding);
assert.equal(shouldHandleMonacoCloseCommand({
hotkeyScheme: "mac",
closeTabBinding: { ...closeTabBinding, mac: "Disabled" },
macPlatform: true,
}), false);
assert.equal(shouldHandleMonacoCloseCommand({
hotkeyScheme: "mac",
closeTabBinding,
macPlatform: true,
}), true);
assert.equal(shouldHandleMonacoCloseCommand({
hotkeyScheme: "mac",
closeTabBinding: { ...closeTabBinding, mac: "⌘ + E" },
macPlatform: true,
}), false);
assert.equal(shouldHandleMonacoCloseCommand({
hotkeyScheme: "disabled",
closeTabBinding,
macPlatform: true,
}), false);
});
+32 -4
View File
@@ -28,6 +28,7 @@ import { HotkeyScheme, KeyBinding, matchesKeyBinding } from '../../domain/models
import { pasteForMonacoEditorCommand } from '../../infrastructure/monaco/monacoClipboardPaste';
import { useNetcattyMonacoTheme } from '../../infrastructure/monaco/useNetcattyMonacoTheme';
import { getLanguageName, getSupportedLanguages } from '../../lib/sftpFileUtils';
import { isMacPlatform } from '../../lib/utils';
import { Button } from '../ui/button';
import { Combobox } from '../ui/combobox';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
@@ -116,6 +117,28 @@ export function getTextEditorContentStats(content: string): { lineCount: number;
return { lineCount, charCount: content.length };
}
export function shouldHandleMonacoCloseCommand({
hotkeyScheme,
closeTabBinding,
macPlatform,
}: {
hotkeyScheme: HotkeyScheme;
closeTabBinding: KeyBinding | undefined;
macPlatform: boolean;
}): boolean {
if (hotkeyScheme === 'disabled' || !closeTabBinding) return false;
const isMacScheme = hotkeyScheme === 'mac';
const keyStr = isMacScheme ? closeTabBinding.mac : closeTabBinding.pc;
return matchesKeyBinding({
key: 'w',
code: 'KeyW',
metaKey: macPlatform,
ctrlKey: !macPlatform,
altKey: false,
shiftKey: false,
} as KeyboardEvent, keyStr, isMacScheme);
}
export const TextEditorPromoteButton: React.FC<{
saving: boolean;
onPromoteToTab: () => void;
@@ -173,6 +196,8 @@ const TextEditorPaneInner: React.FC<TextEditorPaneProps> = ({
() => keyBindings.find((binding) => binding.action === 'closeTab'),
[keyBindings],
);
const closeTabShortcutRef = useRef({ hotkeyScheme, closeTabBinding });
closeTabShortcutRef.current = { hotkeyScheme, closeTabBinding };
const handleSave = useCallback(() => {
if (saving) return;
@@ -263,11 +288,14 @@ const TextEditorPaneInner: React.FC<TextEditorPaneProps> = ({
handleSaveRef.current();
});
// Close-tab shortcut inside Monaco. The capture-phase keydown on the
// Pane's root div also tries to handle this, but Monaco's internal
// key-event dispatcher fires first for focused editor keystrokes, so
// registering the command here is the reliable path.
// Monaco handles the platform's default close key before the pane's DOM
// capture handler. Keep this fallback, but consult the live shortcut
// setting so a disabled or reassigned close-tab binding is not bypassed.
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyW, () => {
if (!shouldHandleMonacoCloseCommand({
...closeTabShortcutRef.current,
macPlatform: isMacPlatform(),
})) return;
handleCloseRef.current?.();
});
@@ -78,6 +78,7 @@ test("Ctrl+F opens and refocuses the active SFTP pane filter without handling in
showHiddenFiles: false,
transferMutationToken: 0,
};
let refreshCalls = 0;
const Harness = ({
isActive,
@@ -93,6 +94,7 @@ test("Ctrl+F opens and refocuses the active SFTP pane filter without handling in
const sftpRef = React.useRef({
leftTabs: { tabs: [pane], activeTabId: pane.id },
rightTabs: { tabs: [], activeTabId: null },
refresh: () => { refreshCalls += 1; },
} as unknown as SftpStateApi);
useSftpKeyboardShortcuts({
@@ -295,6 +297,25 @@ test("Ctrl+F opens and refocuses the active SFTP pane filter without handling in
})).defaultPrevented, true, "the configured PC search shortcut should open the filter");
assert.ok(window.document.querySelector('[data-section="terminal-sftp-filter-bar"]'));
const commandWRefreshBindings = DEFAULT_KEY_BINDINGS.map((binding) => {
if (binding.action === "closeTab") return { ...binding, mac: "⌘ + E" };
if (binding.action === "sftpRefresh") return { ...binding, mac: "⌘ + W" };
return binding;
});
await act(async () => root.render(React.createElement(Harness, {
isActive: true,
hotkeyScheme: "mac",
keyBindings: commandWRefreshBindings,
})));
sftpTarget.focus();
assert.equal((await pressShortcut(sftpTarget, {
key: "w",
code: "KeyW",
ctrlKey: false,
metaKey: true,
})).defaultPrevented, true, "reassigned Command+W should reach the SFTP handler");
assert.equal(refreshCalls, 1);
await act(async () => root.render(React.createElement(Harness, {
isActive: true,
hotkeyScheme: "disabled",
+2 -2
View File
@@ -438,12 +438,12 @@ function closeBrowserWindow(win) {
}
}
function requestWindowCommandClose(win) {
function requestWindowCommandClose(win, request) {
if (!win || win.isDestroyed?.()) return false;
try {
const webContents = win.webContents;
if (!webContents || webContents.isDestroyed?.()) return closeBrowserWindow(win);
webContents.send(WINDOW_COMMAND_CLOSE_CHANNEL);
webContents.send(WINDOW_COMMAND_CLOSE_CHANNEL, request);
return true;
} catch {
return closeBrowserWindow(win);
@@ -275,8 +275,10 @@ function createMainWindowApi(ctx) {
// while preventing Chromium's built-in shortcuts from triggering.
win.webContents.on("before-input-event", (event, input) => {
if (isMac && shouldCloseWindowFromInput(input)) {
event.preventDefault();
requestWindowCommandClose(win);
// Keep the app menu accelerator from consuming Cmd+W, but preserve
// the real DOM key event so the current renderer binding can route it
// through app, terminal, editor, or SFTP shortcut handlers.
win.webContents.setIgnoreMenuShortcuts(true);
return;
}
@@ -484,19 +484,24 @@ test("buildAppMenu renders localized plugin commands with checked and disabled s
});
test("requestWindowCommandClose sends command-close to renderer-capable windows", () => {
const sentChannels = [];
const sentMessages = [];
const win = {
isDestroyed() { return false; },
webContents: {
isDestroyed() { return false; },
send(channel) {
sentChannels.push(channel);
send(channel, request) {
sentMessages.push({ channel, request });
},
},
};
assert.equal(requestWindowCommandClose(win), true);
assert.deepEqual(sentChannels, ["netcatty:window:command-close"]);
const keyboardRequest = { source: "keyboard", input: { key: "w" } };
assert.equal(requestWindowCommandClose(win, keyboardRequest), true);
assert.deepEqual(sentMessages, [
{ channel: "netcatty:window:command-close", request: undefined },
{ channel: "netcatty:window:command-close", request: keyboardRequest },
]);
});
test("shouldCloseWindowFromInput only matches macOS Command+W keydown", () => {
@@ -507,9 +512,10 @@ test("shouldCloseWindowFromInput only matches macOS Command+W keydown", () => {
assert.equal(shouldCloseWindowFromInput({ type: "keyDown", meta: true, shift: true, key: "w" }), false);
});
test("main window asks renderer to close tabs from macOS Command+W before-input-event", async () => {
test("main window lets macOS Command+W reach renderer shortcut handlers without invoking the menu", async () => {
let beforeInputHandler = null;
const commandCloseRequests = [];
const ignoreMenuShortcutValues = [];
class BrowserWindowStub {
constructor() {
@@ -525,7 +531,9 @@ test("main window asks renderer to close tabs from macOS Command+W before-input-
isCrashed() {
return false;
},
setIgnoreMenuShortcuts() {},
setIgnoreMenuShortcuts(value) {
ignoreMenuShortcutValues.push(value);
},
setWindowOpenHandler() {},
openDevTools() {},
};
@@ -580,8 +588,8 @@ test("main window asks renderer to close tabs from macOS Command+W before-input-
createAppWindowOpenHandler() { return {}; },
attachOAuthLoadingOverlay() {},
registerWindowHandlers() {},
requestWindowCommandClose(win) {
commandCloseRequests.push(win);
requestWindowCommandClose(win, request) {
commandCloseRequests.push({ win, request });
return true;
},
shouldCloseWindowFromInput,
@@ -616,8 +624,9 @@ test("main window asks renderer to close tabs from macOS Command+W before-input-
key: "w",
});
assert.equal(prevented, true);
assert.equal(commandCloseRequests.length, 1);
assert.equal(prevented, false);
assert.deepEqual(ignoreMenuShortcutValues, [true]);
assert.deepEqual(commandCloseRequests, []);
});
test("main window leaves primary-modifier reload-like shortcuts available to renderer handlers", async () => {
+1 -1
View File
@@ -1092,7 +1092,7 @@ function createPreloadApi(ctx) {
return () => ipcRenderer.removeListener("netcatty:window:openSession", handler);
},
onWindowCommandCloseRequested: (cb) => {
const handler = () => cb();
const handler = (_event, request) => cb(request);
ipcRenderer.on("netcatty:window:command-close", handler);
return () => ipcRenderer.removeListener("netcatty:window:command-close", handler);
},
+27
View File
@@ -1250,6 +1250,33 @@ test("onWindowFocusRequested is wired to the focus-requested IPC", () => {
}
});
test("onWindowCommandCloseRequested forwards the keyboard request payload", () => {
const preload = loadPreloadWithFakeElectron();
try {
const calls = [];
preload.api.onWindowCommandCloseRequested((request) => {
calls.push(request);
});
const request = {
source: "keyboard",
input: {
key: "w",
code: "KeyW",
metaKey: true,
ctrlKey: false,
altKey: false,
shiftKey: false,
},
};
preload.handlers.get("netcatty:window:command-close")?.({}, request);
assert.deepEqual(calls, [request]);
} finally {
preload.cleanup();
}
});
test("onZmodemEvent unsubscribe removes empty listener set", () => {
const zmodemListeners = new Map();
const api = createPreloadApi({
+3 -1
View File
@@ -63,7 +63,9 @@ declare global {
sourceSession: import("../../domain/models").TerminalSession;
localShellType?: import("../../domain/models").TerminalSession['shellType'];
}) => void): () => void;
onWindowCommandCloseRequested?(cb: () => void): () => void;
onWindowCommandCloseRequested?(
cb: (request?: import('../../application/state/windowCommandClose').WindowCommandCloseRequest) => void,
): () => void;
onWindowFullScreenChanged?(cb: (isFullscreen: boolean) => void): () => void;
onWindowShown?(cb: () => void): () => void;
onWindowFocusRequested?(cb: () => void): () => void;