mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-03 12:32:49 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f66e881499 | ||
|
|
eea3dd856f | ||
|
|
fd6d0ae18c | ||
|
|
b5ba050d92 | ||
|
|
0159a7de26 |
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[] = [];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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) ?? (() => {});
|
||||
}, []);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user