mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 22:40:09 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1ca1a3443 | ||
|
|
a4ec7bab61 | ||
|
|
2012fc53c7 | ||
|
|
2be738b8c2 | ||
|
|
d307a2ebc3 | ||
|
|
d97439f878 | ||
|
|
8285d7010b | ||
|
|
1f0ddd2bdb | ||
|
|
98db9e6fea | ||
|
|
8e53ec829f | ||
|
|
549c5534d5 | ||
|
|
8fae84148c | ||
|
|
6d14f1e3d6 | ||
|
|
aebe25ae70 | ||
|
|
df045bf144 | ||
|
|
32447885d9 | ||
|
|
195a23e380 | ||
|
|
459ac09ebb | ||
|
|
a1ea4cc546 | ||
|
|
25224cef7b | ||
|
|
452058bd42 | ||
|
|
50c25d8789 | ||
|
|
96a38593bf | ||
|
|
e3b9e71d8f | ||
|
|
01b9f745b6 | ||
|
|
e96384cc7f | ||
|
|
a5bf1765d4 | ||
|
|
c921e7d9a4 | ||
|
|
a1b7f3708a | ||
|
|
6a4a1ffd92 | ||
|
|
105107755b | ||
|
|
028e86b111 | ||
|
|
b2886ed16b | ||
|
|
57e11822f2 | ||
|
|
ff05c33fc9 | ||
|
|
a2ff82187b |
No files matched your search
@@ -272,6 +272,55 @@ test('next, previous, and number shortcuts include native plugin view tabs', ()
|
||||
assert.deepEqual(selected, [pluginTabId, 'session-1', pluginTabId]);
|
||||
});
|
||||
|
||||
test('switchToTab uses physical Digit code when Shift remaps e.key', () => {
|
||||
let activeTabId = 'session-1';
|
||||
const noop = () => {};
|
||||
const context = {
|
||||
IS_DEV: false,
|
||||
MOVE_FOCUS_DEBOUNCE_MS: 0,
|
||||
activeTabStore: { getActiveTabId: () => activeTabId },
|
||||
addConnectionLogRef: { current: noop },
|
||||
closeSession: noop,
|
||||
closeTabInFlightRef: { current: false },
|
||||
closeWorkspace: noop,
|
||||
collectSessionIds: () => [],
|
||||
confirmIfBusyLocalTerminal: async () => true,
|
||||
createLocalTerminalWithCurrentShell: noop,
|
||||
editorTabs: [],
|
||||
fromEditorTabId: () => null,
|
||||
handleOpenSettingsRef: { current: noop },
|
||||
handleRequestCloseEditorTabRef: { current: noop },
|
||||
isEditorTabId: () => false,
|
||||
isQuickSwitcherOpen: false,
|
||||
lastMoveFocusTimeRef: { current: 0 },
|
||||
moveFocusInWorkspace: noop,
|
||||
orderedTabs: ['session-1', 'session-2', 'session-3'],
|
||||
resolveCloseIntent: () => ({ kind: 'noop' }),
|
||||
resolveSnippetsShortcutIntent: () => ({ kind: 'noop' }),
|
||||
sessions: [],
|
||||
setActiveTabId: (id: string) => { activeTabId = id; },
|
||||
setAddToWorkspaceDialog: noop,
|
||||
setIsQuickSwitcherOpen: noop,
|
||||
setNavigateToSection: noop,
|
||||
settings: { showSftpTab: false, shellOnlyTabNumberShortcuts: false },
|
||||
splitSessionWithCurrentShell: noop,
|
||||
systemInfoRef: { current: { username: 'user', hostname: 'host' } },
|
||||
toEditorTabId: (id: string) => `editor:${id}`,
|
||||
toggleBroadcast: noop,
|
||||
toggleScriptsSidePanelRef: { current: noop },
|
||||
toggleSidePanelRef: { current: noop },
|
||||
toggleWorkspaceViewMode: noop,
|
||||
workspaces: [],
|
||||
};
|
||||
|
||||
executeHotkeyActionImpl(
|
||||
() => context,
|
||||
'switchToTab',
|
||||
{ key: '@', code: 'Digit3', ctrlKey: true, shiftKey: true } as KeyboardEvent,
|
||||
);
|
||||
assert.equal(activeTabId, 'session-2');
|
||||
});
|
||||
|
||||
test('next tab includes pinned tabs when shell-only shortcut mode is disabled', () => {
|
||||
let activeTabId = '';
|
||||
const noop = () => {};
|
||||
|
||||
@@ -7,6 +7,7 @@ import { getEffectiveHostDistro, classifyDistroId, shouldProbeSessionCwd } from
|
||||
import { sanitizeHostIconFields } from '../../domain/hostIcon';
|
||||
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
|
||||
import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
|
||||
import { tabShortcutDigitFromEvent } from '../../domain/models/keyBindings';
|
||||
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
|
||||
import { captureInheritedCwd } from '../state/inheritedCwd';
|
||||
|
||||
@@ -680,12 +681,10 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
|
||||
});
|
||||
switch (action) {
|
||||
case 'switchToTab': {
|
||||
// Get the number key pressed (1-9)
|
||||
const num = parseInt(e.key, 10);
|
||||
if (num >= 1 && num <= 9) {
|
||||
if (num <= shortcutTabs.length) {
|
||||
setActiveTabId(shortcutTabs[num - 1]);
|
||||
}
|
||||
// Prefer physical Digit code so Shift+[1...9] works when e.key is "!" etc.
|
||||
const num = tabShortcutDigitFromEvent(e);
|
||||
if (num !== null && num <= shortcutTabs.length) {
|
||||
setActiveTabId(shortcutTabs[num - 1]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { getNotesSnapshot } from '../state/notesStore';
|
||||
import { useVaultAgentBridge } from '../state/useVaultAgentBridge';
|
||||
import { useWindowControls } from '../state/useWindowControls';
|
||||
import { useTerminalKeyboardFocus } from '../state/useTerminalKeyboardFocus';
|
||||
import { useEditorTabChromeList } from '../state/editorTabStore';
|
||||
import {
|
||||
isPluginViewTabId,
|
||||
@@ -132,12 +133,15 @@ export function AppSideEffects() {
|
||||
terminalSettings,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
disableTerminalFontZoom,
|
||||
isHotkeyRecording,
|
||||
showSftpTab,
|
||||
shellOnlyTabNumberShortcuts,
|
||||
workspaceFocusStyle,
|
||||
} = settings;
|
||||
|
||||
useTerminalKeyboardFocus(hotkeyScheme !== 'disabled' && !disableTerminalFontZoom);
|
||||
|
||||
const discoveredShells = useDiscoveredShells();
|
||||
|
||||
// Sync workspace focus indicator style to DOM for CSS targeting
|
||||
|
||||
@@ -473,6 +473,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
|
||||
showSftpTab={showSftpTab}
|
||||
showHostTreeSidebar={showHostTreeSidebar}
|
||||
switchTabKeyBinding={keyBindings.find((binding) => binding.action === 'switchToTab') ?? null}
|
||||
dynamicTabTitleMode={dynamicTabTitleMode}
|
||||
editorTabs={editorTabs}
|
||||
pluginViewTabs={pluginViewTabs}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
const TERMINAL_KEYBOARD_TARGET_SELECTOR =
|
||||
".xterm, .xterm-helper-textarea, .xterm-screen, .xterm-viewport";
|
||||
|
||||
type FocusDocument = Pick<Document, "activeElement" | "addEventListener" | "removeEventListener">;
|
||||
type FocusWindow = Pick<Window, "addEventListener" | "removeEventListener">;
|
||||
|
||||
export const isTerminalKeyboardTarget = (target: EventTarget | null): boolean => {
|
||||
const element = target as (Element & {
|
||||
closest?: (selector: string) => Element | null;
|
||||
}) | null;
|
||||
return Boolean(element?.closest?.(TERMINAL_KEYBOARD_TARGET_SELECTOR));
|
||||
};
|
||||
|
||||
export function installTerminalKeyboardFocusTracking(
|
||||
documentRef: FocusDocument,
|
||||
windowRef: FocusWindow,
|
||||
onFocusChange: (focused: boolean) => void,
|
||||
): () => void {
|
||||
let pendingPointerTarget: EventTarget | null | undefined;
|
||||
let pendingPointerClearTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let isTracking = true;
|
||||
let windowFocused = true;
|
||||
|
||||
const clearPendingPointerTarget = () => {
|
||||
if (pendingPointerClearTimer !== undefined) {
|
||||
clearTimeout(pendingPointerClearTimer);
|
||||
pendingPointerClearTimer = undefined;
|
||||
}
|
||||
pendingPointerTarget = undefined;
|
||||
};
|
||||
|
||||
const publishForTarget = (target: EventTarget | null) => {
|
||||
if (!isTracking) return;
|
||||
onFocusChange(windowFocused && isTerminalKeyboardTarget(target));
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: Event) => {
|
||||
clearPendingPointerTarget();
|
||||
pendingPointerTarget = event.target;
|
||||
pendingPointerClearTimer = setTimeout(() => {
|
||||
const pointerTarget = pendingPointerTarget;
|
||||
clearPendingPointerTarget();
|
||||
if (pointerTarget !== undefined) {
|
||||
publishForTarget(documentRef.activeElement);
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleFocusIn = (event: Event) => {
|
||||
clearPendingPointerTarget();
|
||||
publishForTarget(event.target);
|
||||
};
|
||||
|
||||
const handleFocusOut = () => {
|
||||
queueMicrotask(() => {
|
||||
if (!isTracking) return;
|
||||
const pointerTarget = pendingPointerTarget;
|
||||
clearPendingPointerTarget();
|
||||
if (!windowFocused) {
|
||||
onFocusChange(false);
|
||||
return;
|
||||
}
|
||||
if (pointerTarget !== undefined) {
|
||||
publishForTarget(documentRef.activeElement);
|
||||
return;
|
||||
}
|
||||
publishForTarget(documentRef.activeElement);
|
||||
});
|
||||
};
|
||||
|
||||
const handleWindowBlur = () => {
|
||||
windowFocused = false;
|
||||
clearPendingPointerTarget();
|
||||
onFocusChange(false);
|
||||
};
|
||||
|
||||
const handleWindowFocus = () => {
|
||||
windowFocused = true;
|
||||
publishForTarget(documentRef.activeElement);
|
||||
};
|
||||
|
||||
publishForTarget(documentRef.activeElement);
|
||||
documentRef.addEventListener("pointerdown", handlePointerDown, true);
|
||||
documentRef.addEventListener("focusin", handleFocusIn, true);
|
||||
documentRef.addEventListener("focusout", handleFocusOut, true);
|
||||
windowRef.addEventListener("blur", handleWindowBlur);
|
||||
windowRef.addEventListener("focus", handleWindowFocus);
|
||||
|
||||
return () => {
|
||||
isTracking = false;
|
||||
documentRef.removeEventListener("pointerdown", handlePointerDown, true);
|
||||
documentRef.removeEventListener("focusin", handleFocusIn, true);
|
||||
documentRef.removeEventListener("focusout", handleFocusOut, true);
|
||||
windowRef.removeEventListener("blur", handleWindowBlur);
|
||||
windowRef.removeEventListener("focus", handleWindowFocus);
|
||||
clearPendingPointerTarget();
|
||||
};
|
||||
}
|
||||
@@ -2,35 +2,58 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
getShortcutModifierRequirements,
|
||||
isShortcutModifierHeld,
|
||||
shouldReleaseShortcutModifier,
|
||||
type ShortcutModifierEvent,
|
||||
} from './useShortcutModifierHeld.ts';
|
||||
|
||||
const event = (overrides: Partial<ShortcutModifierEvent> = {}): ShortcutModifierEvent => ({
|
||||
key: 'a',
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test('shortcut modifier state follows the configured Mac or PC scheme', () => {
|
||||
assert.equal(isShortcutModifierHeld(event({ key: 'Meta' }), 'mac'), true);
|
||||
assert.equal(isShortcutModifierHeld(event({ metaKey: true }), 'mac'), true);
|
||||
assert.equal(isShortcutModifierHeld(event({ ctrlKey: true }), 'mac'), false);
|
||||
test('shortcut modifier requirements follow the effective Mac or PC binding', () => {
|
||||
const mac = getShortcutModifierRequirements('⌘ + [1...9]', 'mac');
|
||||
assert.deepEqual(mac, { metaKey: true, ctrlKey: false, altKey: false, shiftKey: false });
|
||||
assert.equal(isShortcutModifierHeld(event({ metaKey: true }), mac), true);
|
||||
assert.equal(isShortcutModifierHeld(event({ ctrlKey: true }), mac), false);
|
||||
|
||||
assert.equal(isShortcutModifierHeld(event({ key: 'Control' }), 'pc'), true);
|
||||
assert.equal(isShortcutModifierHeld(event({ ctrlKey: true }), 'pc'), true);
|
||||
assert.equal(isShortcutModifierHeld(event({ metaKey: true }), 'pc'), false);
|
||||
assert.equal(isShortcutModifierHeld(event({ metaKey: true, ctrlKey: true }), 'disabled'), false);
|
||||
const pc = getShortcutModifierRequirements('Alt + Shift + [1...9]', 'pc');
|
||||
assert.deepEqual(pc, { metaKey: false, ctrlKey: false, altKey: true, shiftKey: true });
|
||||
assert.equal(isShortcutModifierHeld(event({ altKey: true, shiftKey: true }), pc), true);
|
||||
assert.equal(isShortcutModifierHeld(event({ altKey: true }), pc), false);
|
||||
assert.equal(isShortcutModifierHeld(event({ altKey: true, metaKey: true, shiftKey: true }), pc), false);
|
||||
});
|
||||
|
||||
test('shortcut modifier state clears on modifier release or a missed modifier keyup', () => {
|
||||
assert.equal(shouldReleaseShortcutModifier(event({ key: 'Meta' }), 'mac'), true);
|
||||
assert.equal(shouldReleaseShortcutModifier(event({ key: 'a', metaKey: true }), 'mac'), false);
|
||||
assert.equal(shouldReleaseShortcutModifier(event({ key: 'a' }), 'mac'), true);
|
||||
test('shortcut number preview is unavailable for disabled, non-range, or modifier-free bindings', () => {
|
||||
assert.equal(getShortcutModifierRequirements('Disabled', 'mac'), null);
|
||||
assert.equal(getShortcutModifierRequirements('⌘ + K', 'mac'), null);
|
||||
assert.equal(getShortcutModifierRequirements('[1...9]', 'mac'), null);
|
||||
assert.equal(getShortcutModifierRequirements('⌘ + [1...9]', 'disabled'), null);
|
||||
});
|
||||
|
||||
assert.equal(shouldReleaseShortcutModifier(event({ key: 'Control' }), 'pc'), true);
|
||||
assert.equal(shouldReleaseShortcutModifier(event({ key: 'a', ctrlKey: true }), 'pc'), false);
|
||||
assert.equal(shouldReleaseShortcutModifier(event({ key: 'a' }), 'pc'), true);
|
||||
test('shortcut modifier state clears when the required combination stops matching', () => {
|
||||
const requirements = getShortcutModifierRequirements('⌘ + [1...9]', 'mac');
|
||||
assert.equal(shouldReleaseShortcutModifier(event({ metaKey: true }), requirements), false);
|
||||
assert.equal(shouldReleaseShortcutModifier(event(), requirements), true);
|
||||
});
|
||||
|
||||
test('shortcut modifier held state tracks exact combo when extras are pressed or released', () => {
|
||||
const requirements = getShortcutModifierRequirements('⌘ + [1...9]', 'mac');
|
||||
// Already matching, then an extra modifier: preview must clear.
|
||||
assert.equal(isShortcutModifierHeld(event({ metaKey: true }), requirements), true);
|
||||
assert.equal(
|
||||
isShortcutModifierHeld(event({ metaKey: true, shiftKey: true }), requirements),
|
||||
false,
|
||||
);
|
||||
// Extra pressed first, then released while required modifiers remain: preview re-arms.
|
||||
assert.equal(
|
||||
isShortcutModifierHeld(event({ metaKey: true, altKey: true }), requirements),
|
||||
false,
|
||||
);
|
||||
assert.equal(isShortcutModifierHeld(event({ metaKey: true }), requirements), true);
|
||||
});
|
||||
@@ -1,51 +1,93 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { HotkeyScheme } from '../../domain/models/keyBindings';
|
||||
import { parseKeyCombo, type HotkeyScheme } from '../../domain/models/keyBindings';
|
||||
|
||||
export type ShortcutModifierEvent = Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey'>;
|
||||
export type ShortcutModifierEvent = Pick<KeyboardEvent, 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey'>;
|
||||
|
||||
function getModifierKey(scheme: Exclude<HotkeyScheme, 'disabled'>): 'Meta' | 'Control' {
|
||||
return scheme === 'mac' ? 'Meta' : 'Control';
|
||||
export type ShortcutModifierRequirements = Readonly<ShortcutModifierEvent>;
|
||||
|
||||
const MODIFIER_TOKENS = {
|
||||
mac: {
|
||||
'⌘': 'metaKey',
|
||||
'⌃': 'ctrlKey',
|
||||
'⌥': 'altKey',
|
||||
Shift: 'shiftKey',
|
||||
},
|
||||
pc: {
|
||||
Ctrl: 'ctrlKey',
|
||||
Alt: 'altKey',
|
||||
Shift: 'shiftKey',
|
||||
Win: 'metaKey',
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Resolve the modifier combination for the effective [1...9] binding.
|
||||
* A non-range or disabled binding cannot reveal tab numbers because the
|
||||
* dispatcher does not use it for the number-switch action.
|
||||
*/
|
||||
export function getShortcutModifierRequirements(
|
||||
keyBinding: string | null,
|
||||
scheme: HotkeyScheme,
|
||||
): ShortcutModifierRequirements | null {
|
||||
if (scheme === 'disabled' || !keyBinding || !keyBinding.includes('[1...9]')) return null;
|
||||
const parsed = parseKeyCombo(keyBinding);
|
||||
if (!parsed || parsed.modifiers.length === 0) return null;
|
||||
|
||||
const requirements: Record<keyof ShortcutModifierEvent, boolean> = {
|
||||
metaKey: false,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
shiftKey: false,
|
||||
};
|
||||
const tokenMap = MODIFIER_TOKENS[scheme];
|
||||
for (const modifier of parsed.modifiers) {
|
||||
const flag = tokenMap[modifier as keyof typeof tokenMap];
|
||||
if (!flag) return null;
|
||||
requirements[flag] = true;
|
||||
}
|
||||
|
||||
return requirements;
|
||||
}
|
||||
|
||||
function hasModifierFlag(event: ShortcutModifierEvent, scheme: Exclude<HotkeyScheme, 'disabled'>): boolean {
|
||||
return scheme === 'mac' ? event.metaKey : event.ctrlKey;
|
||||
}
|
||||
|
||||
/** Whether a keyboard event indicates that the active shortcut modifier is held. */
|
||||
/** Whether a keyboard event has exactly the modifiers required by the binding. */
|
||||
export function isShortcutModifierHeld(
|
||||
event: ShortcutModifierEvent,
|
||||
scheme: HotkeyScheme,
|
||||
requirements: ShortcutModifierRequirements | null,
|
||||
): boolean {
|
||||
if (scheme === 'disabled') return false;
|
||||
return event.key === getModifierKey(scheme) || hasModifierFlag(event, scheme);
|
||||
if (!requirements) return false;
|
||||
return event.metaKey === requirements.metaKey
|
||||
&& event.ctrlKey === requirements.ctrlKey
|
||||
&& event.altKey === requirements.altKey
|
||||
&& event.shiftKey === requirements.shiftKey;
|
||||
}
|
||||
|
||||
/** Whether a keyup event means the active shortcut modifier is no longer held. */
|
||||
/** Whether a keyup event means the required modifier combination is no longer held. */
|
||||
export function shouldReleaseShortcutModifier(
|
||||
event: ShortcutModifierEvent,
|
||||
scheme: HotkeyScheme,
|
||||
requirements: ShortcutModifierRequirements | null,
|
||||
): boolean {
|
||||
if (scheme === 'disabled') return true;
|
||||
return event.key === getModifierKey(scheme) || !hasModifierFlag(event, scheme);
|
||||
return !isShortcutModifierHeld(event, requirements);
|
||||
}
|
||||
|
||||
export function useShortcutModifierHeld(scheme: HotkeyScheme): boolean {
|
||||
export function useShortcutModifierHeld(
|
||||
keyBinding: string | null,
|
||||
scheme: HotkeyScheme,
|
||||
): boolean {
|
||||
const [isHeld, setIsHeld] = useState(false);
|
||||
const requirements = useMemo(
|
||||
() => getShortcutModifierRequirements(keyBinding, scheme),
|
||||
[keyBinding, scheme],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setIsHeld(false);
|
||||
if (scheme === 'disabled') return;
|
||||
if (!requirements) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (isShortcutModifierHeld(event, scheme)) {
|
||||
setIsHeld(true);
|
||||
}
|
||||
};
|
||||
const handleKeyUp = (event: KeyboardEvent) => {
|
||||
if (shouldReleaseShortcutModifier(event, scheme)) {
|
||||
setIsHeld(false);
|
||||
}
|
||||
// Sync on every modifier transition so extras clear the preview and
|
||||
// releasing an extra modifier can re-arm when the exact combo remains.
|
||||
const syncHeldState = (event: KeyboardEvent) => {
|
||||
setIsHeld(isShortcutModifierHeld(event, requirements));
|
||||
};
|
||||
const clearHeldState = () => setIsHeld(false);
|
||||
const handleVisibilityChange = () => {
|
||||
@@ -54,18 +96,18 @@ export function useShortcutModifierHeld(scheme: HotkeyScheme): boolean {
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown, true);
|
||||
window.addEventListener('keyup', handleKeyUp, true);
|
||||
window.addEventListener('keydown', syncHeldState, true);
|
||||
window.addEventListener('keyup', syncHeldState, true);
|
||||
window.addEventListener('blur', clearHeldState);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown, true);
|
||||
window.removeEventListener('keyup', handleKeyUp, true);
|
||||
window.removeEventListener('keydown', syncHeldState, true);
|
||||
window.removeEventListener('keyup', syncHeldState, true);
|
||||
window.removeEventListener('blur', clearHeldState);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [scheme]);
|
||||
}, [requirements]);
|
||||
|
||||
return isHeld;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
installTerminalKeyboardFocusTracking,
|
||||
isTerminalKeyboardTarget,
|
||||
} from "./terminalKeyboardFocus";
|
||||
|
||||
const targetWithClosest = (matches: boolean) => ({
|
||||
closest: (selector: string) => {
|
||||
assert.match(selector, /\.xterm/);
|
||||
return matches ? {} : null;
|
||||
},
|
||||
}) as unknown as EventTarget;
|
||||
|
||||
test("terminal keyboard target detection recognizes xterm descendants", () => {
|
||||
assert.equal(isTerminalKeyboardTarget(targetWithClosest(true)), true);
|
||||
assert.equal(isTerminalKeyboardTarget(targetWithClosest(false)), false);
|
||||
assert.equal(isTerminalKeyboardTarget(null), false);
|
||||
});
|
||||
|
||||
test("terminal keyboard focus tracking publishes deduplicated focus transitions and cleans up", async () => {
|
||||
const listeners = new Map<string, (event?: Event) => void>();
|
||||
const removed: string[] = [];
|
||||
const body = targetWithClosest(false);
|
||||
const terminal = targetWithClosest(true);
|
||||
const documentRef = {
|
||||
activeElement: body as unknown as Element,
|
||||
addEventListener: (type: string, listener: (event?: Event) => void) => {
|
||||
listeners.set(type, listener);
|
||||
},
|
||||
removeEventListener: (type: string) => {
|
||||
removed.push(type);
|
||||
},
|
||||
} as unknown as Document;
|
||||
const windowRef = {
|
||||
addEventListener: (type: string, listener: (event?: Event) => void) => {
|
||||
listeners.set(`window:${type}`, listener);
|
||||
},
|
||||
removeEventListener: (type: string) => {
|
||||
removed.push(`window:${type}`);
|
||||
},
|
||||
} as unknown as Window;
|
||||
const changes: boolean[] = [];
|
||||
const cleanup = installTerminalKeyboardFocusTracking(documentRef, windowRef, (focused) => {
|
||||
if (changes.at(-1) !== focused) changes.push(focused);
|
||||
});
|
||||
|
||||
assert.deepEqual(changes, [false]);
|
||||
|
||||
listeners.get("pointerdown")?.({ target: terminal } as unknown as Event);
|
||||
listeners.get("focusin")?.({ target: terminal } as unknown as Event);
|
||||
assert.deepEqual(changes, [false, true]);
|
||||
|
||||
(documentRef as unknown as { activeElement: Element }).activeElement = terminal as unknown as Element;
|
||||
listeners.get("pointerdown")?.({ target: body } as unknown as Event);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.deepEqual(changes, [false, true]);
|
||||
|
||||
(documentRef as unknown as { activeElement: Element }).activeElement = body as unknown as Element;
|
||||
listeners.get("focusout")?.();
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(changes, [false, true, false]);
|
||||
|
||||
listeners.get("window:blur")?.();
|
||||
assert.deepEqual(changes, [false, true, false]);
|
||||
|
||||
(documentRef as unknown as { activeElement: Element }).activeElement = terminal as unknown as Element;
|
||||
listeners.get("window:focus")?.();
|
||||
assert.deepEqual(changes, [false, true, false, true]);
|
||||
|
||||
cleanup();
|
||||
assert.deepEqual(removed.sort(), ["focusin", "focusout", "pointerdown", "window:blur", "window:focus"]);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useEffect } from "react";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
import { installTerminalKeyboardFocusTracking } from "./terminalKeyboardFocus";
|
||||
|
||||
export function useTerminalKeyboardFocus(enabled = true): void {
|
||||
useEffect(() => {
|
||||
if (!enabled) return undefined;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.setTerminalKeyboardFocus) return undefined;
|
||||
|
||||
let lastPublished: boolean | undefined;
|
||||
const publish = (focused: boolean) => {
|
||||
if (focused === lastPublished) return;
|
||||
lastPublished = focused;
|
||||
try {
|
||||
bridge.setTerminalKeyboardFocus?.(focused);
|
||||
} catch {
|
||||
// Browser preview or a disposed Electron bridge.
|
||||
}
|
||||
};
|
||||
|
||||
const cleanupTracking = installTerminalKeyboardFocusTracking(
|
||||
document,
|
||||
window,
|
||||
publish,
|
||||
);
|
||||
|
||||
return () => {
|
||||
cleanupTracking();
|
||||
publish(false);
|
||||
};
|
||||
}, [enabled]);
|
||||
}
|
||||
+10
-2
@@ -7,7 +7,7 @@ import { buildTabShortcutNumberById } from '../application/app/tabShortcutTarget
|
||||
import { useShortcutModifierHeld } from '../application/state/useShortcutModifierHeld';
|
||||
import type { EditorTabChrome } from '../application/state/editorTabStore';
|
||||
import { collectSessionIds } from '../domain/workspace';
|
||||
import type { DynamicTabTitleMode } from '../domain/models';
|
||||
import type { DynamicTabTitleMode, KeyBinding } from '../domain/models';
|
||||
|
||||
import { getTopTabInsertionTarget, getWorkspaceSessionDragId, hasWorkspaceSessionDrag } from '../application/state/terminalDragData';
|
||||
import {
|
||||
@@ -158,6 +158,7 @@ interface TopTabsProps {
|
||||
) => void;
|
||||
showSftpTab: boolean;
|
||||
showHostTreeSidebar: boolean;
|
||||
switchTabKeyBinding: Pick<KeyBinding, 'mac' | 'pc'> | null;
|
||||
dynamicTabTitleMode?: DynamicTabTitleMode;
|
||||
editorTabs: readonly EditorTabChrome[];
|
||||
pluginViewTabs: readonly PluginViewTab[];
|
||||
@@ -202,6 +203,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onRemoveSessionFromWorkspace,
|
||||
showSftpTab,
|
||||
showHostTreeSidebar,
|
||||
switchTabKeyBinding,
|
||||
dynamicTabTitleMode,
|
||||
editorTabs,
|
||||
pluginViewTabs,
|
||||
@@ -216,7 +218,12 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
showTabNumberBadges,
|
||||
shellOnlyTabNumberShortcuts,
|
||||
} = useSettingsChromeStore();
|
||||
const shortcutModifierHeld = useShortcutModifierHeld(hotkeyScheme);
|
||||
const switchTabKey = hotkeyScheme === 'mac'
|
||||
? switchTabKeyBinding?.mac ?? null
|
||||
: hotkeyScheme === 'pc'
|
||||
? switchTabKeyBinding?.pc ?? null
|
||||
: null;
|
||||
const shortcutModifierHeld = useShortcutModifierHeld(switchTabKey, hotkeyScheme);
|
||||
const tabShortcutNumbers = useMemo(() => {
|
||||
// Keep the tab bar quiet until the modifier for the active shortcut scheme
|
||||
// is held, while retaining the existing setting as the master toggle.
|
||||
@@ -1213,6 +1220,7 @@ export const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean
|
||||
prev.onThemeChange === next.onThemeChange &&
|
||||
prev.showSftpTab === next.showSftpTab &&
|
||||
prev.showHostTreeSidebar === next.showHostTreeSidebar &&
|
||||
prev.switchTabKeyBinding === next.switchTabKeyBinding &&
|
||||
prev.dynamicTabTitleMode === next.dynamicTabTitleMode &&
|
||||
prev.hostById === next.hostById
|
||||
);
|
||||
|
||||
@@ -8,6 +8,25 @@ const layoutSource = readFileSync(
|
||||
"utf8",
|
||||
);
|
||||
|
||||
test("notes tree width resize avoids React setState on every pointermove", () => {
|
||||
// Dragging the sidebar used to call setTreeWidth per pixel and re-render the
|
||||
// whole NotesManager (including MDXEditor). Live width must stay on the DOM
|
||||
// until pointerup commits state + localStorage.
|
||||
assert.match(managerSource, /treeAsideRef/);
|
||||
assert.match(managerSource, /requestAnimationFrame/);
|
||||
assert.match(managerSource, /aside\.style\.width = `\$\{width\}px`/);
|
||||
assert.match(
|
||||
managerSource,
|
||||
/const handlePointerMove = \(moveEvent: PointerEvent\) => \{[\s\S]*?requestAnimationFrame/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
managerSource,
|
||||
/const handlePointerMove = \(moveEvent: PointerEvent\) => \{\s*\n\s*setTreeWidth\(/,
|
||||
);
|
||||
assert.match(managerSource, /persistTreeWidth\(nextWidth\)/);
|
||||
assert.match(managerSource, /isTreeResizing && "pointer-events-none"/);
|
||||
});
|
||||
|
||||
test("note content drafts stay in refs so MDX keystrokes do not rebuild the shell", () => {
|
||||
assert.doesNotMatch(
|
||||
managerSource,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { I18nProvider } from "../../application/i18n/I18nProvider.tsx";
|
||||
import type { VaultNote } from "../../types.ts";
|
||||
import { TooltipProvider } from "../ui/tooltip.tsx";
|
||||
import {
|
||||
clampNotesTreeWidth,
|
||||
getFallbackNoteSelectionState,
|
||||
getNoteActionTargetGroup,
|
||||
getNoteGroupSelectionState,
|
||||
@@ -83,6 +84,36 @@ test("NotesManager balances folder and note tree icon sizes", () => {
|
||||
assert.match(markup, /width="16" height="16"[^>]*class="lucide lucide-file-text/);
|
||||
});
|
||||
|
||||
test("NotesManager tree scroll area constrains width so titles can truncate", () => {
|
||||
const markup = renderNotes();
|
||||
|
||||
// Radix ScrollArea viewport child is display:table by default; force block +
|
||||
// min-w-0 so narrowing the notes sidebar ellipsizes long titles instead of
|
||||
// clipping them. React encodes `&` / `>` in class attributes.
|
||||
assert.match(
|
||||
markup,
|
||||
/data-radix-scroll-area-viewport\](?:>|>)div\]:!block/,
|
||||
);
|
||||
assert.match(
|
||||
markup,
|
||||
/data-radix-scroll-area-viewport\](?:>|>)div\]:!min-w-0/,
|
||||
);
|
||||
assert.match(markup, /data-notes-drop-zone="root"/);
|
||||
assert.match(markup, /min-h-full min-w-0 space-y-1 overflow-hidden/);
|
||||
// Aside stays unclipped so the resize handle's outer half remains hit-testable;
|
||||
// truncation is enforced by the inner tree shell + ScrollArea constraints.
|
||||
assert.match(markup, /aside class="[^"]*min-w-0[^"]*flex-col bg-background/);
|
||||
assert.match(markup, /flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden/);
|
||||
assert.match(markup, /role="separator"/);
|
||||
assert.match(markup, /translate-x-1\/2 cursor-col-resize/);
|
||||
});
|
||||
|
||||
test("clampNotesTreeWidth keeps the sidebar within the design range", () => {
|
||||
assert.equal(clampNotesTreeWidth(100), 160);
|
||||
assert.equal(clampNotesTreeWidth(300), 300);
|
||||
assert.equal(clampNotesTreeWidth(900), 520);
|
||||
});
|
||||
|
||||
test("NotesManager selection helpers keep note and folder selection exclusive", () => {
|
||||
const notes = [note(), note({ id: "note-2", title: "Deploy", group: "Deploy" })];
|
||||
const noteSelection = getNoteSelectionState(notes[0], false);
|
||||
|
||||
@@ -100,11 +100,16 @@ type NotesToolbarPanel = "search" | null;
|
||||
const toolbarIconButtonClass = "netcatty-tab h-6 w-6 shrink-0 rounded-md p-0 hover:bg-transparent";
|
||||
const menuItemClass = "flex h-8 w-full items-center rounded-md px-3 text-left text-sm hover:bg-secondary";
|
||||
const NOTES_TREE_DEFAULT_WIDTH = 300;
|
||||
const NOTES_TREE_MIN_WIDTH = 220;
|
||||
/** Narrow enough for nested folders + ellipsis; toolbar scrolls if needed. */
|
||||
const NOTES_TREE_MIN_WIDTH = 160;
|
||||
const NOTES_TREE_MAX_WIDTH = 520;
|
||||
const NOTE_DRAG_TYPE = "application/x-netcatty-note-id";
|
||||
const NOTE_GROUP_DRAG_TYPE = "application/x-netcatty-note-group-path";
|
||||
|
||||
export function clampNotesTreeWidth(value: number): number {
|
||||
return Math.max(NOTES_TREE_MIN_WIDTH, Math.min(NOTES_TREE_MAX_WIDTH, value));
|
||||
}
|
||||
|
||||
export const normalizeNoteEditorMode = (value: string | null): NoteEditorMode | null =>
|
||||
value === "edit" || value === "preview" ? value : null;
|
||||
|
||||
@@ -388,6 +393,9 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
NOTES_TREE_DEFAULT_WIDTH,
|
||||
{ min: NOTES_TREE_MIN_WIDTH, max: NOTES_TREE_MAX_WIDTH },
|
||||
);
|
||||
const treeAsideRef = useRef<HTMLElement | null>(null);
|
||||
const treeWidthRef = useRef(treeWidth);
|
||||
treeWidthRef.current = treeWidth;
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const importFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const isImportingMarkdownRef = useRef(false);
|
||||
@@ -1232,7 +1240,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
return (
|
||||
<div
|
||||
key={`new-folder-${parent || "root"}`}
|
||||
className="flex h-7 items-center px-2 text-sm"
|
||||
className="flex h-7 min-w-0 items-center px-2 text-sm"
|
||||
style={{ paddingLeft: depth * 16 + 4 }}
|
||||
>
|
||||
<div className="mr-1 h-5 w-4 shrink-0" />
|
||||
@@ -1444,23 +1452,53 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
event.stopPropagation();
|
||||
|
||||
const startX = event.clientX;
|
||||
const startWidth = treeWidth;
|
||||
const startWidth = treeWidthRef.current;
|
||||
const previousCursor = document.body.style.cursor;
|
||||
const previousUserSelect = document.body.style.userSelect;
|
||||
const aside = treeAsideRef.current;
|
||||
let frame = 0;
|
||||
let latestWidth = startWidth;
|
||||
|
||||
// Avoid setState on every pointermove — NotesManager owns the MDX editor and
|
||||
// re-rendering it per pixel makes sidebar resize feel stuck.
|
||||
setIsTreeResizing(true);
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
if (aside) {
|
||||
aside.style.willChange = "width";
|
||||
}
|
||||
|
||||
const clampWidth = (value: number) =>
|
||||
Math.max(NOTES_TREE_MIN_WIDTH, Math.min(NOTES_TREE_MAX_WIDTH, value));
|
||||
|
||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||
setTreeWidth(clampWidth(startWidth + moveEvent.clientX - startX));
|
||||
const applyWidth = (width: number) => {
|
||||
latestWidth = width;
|
||||
if (aside) {
|
||||
aside.style.width = `${width}px`;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerUp = (upEvent: PointerEvent) => {
|
||||
const nextWidth = clampWidth(startWidth + upEvent.clientX - startX);
|
||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||
const nextWidth = clampNotesTreeWidth(startWidth + moveEvent.clientX - startX);
|
||||
if (frame) {
|
||||
latestWidth = nextWidth;
|
||||
return;
|
||||
}
|
||||
latestWidth = nextWidth;
|
||||
frame = window.requestAnimationFrame(() => {
|
||||
frame = 0;
|
||||
applyWidth(latestWidth);
|
||||
});
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
if (frame) {
|
||||
window.cancelAnimationFrame(frame);
|
||||
frame = 0;
|
||||
}
|
||||
const nextWidth = clampNotesTreeWidth(latestWidth);
|
||||
applyWidth(nextWidth);
|
||||
if (aside) {
|
||||
aside.style.willChange = "";
|
||||
}
|
||||
treeWidthRef.current = nextWidth;
|
||||
setTreeWidth(nextWidth);
|
||||
persistTreeWidth(nextWidth);
|
||||
setIsTreeResizing(false);
|
||||
@@ -1474,7 +1512,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", handlePointerUp);
|
||||
window.addEventListener("pointercancel", handlePointerUp);
|
||||
}, [persistTreeWidth, setTreeWidth, treeWidth]);
|
||||
}, [persistTreeWidth, setTreeWidth]);
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full min-h-0 flex-col overflow-hidden">
|
||||
@@ -1491,16 +1529,20 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{shouldShowNotesTree && (
|
||||
<aside
|
||||
ref={treeAsideRef}
|
||||
className={cn(
|
||||
"relative flex flex-col bg-background",
|
||||
isSidebarMode ? "min-w-0 flex-1" : "shrink-0 border-r border-border/60",
|
||||
// min-w-0 on the aside; overflow-hidden stays on the inner tree shell
|
||||
// so the resize separator's translate-x-1/2 hit area is not clipped.
|
||||
"relative flex min-w-0 flex-col bg-background",
|
||||
isSidebarMode ? "flex-1" : "shrink-0 border-r border-border/60",
|
||||
)}
|
||||
style={isSidebarMode ? undefined : { width: treeWidth }}
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="min-w-0 flex-shrink-0 overflow-hidden">
|
||||
<div className={cn(
|
||||
TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS,
|
||||
"flex items-center gap-1 border-b border-border/60 px-1.5",
|
||||
"flex min-w-0 items-center gap-1 overflow-x-auto border-b border-border/60 px-1.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
|
||||
)}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -1645,9 +1687,13 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
{/* Radix ScrollArea viewport child defaults to display:table, which
|
||||
expands to content width and blocks title ellipsis on narrow trees.
|
||||
Force block + min-w-0 so rows shrink with the sidebar (same pattern
|
||||
as AsidePanelContent). */}
|
||||
<ScrollArea className="min-w-0 flex-1 [&>[data-radix-scroll-area-viewport]>div]:!block [&>[data-radix-scroll-area-viewport]>div]:!min-w-0">
|
||||
<div
|
||||
className="min-h-full space-y-1 px-1.5 pt-1.5 pb-4"
|
||||
className="min-h-full min-w-0 space-y-1 overflow-hidden px-1.5 pt-1.5 pb-4"
|
||||
data-notes-drop-zone="root"
|
||||
onDragOver={(event) => {
|
||||
if (!hasNotesTreeDrag(event.dataTransfer)) return;
|
||||
@@ -1667,6 +1713,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
{!isSidebarMode && (
|
||||
<div
|
||||
role="separator"
|
||||
@@ -1685,7 +1732,14 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
)}
|
||||
|
||||
{!isSidebarMode && (
|
||||
<main className="flex min-w-0 flex-1 flex-col bg-background">
|
||||
<main
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 flex-col bg-background",
|
||||
// Keep the editor out of hit-testing / paint thrash while the tree
|
||||
// width is driven from the DOM during resize.
|
||||
isTreeResizing && "pointer-events-none",
|
||||
)}
|
||||
>
|
||||
{selectedNoteView ? (
|
||||
<>
|
||||
<div className="flex min-h-[54px] shrink-0 items-center gap-3 px-8 pt-6 pb-1" data-note-title-row>
|
||||
|
||||
@@ -82,6 +82,30 @@ test("VaultTree labels use CJK-safe line-height under truncate", () => {
|
||||
assert.doesNotMatch(itemMarkup, /leading-none/);
|
||||
});
|
||||
|
||||
test("VaultTree labels expose full title and keep actions from shrinking", () => {
|
||||
const groupMarkup = renderToStaticMarkup(
|
||||
<VaultTreeGroupRow
|
||||
name="Dokploy服务信息"
|
||||
depth={0}
|
||||
actions={<button type="button" data-row-action="menu">…</button>}
|
||||
/>,
|
||||
);
|
||||
const itemMarkup = renderToStaticMarkup(
|
||||
<VaultTreeItemRow
|
||||
label="Security Audit Report - 完整版"
|
||||
depth={1}
|
||||
actions={<button type="button" data-row-action="menu">…</button>}
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(groupMarkup, /title="Dokploy服务信息"/);
|
||||
assert.match(groupMarkup, /min-w-0 truncate/);
|
||||
assert.match(groupMarkup, /shrink-0[^>]*>[\s\S]*data-row-action="menu"/);
|
||||
assert.match(itemMarkup, /title="Security Audit Report - 完整版"/);
|
||||
assert.match(itemMarkup, /min-w-0 truncate leading-5/);
|
||||
assert.match(itemMarkup, /shrink-0[^>]*>[\s\S]*data-row-action="menu"/);
|
||||
});
|
||||
|
||||
test("VaultTreeInlineRenameInput uses shared inline edit marker", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<VaultTreeInlineRenameInput
|
||||
|
||||
@@ -165,17 +165,17 @@ export const VaultTreeGroupRow: React.FC<VaultTreeGroupRowProps> = ({
|
||||
) : (
|
||||
// leading-5 (not leading-none): CJK fallbacks like PingFang paint outside a 1.0 em box and get clipped by truncate.
|
||||
<span className="flex min-h-5 min-w-0 flex-1 items-center gap-1.5 leading-5">
|
||||
<span className="min-w-0 truncate">{name}</span>
|
||||
{labelActions}
|
||||
<span className="min-w-0 truncate" title={name}>{name}</span>
|
||||
{labelActions ? <span className="shrink-0">{labelActions}</span> : null}
|
||||
</span>
|
||||
)}
|
||||
{meta}
|
||||
{meta ? <div className="shrink-0">{meta}</div> : null}
|
||||
{typeof count === "number" && count > 0 && (
|
||||
<span className="shrink-0 rounded-full border border-border bg-background/50 px-1.5 py-0 text-[10px] opacity-70">
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
{actions}
|
||||
{actions ? <div className="shrink-0">{actions}</div> : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -228,7 +228,7 @@ export const VaultTreeItemRow: React.FC<VaultTreeItemRowProps> = ({
|
||||
{leading ?? <div className="mr-1 h-5 w-4 flex-shrink-0" />}
|
||||
{icon ? <div className="mr-2 flex shrink-0 items-center self-center">{icon}</div> : <FileText size={14} className="mr-2 shrink-0 text-muted-foreground" />}
|
||||
{content ?? (
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
{editing && onRenameCommit && onRenameCancel ? (
|
||||
<VaultTreeInlineRenameInput
|
||||
initialName={editingInitialName ?? label}
|
||||
@@ -237,11 +237,11 @@ export const VaultTreeItemRow: React.FC<VaultTreeItemRowProps> = ({
|
||||
/>
|
||||
) : (
|
||||
// leading-5 keeps CJK glyphs inside the line box under truncate overflow.
|
||||
<div className="min-w-0 truncate leading-5">{label}</div>
|
||||
<div className="min-w-0 truncate leading-5" title={label}>{label}</div>
|
||||
)}
|
||||
{detail && <div className="truncate text-xs leading-4 text-muted-foreground">{detail}</div>}
|
||||
</div>
|
||||
)}
|
||||
{actions}
|
||||
{actions ? <div className="shrink-0">{actions}</div> : null}
|
||||
</div>
|
||||
);
|
||||
+13
-1
@@ -1,7 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { keyEventToString, matchesKeyBinding } from './models.ts';
|
||||
import { keyEventToString, matchesKeyBinding, tabShortcutDigitFromEvent } from './models.ts';
|
||||
|
||||
const keyboardEvent = (
|
||||
key: string,
|
||||
@@ -62,3 +62,15 @@ test('shortcut matching preserves shifted number-row symbols', () => {
|
||||
assert.equal(matchesKeyBinding(event, 'Ctrl + Shift + 1', false), false);
|
||||
assert.equal(keyEventToString(event, false), 'Ctrl + Shift + !');
|
||||
});
|
||||
|
||||
test('shifted digit ranges still match via physical Digit code', () => {
|
||||
const event = keyboardEvent('!', 'Digit1', { ctrlKey: true, shiftKey: true });
|
||||
|
||||
assert.equal(matchesKeyBinding(event, 'Ctrl + Shift + [1...9]', false), true);
|
||||
assert.equal(tabShortcutDigitFromEvent(event), 1);
|
||||
});
|
||||
|
||||
test('tab shortcut digit falls back to e.key when code is missing', () => {
|
||||
assert.equal(tabShortcutDigitFromEvent({ key: '3', code: '' }), 3);
|
||||
assert.equal(tabShortcutDigitFromEvent({ key: '!', code: '' }), null);
|
||||
});
|
||||
@@ -35,13 +35,25 @@ const PHYSICAL_SHORTCUT_KEY_NAMES: Record<string, string> = {
|
||||
Slash: '/',
|
||||
};
|
||||
|
||||
const physicalShortcutKeyName = (e: KeyboardEvent): string | null => {
|
||||
const physicalShortcutKeyName = (e: Pick<KeyboardEvent, 'code'>): string | null => {
|
||||
const code = e.code;
|
||||
if (/^Key[A-Z]$/.test(code)) return code.slice(3);
|
||||
if (/^Digit[0-9]$/.test(code)) return code.slice(5);
|
||||
return PHYSICAL_SHORTCUT_KEY_NAMES[code] ?? null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the 1-9 tab shortcut digit from a key event.
|
||||
* Prefer the physical Digit code so Shift+[1...9] still works when e.key is "!" etc.
|
||||
*/
|
||||
export const tabShortcutDigitFromEvent = (
|
||||
e: Pick<KeyboardEvent, 'key' | 'code'>,
|
||||
): number | null => {
|
||||
const key = physicalShortcutKeyName(e) ?? e.key;
|
||||
if (!/^[1-9]$/.test(key)) return null;
|
||||
return Number.parseInt(key, 10);
|
||||
};
|
||||
|
||||
const LATIN_SHORTCUT_KEY_PATTERN = /^\p{Script=Latin}$/u;
|
||||
const ASCII_SHORTCUT_KEY_PATTERN = /^[A-Za-z]$/;
|
||||
const PRINTABLE_NON_LETTER_SHORTCUT_KEY_PATTERN = /^[^\p{Letter}\p{Number}\s]$/u;
|
||||
@@ -104,8 +116,9 @@ export const matchesKeyBinding = (e: KeyboardEvent, keyStr: string, isMac: boole
|
||||
// Handle range patterns like "[1...9]"
|
||||
if (keyStr.includes('[1...9]')) {
|
||||
const basePattern = keyStr.replace('[1...9]', '');
|
||||
const key = physicalShortcutKeyName(e) ?? shortcutEventKey(e);
|
||||
if (!/^[1-9]$/.test(key)) return false;
|
||||
const digit = tabShortcutDigitFromEvent(e);
|
||||
if (digit === null) return false;
|
||||
const key = String(digit);
|
||||
// Check modifiers match the base pattern
|
||||
const testStr = basePattern + key;
|
||||
const physicalDigitEvent = {
|
||||
|
||||
@@ -887,7 +887,10 @@ function resolveLiveAppIcon(fallback = null) {
|
||||
/**
|
||||
* Create the main application window
|
||||
*/
|
||||
const { createMainWindowApi } = require("./windowManager/mainWindow.cjs");
|
||||
const {
|
||||
createMainWindowApi,
|
||||
setTerminalKeyboardFocusForWindow,
|
||||
} = require("./windowManager/mainWindow.cjs");
|
||||
const mainWindowApi = createMainWindowApi({
|
||||
get mainWindow() { return mainWindow; },
|
||||
set mainWindow(value) { mainWindow = value; },
|
||||
@@ -1086,6 +1089,11 @@ function registerWindowHandlers(ipcMain, nativeTheme) {
|
||||
return restoreWindowInputFocus(win);
|
||||
});
|
||||
|
||||
ipcMain.on("netcatty:window:set-terminal-keyboard-focus", (event, payload) => {
|
||||
const win = getWindowForIpcEvent(event);
|
||||
setTerminalKeyboardFocusForWindow(win, payload?.focused === true);
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:window:setTitle", (event, title) => {
|
||||
const win = getWindowForIpcEvent(event);
|
||||
if (!win || win.isDestroyed()) return false;
|
||||
|
||||
@@ -3,6 +3,24 @@ const {
|
||||
windowsFramelessContentChromeOptions,
|
||||
} = require("./windowsWindowChrome.cjs");
|
||||
|
||||
const TERMINAL_KEYBOARD_FOCUS = Symbol("netcattyTerminalKeyboardFocus");
|
||||
|
||||
function setTerminalKeyboardFocusForWindow(win, focused) {
|
||||
if (!win || win.isDestroyed?.() || !win.webContents) return false;
|
||||
const isFocused = focused === true;
|
||||
try {
|
||||
win[TERMINAL_KEYBOARD_FOCUS] = isFocused;
|
||||
win.webContents.setIgnoreMenuShortcuts?.(isFocused);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function hasTerminalKeyboardFocus(win) {
|
||||
return win?.[TERMINAL_KEYBOARD_FOCUS] === true;
|
||||
}
|
||||
|
||||
function createMainWindowApi(ctx) {
|
||||
with (ctx) {
|
||||
async function createWindow(electronModule, options) {
|
||||
@@ -262,6 +280,15 @@ function createMainWindowApi(ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isTerminalFontShortcut =
|
||||
isPrimaryZoomInEqualInput(input)
|
||||
|| isPrimaryZoomOutMinusInput(input)
|
||||
|| isPrimaryResetZoomInput(input);
|
||||
if (hasTerminalKeyboardFocus(win) && isTerminalFontShortcut) {
|
||||
win.webContents.setIgnoreMenuShortcuts(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPrimaryZoomInEqualInput(input) && adjustWindowZoom("in")) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
@@ -513,4 +540,7 @@ function createMainWindowApi(ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { createMainWindowApi };
|
||||
module.exports = {
|
||||
createMainWindowApi,
|
||||
setTerminalKeyboardFocusForWindow,
|
||||
};
|
||||
@@ -19,7 +19,10 @@ const {
|
||||
shouldCloseWindowFromInput,
|
||||
unregisterMainWindow,
|
||||
} = require("./windowManager.cjs");
|
||||
const { createMainWindowApi } = require("./windowManager/mainWindow.cjs");
|
||||
const {
|
||||
createMainWindowApi,
|
||||
setTerminalKeyboardFocusForWindow,
|
||||
} = require("./windowManager/mainWindow.cjs");
|
||||
|
||||
function createWindowStub({ destroyed = false, webContents } = {}) {
|
||||
return {
|
||||
@@ -1101,6 +1104,145 @@ test("main window treats Ctrl+0 as reset-zoom via explicit shortcut handling", a
|
||||
assert.deepEqual(ignoreMenuShortcutValues, []);
|
||||
});
|
||||
|
||||
test("main window leaves terminal font shortcuts for the renderer while terminal input is focused", async () => {
|
||||
let beforeInputHandler = null;
|
||||
const ignoreMenuShortcutValues = [];
|
||||
const zoomFactorCalls = [];
|
||||
let createdWindow = null;
|
||||
|
||||
class BrowserWindowStub {
|
||||
constructor() {
|
||||
createdWindow = this;
|
||||
this.webContents = {
|
||||
id: 1,
|
||||
on(channel, handler) {
|
||||
if (channel === "before-input-event") beforeInputHandler = handler;
|
||||
},
|
||||
once() {},
|
||||
isDestroyed() {
|
||||
return false;
|
||||
},
|
||||
isCrashed() {
|
||||
return false;
|
||||
},
|
||||
setIgnoreMenuShortcuts(value) {
|
||||
ignoreMenuShortcutValues.push(value);
|
||||
},
|
||||
setWindowOpenHandler() {},
|
||||
openDevTools() {},
|
||||
getZoomFactor() {
|
||||
return 1;
|
||||
},
|
||||
setZoomFactor(value) {
|
||||
zoomFactorCalls.push(value);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
on() {}
|
||||
once() {}
|
||||
isDestroyed() { return false; }
|
||||
isMaximized() { return false; }
|
||||
isFullScreen() { return false; }
|
||||
getBounds() { return { x: 0, y: 0, width: 1400, height: 900 }; }
|
||||
setBackgroundColor() {}
|
||||
setOpacity() {}
|
||||
async loadURL() {}
|
||||
close() {}
|
||||
}
|
||||
|
||||
const api = createMainWindowApi({
|
||||
mainWindow: null,
|
||||
electronApp: null,
|
||||
currentTheme: "light",
|
||||
isQuitting: false,
|
||||
pendingWindowStateWrite: null,
|
||||
queuedWindowState: null,
|
||||
windowStateCloseRequested: false,
|
||||
DEFAULT_WINDOW_WIDTH: 1400,
|
||||
DEFAULT_WINDOW_HEIGHT: 900,
|
||||
MIN_WINDOW_WIDTH: 1100,
|
||||
MIN_WINDOW_HEIGHT: 640,
|
||||
V8_CACHE_OPTIONS: "bypassHeatCheck",
|
||||
THEME_COLORS: { light: { background: "#fff" } },
|
||||
unhealthyWebContentsIds: new Set(),
|
||||
rendererReadySeenByWebContentsId: new Set(),
|
||||
__dirname,
|
||||
URL,
|
||||
require,
|
||||
console,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
getGlobalShortcutBridge() {
|
||||
return { handleWindowClose: () => false };
|
||||
},
|
||||
debugLog() {},
|
||||
resolveFrontendBackgroundColor() { return null; },
|
||||
loadWindowState() { return null; },
|
||||
getDevRendererBaseUrl(url) { return url; },
|
||||
getWindowBoundsState() { return null; },
|
||||
queueWindowStateSave() {},
|
||||
saveWindowStateSync() {},
|
||||
setupDeferredShow() {},
|
||||
createExternalOnlyWindowOpenHandler() { return {}; },
|
||||
createAppWindowOpenHandler() { return {}; },
|
||||
attachOAuthLoadingOverlay() {},
|
||||
registerWindowHandlers() {},
|
||||
requestWindowCommandClose() {
|
||||
return true;
|
||||
},
|
||||
shouldCloseWindowFromInput,
|
||||
applyWindowOpacityToWindow() {},
|
||||
closeSettingsWindow() {},
|
||||
hideSettingsWindow() {},
|
||||
});
|
||||
|
||||
await api.createWindow(
|
||||
{
|
||||
BrowserWindow: BrowserWindowStub,
|
||||
nativeTheme: {},
|
||||
app: {},
|
||||
screen: {},
|
||||
shell: {},
|
||||
ipcMain: {},
|
||||
},
|
||||
{
|
||||
preload: "/tmp/preload.cjs",
|
||||
devServerUrl: "http://localhost:5173",
|
||||
isDev: true,
|
||||
appIcon: null,
|
||||
isMac: false,
|
||||
electronDir: __dirname,
|
||||
},
|
||||
);
|
||||
|
||||
setTerminalKeyboardFocusForWindow(createdWindow, true);
|
||||
ignoreMenuShortcutValues.length = 0;
|
||||
|
||||
for (const key of ["=", "-", "0"]) {
|
||||
let prevented = false;
|
||||
beforeInputHandler({ preventDefault: () => { prevented = true; } }, {
|
||||
type: "keyDown",
|
||||
control: true,
|
||||
shift: false,
|
||||
key,
|
||||
});
|
||||
|
||||
assert.equal(prevented, false);
|
||||
}
|
||||
|
||||
assert.deepEqual(zoomFactorCalls, []);
|
||||
assert.deepEqual(ignoreMenuShortcutValues, [true, true, true]);
|
||||
|
||||
beforeInputHandler({ preventDefault() {} }, {
|
||||
type: "keyDown",
|
||||
control: true,
|
||||
shift: false,
|
||||
key: "A",
|
||||
});
|
||||
assert.equal(ignoreMenuShortcutValues.at(-1), false);
|
||||
});
|
||||
|
||||
test("main window clears renderer readiness when the main frame starts navigating", async () => {
|
||||
const webContentsHandlers = {};
|
||||
const rendererReadyIds = new Set([7]);
|
||||
|
||||
@@ -771,11 +771,17 @@ async function handleUpload(zsession, opts) {
|
||||
const fileStats = filePaths.map((fp) => fs.statSync(fp));
|
||||
|
||||
// Conflict handling (SSH only — callbacks absent on local/telnet/serial).
|
||||
// On any failure we fall back to today's behavior (rz silently skips).
|
||||
// On probe failure we still offer files; rz -y (drag-drop) or an explicit
|
||||
// overwrite decision should replace same-named remotes. If the receiver
|
||||
// still ZSKIPs, we fail below instead of reporting a false success (#2863).
|
||||
const isDragDropUpload = Boolean(dragDrop?.filePaths?.length);
|
||||
let plan = { offerIndices: allNames.map((_, i) => i), removeIndices: [], aborted: false };
|
||||
let probeDir = null;
|
||||
let probeModes = null;
|
||||
if (opts.probeReceiveConflicts && opts.requestOverwriteDecision) {
|
||||
// Drag-drop already starts rz with -y, so let the receiver replace files in
|
||||
// place. Pre-deleting a conflict would lose the original if the offer or
|
||||
// transfer fails before the replacement is committed.
|
||||
if (!isDragDropUpload && opts.probeReceiveConflicts && opts.requestOverwriteDecision) {
|
||||
try {
|
||||
const probe = await opts.probeReceiveConflicts(allNames);
|
||||
if (probe && probe.dir && Array.isArray(probe.existing) && probe.existing.length > 0) {
|
||||
@@ -803,10 +809,16 @@ async function handleUpload(zsession, opts) {
|
||||
}
|
||||
}
|
||||
|
||||
const offers = plan.offerIndices.map((i) => ({ filePath: filePaths[i], stat: fileStats[i], name: allNames[i] }));
|
||||
const offers = plan.offerIndices.map((i) => ({
|
||||
originalIndex: i,
|
||||
filePath: filePaths[i],
|
||||
stat: fileStats[i],
|
||||
name: allNames[i],
|
||||
}));
|
||||
const skippedOfferIndices = [];
|
||||
|
||||
for (let i = 0; i < offers.length; i++) {
|
||||
const { filePath, stat, name } = offers[i];
|
||||
const { originalIndex, filePath, stat, name } = offers[i];
|
||||
opts.resetUploadBackpressure?.();
|
||||
|
||||
safeSend(getWebContents(), "netcatty:zmodem:progress", {
|
||||
@@ -831,7 +843,8 @@ async function handleUpload(zsession, opts) {
|
||||
});
|
||||
|
||||
if (!xfer) {
|
||||
// Receiver skipped this file
|
||||
// Receiver protected/skipped this file (e.g. rz without -y).
|
||||
skippedOfferIndices.push(originalIndex);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -892,6 +905,39 @@ async function handleUpload(zsession, opts) {
|
||||
}
|
||||
}
|
||||
|
||||
// rz re-creates overwritten files with the remote umask, dropping their
|
||||
// original permission bits. Restore modes for files that landed on disk
|
||||
// (including when a later offer is ZSKIP'd and we abort the batch — #1079).
|
||||
// Filter by original offer index (not basename) so a duplicate-name ZSKIP
|
||||
// does not suppress mode restore for an earlier accepted overwrite.
|
||||
async function restoreAcceptedOverwriteModes(skippedIndices) {
|
||||
if (!plan.removeIndices.length || !probeDir || !opts.restoreRemoteModes) return;
|
||||
const skippedSet = skippedIndices?.length ? new Set(skippedIndices) : null;
|
||||
const restoreIndices = skippedSet
|
||||
? plan.removeIndices.filter((i) => !skippedSet.has(i))
|
||||
: plan.removeIndices;
|
||||
if (!restoreIndices.length) return;
|
||||
const restores = buildModeRestores(probeDir, allNames, restoreIndices, probeModes);
|
||||
if (!restores.length) return;
|
||||
try {
|
||||
await opts.restoreRemoteModes(restores);
|
||||
} catch (err) {
|
||||
console.warn("[ZMODEM] restoreRemoteModes failed:", err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
if (skippedOfferIndices.length > 0) {
|
||||
try { zsession.abort(); } catch { /* ignore */ }
|
||||
abortRemoteProcess(opts.writeToRemote);
|
||||
await restoreAcceptedOverwriteModes(skippedOfferIndices);
|
||||
const listed = skippedOfferIndices.map((idx) => allNames[idx]).join(", ");
|
||||
throw new Error(
|
||||
skippedOfferIndices.length === offers.length
|
||||
? `Remote protected existing files and skipped the upload (not overwritten): ${listed}`
|
||||
: `Remote skipped some files (not overwritten): ${listed}`,
|
||||
);
|
||||
}
|
||||
|
||||
await waitForUploadHandshake(
|
||||
zsession.close(),
|
||||
uploadSessionCloseTimeoutMs,
|
||||
@@ -899,19 +945,7 @@ async function handleUpload(zsession, opts) {
|
||||
opts,
|
||||
);
|
||||
|
||||
// rz re-creates overwritten files with the remote umask, dropping their
|
||||
// original permission bits. Now that everything is on disk, restore them
|
||||
// to the modes captured before the rm (issue #1079).
|
||||
if (plan.removeIndices.length && probeDir && opts.restoreRemoteModes) {
|
||||
const restores = buildModeRestores(probeDir, allNames, plan.removeIndices, probeModes);
|
||||
if (restores.length) {
|
||||
try {
|
||||
await opts.restoreRemoteModes(restores);
|
||||
} catch (err) {
|
||||
console.warn("[ZMODEM] restoreRemoteModes failed:", err?.message || err);
|
||||
}
|
||||
}
|
||||
}
|
||||
await restoreAcceptedOverwriteModes();
|
||||
|
||||
} finally {
|
||||
if (dragDropTempPaths.length) {
|
||||
|
||||
@@ -520,3 +520,286 @@ test("handleUpload allows a longer final wait after upload backpressure", async
|
||||
assert.equal(closed, true);
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Issue #2863: rz protect/skip must not look like a successful upload.
|
||||
test("handleUpload fails when the remote skips an offered file", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-"));
|
||||
const filePath = path.join(tempDir, "upload.txt");
|
||||
fs.writeFileSync(filePath, "payload");
|
||||
let closed = false;
|
||||
|
||||
const zsession = {
|
||||
async send_offer() {
|
||||
return undefined; // ZSKIP / protect mode
|
||||
},
|
||||
async close() {
|
||||
closed = true;
|
||||
},
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
handleUpload(zsession, {
|
||||
sessionId: "session-1",
|
||||
getWebContents: () => null,
|
||||
writeToRemote: () => true,
|
||||
takeDragDropUpload: () => ({
|
||||
filePaths: [filePath],
|
||||
remoteNames: ["upload.txt"],
|
||||
}),
|
||||
}),
|
||||
/skipped|not overwritten|protect/i,
|
||||
);
|
||||
|
||||
assert.equal(closed, false);
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("drag-drop upload relies on rz overwrite without probing, prompting, or pre-deleting", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-"));
|
||||
const filePath = path.join(tempDir, "upload.txt");
|
||||
fs.writeFileSync(filePath, "payload");
|
||||
let probed = false;
|
||||
let prompted = false;
|
||||
let removed = [];
|
||||
let offerNames = [];
|
||||
|
||||
const zsession = {
|
||||
async send_offer(params) {
|
||||
offerNames.push(params.name);
|
||||
return {
|
||||
send() {},
|
||||
async end() {},
|
||||
};
|
||||
},
|
||||
async close() {},
|
||||
};
|
||||
|
||||
await handleUpload(zsession, {
|
||||
sessionId: "session-1",
|
||||
getWebContents: () => null,
|
||||
writeToRemote: () => true,
|
||||
takeDragDropUpload: () => ({
|
||||
filePaths: [filePath],
|
||||
remoteNames: ["upload.txt"],
|
||||
}),
|
||||
probeReceiveConflicts: async () => {
|
||||
probed = true;
|
||||
return {
|
||||
dir: "/home/u",
|
||||
existing: ["upload.txt"],
|
||||
modes: { "upload.txt": "644" },
|
||||
};
|
||||
},
|
||||
requestOverwriteDecision: async () => {
|
||||
prompted = true;
|
||||
return { action: "skip", applyToRest: false };
|
||||
},
|
||||
removeRemoteFiles: async (paths) => {
|
||||
removed.push(...paths);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(probed, false);
|
||||
assert.equal(prompted, false);
|
||||
assert.deepEqual(removed, []);
|
||||
assert.deepEqual(offerNames, ["upload.txt"]);
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("drag-drop offers duplicate remote names independently without pre-deleting", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-"));
|
||||
const firstPath = path.join(tempDir, "first.txt");
|
||||
const secondPath = path.join(tempDir, "second.txt");
|
||||
fs.writeFileSync(firstPath, "first");
|
||||
fs.writeFileSync(secondPath, "second");
|
||||
const offered = [];
|
||||
const removed = [];
|
||||
|
||||
const zsession = {
|
||||
async send_offer(params) {
|
||||
offered.push(params.name);
|
||||
return {
|
||||
send() {},
|
||||
async end() {},
|
||||
};
|
||||
},
|
||||
async close() {},
|
||||
};
|
||||
|
||||
await handleUpload(zsession, {
|
||||
sessionId: "session-1",
|
||||
getWebContents: () => null,
|
||||
writeToRemote: () => true,
|
||||
takeDragDropUpload: () => ({
|
||||
filePaths: [firstPath, secondPath],
|
||||
remoteNames: ["x.txt", "x.txt"],
|
||||
}),
|
||||
probeReceiveConflicts: async () => {
|
||||
throw new Error("drag-drop should not probe conflicts");
|
||||
},
|
||||
requestOverwriteDecision: async () => {
|
||||
throw new Error("drag-drop should not prompt for conflicts");
|
||||
},
|
||||
removeRemoteFiles: async (paths) => {
|
||||
removed.push(...paths);
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(offered, ["x.txt", "x.txt"]);
|
||||
assert.deepEqual(removed, []);
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("failed drag-drop offer does not delete any existing conflict targets", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-"));
|
||||
const firstPath = path.join(tempDir, "a.txt");
|
||||
const secondPath = path.join(tempDir, "b.txt");
|
||||
fs.writeFileSync(firstPath, "a");
|
||||
fs.writeFileSync(secondPath, "b");
|
||||
const removed = [];
|
||||
|
||||
const zsession = {
|
||||
async send_offer(params) {
|
||||
if (params.name === "a.txt") {
|
||||
throw new Error("simulated early failure");
|
||||
}
|
||||
return {
|
||||
send() {},
|
||||
async end() {},
|
||||
};
|
||||
},
|
||||
abort() {},
|
||||
async close() {},
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
handleUpload(zsession, {
|
||||
sessionId: "session-1",
|
||||
getWebContents: () => null,
|
||||
writeToRemote: () => true,
|
||||
takeDragDropUpload: () => ({
|
||||
filePaths: [firstPath, secondPath],
|
||||
remoteNames: ["a.txt", "b.txt"],
|
||||
}),
|
||||
probeReceiveConflicts: async () => ({
|
||||
dir: "/home/u",
|
||||
existing: ["a.txt", "b.txt"],
|
||||
modes: { "a.txt": "644", "b.txt": "600" },
|
||||
}),
|
||||
requestOverwriteDecision: async () => ({ action: "skip", applyToRest: false }),
|
||||
removeRemoteFiles: async (paths) => {
|
||||
removed.push(...paths);
|
||||
},
|
||||
}),
|
||||
/simulated early failure/,
|
||||
);
|
||||
|
||||
assert.deepEqual(removed, []);
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("handleUpload restores modes for accepted overwrites before partial ZSKIP failure", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-"));
|
||||
const acceptedPath = path.join(tempDir, "a.sh");
|
||||
const skippedPath = path.join(tempDir, "b.txt");
|
||||
fs.writeFileSync(acceptedPath, "a");
|
||||
fs.writeFileSync(skippedPath, "b");
|
||||
let offerCount = 0;
|
||||
let restored = [];
|
||||
|
||||
const zsession = {
|
||||
async send_offer(params) {
|
||||
offerCount += 1;
|
||||
if (params.name === "b.txt") return undefined;
|
||||
return {
|
||||
send() {},
|
||||
async end() {},
|
||||
};
|
||||
},
|
||||
abort() {},
|
||||
async close() {},
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
handleUpload(zsession, {
|
||||
sessionId: "session-1",
|
||||
getWebContents: () => null,
|
||||
writeToRemote: () => true,
|
||||
selectUploadFiles: async () => ({
|
||||
canceled: false,
|
||||
filePaths: [acceptedPath, skippedPath],
|
||||
}),
|
||||
probeReceiveConflicts: async () => ({
|
||||
dir: "/home/u",
|
||||
existing: ["a.sh", "b.txt"],
|
||||
modes: { "a.sh": "755", "b.txt": "644" },
|
||||
}),
|
||||
requestOverwriteDecision: async () => ({ action: "overwrite", applyToRest: true }),
|
||||
removeRemoteFiles: async () => {},
|
||||
restoreRemoteModes: async (entries) => {
|
||||
restored = entries;
|
||||
},
|
||||
}),
|
||||
/Remote skipped some files/,
|
||||
);
|
||||
|
||||
assert.equal(offerCount, 2);
|
||||
assert.deepEqual(restored, [{ path: "/home/u/a.sh", mode: "755" }]);
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("mode restore tracks skipped offers by index for duplicate basenames", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-zmodem-"));
|
||||
const firstDir = path.join(tempDir, "first");
|
||||
const secondDir = path.join(tempDir, "second");
|
||||
fs.mkdirSync(firstDir);
|
||||
fs.mkdirSync(secondDir);
|
||||
const firstPath = path.join(firstDir, "x.txt");
|
||||
const secondPath = path.join(secondDir, "x.txt");
|
||||
fs.writeFileSync(firstPath, "first");
|
||||
fs.writeFileSync(secondPath, "second");
|
||||
let offerCount = 0;
|
||||
let restored = [];
|
||||
|
||||
const zsession = {
|
||||
async send_offer() {
|
||||
offerCount += 1;
|
||||
// Accept the first duplicate-name offer; ZSKIP the second.
|
||||
if (offerCount > 1) return undefined;
|
||||
return {
|
||||
send() {},
|
||||
async end() {},
|
||||
};
|
||||
},
|
||||
abort() {},
|
||||
async close() {},
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
handleUpload(zsession, {
|
||||
sessionId: "session-1",
|
||||
getWebContents: () => null,
|
||||
writeToRemote: () => true,
|
||||
selectUploadFiles: async () => ({
|
||||
canceled: false,
|
||||
filePaths: [firstPath, secondPath],
|
||||
}),
|
||||
probeReceiveConflicts: async () => ({
|
||||
dir: "/home/u",
|
||||
existing: ["x.txt"],
|
||||
modes: { "x.txt": "600" },
|
||||
}),
|
||||
requestOverwriteDecision: async () => ({ action: "overwrite", applyToRest: true }),
|
||||
removeRemoteFiles: async () => {},
|
||||
restoreRemoteModes: async (entries) => {
|
||||
restored = entries;
|
||||
},
|
||||
}),
|
||||
/Remote skipped some files/,
|
||||
);
|
||||
|
||||
assert.equal(offerCount, 2);
|
||||
// Filename-based skip filtering would drop this restore; index-based keeps it.
|
||||
assert.deepEqual(restored, [{ path: "/home/u/x.txt", mode: "600" }]);
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
@@ -1065,6 +1065,10 @@ function createPreloadApi(ctx) {
|
||||
windowIsMaximized: () => ipcRenderer.invoke("netcatty:window:isMaximized"),
|
||||
windowIsFullscreen: () => ipcRenderer.invoke("netcatty:window:isFullscreen"),
|
||||
windowFocus: () => ipcRenderer.invoke("netcatty:window:focus"),
|
||||
setTerminalKeyboardFocus: (focused) => ipcRenderer.send(
|
||||
"netcatty:window:set-terminal-keyboard-focus",
|
||||
{ focused: focused === true },
|
||||
),
|
||||
setWindowTitle: (title) => ipcRenderer.invoke("netcatty:window:setTitle", title),
|
||||
openSessionInNewWindow: (payload) => ipcRenderer.invoke("netcatty:window:openSession", payload),
|
||||
onOpenSessionInNewWindow: (cb) => {
|
||||
|
||||
@@ -32,6 +32,7 @@ function createFakePort() {
|
||||
function loadPreloadWithFakeElectron() {
|
||||
const handlers = new Map();
|
||||
const listenerCounts = new Map();
|
||||
const sent = [];
|
||||
let exposedApi = null;
|
||||
const fakeElectron = {
|
||||
ipcRenderer: {
|
||||
@@ -39,7 +40,9 @@ function loadPreloadWithFakeElectron() {
|
||||
handlers.set(channel, handler);
|
||||
listenerCounts.set(channel, (listenerCounts.get(channel) || 0) + 1);
|
||||
},
|
||||
send() {},
|
||||
send(channel, payload) {
|
||||
sent.push({ channel, payload });
|
||||
},
|
||||
async invoke(channel, payload) {
|
||||
if (channel === "netcatty:local:start") {
|
||||
return { sessionId: payload?.sessionId };
|
||||
@@ -82,6 +85,7 @@ function loadPreloadWithFakeElectron() {
|
||||
api: exposedApi,
|
||||
handlers,
|
||||
listenerCounts,
|
||||
sent,
|
||||
cleanup() {
|
||||
delete require.cache[preloadPath];
|
||||
if (previousElectron) {
|
||||
@@ -98,6 +102,27 @@ function loadPreloadWithFakeElectron() {
|
||||
};
|
||||
}
|
||||
|
||||
test("setTerminalKeyboardFocus sends renderer focus state to the window manager", () => {
|
||||
const preload = loadPreloadWithFakeElectron();
|
||||
try {
|
||||
preload.api.setTerminalKeyboardFocus(true);
|
||||
preload.api.setTerminalKeyboardFocus(false);
|
||||
|
||||
assert.deepEqual(preload.sent, [
|
||||
{
|
||||
channel: "netcatty:window:set-terminal-keyboard-focus",
|
||||
payload: { focused: true },
|
||||
},
|
||||
{
|
||||
channel: "netcatty:window:set-terminal-keyboard-focus",
|
||||
payload: { focused: false },
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
preload.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("plugin contribution subscribers share one IPC listener", (t) => {
|
||||
const preload = loadPreloadWithFakeElectron();
|
||||
t.after(preload.cleanup);
|
||||
|
||||
@@ -380,15 +380,25 @@ async function executeSnippetOrScriptRun(
|
||||
}
|
||||
}
|
||||
|
||||
const command = applySnippetVariables(snippet.command, variableValues);
|
||||
const applied = applySnippetVariables(snippet.command, variableValues);
|
||||
if (!applied.ok) {
|
||||
return { ok: false, error: `Missing snippet variable "${applied.missing.join('", "')}".` };
|
||||
}
|
||||
const command = applied.command;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.aiExec) {
|
||||
return { ok: false, error: 'Terminal execution bridge is unavailable.' };
|
||||
}
|
||||
const chatSessionId = typeof params.chatSessionId === 'string' ? params.chatSessionId : undefined;
|
||||
const result = await bridge.aiExec(sessionId, command, chatSessionId);
|
||||
if (result && typeof result === 'object' && 'ok' in result && result.ok === false) {
|
||||
return { ok: false, error: (result as { error?: string }).error || 'Snippet execution failed.' };
|
||||
// Real execution failures (timeout, disconnect, no stream) carry an `error`
|
||||
// field. A bare ok:false with exitCode/stdout means the command ran and
|
||||
// exited non-zero — preserve that evidence instead of masking it (#2718).
|
||||
const execError = result && typeof result === 'object' && 'ok' in result && result.ok === false
|
||||
? (result as { error?: string }).error
|
||||
: undefined;
|
||||
if (execError) {
|
||||
return { ok: false, error: execError, sessionId, snippetId: snippet.id, command, kind: 'snippet', result };
|
||||
}
|
||||
return { ok: true, sessionId, snippetId: snippet.id, command, kind: 'snippet', result };
|
||||
}
|
||||
|
||||
+3
-3
@@ -1,14 +1,14 @@
|
||||
{
|
||||
version = "1.1.77";
|
||||
version = "1.1.78";
|
||||
|
||||
sources = {
|
||||
x86_64-linux = {
|
||||
appImageArch = "x86_64";
|
||||
hash = "sha256-ciShhwtTig5leU/TNGV/s3ViaZp0Yd4t40m4wpz66AQ=";
|
||||
hash = "sha256-e+ppcULY31xAlRqzub9LoyyXJYcAWcI2U0IXoluLp2U=";
|
||||
};
|
||||
aarch64-linux = {
|
||||
appImageArch = "arm64";
|
||||
hash = "sha256-esESxIY9uCXARsHjo5fZqcrvUqUPuBSvv6AGHRGww1A=";
|
||||
hash = "sha256-hhJUOKEAz4VgFaK5rGdza5ZpU9olYA9phsoWPLIjVkk=";
|
||||
};
|
||||
};
|
||||
}
|
||||
+1
@@ -14,6 +14,7 @@ declare global {
|
||||
windowIsMaximized?(): Promise<boolean>;
|
||||
windowIsFullscreen?(): Promise<boolean>;
|
||||
windowFocus?(): Promise<boolean>;
|
||||
setTerminalKeyboardFocus?(focused: boolean): void;
|
||||
setWindowTitle?(title: string): Promise<boolean>;
|
||||
openSessionInNewWindow?(payload: {
|
||||
title: string;
|
||||
|
||||
Reference in new issue
Block a user