Compare commits

..
Author SHA1 Message Date
bincxz a1ca1a3443 fix(zmodem): preserve drag-drop overwrite targets 2026-08-12 11:14:03 +08:00
netcatty-bot a4ec7bab61 fix: address Codex review on PR #2914 2026-08-12 02:11:24 +00:00
netcatty-bot 2012fc53c7 fix: address Codex review on PR #2914 2026-08-12 01:24:14 +00:00
netcatty-bot 2be738b8c2 fix(#2863): automated Cursor CLI fix 2026-08-12 01:04:52 +00:00
陈大猫 d307a2ebc3 Merge pull request #2910 from binaricat/automation/nix-release-v1.1.78
Update Nix release metadata for v1.1.78
2026-08-12 01:14:30 +08:00
github-actions[bot] d97439f878 Update Nix release metadata for v1.1.78 2026-08-11 17:10:46 +00:00
陈大猫 8285d7010b Merge pull request #2909 from binaricat/perf/notes-tree-resize-dom
perf(notes): drive tree width resize from the DOM
2026-08-12 00:50:36 +08:00
bincxz 1f0ddd2bdb perf(notes): drive tree width resize from the DOM
Pointermove used to setTreeWidth every pixel and re-render NotesManager
including MDXEditor. Apply live width via rAF + aside.style, commit React
state and storage only on pointerup.
2026-08-12 00:48:29 +08:00
陈大猫 98db9e6fea Merge pull request #2908 from binaricat/fix/notes-tree-sidebar-ellipsis
fix(notes): truncate tree titles when sidebar is narrowed
2026-08-12 00:35:03 +08:00
陈大猫 8e53ec829f Merge pull request #2906 from binaricat/codex/fix-issue-2865-terminal-font-shortcuts
[Bug] Keep Ctrl+=/-/0 scoped to terminal font size
2026-08-12 00:32:02 +08:00
netcatty-bot 549c5534d5 fix: address Codex review on PR #2908 2026-08-11 16:30:13 +00:00
bincxz 8fae84148c fix: keep popup shortcuts outside issue scope 2026-08-12 00:17:20 +08:00
bincxz 6d14f1e3d6 fix: keep terminal focus routing across window focus changes 2026-08-12 00:08:29 +08:00
bincxz aebe25ae70 fix(notes): truncate tree titles when sidebar is narrowed
Radix ScrollArea's display:table viewport child blocked flex shrink, so
narrowing the notes tree clipped long titles mid-glyph. Constrain the
tree width chain and ellipsize labels so content adapts with the panel.
2026-08-12 00:08:22 +08:00
bincxz df045bf144 Revert "fix: address Codex review on PR #2906"
This reverts commit a1b7f3708a.
2026-08-11 23:57:57 +08:00
bincxz 32447885d9 Revert "fix: complete terminal shortcut routing"
This reverts commit c921e7d9a4.
2026-08-11 23:57:57 +08:00
bincxz 195a23e380 Revert "fix: honor configured terminal shortcuts"
This reverts commit a5bf1765d4.
2026-08-11 23:57:57 +08:00
bincxz 459ac09ebb Revert "fix: keep popup zoom setting visible"
This reverts commit e96384cc7f.
2026-08-11 23:57:57 +08:00
bincxz a1ea4cc546 Revert "fix: preserve terminal focus after toolbar clicks"
This reverts commit 01b9f745b6.
2026-08-11 23:57:57 +08:00
bincxz 25224cef7b Revert "fix: match physical shortcut keys"
This reverts commit e3b9e71d8f.
2026-08-11 23:57:57 +08:00
bincxz 452058bd42 Revert "fix: match logical shortcut keys by layout"
This reverts commit 50c25d8789.
2026-08-11 23:57:57 +08:00
bincxz 50c25d8789 fix: match logical shortcut keys by layout 2026-08-11 23:51:38 +08:00
陈大猫 96a38593bf Merge pull request #2907 from jingyaogong/main
[fix] preserve snippet execution results
2026-08-11 23:48:44 +08:00
bincxz e3b9e71d8f fix: match physical shortcut keys 2026-08-11 23:44:04 +08:00
bincxz 01b9f745b6 fix: preserve terminal focus after toolbar clicks 2026-08-11 23:30:24 +08:00
bincxz e96384cc7f fix: keep popup zoom setting visible 2026-08-11 23:08:27 +08:00
bincxz a5bf1765d4 fix: honor configured terminal shortcuts 2026-08-11 23:03:14 +08:00
bincxz c921e7d9a4 fix: complete terminal shortcut routing 2026-08-11 23:01:32 +08:00
netcatty-bot a1b7f3708a fix: address Codex review on PR #2906 2026-08-11 14:47:01 +00:00
jingyaogong 6a4a1ffd92 [fix] preserve snippet execution results 2026-08-11 22:41:47 +08:00
bincxz 105107755b fix: route terminal font shortcuts by focus 2026-08-11 22:14:06 +08:00
陈大猫 028e86b111 Merge pull request #2905 from binaricat/codex/fix-fullscreen-context-menu
fix: honor fullscreen app context menu setting
2026-08-11 21:48:00 +08:00
陈大猫 b2886ed16b Merge pull request #2904 from binaricat/codex/follow-up-pr-2900-custom-shortcut-preview 2026-08-11 21:19:12 +08:00
netcatty-bot 57e11822f2 fix: address Codex review on PR #2904 2026-08-11 12:39:55 +00:00
netcatty-bot ff05c33fc9 fix: address Codex review on PR #2904 2026-08-11 12:15:44 +00:00
bincxz a2ff82187b fix(tabs): follow custom shortcut bindings for previews 2026-08-11 19:42:10 +08:00
27 changed files with 1135 additions and 114 deletions

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 = () => {};
+5 -6
View File
@@ -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;
}
+4
View File
@@ -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
+1
View File
@@ -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);
});
+75 -33
View File
@@ -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
View File
@@ -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,
+31
View File
@@ -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\](?:&gt;|>)div\]:!block/,
);
assert.match(
markup,
/data-radix-scroll-area-viewport\](?:&gt;|>)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);
+72 -18
View File
@@ -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>
+24
View File
@@ -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
+7 -7
View File
@@ -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
View File
@@ -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);
});
+16 -3
View File
@@ -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 = {
+9 -1
View File
@@ -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;
+31 -1
View File
@@ -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]);
+52 -18
View File
@@ -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) {
+283
View File
@@ -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 });
});
+4
View File
@@ -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) => {
+26 -1
View File
@@ -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);
+13 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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;