Compare commits

...
Author SHA1 Message Date
bincxz ef364b69fd Fix theme header button alignment 2026-08-12 17:50:10 +08:00
陈大猫 4beccc3d61 Merge pull request #2928 from binaricat/cursor/issue-2926-31561097764
fix(terminal): re-run onConnect scripts on manual reconnect
2026-08-12 16:42:39 +08:00
bincxz 33b3beaea0 Merge remote-tracking branch 'origin/main' into codex/pr-2928 2026-08-12 16:14:56 +08:00
bincxz e65af2fd7b fix(terminal): safely rerun connect scripts on manual reconnect 2026-08-12 16:14:43 +08:00
陈大猫 5d63a9476c Merge pull request #2932 from binaricat/codex/fix-2924-dropbear-stats-limit
fix(ssh): keep server stats safe for Dropbear
2026-08-12 15:35:20 +08:00
bincxz 58c72ace32 fix(ssh): keep server stats safe for Dropbear 2026-08-12 15:29:14 +08:00
陈大猫 75cbda7edc Merge pull request #2917 from binaricat/cursor/issue-2899-31554398579
fix(ssh): stop parked reconnect PID probes
2026-08-12 14:55:52 +08:00
bincxz 6f6d8dc36d fix(ssh): recover cwd safely after parked reconnect 2026-08-12 14:42:25 +08:00
netcatty-bot d0f819efb0 fix: address Codex review on PR #2917 2026-08-12 06:36:25 +00:00
陈大猫 380e96eae5 Merge pull request #2929 from binaricat/codex/fix-docker-tmux-popup-timeout
fix(terminal): start command popups after vault hydration
2026-08-12 14:12:01 +08:00
netcatty-bot 02ce011934 fix: address Codex review on PR #2917 2026-08-12 06:03:20 +00:00
bincxz 8e45763e4c fix(terminal): start command popups after vault hydration 2026-08-12 13:53:54 +08:00
netcatty-bot 0d92c5d738 fix: address Codex review on PR #2928 2026-08-12 05:26:14 +00:00
bincxz 6bf4268e8e fix(ssh): scope parked reconnect cwd guard 2026-08-12 13:17:50 +08:00
bincxz f02b645af7 fix(ssh): avoid stale cwd after parked reconnect 2026-08-12 12:44:11 +08:00
netcatty-bot eb3cd06278 fix: address Codex review on PR #2928 2026-08-12 04:42:43 +00:00
bincxz 7ed0b32489 Merge remote-tracking branch 'origin/main' into codex/pr-2917-final
# Conflicts:
#	electron/bridges/sshBridge.connectionReuse.test.cjs
2026-08-12 12:36:46 +08:00
陈大猫 2fe46cbf6f Merge pull request #2918 from binaricat/cursor/issue-2916-31555268705
fix(workspace): allow dragging hosts into focus-mode sidebar
2026-08-12 12:24:21 +08:00
bincxz 53ebbec58c fix(workspace): expand focus sidebar host drop target 2026-08-12 12:16:52 +08:00
netcatty-bot 253ce57d84 fix: address Codex review on PR #2917 2026-08-12 04:02:40 +00:00
netcatty-bot 9beb7d3108 fix(#2926): automated Cursor CLI fix 2026-08-12 04:00:36 +00:00
陈大猫 525a78e83e Merge pull request #2921 from binaricat/cursor/issue-2919-31558580686
fix(ai): sync side panel color-scheme for light Codex composer
2026-08-12 11:57:04 +08:00
陈大猫 0e94950a5c Merge pull request #2927 from binaricat/cursor/issue-2923-31560157438
fix(ssh): skip idle-park reconnect PID discovery that breaks bastions
2026-08-12 11:56:18 +08:00
陈大猫 a2ea0f6464 Merge pull request #2913 from binaricat/cursor/issue-2912-31541806461
fix(system): exclude NFS/CIFS mounts from overview disk totals
2026-08-12 11:52:44 +08:00
netcatty-bot 42b174abb3 fix(#2923): automated Cursor CLI fix 2026-08-12 03:49:12 +00:00
bincxz e2ad0f526e fix(workspace): complete host drop paths 2026-08-12 11:44:35 +08:00
bincxz cfeee2ec89 fix(ssh): skip redundant PID discovery on reconnect 2026-08-12 11:39:58 +08:00
bincxz be3e971064 fix(system): filter legacy distributed filesystem sources 2026-08-12 11:35:37 +08:00
陈大猫 35bb83f57c Merge pull request #2914 from binaricat/cursor/issue-2863-31551484962
fix(zmodem): fail skipped uploads and auto-overwrite on terminal drag-drop
2026-08-12 11:34:37 +08:00
netcatty-bot 5279ac2836 fix(#2919): automated Cursor CLI fix 2026-08-12 03:16:44 +00:00
bincxz 793ba54bda fix(system): cover generic FUSE and scoped IPv6 mounts 2026-08-12 11:14:40 +08:00
bincxz a1ca1a3443 fix(zmodem): preserve drag-drop overwrite targets 2026-08-12 11:14:03 +08:00
netcatty-bot 3c528c6ab5 fix: address Codex review on PR #2917 2026-08-12 03:13:17 +00:00
netcatty-bot 6dd703afce fix: address Codex review on PR #2918 2026-08-12 03:12:52 +00:00
netcatty-bot 10c399f47b fix(#2916): automated Cursor CLI fix 2026-08-12 02:13:50 +00:00
netcatty-bot a4ec7bab61 fix: address Codex review on PR #2914 2026-08-12 02:11:24 +00:00
netcatty-bot 79215d8896 fix(#2899): automated Cursor CLI fix 2026-08-12 02:02:41 +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
netcatty-bot 93d7f8e1ec fix: address Codex review on PR #2913 2026-08-11 23:16:26 +00:00
netcatty-bot 924c9a0efa fix: address Codex review on PR #2913 2026-08-11 22:56:23 +00:00
netcatty-bot 0d9da150ec fix(#2912): automated Cursor CLI fix 2026-08-11 22:39:50 +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
bincxz 151c15b38f fix: use current mouse mode in context menu 2026-08-11 21:43:52 +08:00
bincxz 544613c007 fix: honor fullscreen app context menu setting 2026-08-11 21:35:31 +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
陈大猫 997bba6e2c Merge pull request #2900 from binaricat/cursor/issue-2898-31478402911
feat(tabs): show optional 1-9 shortcut badges on top tabs
2026-08-11 19:31:55 +08:00
bincxz 13979790e8 feat(tabs): reveal shortcut numbers while holding modifier 2026-08-11 19:31:15 +08:00
陈大猫 19cce8c82b Merge pull request #2901 from binaricat/codex/fix-2893-local-forward-client-close
[Bug] Keep local forwarding listener alive after client disconnect
2026-08-11 19:06:17 +08:00
陈大猫 02746754f3 Merge pull request #2903 from binaricat/cursor/issue-2902-31483214309
fix(vault): keep host search focus after selecting a host
2026-08-11 18:59:51 +08:00
netcatty-bot f940bbc280 fix(#2902): automated Cursor CLI fix 2026-08-11 10:55:50 +00:00
bincxz 7e60b64766 fix(port-forward): keep listener alive after client disconnect 2026-08-11 18:52:24 +08:00
netcatty-bot 18330fd1d1 fix: address Codex review on PR #2900 2026-08-11 10:20:09 +00:00
陈大猫 ebfdb645a7 Merge pull request #2896 from LJAYi/codex/fix-macos-small-app-icon
fix(mac): prevent corrupted small app icons
2026-08-11 18:02:29 +08:00
netcatty-bot b89607dbb0 fix(#2898): automated Cursor CLI fix 2026-08-11 09:55:39 +00:00
LJAYi 2471013aba fix(mac): ship valid small app icon representations 2026-08-11 17:23:06 +08:00
陈大猫 2343f80ed2 Merge pull request #2894 from binaricat/cursor/issue-2891-31471727414
fix(system): exclude network FUSE mounts from disk overview totals
2026-08-11 17:17:34 +08:00
bincxz 8d94252990 fix(system): refine remote FUSE disk filtering 2026-08-11 17:04:08 +08:00
bincxz 87b4089812 fix(system): detect FUSE filesystem types in disk stats 2026-08-11 16:58:22 +08:00
netcatty-bot d7377fe301 fix(#2891): automated Cursor CLI fix 2026-08-11 08:24:07 +00:00
陈大猫 518e4a12ac Merge pull request #2880 from binaricat/cursor/issue-2879-31446551895
fix(terminal): defer keyword scroll refresh while Enter is pending
2026-08-11 16:21:20 +08:00
陈大猫 5dc87ee178 Merge pull request #2888 from binaricat/cursor/issue-2886-31463885953
fix(terminal): keep login-shell cwd after sudo for drag-drop SFTP
2026-08-11 15:23:28 +08:00
陈大猫 a363ac5812 Merge pull request #2884 from binaricat/cursor/issue-2882-31452615583
fix(sftp): keep browse sessions when switching side-panel tools
2026-08-11 15:23:18 +08:00
陈大猫 02bfea153c Merge pull request #2889 from binaricat/codex/port-forwarding-panel-polish
Polish port forwarding side panels
2026-08-11 15:14:32 +08:00
bincxz 43534c34f1 fix(sftp): preserve absent close markers on remap 2026-08-11 15:12:44 +08:00
bincxz a19bf708ac Prevent inline panel content clipping 2026-08-11 15:06:32 +08:00
bincxz bbfa2ac6bf fix(sftp): preserve split-pane close lifecycle 2026-08-11 15:04:04 +08:00
netcatty-bot 16f0304a5b fix: address Codex review on PR #2888 2026-08-11 07:04:02 +00:00
bincxz e78d78b428 Address port forwarding review feedback 2026-08-11 14:57:08 +08:00
bincxz f6d846fbda Polish port forwarding side panels 2026-08-11 14:44:30 +08:00
netcatty-bot b9702b043a fix(#2886): automated Cursor CLI fix 2026-08-11 06:27:49 +00:00
bincxz 89b3e27329 fix(sftp): preserve close lifecycle when switching tools 2026-08-11 14:24:51 +08:00
陈大猫 7e600d54e0 Merge pull request #2885 from binaricat/cursor/issue-2813-31455487268
fix(autocomplete): keep Chinese snippets on Windows CMD path prompts
2026-08-11 12:04:42 +08:00
netcatty-bot 87aa2ea931 fix(#2813): automated Cursor CLI fix 2026-08-11 03:48:30 +00:00
netcatty-bot ac3993b789 fix(#2882): automated Cursor CLI fix 2026-08-11 02:47:51 +00:00
143 changed files with 6011 additions and 439 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 = () => {};
+13 -7
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';
@@ -496,7 +497,14 @@ async function captureCtxInheritedCwd(getCtx: AppContextGetter, sessionId: strin
}
}
const probe = async (id: string, options?: { allowHomeFallback?: boolean; timeoutMs?: number }) =>
const probe = async (
id: string,
options?: {
allowHomeFallback?: boolean;
allowLoginShellFallback?: boolean;
timeoutMs?: number;
},
) =>
(await bridge?.getSessionPwd?.(id, options)) ?? { success: false };
return captureInheritedCwd(source, probe, { liveCwd, allowSshProbe });
}
@@ -673,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
+33 -8
View File
@@ -34,6 +34,7 @@ import { useMainWindowInputFocusRecovery } from '../state/useMainWindowInputFocu
import { useExternalMcpToggleState } from '../state/useExternalMcpToggleState';
import { selectPluginThemeTokens } from '../state/pluginContributionEnvironment';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { resolveEffectiveTerminalHost } from '../../domain/terminalHostResolution';
import { pluginViewTabStore, usePluginViewTabs } from '../state/pluginViewTabStore';
import { buildPluginSettingScopeCatalog } from '../state/usePluginSettingScopeCatalog';
import { useWorkSurfaceHostEditor } from '../state/useWorkSurfaceHostEditor';
@@ -317,6 +318,29 @@ function AppViewInner({ domains }: AppViewProps) {
setAddToWorkspaceDialog({ mode: 'append', workspaceId });
}, [setAddToWorkspaceDialog]);
const validProxyProfileIds = useMemo(
() => new Set(proxyProfiles.map((profile) => profile.id)),
[proxyProfiles],
);
const resolveWorkspaceAppendHost = useCallback((host: typeof hosts[number]) => (
resolveEffectiveTerminalHost({
host,
groupConfigs,
proxyProfiles,
validProxyProfileIds,
})
), [groupConfigs, proxyProfiles, validProxyProfileIds]);
const handleAppendHostToWorkspace = useCallback((workspaceId: string, hostId: string) => {
const host = hosts.find((entry) => entry.id === hostId);
if (!host) return;
const ws = workspaces.find((entry) => entry.id === workspaceId);
if (!ws) return;
const rootDir = ws.root.type === 'split' ? ws.root.direction : 'vertical';
appendHostToWorkspace(workspaceId, resolveWorkspaceAppendHost(host), rootDir);
}, [appendHostToWorkspace, hosts, resolveWorkspaceAppendHost, workspaces]);
const isPeerSessionWindow = typeof window !== 'undefined'
&& window.location.hash.startsWith('#/session-window');
const externalMcpToggle = useExternalMcpToggleState();
@@ -471,8 +495,10 @@ function AppViewInner({ domains }: AppViewProps) {
onEndSessionDrag={handleEndSessionDrag}
onReorderTabs={reorderWorkTabs}
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
onAppendHostToWorkspace={handleAppendHostToWorkspace}
showSftpTab={showSftpTab}
showHostTreeSidebar={showHostTreeSidebar}
switchTabKeyBinding={keyBindings.find((binding) => binding.action === 'switchToTab') ?? null}
dynamicTabTitleMode={dynamicTabTitleMode}
editorTabs={editorTabs}
pluginViewTabs={pluginViewTabs}
@@ -658,6 +684,7 @@ function AppViewInner({ domains }: AppViewProps) {
onCreateWorkspaceFromSessions={createWorkspaceFromSessions}
onAddSessionToWorkspace={addSessionToWorkspace}
onRequestAddToWorkspace={handleRequestAddToWorkspace}
onAppendHostToWorkspace={handleAppendHostToWorkspace}
onUpdateSplitSizes={updateSplitSizes}
onSetDraggingSessionId={setDraggingSessionId}
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
@@ -779,13 +806,7 @@ function AppViewInner({ domains }: AppViewProps) {
<AddToWorkspaceDialog
open
onOpenChange={(open) => { if (!open) setAddToWorkspaceDialog(null); }}
// Filter serial hosts only in append mode — appendHostToWorkspace
// has no serial code path. Create mode goes through
// createWorkspaceFromTargets, which builds a SerialConfig-backed
// session for serial hosts, so those should remain pickable.
hosts={addToWorkspaceDialog.mode === 'append'
? hosts.filter((h) => h.protocol !== 'serial')
: hosts}
hosts={hosts}
workspaceTitle={
addToWorkspaceDialog.mode === 'append'
? workspaces.find((w) => w.id === addToWorkspaceDialog.workspaceId)?.title
@@ -804,7 +825,11 @@ function AppViewInner({ domains }: AppViewProps) {
if (target.kind === 'local') {
appendLocalTerminalToWorkspace(addToWorkspaceDialog.workspaceId, undefined, rootDir);
} else {
appendHostToWorkspace(addToWorkspaceDialog.workspaceId, target.host, rootDir);
appendHostToWorkspace(
addToWorkspaceDialog.workspaceId,
resolveWorkspaceAppendHost(target.host),
rootDir,
);
}
}
} else {
@@ -0,0 +1,19 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const source = readFileSync(new URL('./AppView.tsx', import.meta.url), 'utf8');
test('workspace append resolves group defaults before creating host sessions', () => {
assert.match(source, /resolveEffectiveTerminalHost\(\{/);
assert.match(source, /groupConfigs,/);
assert.match(source, /proxyProfiles,/);
assert.match(
source,
/appendHostToWorkspace\(workspaceId, resolveWorkspaceAppendHost\(host\), rootDir\)/,
);
assert.match(
source,
/appendHostToWorkspace\([\s\S]*?resolveWorkspaceAppendHost\(target\.host\),[\s\S]*?rootDir/,
);
});
+41 -1
View File
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildNumberShortcutTabTargets } from './tabShortcutTargets.ts';
import { buildNumberShortcutTabTargets, buildTabShortcutNumberById } from './tabShortcutTargets.ts';
test('number shortcut tabs include vault and sftp by default', () => {
assert.deepEqual(
@@ -62,3 +62,43 @@ test('pinned tabs cannot be duplicated by a malformed persisted work ordering',
['vault', 'sftp', 'session-1'],
);
});
test('shortcut number map uses 1-based indices matching Ctrl/Cmd+[1...9]', () => {
const map = buildTabShortcutNumberById({
showSftpTab: true,
shellOnlyTabNumberShortcuts: false,
orderedTabs: ['session-1', 'workspace-1'],
editorTabIds: [],
});
assert.equal(map.get('vault'), 1);
assert.equal(map.get('sftp'), 2);
assert.equal(map.get('session-1'), 3);
assert.equal(map.get('workspace-1'), 4);
});
test('shortcut number map skips pinned tabs in shell-only mode', () => {
const map = buildTabShortcutNumberById({
showSftpTab: true,
shellOnlyTabNumberShortcuts: true,
orderedTabs: ['session-1', 'workspace-1'],
editorTabIds: [],
});
assert.equal(map.has('vault'), false);
assert.equal(map.has('sftp'), false);
assert.equal(map.get('session-1'), 1);
assert.equal(map.get('workspace-1'), 2);
});
test('shortcut number map caps at nine entries', () => {
const orderedTabs = Array.from({ length: 12 }, (_, index) => `session-${index + 1}`);
const map = buildTabShortcutNumberById({
showSftpTab: false,
shellOnlyTabNumberShortcuts: true,
orderedTabs,
editorTabIds: [],
});
assert.equal(map.size, 9);
assert.equal(map.get('session-1'), 1);
assert.equal(map.get('session-9'), 9);
assert.equal(map.has('session-10'), false);
});
+19
View File
@@ -12,3 +12,22 @@ export function buildNumberShortcutTabTargets(params: {
const pinnedTabs = params.showSftpTab ? ['vault', 'sftp'] : ['vault'];
return [...new Set([...pinnedTabs, ...workTabs])];
}
/**
* Maps tab ids to Cmd/Ctrl+[1...9] shortcut indices (1-based).
* Only the first nine shortcut targets receive a number.
*/
export function buildTabShortcutNumberById(params: {
showSftpTab: boolean;
shellOnlyTabNumberShortcuts: boolean;
orderedTabs: readonly string[];
editorTabIds: readonly string[];
}): ReadonlyMap<string, number> {
const targets = buildNumberShortcutTabTargets(params);
const map = new Map<string, number>();
const limit = Math.min(9, targets.length);
for (let index = 0; index < limit; index += 1) {
map.set(targets[index], index + 1);
}
return map;
}
+2
View File
@@ -789,6 +789,8 @@ Highlight the focused split pane:
'settings.shortcuts.disableTerminalFontZoom.desc': 'Turn off terminal font zoom shortcuts, including Cmd/Ctrl + mouse wheel.',
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': 'Tab shortcuts skip pinned tabs',
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': 'When enabled, Cmd/Ctrl+[1...9] and Ctrl+Tab switch only work tabs (terminals, workspaces, editors), not the pinned Vault or SFTP tabs.',
'settings.shortcuts.showTabNumberBadges.label': 'Show tab number badges',
'settings.shortcuts.showTabNumberBadges.desc': 'While holding Cmd/Ctrl, show 1-9 on tabs that match Cmd/Ctrl+[1...9], so you can jump accurately.',
'settings.shortcuts.section.custom': 'Custom Shortcuts',
'settings.shortcuts.resetAll': 'Reset All',
'settings.shortcuts.recording': 'Press keys...',
+2
View File
@@ -734,6 +734,8 @@ export const ruCoreMessages: Messages = {
'settings.shortcuts.disableTerminalFontZoom.desc': 'Отключает быстрый масштаб текста в терминале, включая Cmd/Ctrl + колесо мыши.',
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': 'Сочетания без закреплённых вкладок',
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': 'Если включено, Cmd/Ctrl+[1...9] и Ctrl+Tab переключают только рабочие вкладки (терминалы, рабочие области, редакторы), а не закреплённые Vault и SFTP.',
'settings.shortcuts.showTabNumberBadges.label': 'Номера на вкладках',
'settings.shortcuts.showTabNumberBadges.desc': 'Показывать 1-9 на вкладках при удержании Cmd/Ctrl, чтобы точнее переключаться через Cmd/Ctrl+[1...9].',
'settings.shortcuts.section.custom': 'Пользовательские сочетания',
'settings.shortcuts.resetAll': 'Сбросить все',
'settings.shortcuts.recording': 'Нажмите клавиши...',
@@ -513,6 +513,8 @@ export const zhCNTerminalMessages: Messages = {
'settings.shortcuts.disableTerminalFontZoom.desc': '关闭终端文字缩放快捷操作,包括 Cmd/Ctrl 加滚轮。',
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': '标签快捷键跳过固定标签',
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': '开启后,Cmd/Ctrl+[1...9] 和 Ctrl+Tab 仅在终端、工作区、编辑器等可关闭标签页之间切换,不包括固定的 Vault 和 SFTP 标签页。',
'settings.shortcuts.showTabNumberBadges.label': '显示标签页序号角标',
'settings.shortcuts.showTabNumberBadges.desc': '按住 Cmd/Ctrl 时,在对应 Cmd/Ctrl+[1...9] 的标签页上显示 1-9,方便准确切换。',
'settings.shortcuts.section.custom': '自定义快捷键',
'settings.shortcuts.resetAll': '全部重置',
'settings.shortcuts.recording': '请按键...',
@@ -513,6 +513,8 @@ export const zhTWTerminalMessages: Messages = {
'settings.shortcuts.disableTerminalFontZoom.desc': '關閉終端文字縮放快捷操作,包括 Cmd/Ctrl 加滾輪。',
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': '標籤快捷鍵跳過固定標籤',
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': '開啟後,Cmd/Ctrl+[1...9] 和 Ctrl+Tab 僅在終端、工作區、編輯器等可關閉標籤頁之間切換,不包括固定的 Vault 和 SFTP 標籤頁。',
'settings.shortcuts.showTabNumberBadges.label': '顯示標籤頁序號角標',
'settings.shortcuts.showTabNumberBadges.desc': '按住 Cmd/Ctrl 時,在對應 Cmd/Ctrl+[1...9] 的標籤頁上顯示 1-9,方便準確切換。',
'settings.shortcuts.section.custom': '自訂快捷鍵',
'settings.shortcuts.resetAll': '全部重置',
'settings.shortcuts.recording': '請按鍵...',
+5 -1
View File
@@ -2,7 +2,11 @@ import type { TerminalSession } from "../../domain/models";
export type SessionPwdProbe = (
sessionId: string,
options?: { allowHomeFallback?: boolean; timeoutMs?: number },
options?: {
allowHomeFallback?: boolean;
allowLoginShellFallback?: boolean;
timeoutMs?: number;
},
) => Promise<{ success: boolean; cwd?: string }>;
type CaptureSession = Pick<TerminalSession, "id" | "protocol" | "status" | "lastCwd" | "localStartDir">;
@@ -1,11 +1,14 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
runConnectScriptsSequential,
selectScriptOverlayRun,
setScriptRuns,
waitForScriptRun,
} from './scriptAutomationCoordinator.ts';
import type { ScriptRun } from '@/types/global/netcatty-bridge-script.d.ts';
import type { Snippet } from '@/domain/models';
import { netcattyBridge } from '@/infrastructure/services/netcattyBridge.ts';
test('waitForScriptRun resolves when run is already completed on subscribe', async () => {
const runId = 'run-already-done';
@@ -64,3 +67,182 @@ test('selectScriptOverlayRun does not resurface older completed runs after dismi
dismissedRunIds.add(latestRun.runId);
assert.equal(selectScriptOverlayRun([olderRun, latestRun], 'sess1', dismissedRunIds), undefined);
});
test('runConnectScriptsSequential cancels only its own queued run and waits for stop', async () => {
const originalGet = netcattyBridge.get;
const sessionId = 'sess-connect-abort';
let queuedRunId: string | undefined;
let releaseStop: (() => void) | undefined;
const scriptStopCalls: string[] = [];
const storage = new Map<string, string>();
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => { storage.set(key, String(value)); },
removeItem: (key: string) => { storage.delete(key); },
clear: () => { storage.clear(); },
},
});
setScriptRuns([{
runId: 'unrelated-active-run',
scriptId: 'manual-script',
sessionId,
status: 'running',
startedAt: Date.now(),
logs: [],
}]);
netcattyBridge.get = () => ({
scriptRun: async (params) => {
queuedRunId = params.runId;
assert.equal(params.returnWhenQueued, true);
return { runId: params.runId!, runIds: [params.runId!] };
},
scriptStop: (id: string) => new Promise((resolve) => {
scriptStopCalls.push(id);
releaseStop = () => resolve({ ok: true });
}),
}) as ReturnType<typeof netcattyBridge.get>;
const controller = new AbortController();
let stopCurrentRun: (() => Promise<void>) | null = null;
const snippet: Snippet = {
id: 'connect-script',
label: 'Connect',
command: 'nct.session.sleep(60)',
kind: 'script',
};
try {
const running = runConnectScriptsSequential({
scripts: [snippet],
sessionId,
signal: controller.signal,
onCancelableRunChange: (stop) => { stopCurrentRun = stop; },
});
void running.catch(() => {});
await Promise.resolve();
controller.abort();
for (let attempt = 0; attempt < 10 && scriptStopCalls.length === 0; attempt += 1) {
await new Promise<void>((resolve) => setImmediate(resolve));
}
assert.deepEqual(scriptStopCalls, [queuedRunId]);
assert.notEqual(queuedRunId, 'unrelated-active-run');
assert.equal(typeof stopCurrentRun, 'function');
let settled = false;
void running.finally(() => { settled = true; }).catch(() => {});
await Promise.resolve();
assert.equal(settled, false);
releaseStop?.();
await assert.rejects(
() => running,
(error: unknown) => error instanceof DOMException && error.name === 'AbortError',
);
} finally {
netcattyBridge.get = originalGet;
setScriptRuns([]);
Reflect.deleteProperty(globalThis, 'localStorage');
}
});
test('runConnectScriptsSequential retries the exact stop after a transient failure', async () => {
const originalGet = netcattyBridge.get;
const storage = new Map<string, string>();
const stopCalls: string[] = [];
let stopCurrentRun: (() => Promise<void>) | null = null;
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => { storage.set(key, String(value)); },
removeItem: (key: string) => { storage.delete(key); },
clear: () => { storage.clear(); },
},
});
netcattyBridge.get = () => ({
scriptRun: async (params) => ({ runId: params.runId!, runIds: [params.runId!] }),
scriptStop: async (id: string) => {
stopCalls.push(id);
return { ok: stopCalls.length > 1 };
},
}) as ReturnType<typeof netcattyBridge.get>;
const controller = new AbortController();
try {
const running = runConnectScriptsSequential({
scripts: [{ id: 'retry-stop', label: 'Retry stop', command: 'await nct.sleep(60)', kind: 'script' }],
sessionId: 'sess-retry-stop',
signal: controller.signal,
onCancelableRunChange: (stop) => { stopCurrentRun = stop; },
});
void running.catch(() => {});
for (let attempt = 0; attempt < 10 && !stopCurrentRun; attempt += 1) {
await new Promise<void>((resolve) => setImmediate(resolve));
}
controller.abort();
await assert.rejects(() => running, /could not be stopped/);
assert.equal(typeof stopCurrentRun, 'function');
await stopCurrentRun!();
assert.equal(stopCalls.length, 2);
} finally {
netcattyBridge.get = originalGet;
setScriptRuns([]);
Reflect.deleteProperty(globalThis, 'localStorage');
}
});
test('runConnectScriptsSequential does not treat a script error named Aborted as cancellation', async () => {
const originalGet = netcattyBridge.get;
const storage = new Map<string, string>();
const scriptStopCalls: string[] = [];
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => { storage.set(key, String(value)); },
removeItem: (key: string) => { storage.delete(key); },
clear: () => { storage.clear(); },
},
});
netcattyBridge.get = () => ({
scriptRun: async (params) => {
setScriptRuns([{
runId: params.runId!,
scriptId: params.scriptId,
sessionId: params.sessionId!,
status: 'failed',
startedAt: Date.now() - 10,
endedAt: Date.now(),
error: 'Aborted',
logs: [],
}]);
return { runId: params.runId!, runIds: [params.runId!] };
},
scriptStop: async (id: string) => {
scriptStopCalls.push(id);
return { ok: true };
},
}) as ReturnType<typeof netcattyBridge.get>;
try {
await assert.rejects(
() => runConnectScriptsSequential({
scripts: [{ id: 'fails', label: 'Fails', command: "throw new Error('Aborted')", kind: 'script' }],
sessionId: 'sess-real-error',
signal: new AbortController().signal,
}),
/Aborted/,
);
assert.deepEqual(scriptStopCalls, []);
} finally {
netcattyBridge.get = originalGet;
setScriptRuns([]);
Reflect.deleteProperty(globalThis, 'localStorage');
}
});
@@ -83,6 +83,8 @@ export function getActiveScriptRunForSession(sessionId: string): ScriptRun | und
}
export async function runAutomationScript(params: {
runId?: string;
returnWhenQueued?: boolean;
snippet: Snippet;
sessionId: string;
sessionIds?: string[];
@@ -104,6 +106,8 @@ export async function runAutomationScript(params: {
throw new Error('Script bridge unavailable');
}
return bridge.scriptRun({
runId: params.runId,
returnWhenQueued: params.returnWhenQueued,
scriptId: params.snippet.id,
scriptLabel: params.snippet.label,
content: params.snippet.command,
@@ -176,6 +180,7 @@ export async function runConnectScriptsSequential(params: {
scripts: Snippet[];
sessionId: string;
signal?: AbortSignal;
onCancelableRunChange?: (stopCurrentRun: (() => Promise<void>) | null) => void;
onScriptStart?: (snippet: Snippet) => void;
onScriptComplete?: (snippet: Snippet) => void;
sessionMeta?: {
@@ -185,18 +190,63 @@ export async function runConnectScriptsSequential(params: {
username?: string;
};
}): Promise<void> {
for (const snippet of params.scripts) {
const throwIfAborted = () => {
if (params.signal?.aborted) {
throw new Error('Aborted');
throw new DOMException('Connect script run cancelled', 'AbortError');
}
};
for (const snippet of params.scripts) {
throwIfAborted();
const runId = crypto.randomUUID();
let stopPromise: Promise<void> | undefined;
let stopped = false;
params.onScriptStart?.(snippet);
const { runId } = await runAutomationScript({
const queueAccepted = runAutomationScript({
runId,
returnWhenQueued: true,
snippet,
sessionId: params.sessionId,
sessionMeta: params.sessionMeta,
});
await waitForScriptRun(runId, { signal: params.signal });
params.onScriptComplete?.(snippet);
const stopThisRun = () => {
if (stopPromise) return stopPromise;
const attempt = queueAccepted.then(async () => {
const result = await stopScriptRun(runId);
if (!result.ok) {
throw new Error(`Connect script run could not be stopped: ${runId}`);
}
stopped = true;
});
stopPromise = attempt;
void attempt.catch(() => {
if (stopPromise === attempt) stopPromise = undefined;
});
return attempt;
};
params.onCancelableRunChange?.(stopThisRun);
const onAbort = () => {
void stopThisRun().catch(() => {});
};
params.signal?.addEventListener('abort', onAbort, { once: true });
try {
await queueAccepted;
throwIfAborted();
await waitForScriptRun(runId, { signal: params.signal });
params.onScriptComplete?.(snippet);
} catch (err) {
if (params.signal?.aborted) {
await stopThisRun();
throw new DOMException('Connect script run cancelled', 'AbortError');
}
throw err;
} finally {
params.signal?.removeEventListener('abort', onAbort);
if (!params.signal?.aborted || stopped) {
params.onCancelableRunChange?.(null);
}
}
}
}
+35 -5
View File
@@ -2,9 +2,9 @@ import assert from "node:assert/strict";
import test from "node:test";
import type { Host } from "../../domain/models";
import { applyGroupDefaults } from "../../domain/groupConfig";
import { prepareSerialConfigForSavedHost } from "../../domain/serialBackspace";
import { buildTelnetDeepLinkConnectionHost } from "../../domain/telnetDeepLink";
import { resolveEffectiveTerminalHost } from "../../domain/terminalHostResolution";
import {
createHostTerminalSession,
createSerialTerminalSession,
@@ -74,7 +74,34 @@ test("serial session factories snapshot effective legacy Backspace behavior", ()
assert.equal(explicitDefaultSession.serialConfig?.backspaceBehavior, "default");
});
test("saved quick-connect hosts can inherit Ctrl-H after moving into a group", () => {
test("workspace host factory creates a complete serial session", () => {
const session = createWorkspaceHostTerminalSession("session-serial", host({
protocol: "serial",
hostname: "COM7",
port: 57600,
username: "",
serialConfig: {
path: "COM7",
baudRate: 57600,
dataBits: 7,
stopBits: 2,
parity: "even",
},
}), "workspace-1");
assert.equal(session.workspaceId, "workspace-1");
assert.equal(session.protocol, "serial");
assert.deepEqual(session.serialConfig, {
path: "COM7",
baudRate: 57600,
dataBits: 7,
stopBits: 2,
parity: "even",
backspaceBehavior: "default",
});
});
test("workspace append snapshots serial Backspace behavior inherited from a group", () => {
const savedHost = host({
protocol: "serial",
hostname: "COM3",
@@ -87,12 +114,15 @@ test("saved quick-connect hosts can inherit Ctrl-H after moving into a group", (
backspaceBehavior: "default",
}),
});
const effectiveHost = applyGroupDefaults(savedHost, {
backspaceBehavior: "ctrl-h",
const effectiveHost = resolveEffectiveTerminalHost({
host: savedHost,
groupConfigs: [{ path: "network", backspaceBehavior: "ctrl-h" }],
proxyProfiles: [],
});
const session = createHostTerminalSession("session-1", effectiveHost);
const session = createWorkspaceHostTerminalSession("session-1", effectiveHost, "workspace-1");
assert.equal(session.workspaceId, "workspace-1");
assert.equal(session.serialConfig?.backspaceBehavior, "ctrl-h");
});
+10
View File
@@ -1,6 +1,7 @@
import { useSyncExternalStore } from 'react';
import type { UILanguage } from '../../domain/models/connection';
import type { HotkeyScheme } from '../../domain/models/keyBindings';
import type { DynamicTabTitleMode } from '../../domain/models/terminal';
import type { HostClickBehavior } from '../../domain/hostClickBehavior';
import type { TerminalSidePanelAutoOpenTab } from '../../domain/terminalSidePanelAutoOpen';
@@ -29,6 +30,9 @@ export type SettingsChromeSnapshot = {
showOnlyUngroupedHostsInRoot: boolean;
dynamicTabTitleMode: DynamicTabTitleMode;
disableTerminalFontZoom: boolean;
hotkeyScheme: HotkeyScheme;
shellOnlyTabNumberShortcuts: boolean;
showTabNumberBadges: boolean;
restoreTerminalCwd: boolean;
terminalSidePanelAutoOpen: boolean;
terminalSidePanelAutoOpenTab: TerminalSidePanelAutoOpenTab;
@@ -58,6 +62,9 @@ export const DEFAULT_SETTINGS_CHROME_SNAPSHOT: SettingsChromeSnapshot = Object.f
showOnlyUngroupedHostsInRoot: false,
dynamicTabTitleMode: 'off',
disableTerminalFontZoom: false,
hotkeyScheme: 'pc',
shellOnlyTabNumberShortcuts: false,
showTabNumberBadges: true,
restoreTerminalCwd: true,
terminalSidePanelAutoOpen: false,
terminalSidePanelAutoOpenTab: 'ai',
@@ -80,6 +87,9 @@ export function settingsChromeSnapshotsEqual(
&& a.showOnlyUngroupedHostsInRoot === b.showOnlyUngroupedHostsInRoot
&& a.dynamicTabTitleMode === b.dynamicTabTitleMode
&& a.disableTerminalFontZoom === b.disableTerminalFontZoom
&& a.hotkeyScheme === b.hotkeyScheme
&& a.shellOnlyTabNumberShortcuts === b.shellOnlyTabNumberShortcuts
&& a.showTabNumberBadges === b.showTabNumberBadges
&& a.restoreTerminalCwd === b.restoreTerminalCwd
&& a.terminalSidePanelAutoOpen === b.terminalSidePanelAutoOpen
&& a.terminalSidePanelAutoOpenTab === b.terminalSidePanelAutoOpenTab;
@@ -82,6 +82,7 @@ export const DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT = false;
export const DEFAULT_SHOW_SFTP_TAB = true;
export const DEFAULT_SHOW_HOST_TREE_SIDEBAR = true;
export const DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS = false;
export const DEFAULT_SHOW_TAB_NUMBER_BADGES = true;
export const DEFAULT_DISABLE_TERMINAL_FONT_ZOOM = false;
export { DEFAULT_RESTORE_PREVIOUS_SESSION } from './sessionRestoreSettings';
+13 -4
View File
@@ -37,6 +37,7 @@ import {
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
STORAGE_KEY_TERM_FONT_FAMILY,
@@ -92,6 +93,7 @@ interface UseSettingsStorageSyncParams {
terminalSidePanelAutoOpen: boolean;
terminalSidePanelAutoOpenTab: TerminalSidePanelAutoOpenTab;
shellOnlyTabNumberShortcuts: boolean;
showTabNumberBadges: boolean;
disableTerminalFontZoom: boolean;
restorePreviousSession: boolean;
restoreTerminalCwd: boolean;
@@ -139,6 +141,7 @@ interface UseSettingsStorageSyncParams {
setTerminalSidePanelAutoOpenState: Dispatch<SetStateAction<boolean>>;
setTerminalSidePanelAutoOpenTabState: Dispatch<SetStateAction<TerminalSidePanelAutoOpenTab>>;
setShellOnlyTabNumberShortcutsState: Dispatch<SetStateAction<boolean>>;
setShowTabNumberBadgesState: Dispatch<SetStateAction<boolean>>;
setDisableTerminalFontZoomState: Dispatch<SetStateAction<boolean>>;
setRestorePreviousSessionState: Dispatch<SetStateAction<boolean>>;
setRestoreTerminalCwdState: Dispatch<SetStateAction<boolean>>;
@@ -169,7 +172,7 @@ export function useSettingsStorageSync({
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, applyIncomingCustomAccent,
@@ -178,7 +181,7 @@ export function useSettingsStorageSync({
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState, setJmsDeepLinkEnabledState, setExplorerContextMenuEnabledState,
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
@@ -193,7 +196,7 @@ export function useSettingsStorageSync({
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
});
@@ -203,7 +206,7 @@ export function useSettingsStorageSync({
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
};
@@ -453,6 +456,12 @@ export function useSettingsStorageSync({
setShellOnlyTabNumberShortcutsState(newValue);
}
}
if (e.key === STORAGE_KEY_SHOW_TAB_NUMBER_BADGES && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.showTabNumberBadges) {
setShowTabNumberBadgesState(newValue);
}
}
if (e.key === STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.disableTerminalFontZoom) {
@@ -39,6 +39,32 @@ test("keeps a hidden SFTP owner interactive while its promoted editor tab is ope
assert.equal(shouldParkBrowseSessions({ interactive, browseParked: false }), false);
});
test("keeps browse warm while the terminal side panel stays open on another tool", () => {
// SFTP→History/System replaces the focused pane tool, so surfaceVisible is
// false, but the owner stays mounted for instant switch-back. Parking here
// forces reconnect + directory reload (and intermittent blank lists).
const interactive = isBrowseSessionInteractive({
surfaceVisible: false,
ownerPanelOpen: true,
hasOwnedEditorTab: false,
});
assert.equal(interactive, true);
assert.equal(shouldParkBrowseSessions({ interactive, browseParked: false }), false);
});
test("parks browse when the side panel is closed and nothing else retains it", () => {
const interactive = isBrowseSessionInteractive({
surfaceVisible: false,
ownerPanelOpen: false,
hasOwnedEditorTab: false,
hasActiveExternalEdit: false,
});
assert.equal(interactive, false);
assert.equal(shouldParkBrowseSessions({ interactive, browseParked: false }), true);
});
test("parks browse only when the interactive surface hides and not already parked", () => {
assert.equal(shouldParkBrowseSessions({ interactive: false, browseParked: false }), true);
assert.equal(shouldParkBrowseSessions({ interactive: false, browseParked: true }), false);
@@ -1,18 +1,27 @@
/**
* Browse vs transfer session lifecycle helpers.
*
* FileZilla model: the interactive browser can soft-close its SFTP channels
* while bulk transfers keep dedicated pool connections (and any leased browse
* sessions held by in-flight streams).
* FileZilla model: a *closed* terminal SFTP side panel can soft-close its browse
* SFTP channels while bulk transfers keep dedicated pool connections (and any
* leased browse sessions held by in-flight streams). Switching to another side
* panel tool (History / System / …) must keep browse warm — the owner stays
* mounted for instant switch-back, and parking would force reconnect + reload.
*/
export function isBrowseSessionInteractive(params: {
surfaceVisible: boolean;
/**
* Terminal side panel still open for this owner (another tool may be focused).
* Keeps browse sessions alive across tool switches without treating that as a
* full panel dismiss.
*/
ownerPanelOpen?: boolean;
hasOwnedEditorTab: boolean;
/** External editor temp files (Notepad++ etc.) still need the browse session. */
hasActiveExternalEdit?: boolean;
}): boolean {
return params.surfaceVisible
|| !!params.ownerPanelOpen
|| params.hasOwnedEditorTab
|| !!params.hasActiveExternalEdit;
}
@@ -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();
};
}
+1
View File
@@ -3,6 +3,7 @@ import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export interface DiskInfo {
capacityKey?: string; // Filesystem or shared-pool identity for deduplication
filesystemType?: string; // Filesystem type reported by df (for network/FUSE filtering)
mountPoint: string;
used: number; // Used in GB
total: number; // Total in GB
-5
View File
@@ -797,11 +797,6 @@ export const useSessionState = ({
host: Host,
direction: SplitDirection = 'vertical',
): string | null => {
// Serial hosts use a different session constructor; they currently
// only enter workspaces via createSerialSession + drag, so reject
// them here to avoid a partially-constructed session.
if (host.protocol === 'serial') return null;
// Cheap early-exit using the ref when the workspace is clearly
// absent. The authoritative check lives inside the setWorkspaces
// updater below so we also cover the concurrent-close race.
@@ -105,4 +105,8 @@ test('workspace host creation paths use the host session snapshot factory', () =
source,
/createWorkspaceHostTerminalSession\(newSessionId,\s*host,\s*workspaceId\)/,
);
assert.doesNotMatch(
source,
/if \(host\.protocol === 'serial'\) return null/,
);
});
+26 -3
View File
@@ -75,6 +75,7 @@ import {
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN,
STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
} from '../../infrastructure/config/storageKeys';
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
@@ -121,6 +122,7 @@ import {
DEFAULT_SHOW_SFTP_TAB,
DEFAULT_SHOW_HOST_TREE_SIDEBAR,
DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
DEFAULT_SHOW_TAB_NUMBER_BADGES,
DEFAULT_DISABLE_TERMINAL_FONT_ZOOM,
DEFAULT_SSH_DEBUG_LOGS_ENABLED,
DEFAULT_SSH_DEEP_LINK_ENABLED,
@@ -413,6 +415,10 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
return stored ?? DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS;
});
const [showTabNumberBadges, setShowTabNumberBadgesState] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_TAB_NUMBER_BADGES);
return stored ?? DEFAULT_SHOW_TAB_NUMBER_BADGES;
});
const [disableTerminalFontZoom, setDisableTerminalFontZoomState] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM);
return stored ?? DEFAULT_DISABLE_TERMINAL_FONT_ZOOM;
@@ -941,6 +947,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
);
const storedShellOnlyTabNumberShortcuts = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
setShellOnlyTabNumberShortcutsState(storedShellOnlyTabNumberShortcuts ?? DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
const storedShowTabNumberBadges = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_TAB_NUMBER_BADGES);
setShowTabNumberBadgesState(storedShowTabNumberBadges ?? DEFAULT_SHOW_TAB_NUMBER_BADGES);
const storedDisableTerminalFontZoom = localStorageAdapter.readBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM);
setDisableTerminalFontZoomState(storedDisableTerminalFontZoom ?? DEFAULT_DISABLE_TERMINAL_FONT_ZOOM);
const storedRestorePreviousSession = localStorageAdapter.readBoolean(STORAGE_KEY_RESTORE_PREVIOUS_SESSION);
@@ -1145,7 +1153,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode,
@@ -1155,7 +1163,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize: applyIncomingTerminalFontSize,
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled, setJmsDeepLinkEnabledState: applyIncomingJmsDeepLinkEnabled, setExplorerContextMenuEnabledState: applyIncomingExplorerContextMenuEnabled,
setGlobalHotkeyEnabled, setWindowOpacity: applyIncomingWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
@@ -1321,6 +1329,13 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
notifySettingsChanged(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS, enabled);
}, [notifySettingsChanged]);
const setShowTabNumberBadges = useCallback((enabled: boolean) => {
setShowTabNumberBadgesState(enabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_TAB_NUMBER_BADGES, enabled);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SHOW_TAB_NUMBER_BADGES, enabled);
}, [notifySettingsChanged]);
const setDisableTerminalFontZoom = useCallback((enabled: boolean) => {
setDisableTerminalFontZoomState(enabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM, enabled);
@@ -1746,6 +1761,9 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
showOnlyUngroupedHostsInRoot,
dynamicTabTitleMode: terminalSettings.dynamicTabTitleMode,
disableTerminalFontZoom,
hotkeyScheme,
shellOnlyTabNumberShortcuts,
showTabNumberBadges,
restoreTerminalCwd,
terminalSidePanelAutoOpen,
terminalSidePanelAutoOpenTab,
@@ -1754,13 +1772,16 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
darkUiThemeId,
disableTerminalFontZoom,
hostClickBehavior,
hotkeyScheme,
lightUiThemeId,
resolvedTheme,
restoreTerminalCwd,
shellOnlyTabNumberShortcuts,
showHostTreeSidebar,
showOnlyUngroupedHostsInRoot,
showRecentHosts,
showSftpTab,
showTabNumberBadges,
terminalSettings.dynamicTabTitleMode,
terminalSidePanelAutoOpen,
terminalSidePanelAutoOpenTab,
@@ -1935,6 +1956,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
setTerminalSidePanelAutoOpenTab,
shellOnlyTabNumberShortcuts,
setShellOnlyTabNumberShortcuts,
showTabNumberBadges,
setShowTabNumberBadges,
disableTerminalFontZoom,
setDisableTerminalFontZoom,
restorePreviousSession,
@@ -1994,7 +2017,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
customKeyBindings, editorWordWrap,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom,
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
]),
};
+5 -4
View File
@@ -643,10 +643,11 @@ export const useSftpState = (
[resolveTransferConflict, resolveUploadConflict, uploadConflicts],
);
// FileZilla-style: when the browser UI is hidden, soft-close browse SFTP
// channels. Defer park while this owner still has unfinished transfers so
// pre-lease prep (conflict/stat) cannot race a hard-close of the browse id.
// In-flight streams also soft-close via leases; pool handles bulk I/O.
// FileZilla-style: when the side panel is closed (not merely showing another
// tool), soft-close browse SFTP channels. Defer park while this owner still
// has unfinished transfers so pre-lease prep (conflict/stat) cannot race a
// hard-close of the browse id. In-flight streams also soft-close via leases;
// pool handles bulk I/O.
const interactive = options?.interactive !== false;
useEffect(() => {
const gen = ++browseLifecycleGenRef.current;
@@ -0,0 +1,59 @@
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 => ({
metaKey: false,
ctrlKey: false,
altKey: false,
shiftKey: false,
...overrides,
});
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);
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 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);
});
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);
});
@@ -0,0 +1,113 @@
import { useEffect, useMemo, useState } from 'react';
import { parseKeyCombo, type HotkeyScheme } from '../../domain/models/keyBindings';
export type ShortcutModifierEvent = Pick<KeyboardEvent, 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey'>;
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;
}
/** Whether a keyboard event has exactly the modifiers required by the binding. */
export function isShortcutModifierHeld(
event: ShortcutModifierEvent,
requirements: ShortcutModifierRequirements | null,
): boolean {
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 required modifier combination is no longer held. */
export function shouldReleaseShortcutModifier(
event: ShortcutModifierEvent,
requirements: ShortcutModifierRequirements | null,
): boolean {
return !isShortcutModifierHeld(event, requirements);
}
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 (!requirements) return;
// 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 = () => {
if (document.visibilityState === 'hidden') {
clearHeldState();
}
};
window.addEventListener('keydown', syncHeldState, true);
window.addEventListener('keyup', syncHeldState, true);
window.addEventListener('blur', clearHeldState);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
window.removeEventListener('keydown', syncHeldState, true);
window.removeEventListener('keyup', syncHeldState, true);
window.removeEventListener('blur', clearHeldState);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [requirements]);
return isHeld;
}
+4 -1
View File
@@ -513,7 +513,10 @@ export const useTerminalBackend = () => {
return bridge?.onZmodemEvent?.(sessionId, cb) ?? (() => {});
}, []);
const getSessionPwd = useCallback(async (sessionId: string, options?: { allowHomeFallback?: boolean }) => {
const getSessionPwd = useCallback(async (
sessionId: string,
options?: { allowHomeFallback?: boolean; allowLoginShellFallback?: boolean },
) => {
const bridge = netcattyBridge.get();
if (!bridge?.getSessionPwd) return { success: false, error: 'getSessionPwd unavailable' };
return bridge.getSessionPwd(sessionId, options);
@@ -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]);
}
+7
View File
@@ -87,6 +87,7 @@ import {
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_AI_PROVIDERS,
@@ -298,6 +299,7 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_AI_PROVIDERS,
STORAGE_KEY_AI_ACTIVE_PROVIDER,
@@ -519,6 +521,8 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
if (showSftpTab != null) settings.showSftpTab = showSftpTab;
const shellOnlyTabNumberShortcuts = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
if (shellOnlyTabNumberShortcuts != null) settings.shellOnlyTabNumberShortcuts = shellOnlyTabNumberShortcuts;
const showTabNumberBadges = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_TAB_NUMBER_BADGES);
if (showTabNumberBadges != null) settings.showTabNumberBadges = showTabNumberBadges;
const disableTerminalFontZoom = localStorageAdapter.readBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM);
if (disableTerminalFontZoom != null) settings.disableTerminalFontZoom = disableTerminalFontZoom;
const showHostTreeSidebar = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
@@ -757,6 +761,9 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
if (settings.shellOnlyTabNumberShortcuts != null) {
localStorageAdapter.writeBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS, settings.shellOnlyTabNumberShortcuts);
}
if (settings.showTabNumberBadges != null) {
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_TAB_NUMBER_BADGES, settings.showTabNumberBadges);
}
if (settings.disableTerminalFontZoom != null) {
localStorageAdapter.writeBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM, settings.disableTerminalFontZoom);
}
BIN
View File
Binary file not shown.
+5 -1
View File
@@ -759,7 +759,11 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
: -1}
onFocus={() => onActiveTreeItemChange(`host:${host.id}`)}
draggable={!isMultiSelectMode}
onDragStart={(e) => e.dataTransfer.setData("host-id", host.id)}
onDragStart={(e) => {
// copyMove: vault reorder uses move; focus-sidebar append uses copy.
e.dataTransfer.effectAllowed = "copyMove";
e.dataTransfer.setData("host-id", host.id);
}}
onClick={() => {
const action = resolveHostActivateAction({
behavior: normalizedHostClickBehavior,
+23 -5
View File
@@ -11,6 +11,7 @@ import {
import React, { useCallback, useMemo, useRef, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { usePortForwardingState } from "../application/state/usePortForwardingState";
import { STORAGE_KEY_PORT_FORWARDING_PANEL_WIDTH } from "../infrastructure/config/storageKeys";
import {
GroupConfig,
Host,
@@ -94,6 +95,11 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
terminalSettings,
}) => {
const { t } = useI18n();
const portForwardingPanelResizeProps = {
resizable: true as const,
persistWidthStorageKey: STORAGE_KEY_PORT_FORWARDING_PANEL_WIDTH,
resizeAriaLabel: t("vault.panel.resizeWidth"),
};
const {
rules: _rules,
selectedRuleId,
@@ -312,6 +318,12 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
// Start new rule - wizard or form based on user preference
const startNewRule = (type: PortForwardingType) => {
setShowNewMenu(false);
// The new-rule flow replaces an open editor; never leave both side panels mounted.
setShowEditPanel(false);
setEditingRule(null);
setEditDraft({});
setSelectedRuleId(null);
setShowHostSelector(false);
if (preferFormMode) {
// Form mode: show all-in-one form
@@ -604,8 +616,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
{/* Main Content */}
<div
className={cn(
"flex-1 flex flex-col min-h-0",
showWizard || showEditPanel || showNewForm ? "mr-[360px]" : "",
"flex-1 min-w-0 flex flex-col min-h-0",
)}
>
<VaultPageHeader className="z-20">
@@ -773,7 +784,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
</div>
{/* Edit Panel - shown when a rule is selected */}
{showEditPanel && editingRule && (
{showEditPanel && editingRule && !showHostSelector && (
<EditPanel
rule={editingRule}
draft={editDraft}
@@ -789,11 +800,12 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
}}
onDelete={() => handleDeleteRule(editingRule)}
onOpenHostSelector={() => setShowHostSelector(true)}
{...portForwardingPanelResizeProps}
/>
)}
{/* Wizard Panel */}
{showWizard && (
{showWizard && !showHostSelector && (
<AsidePanel
open={true}
onClose={() => {
@@ -802,6 +814,8 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
}}
title={isEditing ? t("pf.wizard.editTitle") : t("pf.wizard.newTitle")}
width="w-[360px]"
layout="inline"
{...portForwardingPanelResizeProps}
showBackButton={!!getPrevStep()}
onBack={
getPrevStep()
@@ -902,11 +916,14 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
managedSources={managedSources}
onSaveHost={onSaveHost}
onCreateGroup={_onCreateGroup}
width="w-[360px]"
layout="inline"
{...portForwardingPanelResizeProps}
/>
)}
{/* New Form Panel (skip wizard mode) */}
{showNewForm && (
{showNewForm && !showHostSelector && (
<NewFormPanel
draft={newFormDraft}
hosts={hosts}
@@ -918,6 +935,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
onOpenHostSelector={() => setShowHostSelector(true)}
onOpenWizard={openWizardFromForm}
isValid={isNewFormValid()}
{...portForwardingPanelResizeProps}
/>
)}
+3
View File
@@ -25,6 +25,7 @@ interface SelectHostPanelProps {
title?: string;
subtitle?: string;
className?: string;
width?: string;
layout?: AsidePanelLayout;
resizable?: boolean;
persistWidthStorageKey?: string;
@@ -50,6 +51,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
title,
subtitle,
className,
width,
layout = "overlay",
resizable = false,
persistWidthStorageKey,
@@ -79,6 +81,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
showBackButton={true}
onBack={onBack}
className={cn(layout === "overlay" && "z-40", newHostPanelOpen && "overflow-visible", className)}
width={width}
layout={layout}
resizable={resizable}
persistWidthStorageKey={persistWidthStorageKey}
+1 -1
View File
@@ -417,7 +417,7 @@ export const SelectHostPanelContent: React.FC<SelectHostPanelContentProps> = ({
aria-selected={multiSelect ? groupState === 'all' : false}
data-active={isActive ? 'true' : undefined}
className={cn(
'flex h-full min-h-0 items-center gap-2.5 overflow-hidden px-2.5 rounded-lg transition-colors',
'flex h-full min-h-0 items-center gap-2.5 overflow-hidden rounded-lg px-2.5 transition-colors',
isActive ? 'bg-primary/10 ring-1 ring-primary/40' : 'hover:bg-muted/70',
)}
onClick={() => {
+2
View File
@@ -572,6 +572,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
setHotkeyScheme={settings.setHotkeyScheme}
shellOnlyTabNumberShortcuts={settings.shellOnlyTabNumberShortcuts}
setShellOnlyTabNumberShortcuts={settings.setShellOnlyTabNumberShortcuts}
showTabNumberBadges={settings.showTabNumberBadges}
setShowTabNumberBadges={settings.setShowTabNumberBadges}
disableTerminalFontZoom={settings.disableTerminalFontZoom}
setDisableTerminalFontZoom={settings.setDisableTerminalFontZoom}
keyBindings={settings.keyBindings}
+11
View File
@@ -128,6 +128,11 @@ interface SftpSidePanelProps {
onActiveExternalEditsChange?: (count: number) => void;
showWorkspaceHostHeader?: boolean;
isVisible?: boolean;
/**
* Side panel chrome still open for this terminal tab (another tool may be
* focused). Keeps browse SFTP sessions warm across History/System switches.
*/
ownerPanelOpen?: boolean;
renderOverlays?: boolean;
pendingUpload?: {
requestId: string;
@@ -177,6 +182,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
onActiveExternalEditsChange,
showWorkspaceHostHeader = false,
isVisible = true,
ownerPanelOpen = false,
renderOverlays = true,
pendingUpload = null,
onPendingUploadHandled,
@@ -258,8 +264,11 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
// becomes hidden, so its browse channel must stay alive until the editor closes.
// External editor temps (Notepad++ etc.) likewise need the session: parking
// calls closeSftp which deletes those local files.
// Keep browse warm while the side panel stays open on another tool
// (History / System / …) so switch-back does not reconnect + reload.
interactive: isBrowseSessionInteractive({
surfaceVisible: isVisible,
ownerPanelOpen,
hasOwnedEditorTab,
hasActiveExternalEdit: activeExternalEditCountRef.current > 0,
}),
@@ -275,6 +284,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
fileWatchHandlers,
hasOwnedEditorTab,
isVisible,
ownerPanelOpen,
transferOwnerId,
sftpUseCompressedUpload,
sftpShowHiddenFiles,
@@ -1861,6 +1871,7 @@ const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps):
prev.focusedSessionId === next.focusedSessionId &&
prev.showWorkspaceHostHeader === next.showWorkspaceHostHeader &&
prev.isVisible === next.isVisible &&
prev.ownerPanelOpen === next.ownerPanelOpen &&
prev.renderOverlays === next.renderOverlays &&
prev.pendingUpload?.requestId === next.pendingUpload?.requestId &&
prev.onPendingUploadHandled === next.onPendingUploadHandled &&
+3 -2
View File
@@ -126,8 +126,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
...fileWatchHandlers,
transferOwnerId: "main-sftp-view",
// Main SFTP page stays interactive while mounted so top-tab switches
// (e.g. Terminal ↔ SFTP) must not soft-close every tab's session;
// the terminal side panel still parks when its panel is hidden.
// (e.g. Terminal ↔ SFTP) must not soft-close every tab's session.
// The terminal side panel parks only after the panel is closed (not when
// switching History/System while the chrome stays open).
// Bulk transfers use dedicated pool sessions regardless.
interactive: true,
useCompressedUpload: sftpUseCompressedUpload,
+114 -19
View File
@@ -225,8 +225,16 @@ import {
} from "./terminal/terminalRuntimeMount";
import type { CreateXTermRuntimeContext } from "./terminal/runtime/createXTermRuntime";
import { TerminalView } from "./terminal/TerminalView";
import {
cancelConnectAutomationBatch,
createConnectAutomationBatch,
trackConnectAutomationStop,
type ConnectAutomationBatch,
} from "./terminal/connectAutomationBatch";
import {
getInitialTerminalStatus,
resolveTerminalVaultInitialized,
shouldResetConnectAutomationOnReconnect,
shouldSuppressHostStartupCommandOnReconnect,
shouldStartTerminalBackend,
} from "./terminal/restoredSessionGate";
@@ -270,6 +278,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
sessionId,
workspaceId,
restoreState,
vaultInitializedOverride,
pendingInitialCwd,
shellType,
lastCwd,
@@ -359,10 +368,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}, []);
// Reactive vault-ready flag so restored panes re-arm boot after hydration
// (module isVaultInitialized() alone is not a React dependency).
const vaultInitialized = useVaultSnapshotField("isVaultInitialized");
const sharedVaultInitialized = useVaultSnapshotField("isVaultInitialized");
const vaultInitialized = resolveTerminalVaultInitialized(
sharedVaultInitialized,
vaultInitializedOverride,
);
const connectScriptsConsumedRef = useRef(false);
const connectScriptsCompletedIdsRef = useRef(new Set<string>());
const connectScriptsInFlightRef = useRef(false);
const connectScriptsBatchRef = useRef<ConnectAutomationBatch | null>(null);
const pendingScriptRunIdRef = useRef<string | null>(null);
const pendingScriptHandledRef = useRef<Snippet | null>(null);
const pendingScriptRef = useRef(pendingScript);
@@ -2511,9 +2525,19 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (!shouldEvaluateConnect && !hasPendingWork) return;
if (connectScriptsInFlightRef.current) return;
const batch = createConnectAutomationBatch();
connectScriptsBatchRef.current = batch;
connectScriptsInFlightRef.current = true;
const batchStillActive = () => (
!batch.controller.signal.aborted && connectScriptsBatchRef.current === batch
);
// Defer until xterm has rendered login output and the main-process output tap
// has populated SessionOutputBuffer (avoids waitForPrompt racing an empty buffer).
let timerFired = false;
const timer = window.setTimeout(() => {
timerFired = true;
if (!batchStillActive()) return;
const runPending = Boolean(pendingOne);
const connectQueueNow = connectScriptsConsumedRef.current
? []
@@ -2542,6 +2566,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
})) {
connectScriptsConsumedRef.current = true;
}
if (connectScriptsBatchRef.current === batch) {
connectScriptsBatchRef.current = null;
connectScriptsInFlightRef.current = false;
}
return;
}
@@ -2550,11 +2578,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
connectQueueNow.map((item) => item.id).filter((id): id is string => Boolean(id)),
);
connectScriptsInFlightRef.current = true;
void runConnectScriptsSequential({
scripts: scriptsToRun,
sessionId,
signal: batch.controller.signal,
onCancelableRunChange: (stopCurrentRun) => (
trackConnectAutomationStop(batch, stopCurrentRun)
),
sessionMeta: {
connected: true,
name: scriptSessionName,
@@ -2562,6 +2592,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
username: host.username,
},
onScriptComplete: (snippet) => {
if (!batchStillActive()) return;
if (snippet.id && connectIdsInBatch.has(snippet.id)) {
connectScriptsCompletedIdsRef.current.add(snippet.id);
}
@@ -2575,6 +2606,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
},
})
.then(() => {
if (!batchStillActive()) return;
const resolvedAfterRun = resolveConnectScriptsForHost(host, snippets);
const doneAfterRun = resolvedAfterRun.length === 0
|| resolvedAfterRun.every(
@@ -2585,6 +2617,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
})
.catch(async (err) => {
if (!batchStillActive()) return;
const message = err instanceof Error ? err.message : String(err);
toast.error(message.includes('Observer mode') ? t('scripts.observer.blocked') : message);
connectScriptsConsumedRef.current = true;
@@ -2600,6 +2633,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
await runConnectScriptsSequential({
scripts: [pendingScriptToMark],
sessionId,
signal: batch.controller.signal,
onCancelableRunChange: (stopCurrentRun) => (
trackConnectAutomationStop(batch, stopCurrentRun)
),
sessionMeta: {
connected: true,
name: scriptSessionName,
@@ -2607,6 +2644,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
username: host.username,
},
onScriptComplete: (snippet) => {
if (!batchStillActive()) return;
if (snippet.id) {
pendingScriptRunIdRef.current = snippet.id;
} else {
@@ -2615,16 +2653,32 @@ const TerminalComponent: React.FC<TerminalProps> = ({
},
});
} catch (pendingErr) {
if (!batchStillActive()) return;
const pendingMessage = pendingErr instanceof Error ? pendingErr.message : String(pendingErr);
toast.error(pendingMessage.includes('Observer mode') ? t('scripts.observer.blocked') : pendingMessage);
}
})
.finally(() => {
connectScriptsInFlightRef.current = false;
if (
!batch.controller.signal.aborted
&& connectScriptsBatchRef.current === batch
) {
connectScriptsBatchRef.current = null;
connectScriptsInFlightRef.current = false;
}
});
}, 400);
return () => window.clearTimeout(timer);
return () => {
window.clearTimeout(timer);
if (!timerFired && connectScriptsBatchRef.current === batch) {
batch.controller.abort();
connectScriptsBatchRef.current = null;
connectScriptsInFlightRef.current = false;
} else if (statusRef.current !== "connected") {
void cancelConnectAutomationBatch(batch).catch(() => {});
}
};
}, [effectiveTerminalProtocol, host, isPendingScriptAlreadyHandled, moshShellReady, pendingScript, pendingScriptId, scriptSessionName, sessionId, snippets, status, t]);
useEffect(() => {
@@ -3352,20 +3406,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return;
}
if (!termRef.current) return;
if (mode === "manual") {
clearAutoReconnect();
prepareRestoredReconnect();
// A clone's first connection can fail (auth/host-key/transport) before the
// inherited `cd` is consumed. prepareRestoredReconnect() just cleared the
// intent for non-restored sessions, so re-arm it here; the callback no-ops
// once the cwd was consumed or when there is no pending inherited cwd.
prepareInitialCwdIntent();
} else {
restoreCwdIntentRef.current = null;
suppressHostStartupCommandRef.current = shouldSuppressHostStartupCommandOnReconnect("automatic");
}
// Claim the retry before awaiting close. A close/cancel/unmount during the
// awaited backend cleanup invalidates this token and stops the continuation.
// Claim the retry before awaiting either script cancellation or backend
// cleanup. A second reconnect/cancel invalidates this continuation.
const retryToken = Symbol("retry");
retryTokenRef.current = retryToken;
reconnectPreparationTokenRef.current = retryToken;
@@ -3376,6 +3418,59 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
};
const connectAutomationBatch = connectScriptsBatchRef.current;
if (connectAutomationBatch) {
try {
await cancelConnectAutomationBatch(connectAutomationBatch);
} catch (error) {
finishReconnectPreparation();
const message = error instanceof Error ? error.message : String(error);
toast.error(message);
if (mode === "auto" && retryTokenStillCurrent()) {
// Return to disconnected so the existing auto-reconnect loop can
// schedule another attempt, including another exact stop request.
updateStatus("disconnected");
}
return;
}
if (!retryTokenStillCurrent()) {
finishReconnectPreparation();
return;
}
if (connectScriptsBatchRef.current === connectAutomationBatch) {
connectScriptsBatchRef.current = null;
connectScriptsInFlightRef.current = false;
}
}
if (mode === "manual") {
clearAutoReconnect();
// Manual reconnect skips the disconnected status transition that normally
// clears these refs, so onConnect scripts would otherwise stay consumed.
if (shouldResetConnectAutomationOnReconnect(
restoreState === "restored-disconnected" ? "restored" : "manual",
)) {
// The prior batch is now stopped, so its callbacks cannot mutate these
// newly allocated guards for the fresh manual connection.
connectScriptsConsumedRef.current = false;
connectScriptsCompletedIdsRef.current = new Set();
connectScriptsInFlightRef.current = false;
}
prepareRestoredReconnect();
// A clone's first connection can fail (auth/host-key/transport) before the
// inherited `cd` is consumed. prepareRestoredReconnect() just cleared the
// intent for non-restored sessions, so re-arm it here; the callback no-ops
// once the cwd was consumed or when there is no pending inherited cwd.
prepareInitialCwdIntent();
} else {
// An automatic reconnect replaces the transport but remains the same user
// session. Stop old automation above, then preserve the existing policy of
// not starting onConnect scripts again on the replacement transport.
connectScriptsConsumedRef.current = true;
restoreCwdIntentRef.current = null;
suppressHostStartupCommandRef.current = shouldSuppressHostStartupCommandOnReconnect("automatic");
}
try {
await cleanupSession();
} catch (error) {
+24 -2
View File
@@ -93,6 +93,7 @@ import {
shouldCloseSftpSidePanel,
shouldClearSftpPanelAfterTransferChange,
shouldKeepSftpMountedAfterClose,
shouldMarkSftpPaneClosed,
shouldScheduleSftpRetainedPanelCleanup,
terminalSftpTransferOwnerId,
} from './terminalLayer/sftpPanelLifecycle';
@@ -219,6 +220,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onCreateWorkspaceFromSessions,
onAddSessionToWorkspace,
onRequestAddToWorkspace,
onAppendHostToWorkspace,
onUpdateSplitSizes,
onSetDraggingSessionId,
onToggleWorkspaceViewMode,
@@ -411,6 +413,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
lastSidePanelTabRef.current.set(tabId, targetPanel);
if (targetPanel === 'sftp') {
sftpPaneClosedTabIdsRef.current.delete(tabId);
const host = hostsRef.current.find(h => h.id === session.hostId);
const hostWithOverrides: Host = host
? {
@@ -578,6 +581,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const sftpActiveTransfersByTabRef = useRef<Map<string, number>>(new Map());
const sftpActiveExternalEditsByTabRef = useRef<Map<string, number>>(new Map());
const sftpRetainedAfterCloseTabIdsRef = useRef<Set<string>>(new Set());
const sftpPaneClosedTabIdsRef = useRef<Set<string>>(new Set());
const sftpOpeningTabIdsRef = useRef<Set<string>>(new Set());
const sftpRetainedCleanupTimersRef = useRef<Map<string, number>>(new Map());
const sftpLastPathForSourceRef = useRef<Map<string, SftpRememberedLocation>>(new Map());
@@ -613,6 +617,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
sftpActiveTransfersByTabRef.current.delete(tabId);
sftpActiveExternalEditsByTabRef.current.delete(tabId);
sftpRetainedAfterCloseTabIdsRef.current.delete(tabId);
sftpPaneClosedTabIdsRef.current.delete(tabId);
sftpOpeningTabIdsRef.current.delete(tabId);
setSftpHostForTab(prev => {
if (!prev.has(tabId)) return prev;
@@ -830,6 +835,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
sftpRetainedCleanupTimersRef.current.delete(tabId);
}
sftpRetainedAfterCloseTabIdsRef.current.delete(tabId);
sftpPaneClosedTabIdsRef.current.delete(tabId);
setSidePanelOpenTabs(prev => {
const next = new Map(prev);
@@ -1329,6 +1335,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
sftpRetainedCleanupTimersRef.current.delete(tabId);
}
sftpRetainedAfterCloseTabIdsRef.current.delete(tabId);
sftpPaneClosedTabIdsRef.current.delete(tabId);
}
// If switching to SFTP and no host is stored yet, resolve it
@@ -1410,10 +1417,22 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const handleCloseSidePanelPane = useCallback((paneId: string) => {
const tabId = activeTabIdRef.current;
if (!tabId) return;
if (closeSidePanelPaneForTab(tabId, paneId)) {
const layout = sidePanelLayoutsRef.current.get(tabId);
const closingPane = layout
? collectSidePanelPanes(layout.root).find((pane) => pane.id === paneId)
: undefined;
const closesWholePanel = closeSidePanelPaneForTab(tabId, paneId);
if (closesWholePanel) {
handleCloseSidePanel();
return;
}
}, [closeSidePanelPaneForTab, handleCloseSidePanel]);
if (shouldMarkSftpPaneClosed({
closingPaneTool: closingPane?.tool,
closesWholePanel: closesWholePanel,
})) {
sftpPaneClosedTabIdsRef.current.add(tabId);
}
}, [closeSidePanelPaneForTab, handleCloseSidePanel, sidePanelLayoutsRef]);
const handleResizeSidePanelSplit = useCallback((splitId: string, sizes: number[]) => {
const tabId = activeTabIdRef.current;
@@ -2089,6 +2108,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onCopySession,
onCopySessionToNewWindow,
onRequestAddToWorkspace,
onAppendHostToWorkspace,
onSessionData,
onSetDraggingSessionId,
onSetWorkspaceFocusedSession,
@@ -2155,6 +2175,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
sftpHostForTab,
sftpInitialLocationForTab,
sftpPendingUploadsForTab,
sftpPaneClosedTabIdsRef,
sftpRetainedAfterCloseTabIdsRef,
sftpShowHiddenFiles,
SftpSidePanel,
sftpUseCompressedUpload,
+4
View File
@@ -141,6 +141,10 @@ test('popup terminals resolve complete host config and pass jump hosts into Term
assert.match(source, /onDeleteSnippets=\{deleteSelectedSnippets\}/);
});
test('popup terminals use their window-local vault readiness', () => {
assert.match(source, /vaultInitializedOverride=\{vaultInitialized\}/);
});
test('popup provider tree mounts the plugin authentication host', () => {
assert.match(source, /import \{ PluginAuthenticationHost \} from '\.\/plugins\/PluginAuthenticationHost';/);
assert.match(
+1
View File
@@ -467,6 +467,7 @@ function TerminalPopupPageInner() {
accentMode={settings.accentMode}
customAccent={settings.customAccent}
terminalSettings={settings.terminalSettings}
vaultInitializedOverride={vaultInitialized}
disableTerminalFontZoom={settings.disableTerminalFontZoom}
sessionId={sessionId}
startupCommand={isAttachMode ? undefined : config.startupCommand}
+8
View File
@@ -361,6 +361,14 @@ test("workspace session drag data is recognized with a dedicated drag type", ()
assert.equal(getWorkspaceSessionDragId(transfer), "session-1");
});
test("TopTabs wires host drops to workspace tabs without replacing tab reorder", () => {
assert.match(topTabsSource, /appendHostFromWorkspaceDrop/);
assert.match(topTabsSource, /workspaceMap\.has\(targetTabId\)/);
assert.match(topTabsSource, /dropEffect = 'copy'/);
assert.match(topTabsSource, /isHostDropTarget=\{hostDropWorkspaceId === workspace\.id\}/);
assert.match(appViewSource, /<TopTabs[\s\S]*?onAppendHostToWorkspace=\{handleAppendHostToWorkspace\}/);
});
test("workspace session drag id falls back to the legacy session id", () => {
const transfer = {
types: ["session-id"],
+89 -5
View File
@@ -1,11 +1,17 @@
import { Folder, FolderLock, Menu, MoreHorizontal, Plus, Settings, Sparkles } from 'lucide-react';
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { fromEditorTabId, isEditorTabId, useActiveTabId } from '../application/state/activeTabStore';
import { fromEditorTabId, isEditorTabId, toEditorTabId, useActiveTabId } from '../application/state/activeTabStore';
import { topTabsSessionsEqual } from '../domain/topTabsSessionsEqual';
import { isHostTreeWorkTabSurface } from '../application/app/workTabSurface';
import { buildTabShortcutNumberById } from '../application/app/tabShortcutTargets';
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 {
appendHostFromWorkspaceDrop,
resolveFocusSidebarDragKind,
} from '../domain/focusSidebarHostDrop';
import type { DynamicTabTitleMode, KeyBinding } from '../domain/models';
import { getTopTabInsertionTarget, getWorkspaceSessionDragId, hasWorkspaceSessionDrag } from '../application/state/terminalDragData';
import {
@@ -15,6 +21,7 @@ import {
} from '../application/state/terminalHostTreeStore';
import type { LogView } from '../application/state/logViewState';
import { useWindowControls } from '../application/state/useWindowControls';
import { useSettingsChromeStore } from '../application/state/settingsChromeStore';
import { useI18n } from '../application/i18n/I18nProvider';
import { Host, TerminalSession, Workspace } from '../types';
import { cn } from '../lib/utils';
@@ -153,8 +160,10 @@ interface TopTabsProps {
sessionId: string,
tabInsertionTarget?: { tabId: string; position: 'before' | 'after'; additionalTabIds?: readonly string[] },
) => void;
onAppendHostToWorkspace?: (workspaceId: string, hostId: string) => void;
showSftpTab: boolean;
showHostTreeSidebar: boolean;
switchTabKeyBinding: Pick<KeyBinding, 'mac' | 'pc'> | null;
dynamicTabTitleMode?: DynamicTabTitleMode;
editorTabs: readonly EditorTabChrome[];
pluginViewTabs: readonly PluginViewTab[];
@@ -197,8 +206,10 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onEndSessionDrag,
onReorderTabs,
onRemoveSessionFromWorkspace,
onAppendHostToWorkspace,
showSftpTab,
showHostTreeSidebar,
switchTabKeyBinding,
dynamicTabTitleMode,
editorTabs,
pluginViewTabs,
@@ -208,6 +219,28 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
}) => {
const { t } = useI18n();
const { maximize, isFullscreen, onFullscreenChanged } = useWindowControls();
const {
hotkeyScheme,
showTabNumberBadges,
shellOnlyTabNumberShortcuts,
} = useSettingsChromeStore();
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.
if (!showTabNumberBadges || hotkeyScheme === 'disabled' || !shortcutModifierHeld) return null;
return buildTabShortcutNumberById({
showSftpTab,
shellOnlyTabNumberShortcuts,
orderedTabs,
editorTabIds: editorTabs.map((tab) => toEditorTabId(tab.id)),
});
}, [hotkeyScheme, showTabNumberBadges, showSftpTab, shellOnlyTabNumberShortcuts, shortcutModifierHeld, orderedTabs, editorTabs]);
const isHostTreeOpen = useTerminalHostTreeOpen();
const hostTreeLayoutWidth = useTerminalHostTreeLayoutWidth();
const toggleHostTree = useToggleTerminalHostTree();
@@ -233,6 +266,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
// Tab reorder drag state
const [dropIndicator, setDropIndicator] = useState<{ tabId: string; position: 'before' | 'after' } | null>(null);
const [isDraggingForReorder, setIsDraggingForReorder] = useState(false);
const [hostDropWorkspaceId, setHostDropWorkspaceId] = useState<string | null>(null);
const draggedTabIdRef = useRef<string | null>(null);
const [isWindowFullscreen, setIsWindowFullscreen] = useState(false);
@@ -494,12 +528,28 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
draggedTabIdRef.current = null;
setDropIndicator(null);
setIsDraggingForReorder(false);
setHostDropWorkspaceId(null);
onEndSessionDrag();
}, [onEndSessionDrag]);
const handleTabDragOver = useCallback((e: React.DragEvent, tabId: string) => {
const dragKind = resolveFocusSidebarDragKind({ types: e.dataTransfer.types });
if (dragKind === 'host-append') {
if (!onAppendHostToWorkspace || !workspaceMap.has(tabId)) {
setHostDropWorkspaceId(null);
return;
}
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'copy';
setDropIndicator(null);
setHostDropWorkspaceId(tabId);
return;
}
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
setHostDropWorkspaceId(null);
if (hasWorkspaceSessionDrag(e.dataTransfer)) {
setDropIndicator(null);
@@ -517,16 +567,40 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
// Always update drop indicator on drag over to ensure it doesn't get stuck
setDropIndicator({ tabId, position });
}, []);
}, [onAppendHostToWorkspace, workspaceMap]);
const handleTabDragLeave = useCallback((_e: React.DragEvent) => {
const handleTabDragLeave = useCallback((e: React.DragEvent) => {
const next = e.relatedTarget;
if (next instanceof Node && e.currentTarget.contains(next)) return;
setHostDropWorkspaceId(null);
// Don't clear drop indicator on drag leave - let onDragOver manage it
// This prevents the indicator from flickering/disappearing during fast drags
// The indicator will be cleared when drag ends or on drop
}, []);
const handleTabDrop = useCallback((e: React.DragEvent, targetTabId: string) => {
const dragKind = resolveFocusSidebarDragKind({ types: e.dataTransfer.types });
if (dragKind === 'host-append') {
const handled = Boolean(
onAppendHostToWorkspace
&& workspaceMap.has(targetTabId)
&& appendHostFromWorkspaceDrop({
types: e.dataTransfer.types,
getData: (type) => e.dataTransfer.getData(type),
workspaceId: targetTabId,
onAppendHostToWorkspace,
}),
);
setHostDropWorkspaceId(null);
setDropIndicator(null);
if (!handled) return;
e.preventDefault();
e.stopPropagation();
return;
}
e.preventDefault();
setHostDropWorkspaceId(null);
if (hasWorkspaceSessionDrag(e.dataTransfer)) {
const draggedSessionId = getWorkspaceSessionDragId(e.dataTransfer);
const draggedSession = sessions.find((s) => s.id === draggedSessionId);
@@ -555,7 +629,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
setDropIndicator(null);
setIsDraggingForReorder(false);
}, [dropIndicator, onEndSessionDrag, onRemoveSessionFromWorkspace, onReorderTabs, sessions, workspaces]);
}, [dropIndicator, onAppendHostToWorkspace, onEndSessionDrag, onRemoveSessionFromWorkspace, onReorderTabs, sessions, workspaceMap, workspaces]);
const handleTabBarDrop = useCallback((e: React.DragEvent) => {
if (!hasWorkspaceSessionDrag(e.dataTransfer)) return;
@@ -752,6 +826,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onTabDragLeave={handleTabDragLeave}
onTabDrop={handleTabDrop}
tabAnimationClass={getTabAnimationClass(tabId)}
shortcutNumber={tabShortcutNumbers?.get(tabId)}
/>
);
}
@@ -776,6 +851,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onTabDragLeave={handleTabDragLeave}
onTabDrop={handleTabDrop}
tabAnimationClass={getTabAnimationClass(tabId)}
shortcutNumber={tabShortcutNumbers?.get(tabId)}
/>
);
}
@@ -811,6 +887,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
dynamicTabTitleMode={dynamicTabTitleMode}
t={t}
tabAnimationClass={getTabAnimationClass(session.id)}
shortcutNumber={tabShortcutNumbers?.get(session.id)}
/>
);
}
@@ -842,6 +919,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
shiftStyle={shiftStyle}
showDropIndicatorBefore={showDropIndicatorBefore}
showDropIndicatorAfter={showDropIndicatorAfter}
isHostDropTarget={hostDropWorkspaceId === workspace.id}
onTabDragStart={handleTabDragStart}
onTabDragEnd={handleTabDragEnd}
onTabDragOver={handleTabDragOver}
@@ -854,6 +932,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
renderBulkCloseItems={renderBulkCloseItems}
t={t}
tabAnimationClass={getTabAnimationClass(workspace.id)}
shortcutNumber={tabShortcutNumbers?.get(workspace.id)}
/>
);
}
@@ -882,6 +961,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onTabDrop={handleTabDrop}
t={t}
tabAnimationClass={getTabAnimationClass(logView.id)}
shortcutNumber={tabShortcutNumbers?.get(logView.id)}
/>
);
}
@@ -933,6 +1013,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
icon={<FolderLock size={14} />}
className="rounded"
compact={rootTabsCompact}
shortcutNumber={tabShortcutNumbers?.get('vault')}
/>
{showSftpTab && (
<RootTopTab
@@ -941,6 +1022,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
icon={<Folder size={14} />}
className="rounded-t-md"
compact={rootTabsCompact}
shortcutNumber={tabShortcutNumbers?.get('sftp')}
/>
)}
</div>
@@ -1174,6 +1256,7 @@ export const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean
prev.onCopySession === next.onCopySession &&
prev.onCopySessionToNewWindow === next.onCopySessionToNewWindow &&
prev.onEditHost === next.onEditHost &&
prev.onAppendHostToWorkspace === next.onAppendHostToWorkspace &&
prev.onCopyWorkspace === next.onCopyWorkspace &&
prev.onOpenSettings === next.onOpenSettings &&
prev.externalMcpEnabled === next.externalMcpEnabled &&
@@ -1186,6 +1269,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
);
+19 -18
View File
@@ -70,7 +70,7 @@ export const TrafficDiagram: React.FC<TrafficDiagramProps> = ({ type, isAnimatin
<div className="relative w-full h-48 flex items-center justify-center overflow-hidden rounded-xl bg-secondary/60 border border-border/50">
{/* ========== LOCAL FORWARDING ========== */}
{type === 'local' && (
<div className="relative w-full h-full">
<div className="relative w-[300px] max-w-full aspect-[300/170]">
{/* App Logo - left (same line as firewall) */}
<div className={`absolute left-6 top-5 z-10 transition-opacity duration-300 ${getOpacity('app')}`}>
<AppLogo className="h-12 w-12" />
@@ -95,21 +95,21 @@ export const TrafficDiagram: React.FC<TrafficDiagramProps> = ({ type, isAnimatin
</div>
{/* SVG Lines */}
<svg className="absolute inset-0 w-full h-full" style={{ zIndex: 1 }}>
<svg className="absolute inset-0 w-full h-full" viewBox="0 0 300 170" preserveAspectRatio="none" style={{ zIndex: 1 }}>
{/* App to Firewall - blocked red line (with gap) */}
<AnimatedLine x1={78} y1={40} x2={127} y2={40} isAnimating={false} isBlocked />
<AnimatedLine x1={78} y1={44} x2={122} y2={44} isAnimating={false} isBlocked />
{/* App to SSH Server - blue animated (with gap) */}
<AnimatedLine x1={78} y1={58} x2={145} y2={138} isAnimating={isAnimating} />
<AnimatedLine x1={76} y1={64} x2={126} y2={110} isAnimating={isAnimating} />
{/* SSH Server to targets - blue animated (with gap) */}
<AnimatedLine x1={178} y1={148} x2={238} y2={58} isAnimating={isAnimating} />
<AnimatedLine x1={178} y1={152} x2={238} y2={88} isAnimating={isAnimating} />
<AnimatedLine x1={174} y1={112} x2={242} y2={28} isAnimating={isAnimating} />
<AnimatedLine x1={178} y1={120} x2={242} y2={68} isAnimating={isAnimating} />
</svg>
</div>
)}
{/* ========== REMOTE FORWARDING ========== */}
{type === 'remote' && (
<div className="relative w-full h-full">
<div className="relative w-[300px] max-w-full aspect-[300/170]">
{/* Left Server - the remote SSH server where port will be opened */}
<div className={`absolute left-6 top-5 z-10 transition-opacity duration-300 ${getOpacity('ssh-server')}`}>
<ServerIcon className="h-10 w-10" />
@@ -131,20 +131,20 @@ export const TrafficDiagram: React.FC<TrafficDiagramProps> = ({ type, isAnimatin
</div>
{/* SVG Lines */}
<svg className="absolute inset-0 w-full h-full" style={{ zIndex: 1 }}>
<svg className="absolute inset-0 w-full h-full" viewBox="0 0 300 170" preserveAspectRatio="none" style={{ zIndex: 1 }}>
{/* Left server to Firewall - blocked (with gap) */}
<AnimatedLine x1={68} y1={38} x2={128} y2={38} isAnimating={false} isBlocked />
<AnimatedLine x1={70} y1={40} x2={122} y2={40} isAnimating={false} isBlocked />
{/* Left server to App - blue animated (with gap) */}
<AnimatedLine x1={58} y1={58} x2={145} y2={135} isAnimating={isAnimating} />
<AnimatedLine x1={68} y1={58} x2={124} y2={128} isAnimating={isAnimating} />
{/* Right server to App - blue animated (with gap) */}
<AnimatedLine x1={262} y1={58} x2={175} y2={135} isAnimating={isAnimating} reverse />
<AnimatedLine x1={250} y1={58} x2={176} y2={128} isAnimating={isAnimating} reverse />
</svg>
</div>
)}
{/* ========== DYNAMIC FORWARDING ========== */}
{type === 'dynamic' && (
<div className="relative w-full h-full">
<div className="relative w-[300px] max-w-full aspect-[300/170]">
{/* App Logo - left (same line as firewall) */}
<div className={`absolute left-6 top-5 z-10 transition-opacity duration-300 ${getOpacity('app')}`}>
<AppLogo className="h-12 w-12" />
@@ -175,15 +175,16 @@ export const TrafficDiagram: React.FC<TrafficDiagramProps> = ({ type, isAnimatin
</div>
{/* SVG Lines */}
<svg className="absolute inset-0 w-full h-full" style={{ zIndex: 1 }}>
<svg className="absolute inset-0 w-full h-full" viewBox="0 0 300 170" preserveAspectRatio="none" style={{ zIndex: 1 }}>
{/* App to Firewall - blocked (with gap) */}
<AnimatedLine x1={78} y1={42} x2={128} y2={42} isAnimating={false} isBlocked />
<AnimatedLine x1={78} y1={44} x2={122} y2={44} isAnimating={false} isBlocked />
{/* App to SSH Server - blue animated (with gap) */}
<AnimatedLine x1={78} y1={58} x2={145} y2={138} isAnimating={isAnimating} />
<AnimatedLine x1={76} y1={64} x2={126} y2={110} isAnimating={isAnimating} />
{/* SSH Server to clouds - blue animated (with gap) */}
<AnimatedLine x1={178} y1={142} x2={238} y2={42} isAnimating={isAnimating} />
<AnimatedLine x1={178} y1={148} x2={238} y2={72} isAnimating={isAnimating} />
<AnimatedLine x1={178} y1={155} x2={238} y2={148} isAnimating={isAnimating} />
<AnimatedLine x1={174} y1={112} x2={246} y2={26} isAnimating={isAnimating} />
<AnimatedLine x1={178} y1={120} x2={246} y2={58} isAnimating={isAnimating} />
<AnimatedLine x1={178} y1={128} x2={246} y2={90} isAnimating={isAnimating} />
<AnimatedLine x1={174} y1={128} x2={234} y2={130} isAnimating={isAnimating} />
</svg>
</div>
)}
@@ -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>
+20 -3
View File
@@ -8,13 +8,21 @@ import { useI18n } from '../../application/i18n/I18nProvider';
import { Host,PortForwardingRule } from '../../domain/models';
import { DistroAvatar } from '../DistroAvatar';
import { TrafficDiagram } from '../TrafficDiagram';
import { AsideActionMenu,AsideActionMenuItem,AsidePanel,AsidePanelContent,AsidePanelFooter } from '../ui/aside-panel';
import {
AsideActionMenu,
AsideActionMenuItem,
AsidePanel,
AsidePanelContent,
AsidePanelFooter,
type AsidePanelLayout,
type AsidePanelResizeProps,
} from '../ui/aside-panel';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Switch } from '../ui/switch';
export interface EditPanelProps {
export interface EditPanelProps extends AsidePanelResizeProps {
rule: PortForwardingRule;
draft: Partial<PortForwardingRule>;
hosts: Host[];
@@ -24,6 +32,7 @@ export interface EditPanelProps {
onDuplicate: () => void;
onDelete: () => void;
onOpenHostSelector: () => void;
layout?: AsidePanelLayout;
}
export const EditPanel: React.FC<EditPanelProps> = ({
@@ -36,6 +45,10 @@ export const EditPanel: React.FC<EditPanelProps> = ({
onDuplicate,
onDelete,
onOpenHostSelector,
layout = 'inline',
resizable = false,
persistWidthStorageKey,
resizeAriaLabel,
}) => {
const { t } = useI18n();
const selectedHost = hosts.find(h => h.id === draft.hostId);
@@ -46,6 +59,10 @@ export const EditPanel: React.FC<EditPanelProps> = ({
onClose={onClose}
title={t('pf.wizard.editTitle')}
width="w-[360px]"
layout={layout}
resizable={resizable}
persistWidthStorageKey={persistWidthStorageKey}
resizeAriaLabel={resizeAriaLabel}
actions={
<AsideActionMenu>
<AsideActionMenuItem
@@ -117,7 +134,7 @@ export const EditPanel: React.FC<EditPanelProps> = ({
<DistroAvatar
host={selectedHost}
fallback={selectedHost.os[0].toUpperCase()}
className="h-6 w-6"
size="tree"
/>
<span>{selectedHost.label}</span>
</div>
+18 -3
View File
@@ -9,7 +9,13 @@ import { Host,PortForwardingRule,PortForwardingType } from '../../domain/models'
import { cn } from '../../lib/utils';
import { DistroAvatar } from '../DistroAvatar';
import { TrafficDiagram } from '../TrafficDiagram';
import { AsidePanel,AsidePanelContent,AsidePanelFooter } from '../ui/aside-panel';
import {
AsidePanel,
AsidePanelContent,
AsidePanelFooter,
type AsidePanelLayout,
type AsidePanelResizeProps,
} from '../ui/aside-panel';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
@@ -17,7 +23,7 @@ import { Label } from '../ui/label';
import { Switch } from '../ui/switch';
import { getTypeLabel } from './utils';
export interface NewFormPanelProps {
export interface NewFormPanelProps extends AsidePanelResizeProps {
draft: Partial<PortForwardingRule>;
hosts: Host[];
onDraftChange: (updates: Partial<PortForwardingRule>) => void;
@@ -26,6 +32,7 @@ export interface NewFormPanelProps {
onOpenHostSelector: () => void;
onOpenWizard: () => void;
isValid: boolean;
layout?: AsidePanelLayout;
}
export const NewFormPanel: React.FC<NewFormPanelProps> = ({
@@ -37,6 +44,10 @@ export const NewFormPanel: React.FC<NewFormPanelProps> = ({
onOpenHostSelector,
onOpenWizard,
isValid,
layout = 'inline',
resizable = false,
persistWidthStorageKey,
resizeAriaLabel,
}) => {
const { t } = useI18n();
const selectedHost = hosts.find(h => h.id === draft.hostId);
@@ -47,6 +58,10 @@ export const NewFormPanel: React.FC<NewFormPanelProps> = ({
onClose={onClose}
title={t('pf.wizard.newTitle')}
width="w-[360px]"
layout={layout}
resizable={resizable}
persistWidthStorageKey={persistWidthStorageKey}
resizeAriaLabel={resizeAriaLabel}
>
<AsidePanelContent>
{/* Type Selector */}
@@ -119,7 +134,7 @@ export const NewFormPanel: React.FC<NewFormPanelProps> = ({
<DistroAvatar
host={selectedHost}
fallback={selectedHost.os[0].toUpperCase()}
className="h-6 w-6"
size="tree"
/>
<span>{selectedHost.label}</span>
</div>
@@ -12,6 +12,8 @@ export default function SettingsShortcutsTab(props: {
setHotkeyScheme: (scheme: HotkeyScheme) => void;
shellOnlyTabNumberShortcuts: boolean;
setShellOnlyTabNumberShortcuts: (enabled: boolean) => void;
showTabNumberBadges: boolean;
setShowTabNumberBadges: (enabled: boolean) => void;
disableTerminalFontZoom: boolean;
setDisableTerminalFontZoom: (enabled: boolean) => void;
keyBindings: KeyBinding[];
@@ -25,6 +27,8 @@ export default function SettingsShortcutsTab(props: {
setHotkeyScheme,
shellOnlyTabNumberShortcuts,
setShellOnlyTabNumberShortcuts,
showTabNumberBadges,
setShowTabNumberBadges,
disableTerminalFontZoom,
setDisableTerminalFontZoom,
keyBindings,
@@ -165,6 +169,16 @@ export default function SettingsShortcutsTab(props: {
onChange={setShellOnlyTabNumberShortcuts}
/>
</SettingRow>
<SettingRow
anchorId="shortcuts-show-tab-number-badges"
label={t("settings.shortcuts.showTabNumberBadges.label")}
description={t("settings.shortcuts.showTabNumberBadges.desc")}
>
<Toggle
checked={showTabNumberBadges}
onChange={setShowTabNumberBadges}
/>
</SettingRow>
</div>
{hotkeyScheme !== "disabled" && (
+73 -1
View File
@@ -1,7 +1,10 @@
import test from "node:test";
import assert from "node:assert/strict";
import { getAlignedPrompt } from "./autocomplete/promptDetector.ts";
import { detectPrompt, getAlignedPrompt } from "./autocomplete/promptDetector.ts";
import { resolveAutocompleteQueryInput } from "./autocomplete/terminalAutocompletePrompt.ts";
import { getSnippetSuggestions } from "./autocomplete/snippetCompleter.ts";
import { stringCellWidth } from "./autocomplete/terminalStringCellWidth.ts";
import { getCommandToRecordOnEnter } from "./autocomplete/useTerminalAutocomplete.ts";
function createFakeTerm(lineText: string, cursorX: number) {
@@ -25,6 +28,13 @@ function createFakeTerm(lineText: string, cursorX: number) {
};
}
/** Simulates xterm padding empty cells as trailing spaces after wide glyphs. */
function createPaddedFakeTerm(content: string, cursorX: number, cols = 80) {
const pad = Math.max(0, cols - stringCellWidth(content));
const lineText = content + " ".repeat(pad);
return createFakeTerm(lineText, cursorX);
}
function createWrappedFakeTerm(rows: string[], cursorY: number, cursorX: number, cols: number) {
return {
cols,
@@ -745,3 +755,65 @@ test("does not record partial standard prompt input while reliable typed input i
null,
);
});
test("CMD path prompts keep pre-echo Chinese input usable for snippet matching (#2813)", () => {
const prompts = [
String.raw`C:\Users\foo>`,
String.raw`C:\Users\用户>`,
String.raw`PS C:\Users\foo> `,
String.raw`PS C:\Users\用户> `,
];
const typedInput = "部署";
const snippet = { id: "zh", label: "部署服务", command: "echo deploy" };
for (const prompt of prompts) {
const term = createPaddedFakeTerm(prompt, stringCellWidth(prompt));
const raw = detectPrompt(term as never);
assert.equal(raw.isAtPrompt, true, prompt);
assert.equal(raw.userInput.trim(), "", prompt);
const aligned = getAlignedPrompt(term as never, typedInput, true);
assert.equal(aligned.prompt.isAtPrompt, true, prompt);
assert.equal(aligned.prompt.userInput, typedInput, prompt);
assert.equal(aligned.alignedTyped, null, prompt);
assert.equal(aligned.allowExternalProviders, false, prompt);
const query = resolveAutocompleteQueryInput(
aligned.prompt,
typedInput,
true,
);
assert.equal(query, typedInput, prompt);
assert.equal(
getSnippetSuggestions(query ?? "", [snippet as never], {})[0]?.snippet?.id,
"zh",
prompt,
);
}
});
test("CMD path prompts with CJK directories do not absorb padding into echoed Chinese input", () => {
const prompt = String.raw`C:\Users\用户>`;
const typedInput = "部署";
const content = `${prompt}${typedInput}`;
const term = createPaddedFakeTerm(content, stringCellWidth(content));
const raw = detectPrompt(term as never);
assert.equal(raw.isAtPrompt, true);
assert.equal(raw.promptText, prompt);
assert.equal(raw.userInput, typedInput);
assert.equal(raw.cursorOffset, typedInput.length);
// Unreliable typed buffer must still match snippets from the live line
// (fresh local CMD sessions often clear keystroke reliability on startup).
const query = resolveAutocompleteQueryInput(raw, "", false);
assert.equal(query, typedInput);
assert.equal(
getSnippetSuggestions(query ?? "", [{
id: "zh",
label: "部署服务",
command: "echo deploy",
} as never], {})[0]?.snippet?.id,
"zh",
);
});
@@ -20,6 +20,7 @@ const shouldSuppressMouseTrackingContextMenu = (
terminalContextMenu as {
shouldSuppressMouseTrackingContextMenu?: (options: {
isAlternateScreen?: boolean;
terminalMouseTrackingMode?: string;
showReconnectAction?: boolean;
forceMenuInAlternateScreen?: boolean;
}) => boolean;
@@ -41,6 +42,7 @@ const shouldOpenTerminalContextMenu = (
event: { shiftKey?: boolean; nativeEvent: MouseEvent };
rightClickBehavior?: "context-menu" | "paste" | "select-word";
isAlternateScreen?: boolean;
terminalMouseTrackingMode?: string;
showReconnectAction?: boolean;
forceMenuInAlternateScreen?: boolean;
}) => boolean;
@@ -50,6 +52,7 @@ const shouldRenderTerminalContextMenuContent = (
terminalContextMenu as {
shouldRenderTerminalContextMenuContent?: (options: {
isAlternateScreen?: boolean;
terminalMouseTrackingMode?: string;
showReconnectAction?: boolean;
allowSuppressedMenuContent?: boolean;
}) => boolean;
@@ -60,6 +63,7 @@ const shouldAllowSuppressedTerminalContextMenuContent = (
shouldAllowSuppressedTerminalContextMenuContent?: (options: {
event: { shiftKey?: boolean; nativeEvent: MouseEvent };
isAlternateScreen?: boolean;
terminalMouseTrackingMode?: string;
showReconnectAction?: boolean;
}) => boolean;
}
@@ -338,6 +342,65 @@ test("opens and renders middle-click menu while alternate-screen mouse tracking
);
});
test("uses the current mouse tracking mode when the cached state is stale", () => {
assert.equal(typeof shouldOpenTerminalContextMenu, "function");
assert.equal(typeof shouldRenderTerminalContextMenuContent, "function");
if (
typeof shouldOpenTerminalContextMenu !== "function" ||
typeof shouldRenderTerminalContextMenuContent !== "function"
) {
return;
}
const event = {
nativeEvent: {} as MouseEvent,
};
// xterm has already stopped reporting mouse events, but React still has
// the previous tracking state: paste/select-word must not be dropped.
assert.equal(
shouldOpenTerminalContextMenu({
event,
rightClickBehavior: "paste",
isAlternateScreen: true,
terminalMouseTrackingMode: "none",
showReconnectAction: false,
}),
false,
);
assert.equal(
shouldRenderTerminalContextMenuContent({
isAlternateScreen: true,
terminalMouseTrackingMode: "none",
showReconnectAction: false,
allowSuppressedMenuContent: false,
}),
true,
);
// Conversely, a newly active xterm mode must still suppress the app menu
// while the cached React state has not caught up.
assert.equal(
shouldOpenTerminalContextMenu({
event,
rightClickBehavior: "context-menu",
isAlternateScreen: false,
terminalMouseTrackingMode: "vt200",
showReconnectAction: false,
}),
false,
);
assert.equal(
shouldRenderTerminalContextMenuContent({
isAlternateScreen: false,
terminalMouseTrackingMode: "vt200",
showReconnectAction: false,
allowSuppressedMenuContent: false,
}),
false,
);
});
test("opens Shift right-click menu content for all right-click modes while mouse tracking suppresses unmodified menus", () => {
assert.equal(typeof shouldOpenTerminalContextMenu, "function");
assert.equal(typeof shouldRenderTerminalContextMenuContent, "function");
+31 -7
View File
@@ -27,7 +27,7 @@ import {
ContextMenuShortcut,
ContextMenuTrigger,
} from '../ui/context-menu';
import { isMiddleClickContextMenuEvent } from './runtime/middleClickBehavior';
import { isMiddleClickContextMenuEvent, isMouseTrackingActive } from './runtime/middleClickBehavior';
import { collectOwnedPluginMenus, comparePluginMenus, usePluginContributions } from '../../application/state/usePluginContributions';
import { buildTerminalPluginContributionContext } from '../../application/state/pluginContributionContexts';
import { PluginContributionIcon } from '../plugins/PluginContributionIcon';
@@ -44,6 +44,8 @@ export interface TerminalContextMenuProps {
keyBindings?: KeyBinding[];
rightClickBehavior?: RightClickBehavior;
isAlternateScreen?: boolean;
/** Read the current xterm mouse-tracking mode when handling a right-click. */
getMouseTrackingMode?: () => string | undefined;
/** When true, show the app context menu even while a fullscreen app (tmux/vim) holds mouse tracking. */
showContextMenuOverFullscreenApps?: boolean;
onCopy?: () => void;
@@ -75,13 +77,22 @@ export const shouldShowReconnectAction = ({
export const shouldSuppressMouseTrackingContextMenu = ({
isAlternateScreen,
terminalMouseTrackingMode,
showReconnectAction,
forceMenuInAlternateScreen,
}: {
isAlternateScreen?: boolean;
terminalMouseTrackingMode?: string;
showReconnectAction?: boolean;
forceMenuInAlternateScreen?: boolean;
}): boolean => Boolean(isAlternateScreen && !showReconnectAction && !forceMenuInAlternateScreen);
}): boolean => Boolean(
isMouseTrackingActive({
mouseTracking: Boolean(isAlternateScreen),
terminalMouseTrackingMode,
})
&& !showReconnectAction
&& !forceMenuInAlternateScreen,
);
export const shouldShowAddSelectionToAIContextMenuAction = (
onAddSelectionToAI?: () => void,
@@ -93,42 +104,48 @@ export const shouldShowUploadClipboardImageContextMenuAction = (
export const shouldRenderTerminalContextMenuContent = ({
isAlternateScreen,
terminalMouseTrackingMode,
showReconnectAction,
allowSuppressedMenuContent,
forceMenuInAlternateScreen,
}: {
isAlternateScreen?: boolean;
terminalMouseTrackingMode?: string;
showReconnectAction?: boolean;
allowSuppressedMenuContent?: boolean;
forceMenuInAlternateScreen?: boolean;
}): boolean =>
allowSuppressedMenuContent ||
!shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, showReconnectAction, forceMenuInAlternateScreen });
!shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, terminalMouseTrackingMode, showReconnectAction, forceMenuInAlternateScreen });
export const shouldAllowSuppressedTerminalContextMenuContent = ({
event,
isAlternateScreen,
terminalMouseTrackingMode,
showReconnectAction,
forceMenuInAlternateScreen,
}: {
event: { shiftKey?: boolean; nativeEvent: MouseEvent };
isAlternateScreen?: boolean;
terminalMouseTrackingMode?: string;
showReconnectAction?: boolean;
forceMenuInAlternateScreen?: boolean;
}): boolean =>
isMiddleClickContextMenuEvent(event.nativeEvent)
|| Boolean(event.shiftKey && shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, showReconnectAction, forceMenuInAlternateScreen }));
|| Boolean(event.shiftKey && shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, terminalMouseTrackingMode, showReconnectAction, forceMenuInAlternateScreen }));
export const shouldOpenTerminalContextMenu = ({
event,
rightClickBehavior = 'context-menu',
isAlternateScreen,
terminalMouseTrackingMode,
showReconnectAction,
forceMenuInAlternateScreen,
}: {
event: { shiftKey?: boolean; nativeEvent: MouseEvent };
rightClickBehavior?: RightClickBehavior;
isAlternateScreen?: boolean;
terminalMouseTrackingMode?: string;
showReconnectAction?: boolean;
forceMenuInAlternateScreen?: boolean;
}): boolean => {
@@ -140,7 +157,7 @@ export const shouldOpenTerminalContextMenu = ({
return true;
}
if (shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, showReconnectAction, forceMenuInAlternateScreen })) {
if (shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, terminalMouseTrackingMode, showReconnectAction, forceMenuInAlternateScreen })) {
return false;
}
@@ -159,6 +176,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
keyBindings,
rightClickBehavior = 'context-menu',
isAlternateScreen = false,
getMouseTrackingMode,
showContextMenuOverFullscreenApps = false,
onCopy,
onPaste,
@@ -233,6 +251,8 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
const clearShortcut = getShortcut('clear-buffer');
const showReconnectAction = shouldShowReconnectAction({ isReconnectable, onReconnect });
const terminalMouseTrackingMode = getMouseTrackingMode?.();
// Handle right-click: intercept for paste/select-word unless Shift is held
// or rightClickBehavior is 'context-menu'. The ContextMenuTrigger stays always
// enabled so Shift+Right-Click opens the menu on the first click.
@@ -241,15 +261,17 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
// In alternate screen (tmux, vim, etc.), let the terminal application
// handle right-click natively to avoid conflicting menus. Reconnect is
// still available after disconnect, even if mouse tracking was left on.
const currentMouseTrackingMode = getMouseTrackingMode?.();
const shouldOpenMenu = shouldOpenTerminalContextMenu({
event: e,
rightClickBehavior,
isAlternateScreen,
terminalMouseTrackingMode: currentMouseTrackingMode,
showReconnectAction,
forceMenuInAlternateScreen: showContextMenuOverFullscreenApps,
});
if (!shouldOpenMenu && shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, showReconnectAction, forceMenuInAlternateScreen: showContextMenuOverFullscreenApps })) {
if (!shouldOpenMenu && shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, terminalMouseTrackingMode: currentMouseTrackingMode, showReconnectAction, forceMenuInAlternateScreen: showContextMenuOverFullscreenApps })) {
e.preventDefault();
return;
}
@@ -265,6 +287,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
setAllowSuppressedMenuContent(shouldAllowSuppressedTerminalContextMenuContent({
event: e,
isAlternateScreen,
terminalMouseTrackingMode: currentMouseTrackingMode,
showReconnectAction,
forceMenuInAlternateScreen: showContextMenuOverFullscreenApps,
}));
@@ -279,7 +302,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
onSelectWord?.();
}
},
[rightClickBehavior, onPaste, onSelectWord, isAlternateScreen, showReconnectAction, showContextMenuOverFullscreenApps],
[rightClickBehavior, onPaste, onSelectWord, isAlternateScreen, getMouseTrackingMode, showReconnectAction, showContextMenuOverFullscreenApps],
);
// Always use ContextMenu wrapper to maintain consistent React tree structure
@@ -294,6 +317,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
</ContextMenuTrigger>
{shouldRenderTerminalContextMenuContent({
isAlternateScreen,
terminalMouseTrackingMode,
showReconnectAction,
allowSuppressedMenuContent,
forceMenuInAlternateScreen: showContextMenuOverFullscreenApps,
+1
View File
@@ -492,6 +492,7 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
keyBindings={keyBindings}
rightClickBehavior={terminalSettings?.rightClickBehavior}
isAlternateScreen={hasMouseTracking}
getMouseTrackingMode={() => termRef.current?.modes.mouseTrackingMode}
showContextMenuOverFullscreenApps={terminalSettings?.showContextMenuOverFullscreenApps}
onCopy={terminalContextActions.onCopy}
onPaste={terminalContextActions.onPaste}
@@ -13,3 +13,10 @@ test("hidden selected theme uses the normal theme item row", () => {
assert.match(source, /hiddenSelectedTheme && \(\s*<ThemeItem/);
assert.doesNotMatch(source, /terminal\.hiddenTheme\.title[\s\S]*terminal\.hiddenTheme\.desc/);
});
test("theme side panel tabs are vertically centered in the header", () => {
assert.match(
source,
/TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS, 'flex items-center px-1\.5 gap-0\.5 border-b'/,
);
});
+1 -1
View File
@@ -311,7 +311,7 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
>
{/* Tab Bar */}
<div
className={cn(TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS, 'flex px-1.5 gap-0.5 border-b')}
className={cn(TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS, 'flex items-center px-1.5 gap-0.5 border-b')}
style={{ borderColor: 'var(--terminal-panel-border)' }}
>
<button
@@ -9,6 +9,7 @@
import type { Terminal as XTerm } from "@xterm/xterm";
import { isSensitiveTerminalChallenge } from "../../../domain/terminalPromptSecurity";
import { sliceStringByCellColumns } from "./terminalStringCellWidth";
import { COMMON_SHELL_COMMANDS, NON_PROMPT_PATTERNS, PROMPT_CHARS } from "./promptDetectorPatterns";
export interface PromptDetectionResult {
@@ -83,7 +84,8 @@ function getCursorLinePrefix(term: XTerm): string | null {
if (!line) return null;
return line.translateToString(false).substring(0, Math.max(0, buffer.cursorX));
const lineText = line.translateToString(false);
return sliceStringByCellColumns(lineText, 0, Math.max(0, buffer.cursorX), term);
}
function getWrappedCursorPrefix(term: XTerm): string | null {
@@ -112,7 +114,8 @@ function getWrappedCursorPrefix(term: XTerm): string | null {
prefix += rowLine.translateToString(false);
}
return prefix + line.translateToString(false).substring(0, Math.max(0, cursorX));
const cursorRowText = line.translateToString(false);
return prefix + sliceStringByCellColumns(cursorRowText, 0, Math.max(0, cursorX), term);
}
function inferPromptTextBeforeTypedInput(
@@ -424,13 +427,27 @@ function endsWithHostStyleGreaterThanPrompt(promptText: string): boolean {
return /^[\w.-]+$/.test(promptName) && !isLikelyBareMongoPromptName(promptName);
}
function endsWithWindowsPathGreaterThanPrompt(promptText: string): boolean {
const trimmed = promptText.trimEnd();
if (!trimmed.endsWith(">")) return false;
const before = trimmed.slice(0, -1).trimEnd();
// cmd.exe: `C:\path>` / `C:\>`; PowerShell: `PS C:\path>`
if (/^[A-Za-z]:[\\/]/.test(before)) return true;
if (/^PS\s+[A-Za-z]:[\\/]/i.test(before)) return true;
return false;
}
function endsWithStandardShellPrompt(promptText: string): boolean {
const finalChar = promptText.trimEnd().at(-1);
return finalChar === "$" || finalChar === "#" || finalChar === "%";
}
function allowsShortPromptEcho(promptText: string): boolean {
return endsWithStandardShellPrompt(promptText) || endsWithHostStyleGreaterThanPrompt(promptText);
return (
endsWithStandardShellPrompt(promptText) ||
endsWithHostStyleGreaterThanPrompt(promptText) ||
endsWithWindowsPathGreaterThanPrompt(promptText)
);
}
function isReliableTypedPrefix(
@@ -539,25 +556,29 @@ export function detectPrompt(term: XTerm): PromptDetectionResult {
// Empty line
if (lineText.trim().length === 0) return NO_PROMPT;
const cursorLinePrefix = lineText.substring(0, Math.max(0, cursorX));
// cursorX is a cell column; lineText is characters. Wide glyphs (CJK in a
// Windows `C:\Users\用户>` prompt) make substring(cursorX) overshoot into
// xterm's empty-cell padding and poison userInput with spaces (#2813 CMD).
const cursorLinePrefix = sliceStringByCellColumns(lineText, 0, Math.max(0, cursorX), term);
const afterCursor = sliceStringByCellColumns(lineText, Math.max(0, cursorX), undefined, term);
// Try to find the prompt boundary on the current line. xterm buffer rows are
// padded with blank cells; when the cursor is at the visible row end, scan
// only up to the cursor so prompts like "root@host:~#" do not inherit a fake
// trailing space. If there is command text to the right of the cursor, keep
// the full line so "$" / ">" inside mid-line edits are validated against
// their real following character.
const promptScanText = lineText.slice(Math.max(0, cursorX)).trim().length > 0
const promptScanText = afterCursor.trim().length > 0
? lineText
: cursorLinePrefix;
const promptEnd = findPromptBoundary(promptScanText);
if (promptEnd >= 0) {
const promptText = lineText.substring(0, promptEnd);
// Use cursor position to determine actual input length — don't trim trailing
// spaces since they're significant for autocomplete (e.g., "git commit " should
// produce an empty trailing token to trigger option suggestions).
const rawInput = lineText.substring(promptEnd);
const userInput = rawInput.substring(0, Math.max(0, cursorX - promptEnd));
const cursorOffset = Math.max(0, cursorX - promptEnd);
// Input is whatever sits between the prompt and the cursor on the cell-
// accurate prefix — don't use cursorX as a character index.
const userInput = cursorLinePrefix.length >= promptEnd
? cursorLinePrefix.substring(promptEnd)
: "";
const cursorOffset = userInput.length;
return { isAtPrompt: true, promptText, userInput, cursorOffset };
}
@@ -102,3 +102,62 @@ export function stringCellWidth(
}
return fallbackStringCellWidth(s);
}
/**
* Slice a terminal line string by cell columns (xterm `cursorX` units).
*
* `translateToString()` returns characters, but `buffer.cursorX` is a cell
* column. Mixing them with `String#substring(cursorX)` pulls padding spaces
* into user input whenever the prompt contains wide glyphs (CJK paths in
* Windows CMD / PowerShell), which breaks autocomplete matching (#2813).
*/
export function sliceStringByCellColumns(
text: string,
startCell: number,
endCell?: number,
term?: XTerm | TermWithUnicodeService | null,
): string {
if (!text) return "";
const start = Math.max(0, startCell);
const end = endCell === undefined ? Number.POSITIVE_INFINITY : Math.max(start, endCell);
if (end === 0) return "";
let cell = 0;
let startIndex = 0;
let endIndex = text.length;
let sawStart = false;
const advance = (segment: string, index: number, segmentLength: number): boolean => {
const width = stringCellWidth(segment, term);
const nextCell = cell + width;
if (!sawStart && nextCell > start) {
startIndex = index;
sawStart = true;
}
if (nextCell >= end) {
endIndex = nextCell === end ? index + segmentLength : index;
if (!sawStart) {
startIndex = index;
sawStart = true;
}
return true;
}
cell = nextCell;
return false;
};
if (graphemeSegmenter) {
for (const { segment, index } of graphemeSegmenter.segment(text)) {
if (advance(segment, index, segment.length)) break;
}
} else {
let index = 0;
for (const ch of text) {
if (advance(ch, index, ch.length)) break;
index += ch.length;
}
}
if (!sawStart) return "";
return text.slice(startIndex, endIndex);
}
@@ -43,6 +43,7 @@ import {
resolveAutocompleteQueryInput,
shouldBlockAutocompleteForSensitivePrompt,
} from "./terminalAutocompletePrompt";
import { sliceStringByCellColumns } from "./terminalStringCellWidth";
import { isTerminalAlternateScreenActive } from "../terminalHibernateRuntime";
@@ -827,8 +828,15 @@ export function useTerminalAutocomplete(
// Suppress autocomplete when cursor is not at end of input —
// inserting text mid-line would corrupt the command (e.g., "git st|tus" → "git statustus")
const buffer = term.buffer.active;
const lineAfterCursor = buffer.getLine(buffer.cursorY + buffer.baseY)
?.translateToString(false).substring(buffer.cursorX).trimEnd();
const cursorLine = buffer.getLine(buffer.cursorY + buffer.baseY);
const lineAfterCursor = cursorLine
? sliceStringByCellColumns(
cursorLine.translateToString(false),
Math.max(0, buffer.cursorX),
undefined,
term,
).trimEnd()
: "";
if (lineAfterCursor && lineAfterCursor.length > 0) {
clearState();
return;
@@ -0,0 +1,57 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
cancelConnectAutomationBatch,
createConnectAutomationBatch,
trackConnectAutomationStop,
} from "./connectAutomationBatch.ts";
test("cancel waits for the stop operation registered by the abort listener", async () => {
const batch = createConnectAutomationBatch();
let releaseStop: (() => void) | undefined;
let stopFinished = false;
batch.controller.signal.addEventListener("abort", () => {
trackConnectAutomationStop(batch, () => new Promise<void>((resolve) => {
releaseStop = () => {
stopFinished = true;
resolve();
};
}));
}, { once: true });
const cancelling = cancelConnectAutomationBatch(batch);
await Promise.resolve();
assert.equal(stopFinished, false);
releaseStop?.();
await cancelling;
assert.equal(stopFinished, true);
});
test("cancel surfaces a backend stop failure", async () => {
const batch = createConnectAutomationBatch();
batch.controller.signal.addEventListener("abort", () => {
trackConnectAutomationStop(batch, async () => {
throw new Error("stop failed");
});
}, { once: true });
await assert.rejects(() => cancelConnectAutomationBatch(batch), /stop failed/);
});
test("cancel can retry a stop operation after a transient failure", async () => {
const batch = createConnectAutomationBatch();
let attempts = 0;
trackConnectAutomationStop(batch, async () => {
attempts += 1;
if (attempts === 1) throw new Error("temporary stop failure");
});
await assert.rejects(
() => cancelConnectAutomationBatch(batch),
/temporary stop failure/,
);
await cancelConnectAutomationBatch(batch);
assert.equal(attempts, 2);
});
@@ -0,0 +1,25 @@
export interface ConnectAutomationBatch {
controller: AbortController;
stopCurrentRun: (() => Promise<void>) | null;
}
export const createConnectAutomationBatch = (): ConnectAutomationBatch => ({
controller: new AbortController(),
stopCurrentRun: null,
});
export const trackConnectAutomationStop = (
batch: ConnectAutomationBatch,
stopCurrentRun: (() => Promise<void>) | null,
): void => {
batch.stopCurrentRun = stopCurrentRun;
};
export const cancelConnectAutomationBatch = async (
batch: ConnectAutomationBatch,
): Promise<void> => {
batch.controller.abort();
if (batch.stopCurrentRun) {
await batch.stopCurrentRun();
}
};
@@ -4,6 +4,8 @@ import { readFileSync } from "node:fs";
import {
getInitialTerminalStatus,
resolveTerminalVaultInitialized,
shouldResetConnectAutomationOnReconnect,
shouldSuppressHostStartupCommandOnReconnect,
shouldStartTerminalBackend,
} from "./restoredSessionGate.ts";
@@ -41,12 +43,108 @@ test("terminal boot waits for vaultInitialized before creating a backend session
);
});
test("terminal popup can supply its own completed vault hydration state", () => {
const terminalSource = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
const popupSource = readFileSync(new URL("../TerminalPopupPage.tsx", import.meta.url), "utf8");
assert.match(
terminalSource,
/const vaultInitialized = resolveTerminalVaultInitialized\(\s*sharedVaultInitialized,\s*vaultInitializedOverride,\s*\);/,
);
assert.match(popupSource, /vaultInitializedOverride=\{vaultInitialized\}/);
assert.equal(resolveTerminalVaultInitialized(false, true), true);
assert.equal(resolveTerminalVaultInitialized(false), false);
assert.equal(resolveTerminalVaultInitialized(true, false), false);
});
test("host startup command policy distinguishes restored and automatic reconnects", () => {
assert.equal(shouldSuppressHostStartupCommandOnReconnect("restored"), false);
assert.equal(shouldSuppressHostStartupCommandOnReconnect("manual"), false);
assert.equal(shouldSuppressHostStartupCommandOnReconnect("automatic"), true);
});
test("connect automation reset policy distinguishes manual and automatic reconnects", () => {
assert.equal(shouldResetConnectAutomationOnReconnect("manual"), true);
assert.equal(shouldResetConnectAutomationOnReconnect("restored"), true);
assert.equal(shouldResetConnectAutomationOnReconnect("automatic"), false);
});
test("manual reconnect resets connect automation before opening a new session", () => {
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
const reconnectIndex = source.indexOf("const startReconnect = ");
const cancelBatchIndex = source.indexOf(
"await cancelConnectAutomationBatch(connectAutomationBatch)",
reconnectIndex,
);
const manualBranchIndex = source.indexOf('if (mode === "manual")', reconnectIndex);
const stopFailureRetryIndex = source.indexOf(
'if (mode === "auto" && retryTokenStillCurrent())',
cancelBatchIndex,
);
const stopFailureDisconnectedIndex = source.indexOf(
'updateStatus("disconnected")',
stopFailureRetryIndex,
);
const resetConsumedIndex = source.indexOf(
"connectScriptsConsumedRef.current = false",
manualBranchIndex,
);
const resetCompletedIndex = source.indexOf(
"connectScriptsCompletedIdsRef.current = new Set()",
manualBranchIndex,
);
const resetInFlightIndex = source.indexOf(
"connectScriptsInFlightRef.current = false",
manualBranchIndex,
);
const connectingIndex = source.indexOf('updateStatus("connecting")', manualBranchIndex);
const autoElseIndex = source.indexOf("} else {", manualBranchIndex);
const autoSuppressIndex = source.indexOf(
'shouldSuppressHostStartupCommandOnReconnect("automatic")',
autoElseIndex,
);
assert.notEqual(reconnectIndex, -1);
assert.notEqual(cancelBatchIndex, -1);
assert.notEqual(manualBranchIndex, -1);
assert.notEqual(stopFailureRetryIndex, -1);
assert.notEqual(stopFailureDisconnectedIndex, -1);
assert.notEqual(resetConsumedIndex, -1);
assert.notEqual(resetCompletedIndex, -1);
assert.notEqual(resetInFlightIndex, -1);
assert.notEqual(connectingIndex, -1);
assert.notEqual(autoElseIndex, -1);
assert.notEqual(autoSuppressIndex, -1);
assert.ok(
cancelBatchIndex < manualBranchIndex
&& cancelBatchIndex < resetConsumedIndex
&& cancelBatchIndex < resetCompletedIndex
&& cancelBatchIndex < resetInFlightIndex,
"every reconnect must await onConnect cancellation before manual guards are cleared",
);
assert.ok(
stopFailureRetryIndex < stopFailureDisconnectedIndex
&& stopFailureDisconnectedIndex < manualBranchIndex,
"automatic reconnect must return to disconnected when stopping the old batch fails",
);
assert.ok(
resetConsumedIndex < connectingIndex && resetCompletedIndex < connectingIndex,
"manual reconnect must clear connect-automation consumption before status becomes connecting",
);
assert.ok(
autoElseIndex < autoSuppressIndex && autoSuppressIndex < connectingIndex,
"automatic reconnect must keep the existing connect-automation consumption decision",
);
assert.ok(
!source.slice(autoElseIndex, connectingIndex).includes("connectScriptsConsumedRef.current = false"),
"automatic reconnect must not reset connect-automation refs",
);
assert.match(
source.slice(autoElseIndex, connectingIndex),
/connectScriptsConsumedRef\.current = true/,
"automatic reconnect must stop the old batch without queuing it again",
);
});
test("restored disconnected sessions still create a terminal runtime before backend startup", () => {
const source = readFileSync(new URL("./useTerminalEffects.ts", import.meta.url), "utf8");
const runtimeIndex = source.indexOf("const runtime = createXTermRuntime");
@@ -7,6 +7,11 @@ export const getInitialTerminalStatus = (): TerminalSession["status"] => (
"connecting"
);
export const resolveTerminalVaultInitialized = (
sharedVaultInitialized: boolean,
windowVaultInitialized?: boolean,
): boolean => windowVaultInitialized ?? sharedVaultInitialized;
/**
* Backend start waits for vault hydration so restored sessions cannot dial SSH
* with empty keys while hosts have already been published mid-init.
@@ -16,3 +21,11 @@ export const shouldStartTerminalBackend = (): boolean => isVaultInitialized();
export const shouldSuppressHostStartupCommandOnReconnect = (
mode: TerminalReconnectMode,
): boolean => mode === "automatic";
/**
* Manual and restored reconnects are a fresh "connect" for automation scripts.
* Automatic reconnect after a drop must not re-run onConnect scripts mid-session.
*/
export const shouldResetConnectAutomationOnReconnect = (
mode: TerminalReconnectMode,
): boolean => mode !== "automatic";
@@ -70,6 +70,27 @@ test("mouse-tracking context menu capture lets Shift-modified mouse events pass
);
});
test("mouse-tracking context menu capture prefers the terminal's current mode over stale cached state", () => {
assert.equal(
shouldInterceptMouseTrackingContextMenu({
event: { shiftKey: false } as MouseEvent,
mouseTracking: false,
terminalMouseTrackingMode: "vt200",
status: "connected",
}),
true,
);
assert.equal(
shouldInterceptMouseTrackingContextMenu({
event: { shiftKey: false } as MouseEvent,
mouseTracking: true,
terminalMouseTrackingMode: "none",
status: "connected",
}),
false,
);
});
test("mouse-tracking context menu capture yields to the fullscreen-apps menu setting for context-menu clicks", () => {
// Setting on + context-menu behavior: do NOT intercept, so Radix opens the menu.
assert.equal(
@@ -255,6 +276,31 @@ test("right-click mousedown is stopped when the fullscreen-apps menu setting for
);
});
test("right-click mousedown also uses the terminal's current mode", () => {
assert.equal(
shouldStopShiftRightClickMouseTrackingMouseDown({
event: { button: 2, shiftKey: false } as MouseEvent,
mouseTracking: false,
terminalMouseTrackingMode: "vt200",
status: "connected",
rightClickBehavior: "context-menu",
forceMenuInAlternateScreen: true,
}),
true,
);
assert.equal(
shouldStopShiftRightClickMouseTrackingMouseDown({
event: { button: 2, shiftKey: false } as MouseEvent,
mouseTracking: true,
terminalMouseTrackingMode: "none",
status: "connected",
rightClickBehavior: "context-menu",
forceMenuInAlternateScreen: true,
}),
false,
);
});
test("Shift right-click mousedown capture is limited to connected mouse tracking", () => {
const baseEvent = {
button: 2,
@@ -16,6 +16,8 @@ type ShiftSelectionReplayMouseEvent = MouseEvent & {
export interface MouseTrackingContextMenuCaptureState {
event: MouseEvent;
mouseTracking: boolean;
/** Current xterm mouse tracking mode, when available at event time. */
terminalMouseTrackingMode?: string;
status?: string | null;
/** The user's configured right-click action. */
rightClickBehavior?: RightClickBehavior;
@@ -33,6 +35,8 @@ export interface ShiftMouseSelectionReplayState {
export interface ShiftRightClickMouseDownCaptureState {
event: MouseEvent;
mouseTracking: boolean;
/** Current xterm mouse tracking mode, when available at event time. */
terminalMouseTrackingMode?: string;
status?: string | null;
/** The user's configured right-click action. */
rightClickBehavior?: RightClickBehavior;
@@ -90,14 +94,23 @@ const forcesMenuOverMouseTracking = ({
forceMenuInAlternateScreen?: boolean;
}): boolean => Boolean(forceMenuInAlternateScreen && rightClickBehavior === "context-menu");
export const isMouseTrackingActive = ({
mouseTracking,
terminalMouseTrackingMode,
}: Pick<MouseTrackingContextMenuCaptureState, "mouseTracking" | "terminalMouseTrackingMode">): boolean =>
terminalMouseTrackingMode === undefined
? mouseTracking
: terminalMouseTrackingMode !== "none";
export const shouldInterceptMouseTrackingContextMenu = ({
event,
mouseTracking,
terminalMouseTrackingMode,
status,
rightClickBehavior,
forceMenuInAlternateScreen,
}: MouseTrackingContextMenuCaptureState): boolean =>
mouseTracking
isMouseTrackingActive({ mouseTracking, terminalMouseTrackingMode })
&& status === "connected"
&& !event.shiftKey
&& !isMiddleClickContextMenuEvent(event)
@@ -122,11 +135,12 @@ export const shouldReplayShiftMouseSelectionAsMacOption = ({
export const shouldStopShiftRightClickMouseTrackingMouseDown = ({
event,
mouseTracking,
terminalMouseTrackingMode,
status,
rightClickBehavior,
forceMenuInAlternateScreen,
}: ShiftRightClickMouseDownCaptureState): boolean =>
mouseTracking
isMouseTrackingActive({ mouseTracking, terminalMouseTrackingMode })
&& status === "connected"
&& event.button === 2
&& (event.shiftKey || forcesMenuOverMouseTracking({ rightClickBehavior, forceMenuInAlternateScreen }));
+8 -2
View File
@@ -16,7 +16,10 @@ test("resolvePreferredTerminalCwd prefers fresh backend pwd when requested", asy
preferFreshBackend: true,
getSessionPwd: async (_sessionId, options) => {
backendCalls += 1;
assert.deepEqual(options, { allowHomeFallback: false });
assert.deepEqual(options, {
allowHomeFallback: false,
allowLoginShellFallback: true,
});
return { success: true, cwd: "/lost+found" };
},
});
@@ -47,7 +50,10 @@ test("resolvePreferredTerminalCwd falls back to renderer cwd when fresh backend
sessionId: "session-1",
preferFreshBackend: true,
getSessionPwd: async (_sessionId, options) => {
assert.deepEqual(options, { allowHomeFallback: false });
assert.deepEqual(options, {
allowHomeFallback: false,
allowLoginShellFallback: true,
});
return { success: false, error: "Could not determine cwd" };
},
});
+11 -1
View File
@@ -5,6 +5,11 @@ type SessionPwdResult = {
type SessionPwdOptions = {
allowHomeFallback?: boolean;
/**
* When true, fall back to the same-uid login shell cwd after su/sudo.
* When omitted, follows allowHomeFallback (backend default).
*/
allowLoginShellFallback?: boolean;
};
type ResolvePreferredTerminalCwdOptions = {
@@ -57,7 +62,12 @@ export const resolvePreferredTerminalCwd = async ({
try {
const result = await getSessionPwd(
sessionId,
preferFreshBackend ? { allowHomeFallback: false } : undefined,
// Disable ~ guessing so we do not open SFTP on a fabricated home path,
// but keep login-shell fallback after su/sudo so drag-drop still
// resolves a writable cwd (#2886).
preferFreshBackend
? { allowHomeFallback: false, allowLoginShellFallback: true }
: undefined,
);
const backendCwd = result.success ? normalizeCwd(result.cwd) : null;
return backendCwd ?? (allowRendererFallback ? knownCwd : null);
@@ -380,7 +380,10 @@ test("fresh cwd resolution falls back to the renderer cwd when backend probe has
sessionId: "session-1",
preferFreshBackend: true,
getSessionPwd: async (_sessionId, options) => {
assert.deepEqual(options, { allowHomeFallback: false });
assert.deepEqual(options, {
allowHomeFallback: false,
allowLoginShellFallback: true,
});
return { success: false, error: "Could not determine cwd" };
},
});
+2
View File
@@ -136,6 +136,8 @@ export interface TerminalProps {
sessionId: string;
workspaceId?: string;
restoreState?: TerminalSession["restoreState"];
/** Secondary windows hydrate their own vault state outside the main snapshot store. */
vaultInitializedOverride?: boolean;
pendingInitialCwd?: string;
shellType?: TerminalSession["shellType"];
lastCwd?: string;
+7
View File
@@ -40,6 +40,13 @@ test("terminal memo refreshes when restored local shell type changes", () => {
);
});
test("terminal memo refreshes when a popup vault finishes hydrating", () => {
assert.equal(
terminalPropsAreEqual(baseProps, { ...baseProps, vaultInitializedOverride: true }),
false,
);
});
test("terminal memo refreshes when terminal context reader callback changes", () => {
assert.equal(
terminalPropsAreEqual(baseProps, {
+1
View File
@@ -36,6 +36,7 @@ export const terminalPropsAreEqual = (
&& prev.terminalSettings === next.terminalSettings
&& prev.sessionId === next.sessionId
&& prev.restoreState === next.restoreState
&& prev.vaultInitializedOverride === next.vaultInitializedOverride
&& prev.shellType === next.shellType
&& prev.lastCwd === next.lastCwd
&& prev.restoreTerminalCwd === next.restoreTerminalCwd
@@ -1,8 +1,8 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import { stringCellWidth } from "./autocomplete/terminalStringCellWidth.ts";
import { stringCellWidth, sliceStringByCellColumns } from "./autocomplete/terminalStringCellWidth.ts";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
@@ -14,6 +14,20 @@ test("stringCellWidth counts CJK ideographs as two cells each", () => {
assert.equal(stringCellWidth("部署"), 4);
});
test("sliceStringByCellColumns respects wide CJK cells", () => {
const prompt = String.raw`C:\Users\用户>`;
const line = `${prompt}部署`;
const promptCells = stringCellWidth(prompt);
assert.equal(sliceStringByCellColumns(line, 0, promptCells), prompt);
assert.equal(sliceStringByCellColumns(line, promptCells), "部署");
// Padding spaces after the content must not leak into the cursor prefix.
const padded = line + " ".repeat(20);
assert.equal(
sliceStringByCellColumns(padded, 0, stringCellWidth(line)),
line,
);
});
test("stringCellWidth collapses ZWJ emoji to one wide grapheme", () => {
assert.equal(stringCellWidth("👨‍💻"), 2);
});
@@ -1692,6 +1692,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
if (!shouldInterceptMouseTrackingContextMenu({
event: e,
mouseTracking: mouseTrackingRef.current,
terminalMouseTrackingMode: termRef.current?.modes.mouseTrackingMode,
status: statusRef.current,
rightClickBehavior: terminalSettingsRef.current?.rightClickBehavior,
forceMenuInAlternateScreen: terminalSettingsRef.current?.showContextMenuOverFullscreenApps,
@@ -1724,6 +1725,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
if (rightClickPressClaim.noteMouseDown({
event: e,
mouseTracking: mouseTrackingRef.current,
terminalMouseTrackingMode: termRef.current?.modes.mouseTrackingMode,
status: statusRef.current,
rightClickBehavior: terminalSettingsRef.current?.rightClickBehavior,
forceMenuInAlternateScreen: terminalSettingsRef.current?.showContextMenuOverFullscreenApps,
@@ -7,3 +7,31 @@ const source = readFileSync(new URL('./TerminalFocusSidebar.tsx', import.meta.ur
test('focus sidebar row memo refreshes when dynamic title mode changes', () => {
assert.match(source, /prev\.dynamicTabTitleMode === next\.dynamicTabTitleMode/);
});
test('focus sidebar accepts host-id drops for append-to-workspace', () => {
assert.match(source, /onAppendHostToWorkspace/);
assert.match(source, /resolveFocusSidebarDragKind/);
assert.match(source, /appendHostFromWorkspaceDrop/);
assert.match(source, /dropEffect = 'copy'/);
assert.match(source, /data-host-drop-active/);
});
test('the entire focus sidebar is the host drop target', () => {
const sidebarStart = source.indexOf('data-section="terminal-workspace-sidebar"');
const scrollAreaStart = source.indexOf('<ScrollArea className="flex-1">', sidebarStart);
assert.ok(sidebarStart >= 0 && scrollAreaStart > sidebarStart);
const sidebarOpeningTag = source.slice(sidebarStart, scrollAreaStart);
assert.match(sidebarOpeningTag, /data-focus-sidebar-drop-zone/);
assert.match(sidebarOpeningTag, /onDragOver=\{handleFocusSidebarHostDragOver\}/);
assert.match(sidebarOpeningTag, /onDrop=\{handleFocusSidebarHostDrop\}/);
assert.doesNotMatch(source.slice(scrollAreaStart), /\sdata-focus-sidebar-drop-zone(?:\s|>)/);
});
test('focus sidebar clears host-drop feedback when the drag leaves from a session row', () => {
assert.match(source, /data-focus-sidebar-drop-zone/);
assert.match(source, /onDragLeave=\{handleFocusSidebarHostDragLeave\}/);
assert.match(source, /onHostDragLeave=\{handleFocusSidebarHostDragLeave\}/);
assert.match(source, /dropZone\?\.contains\(next\)/);
assert.match(source, /clearFocusSidebarHostDrop\(\)/);
});
+111 -14
View File
@@ -7,6 +7,11 @@ import {
} from '../../application/state/sessionPresentationStore';
import { useStoredNumber } from '../../application/state/useStoredNumber';
import { terminalReconnectRegistry } from '../../application/state/terminalReconnectRegistry';
import {
appendHostFromWorkspaceDrop,
FOCUS_SIDEBAR_SESSION_DRAG_TYPE,
resolveFocusSidebarDragKind,
} from '../../domain/focusSidebarHostDrop';
import { resolveWorkspaceFocusSessionOrder } from '../../domain/workspace';
import { resolveSessionTabTitle } from '../../domain/sessionTabTitle';
import type { DynamicTabTitleMode } from '../../domain/models';
@@ -27,6 +32,7 @@ interface TerminalFocusSidebarProps {
focusedSessionId: string | undefined;
onReorderWorkspaceSessions?: (workspaceId: string, draggedSessionId: string, targetSessionId: string, position: 'before' | 'after') => void;
onRequestAddToWorkspace?: (workspaceId: string) => void;
onAppendHostToWorkspace?: (workspaceId: string, hostId: string) => void;
onCloseSession: (sessionId: string) => void;
onCopySession?: (sessionId: string) => void;
onCopySessionToNewWindow?: (sessionId: string) => void;
@@ -71,6 +77,7 @@ type WorkspaceFocusSessionRowProps = {
onSelect: (sessionId: string) => void;
onDragStart: (event: DragEvent, sessionId: string) => void;
onDragOver: (event: DragEvent, sessionId: string) => void;
onHostDragLeave: (event: DragEvent<HTMLDivElement>) => void;
onDrop: (event: DragEvent, sessionId: string) => void;
onDragEnd: () => void;
dynamicTabTitleMode?: DynamicTabTitleMode;
@@ -96,6 +103,7 @@ const WorkspaceFocusSessionRow = memo<WorkspaceFocusSessionRowProps>(({
onSelect,
onDragStart,
onDragOver,
onHostDragLeave,
onDrop,
onDragEnd,
dynamicTabTitleMode,
@@ -142,9 +150,7 @@ const WorkspaceFocusSessionRow = memo<WorkspaceFocusSessionRowProps>(({
onContextMenu={() => onSelect(session.id)}
onDragStart={(event) => onDragStart(event, session.id)}
onDragOver={(event) => onDragOver(event, session.id)}
onDragLeave={(event) => {
event.stopPropagation();
}}
onDragLeave={onHostDragLeave}
onDrop={(event) => onDrop(event, session.id)}
onDragEnd={onDragEnd}
onMouseEnter={(event) => {
@@ -247,6 +253,7 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
focusedSessionId,
onReorderWorkspaceSessions,
onRequestAddToWorkspace,
onAppendHostToWorkspace,
onCloseSession,
onCopySession,
onCopySessionToNewWindow,
@@ -262,6 +269,7 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
}) => {
const [focusSidebarSearch, setFocusSidebarSearch] = useState('');
const [focusSidebarDragSessionId, setFocusSidebarDragSessionId] = useState<string | null>(null);
const [focusSidebarHostDropActive, setFocusSidebarHostDropActive] = useState(false);
const [focusSidebarDropIndicator, setFocusSidebarDropIndicator] = useState<{
sessionId: string;
position: 'before' | 'after';
@@ -343,10 +351,23 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
const handleFocusSidebarDragStart = useCallback((event: DragEvent, sessionId: string) => {
event.stopPropagation();
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('workspace-focus-session-id', sessionId);
event.dataTransfer.setData(FOCUS_SIDEBAR_SESSION_DRAG_TYPE, sessionId);
setFocusSidebarDragSessionId(sessionId);
setFocusSidebarHostDropActive(false);
}, []);
const clearFocusSidebarHostDrop = useCallback(() => {
setFocusSidebarHostDropActive(false);
}, []);
const handleFocusSidebarHostDragLeave = useCallback((event: DragEvent<HTMLDivElement>) => {
event.stopPropagation();
const dropZone = event.currentTarget.closest<HTMLElement>('[data-focus-sidebar-drop-zone]');
const next = event.relatedTarget;
if (next instanceof Node && dropZone?.contains(next)) return;
clearFocusSidebarHostDrop();
}, [clearFocusSidebarHostDrop]);
const getFocusSidebarContainerDropTarget = useCallback((
container: HTMLElement,
clientY: number,
@@ -379,21 +400,45 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
return lastSessionId ? { sessionId: lastSessionId, position: 'after' } : null;
}, []);
const acceptFocusSidebarHostDrag = useCallback((event: DragEvent) => {
if (!onAppendHostToWorkspace) return false;
if (resolveFocusSidebarDragKind({
types: event.dataTransfer.types,
activeSessionDragId: focusSidebarDragSessionId,
}) !== 'host-append') {
return false;
}
event.preventDefault();
event.dataTransfer.dropEffect = 'copy';
setFocusSidebarDropIndicator(null);
setFocusSidebarHostDropActive(true);
return true;
}, [focusSidebarDragSessionId, onAppendHostToWorkspace]);
const handleFocusSidebarHostDragOver = useCallback((event: DragEvent<HTMLDivElement>) => {
acceptFocusSidebarHostDrag(event);
}, [acceptFocusSidebarHostDrag]);
const handleFocusSidebarDragOver = useCallback((event: DragEvent, targetSessionId: string) => {
const draggedSessionId = event.dataTransfer.getData('workspace-focus-session-id') || focusSidebarDragSessionId;
if (acceptFocusSidebarHostDrag(event)) return;
const draggedSessionId = event.dataTransfer.getData(FOCUS_SIDEBAR_SESSION_DRAG_TYPE) || focusSidebarDragSessionId;
if (!draggedSessionId || draggedSessionId === targetSessionId) return;
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = 'move';
setFocusSidebarHostDropActive(false);
const rect = event.currentTarget.getBoundingClientRect();
const position = event.clientY < rect.top + rect.height / 2 ? 'before' : 'after';
setFocusSidebarDropIndicator({ sessionId: targetSessionId, position });
}, [focusSidebarDragSessionId]);
}, [acceptFocusSidebarHostDrag, focusSidebarDragSessionId]);
const handleFocusSidebarContainerDragOver = useCallback((event: DragEvent<HTMLDivElement>) => {
const draggedSessionId = event.dataTransfer.getData('workspace-focus-session-id') || focusSidebarDragSessionId;
if (acceptFocusSidebarHostDrag(event)) return;
const draggedSessionId = event.dataTransfer.getData(FOCUS_SIDEBAR_SESSION_DRAG_TYPE) || focusSidebarDragSessionId;
if (!draggedSessionId) return;
const target = getFocusSidebarContainerDropTarget(event.currentTarget, event.clientY, draggedSessionId);
@@ -401,11 +446,36 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
setFocusSidebarHostDropActive(false);
setFocusSidebarDropIndicator(target);
}, [focusSidebarDragSessionId, getFocusSidebarContainerDropTarget]);
}, [acceptFocusSidebarHostDrag, focusSidebarDragSessionId, getFocusSidebarContainerDropTarget]);
const appendHostFromFocusSidebarDrop = useCallback((event: DragEvent) => {
if (!onAppendHostToWorkspace) return false;
const handled = appendHostFromWorkspaceDrop({
types: event.dataTransfer.types,
getData: (type) => event.dataTransfer.getData(type),
activeSessionDragId: focusSidebarDragSessionId,
workspaceId: activeWorkspace.id,
onAppendHostToWorkspace,
});
if (!handled) return false;
event.preventDefault();
event.stopPropagation();
setFocusSidebarHostDropActive(false);
setFocusSidebarDropIndicator(null);
return true;
}, [activeWorkspace.id, focusSidebarDragSessionId, onAppendHostToWorkspace]);
const handleFocusSidebarHostDrop = useCallback((event: DragEvent<HTMLDivElement>) => {
appendHostFromFocusSidebarDrop(event);
}, [appendHostFromFocusSidebarDrop]);
const handleFocusSidebarContainerDrop = useCallback((event: DragEvent<HTMLDivElement>) => {
const draggedSessionId = event.dataTransfer.getData('workspace-focus-session-id') || focusSidebarDragSessionId;
if (appendHostFromFocusSidebarDrop(event)) return;
const draggedSessionId = event.dataTransfer.getData(FOCUS_SIDEBAR_SESSION_DRAG_TYPE) || focusSidebarDragSessionId;
if (!draggedSessionId) return;
const target = focusSidebarDropIndicator
@@ -416,8 +486,10 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
onReorderWorkspaceSessions?.(activeWorkspace.id, draggedSessionId, target.sessionId, target.position);
setFocusSidebarDragSessionId(null);
setFocusSidebarDropIndicator(null);
setFocusSidebarHostDropActive(false);
}, [
activeWorkspace.id,
appendHostFromFocusSidebarDrop,
focusSidebarDragSessionId,
focusSidebarDropIndicator,
getFocusSidebarContainerDropTarget,
@@ -425,7 +497,9 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
]);
const handleFocusSidebarDrop = useCallback((event: DragEvent, targetSessionId: string) => {
const draggedSessionId = event.dataTransfer.getData('workspace-focus-session-id') || focusSidebarDragSessionId;
if (appendHostFromFocusSidebarDrop(event)) return;
const draggedSessionId = event.dataTransfer.getData(FOCUS_SIDEBAR_SESSION_DRAG_TYPE) || focusSidebarDragSessionId;
if (!draggedSessionId || draggedSessionId === targetSessionId) return;
event.preventDefault();
@@ -437,11 +511,19 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
onReorderWorkspaceSessions?.(activeWorkspace.id, draggedSessionId, targetSessionId, position);
setFocusSidebarDragSessionId(null);
setFocusSidebarDropIndicator(null);
}, [activeWorkspace.id, focusSidebarDragSessionId, focusSidebarDropIndicator, onReorderWorkspaceSessions]);
setFocusSidebarHostDropActive(false);
}, [
activeWorkspace.id,
appendHostFromFocusSidebarDrop,
focusSidebarDragSessionId,
focusSidebarDropIndicator,
onReorderWorkspaceSessions,
]);
const handleFocusSidebarDragEnd = useCallback(() => {
setFocusSidebarDragSessionId(null);
setFocusSidebarDropIndicator(null);
setFocusSidebarHostDropActive(false);
}, []);
const handleSelectSession = useCallback((sessionId: string) => {
@@ -469,14 +551,27 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
return (
<div
className="flex-shrink-0 flex flex-col relative"
className={cn(
'flex-shrink-0 flex flex-col relative transition-[box-shadow,background-color]',
focusSidebarHostDropActive && 'ring-1 ring-inset',
)}
style={{
width: focusSidebarWidth,
backgroundColor: theme.termBg,
backgroundColor: focusSidebarHostDropActive
? `color-mix(in srgb, ${theme.termFg} 8%, ${theme.termBg})`
: theme.termBg,
color: theme.termFg,
boxShadow: focusSidebarHostDropActive
? `inset 0 0 0 1px color-mix(in srgb, ${theme.termFg} 28%, transparent)`
: undefined,
['--terminal-workspace-sidebar-border' as string]: `1px solid ${theme.separator}`,
}}
data-section="terminal-workspace-sidebar"
data-focus-sidebar-drop-zone
data-host-drop-active={focusSidebarHostDropActive ? 'true' : 'false'}
onDragOver={handleFocusSidebarHostDragOver}
onDragLeave={handleFocusSidebarHostDragLeave}
onDrop={handleFocusSidebarHostDrop}
>
<div
className="absolute top-0 right-[-3px] h-full w-2 cursor-ew-resize z-30"
@@ -534,7 +629,7 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
<ScrollArea className="flex-1">
<div
className="p-2 space-y-1"
className="p-2 space-y-1 min-h-full rounded-md"
onDragOver={handleFocusSidebarContainerDragOver}
onDrop={handleFocusSidebarContainerDrop}
>
@@ -563,6 +658,7 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
onSelect={handleSelectSession}
onDragStart={handleFocusSidebarDragStart}
onDragOver={handleFocusSidebarDragOver}
onHostDragLeave={handleFocusSidebarHostDragLeave}
onDrop={handleFocusSidebarDrop}
onDragEnd={handleFocusSidebarDragEnd}
dynamicTabTitleMode={dynamicTabTitleMode}
@@ -592,6 +688,7 @@ function terminalFocusSidebarPropsEqual(
if (prev.t !== next.t) return false;
if (prev.onReorderWorkspaceSessions !== next.onReorderWorkspaceSessions) return false;
if (prev.onRequestAddToWorkspace !== next.onRequestAddToWorkspace) return false;
if (prev.onAppendHostToWorkspace !== next.onAppendHostToWorkspace) return false;
if (prev.onSetWorkspaceFocusedSession !== next.onSetWorkspaceFocusedSession) return false;
if (prev.onToggleWorkspaceViewMode !== next.onToggleWorkspaceViewMode) return false;
const prevWs = prev.activeWorkspace;
@@ -22,6 +22,7 @@ const {
getTerminalHostTreeInitialLayoutWidth,
getTerminalHostTreeLayoutTargetWidth,
getTerminalHostTreeMeasuredLayoutWidth,
resolveTerminalHostTreeDragCapabilities,
getTerminalHostTreeSidebarPanelStyle,
getTerminalHostTreeSidebarShellStyle,
isTerminalHostTreeSidebarVisible,
@@ -186,6 +187,25 @@ test('host tree host inline rename rejects empty names without changing hosts',
assert.equal(result.hosts, hosts);
});
test('filtered host rows can drag outward without enabling filtered tree reorder', () => {
assert.deepEqual(resolveTerminalHostTreeDragCapabilities({
kind: 'host',
canReorder: false,
isInlineEditing: false,
}), {
draggable: true,
canAcceptDrop: false,
});
assert.deepEqual(resolveTerminalHostTreeDragCapabilities({
kind: 'group',
canReorder: false,
isInlineEditing: false,
}), {
draggable: false,
canAcceptDrop: false,
});
});
test('host tree hover card is hidden while the same host is inline editing', () => {
assert.equal(shouldShowTerminalHostHoverCard('host-1', null), true);
assert.equal(shouldShowTerminalHostHoverCard('host-1', 'host-2'), true);
@@ -217,3 +237,17 @@ test('host tree row icons, labels, and protocol badges share centered line boxes
assert.match(source, /flex h-5 shrink-0 items-center">\s*\{isExpanded/);
assert.match(source, /flex min-w-0 flex-1 items-center truncate leading-5">\{node\.name\}/);
});
test('filtered host rows can still start host-id drag for focus-sidebar append', () => {
const source = readFileSync(new URL('./TerminalHostTreeSidebar.tsx', import.meta.url), 'utf8');
const hostRowStart = source.indexOf('data-row-type="host"');
const groupRowStart = source.indexOf('data-row-type="group"');
assert.ok(hostRowStart >= 0 && groupRowStart > hostRowStart);
const hostRowBlock = source.slice(hostRowStart, groupRowStart);
assert.match(hostRowBlock, /draggable=\{dragCapabilities\.draggable\}/);
assert.match(hostRowBlock, /effectAllowed = canReorder \? 'copyMove' : 'copy'/);
assert.match(hostRowBlock, /if \(!dragCapabilities\.draggable\) return;/);
assert.match(source, /const canReorder = Boolean\(menuActions\) && !searchActive && !tagsActive;/);
assert.match(source, /canAcceptDrop: input\.canReorder/);
});
@@ -104,6 +104,17 @@ type HostTreeDropTarget =
| { kind: 'root' }
| { kind: 'group'; path: string };
export function resolveTerminalHostTreeDragCapabilities(input: {
kind: HostTreeFlatRow['kind'];
canReorder: boolean;
isInlineEditing: boolean;
}): { draggable: boolean; canAcceptDrop: boolean } {
return {
draggable: !input.isInlineEditing && (input.kind === 'host' || input.canReorder),
canAcceptDrop: input.canReorder,
};
}
interface TerminalHostTreeSidebarProps {
enabled?: boolean;
surfaceVisible?: boolean;
@@ -309,7 +320,7 @@ type HostTreeFlatRowProps = {
activeHostId?: string | null;
expandedPaths: Set<string>;
searchActive: boolean;
canDrag: boolean;
canReorder: boolean;
isDragOver: boolean;
isInlineEditing: boolean;
inlineEditInitialName?: string;
@@ -331,7 +342,7 @@ const HostTreeFlatRowItem = memo<HostTreeFlatRowProps>(({
activeHostId,
expandedPaths,
searchActive,
canDrag,
canReorder,
isDragOver,
isInlineEditing,
inlineEditInitialName,
@@ -348,6 +359,11 @@ const HostTreeFlatRowItem = memo<HostTreeFlatRowProps>(({
menuActions,
}) => {
const { t } = useI18n();
const dragCapabilities = resolveTerminalHostTreeDragCapabilities({
kind: row.kind,
canReorder,
isInlineEditing,
});
if (row.kind === 'host') {
const isActive = activeHostId === row.host.id;
@@ -373,14 +389,18 @@ const HostTreeFlatRowItem = memo<HostTreeFlatRowProps>(({
paddingLeft: row.depth * 16 + 8,
backgroundColor: isActive ? theme.rowActiveBg : (isDragOver ? theme.rowDropBg : undefined),
}}
draggable={canDrag && !isInlineEditing}
// Host rows stay draggable under search/tag filters so focus-sidebar
// append can still receive host-id; in-tree reorder/drop stays disabled.
draggable={dragCapabilities.draggable}
onDragStart={(event) => {
if (!canDrag || isInlineEditing) return;
if (!dragCapabilities.draggable) return;
event.dataTransfer.setData(HOST_TREE_DRAG_HOST_ID, row.host.id);
event.dataTransfer.effectAllowed = 'move';
// copyMove: tree reorder uses move; focus-sidebar append uses copy.
// Filtered trees disable reorder, so allow copy-only for append.
event.dataTransfer.effectAllowed = canReorder ? 'copyMove' : 'copy';
}}
onDragOver={(event) => {
if (!canDrag) return;
if (!dragCapabilities.canAcceptDrop) return;
event.preventDefault();
event.stopPropagation();
if (hasDragType(event.dataTransfer, HOST_TREE_DRAG_HOST_ID)) {
@@ -393,7 +413,7 @@ const HostTreeFlatRowItem = memo<HostTreeFlatRowProps>(({
}}
onDragLeave={onDragLeaveRow}
onDrop={(event) => {
if (!canDrag) return;
if (!dragCapabilities.canAcceptDrop) return;
event.preventDefault();
event.stopPropagation();
clearHostTreeDropIndicators(event.currentTarget.ownerDocument);
@@ -507,14 +527,14 @@ const HostTreeFlatRowItem = memo<HostTreeFlatRowProps>(({
color: theme.termFg,
backgroundColor: isDragOver ? theme.rowDropBg : undefined,
}}
draggable={canDrag && !isInlineEditing}
draggable={dragCapabilities.draggable}
onDragStart={(event) => {
if (!canDrag || isInlineEditing) return;
if (!dragCapabilities.draggable) return;
event.dataTransfer.setData(HOST_TREE_DRAG_GROUP_PATH, node.path);
event.dataTransfer.effectAllowed = 'move';
}}
onDragOver={(event) => {
if (!canDrag) return;
if (!dragCapabilities.canAcceptDrop) return;
event.preventDefault();
event.stopPropagation();
const isDraggingGroup = hasDragType(event.dataTransfer, HOST_TREE_DRAG_GROUP_PATH);
@@ -529,7 +549,7 @@ const HostTreeFlatRowItem = memo<HostTreeFlatRowProps>(({
}}
onDragLeave={onDragLeaveRow}
onDrop={(event) => {
if (!canDrag) return;
if (!dragCapabilities.canAcceptDrop) return;
event.preventDefault();
event.stopPropagation();
clearHostTreeDropIndicators(event.currentTarget.ownerDocument);
@@ -615,7 +635,7 @@ const HostTreeFlatRowItem = memo<HostTreeFlatRowProps>(({
if (prev.row !== next.row) return false;
if (prev.expandedPaths !== next.expandedPaths) return false;
if (prev.searchActive !== next.searchActive) return false;
if (prev.canDrag !== next.canDrag) return false;
if (prev.canReorder !== next.canReorder) return false;
if (prev.isDragOver !== next.isDragOver) return false;
if (prev.isInlineEditing !== next.isInlineEditing) return false;
if (prev.inlineEditInitialName !== next.inlineEditInitialName) return false;
@@ -739,7 +759,7 @@ const TerminalHostTreeSidebarInner: React.FC<TerminalHostTreeSidebarProps> = ({
});
}, [expandedPaths, filteredTree, filteredUngrouped, treeExpandAll]);
const canDrag = Boolean(menuActions) && !searchActive && !tagsActive;
const canReorder = Boolean(menuActions) && !searchActive && !tagsActive;
const handleNewRootGroup = useCallback(() => {
if (!menuActions) return;
@@ -826,10 +846,10 @@ const TerminalHostTreeSidebarInner: React.FC<TerminalHostTreeSidebarProps> = ({
}, [clearDragOver]);
const handleRootDragOver = useCallback((event: React.DragEvent<HTMLDivElement>) => {
if (!canDrag) return;
if (!canReorder) return;
event.preventDefault();
setDragOverTarget({ kind: 'root' });
}, [canDrag]);
}, [canReorder]);
const handleRootDragLeave = useCallback((event: React.DragEvent<HTMLDivElement>) => {
const nextTarget = event.relatedTarget;
@@ -841,11 +861,11 @@ const TerminalHostTreeSidebarInner: React.FC<TerminalHostTreeSidebarProps> = ({
}, [clearDragOver]);
const handleRootDrop = useCallback((event: React.DragEvent<HTMLDivElement>) => {
if (!canDrag) return;
if (!canReorder) return;
event.preventDefault();
clearHostTreeDropIndicators(event.currentTarget.ownerDocument);
handleDropToParent(null, event.dataTransfer);
}, [canDrag, handleDropToParent]);
}, [canReorder, handleDropToParent]);
const handleListPointerDownCapture = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
if (!menuActions) return;
@@ -907,7 +927,7 @@ const TerminalHostTreeSidebarInner: React.FC<TerminalHostTreeSidebarProps> = ({
activeHostId={activeHostId}
expandedPaths={expandedPaths}
searchActive={treeExpandAll}
canDrag={canDrag}
canReorder={canReorder}
isDragOver={isRowDragOver(row)}
isInlineEditing={
(row.kind === 'group' && inlineEdit?.groupPath === row.node.path)
@@ -934,7 +954,7 @@ const TerminalHostTreeSidebarInner: React.FC<TerminalHostTreeSidebarProps> = ({
/>
), [
activeHostId,
canDrag,
canReorder,
clearDragOver,
expandedPaths,
inlineEdit,
@@ -15,6 +15,7 @@ function TerminalLayerFocusSidebarSectionInner({ ctx }: { ctx: FocusSidebarConte
focusedSessionId={ctx.focusedSessionId}
onReorderWorkspaceSessions={ctx.onReorderWorkspaceSessions}
onRequestAddToWorkspace={ctx.onRequestAddToWorkspace}
onAppendHostToWorkspace={ctx.onAppendHostToWorkspace}
onCloseSession={ctx.handleCloseSession}
onCopySession={ctx.onCopySession}
onCopySessionToNewWindow={ctx.onCopySessionToNewWindow}
@@ -165,6 +165,17 @@ test('side panel content scopes app color utilities to the resolved terminal the
assert.match(sectionSource, /\.\.\.sidePanelCssVars/);
});
test('side panel sets color-scheme from the terminal theme so native inputs match light panels', () => {
const sectionSource = readFileSync(new URL('./TerminalLayerSidePanelSection.tsx', import.meta.url), 'utf8');
// When a light terminal theme remaps --background under a dark app chrome,
// form controls (Codex/AI composer textarea) still follow color-scheme.
assert.match(
sectionSource,
/colorScheme:\s*resolvedSidePanelTerminalTheme\.type/,
);
});
test('a visible tool without a ready pane host stays in the hidden parking host', () => {
const parkingHost = { id: 'parking' };
const paneHost = { id: 'pane' };
@@ -987,6 +987,9 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
data-side-panel-tab={isSidePanelOpenForCurrentTab ? (activeSidePanelTab ?? undefined) : undefined}
style={{
...sidePanelCssVars,
// Keep native form controls (AI/Codex composer textarea) in sync when a
// light terminal theme remaps --background under dark app chrome.
colorScheme: resolvedSidePanelTerminalTheme.type,
backgroundColor: sidePanelTheme.termBg,
color: sidePanelTheme.termFg,
...(isSidePanelOpenForCurrentTab && sidePanelPosition === 'left'
@@ -671,6 +671,7 @@ export interface TerminalLayerProps {
onCreateWorkspaceFromSessions: (baseSessionId: string, joiningSessionId: string, hint: Exclude<SplitHint, null>) => void;
onAddSessionToWorkspace: (workspaceId: string, sessionId: string, hint: Exclude<SplitHint, null>) => void;
onRequestAddToWorkspace?: (workspaceId: string) => void;
onAppendHostToWorkspace?: (workspaceId: string, hostId: string) => void;
onUpdateSplitSizes: (workspaceId: string, splitId: string, sizes: number[]) => void;
onSetDraggingSessionId: (id: string | null) => void;
onToggleWorkspaceViewMode?: (workspaceId: string) => void;
@@ -377,6 +377,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
sidePanelWidth: s.sidePanelWidth,
sftpActiveHost,
sftpHostForTab,
sftpPaneClosedTabIdsRef: s.sftpPaneClosedTabIdsRef,
shouldMarkSessionActivity: s.shouldMarkSessionActivity,
sidePanelOpenTabs,
splitHorizontalHandlersRef: s.splitHorizontalHandlersRef,
@@ -536,6 +537,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
onUpdateSessionDynamicTitle: s.onUpdateSessionDynamicTitle,
onUpdateSessionCodingCliProvider: s.onUpdateSessionCodingCliProvider,
onRequestAddToWorkspace: s.onRequestAddToWorkspace,
onAppendHostToWorkspace: s.onAppendHostToWorkspace,
onSetWorkspaceFocusedSession: s.onSetWorkspaceFocusedSession,
onStartSessionRename: s.onStartSessionRename,
onSubmitSessionRename: s.onSubmitSessionRename,
@@ -581,6 +583,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
sftpFollowTerminalCwd: s.sftpFollowTerminalCwd,
sftpInitialLocationForTab: s.sftpInitialLocationForTab,
sftpPendingUploadsForTab: s.sftpPendingUploadsForTab,
sftpPaneClosedTabIdsRef: s.sftpPaneClosedTabIdsRef,
sftpShowHiddenFiles: s.sftpShowHiddenFiles,
SftpSidePanel: s.SftpSidePanel,
sftpUseCompressedUpload: s.sftpUseCompressedUpload,
@@ -3,6 +3,12 @@ import { readFileSync } from "node:fs";
import test from "node:test";
import type { TransferTask } from "../../domain/models";
import {
closeSidePanelPane,
collectSidePanelPanes,
createSidePanelLayout,
splitSidePanelPane,
} from "../../domain/sidePanelLayout.ts";
import {
SFTP_TRANSFER_HISTORY_RETENTION_MS,
countTransfersRetainingSftpOwner,
@@ -13,6 +19,8 @@ import {
shouldCloseSftpSidePanel,
shouldClearSftpPanelAfterTransferChange,
shouldKeepSftpMountedAfterClose,
shouldKeepSftpBrowseSessionInteractive,
shouldMarkSftpPaneClosed,
shouldScheduleSftpRetainedPanelCleanup,
terminalSftpTransferOwnerId,
} from "./sftpPanelLifecycle.ts";
@@ -43,6 +51,15 @@ test("single-pane SFTP close uses the shared full-panel cleanup and stops openin
assert.match(layerSource, /closeTerminalSidePanelTab[\s\S]*setAiMountedTabIds[\s\S]*setNotesOpenNoteByTab/);
});
test("all SFTP reopen paths clear the split-close marker", () => {
const layerSource = readFileSync(new URL("../TerminalLayer.tsx", import.meta.url), "utf8");
const effectsSource = readFileSync(new URL("./useTerminalLayerEffects.ts", import.meta.url), "utf8");
assert.match(layerSource, /if \(targetPanel === 'sftp'\) \{\s*sftpPaneClosedTabIdsRef\.current\.delete\(tabId\)/);
assert.match(effectsSource, /const applySftpTargetOnTab[\s\S]*sftpPaneClosedTabIdsRef\.current\.delete\(tabId\)/);
assert.match(effectsSource, /navigation\.kind === 'local-copy-panel'[\s\S]*sftpPaneClosedTabIdsRef\.current\.delete\(currentTabId!\)/);
});
function task(
partial: Partial<TransferTask> & Pick<TransferTask, "id" | "status">,
): Pick<TransferTask, "id" | "status" | "parentTaskId" | "ownerId"> {
@@ -74,6 +91,62 @@ test("closing an idle panel still releases its SFTP state", () => {
assert.equal(shouldKeepSftpMountedAfterClose({ activeTransfersCount: 0 }), false);
});
test("closing SFTP keeps another tool from reviving its browse session", () => {
assert.equal(shouldKeepSftpBrowseSessionInteractive({
sidePanelOpen: true,
retainedAfterClose: false,
sftpPaneClosed: false,
}), true);
assert.equal(shouldKeepSftpBrowseSessionInteractive({
sidePanelOpen: true,
retainedAfterClose: true,
sftpPaneClosed: false,
}), false);
assert.equal(shouldKeepSftpBrowseSessionInteractive({
sidePanelOpen: true,
retainedAfterClose: false,
sftpPaneClosed: true,
}), false);
assert.equal(shouldKeepSftpBrowseSessionInteractive({
sidePanelOpen: false,
retainedAfterClose: true,
sftpPaneClosed: false,
}), false);
});
test("closing only the SFTP split pane parks its browse session while the other pane stays open", () => {
let layout = createSidePanelLayout("sftp", "pane-sftp");
layout = splitSidePanelPane(layout, "pane-sftp", "history", "vertical", {
paneId: "pane-history",
splitId: "split-root",
}, 400);
const sftpPane = collectSidePanelPanes(layout.root).find((pane) => pane.tool === "sftp");
assert.ok(sftpPane);
const remaining = closeSidePanelPane(layout, sftpPane.id);
assert.ok(remaining);
assert.deepEqual(collectSidePanelPanes(remaining.root).map((pane) => pane.tool), ["history"]);
let sftpPaneClosed = shouldMarkSftpPaneClosed({
closingPaneTool: sftpPane.tool,
closesWholePanel: false,
});
assert.equal(shouldKeepSftpBrowseSessionInteractive({
sidePanelOpen: true,
retainedAfterClose: false,
sftpPaneClosed,
}), false);
sftpPaneClosed = false;
assert.equal(shouldKeepSftpBrowseSessionInteractive({
sidePanelOpen: true,
retainedAfterClose: false,
sftpPaneClosed,
}), true);
assert.equal(shouldMarkSftpPaneClosed({
closingPaneTool: sftpPane.tool,
closesWholePanel: true,
}), false);
});
test("a transfer retained by close keeps its history after completion", () => {
assert.equal(shouldClearSftpPanelAfterTransferChange({
activeTransfersCount: 0,
@@ -214,6 +287,7 @@ test("terminal side panel reports transfer activity and uses store-backed retain
assert.doesNotMatch(panelSource, /useEffect\(\(\) => \(\) => \{\s*onActiveTransfersChange\?\.\(0\);\s*\}, \[onActiveTransfersChange\]\)/);
assert.match(panelSource, /interactive:\s*isBrowseSessionInteractive\(\{/);
assert.match(panelSource, /surfaceVisible:\s*isVisible/);
assert.match(panelSource, /ownerPanelOpen/);
assert.match(panelSource, /useEditorTabPresenceRevision\(\)/);
assert.match(panelSource, /hasOwnedEditorTab/);
assert.match(panelSource, /hasActiveExternalEdit/);
@@ -228,10 +302,15 @@ test("terminal side panel reports transfer activity and uses store-backed retain
assert.match(layerSource, /sftpActiveExternalEditsByTabRef/);
assert.match(layerSource, /sftpRetainedAfterCloseTabIdsRef/);
assert.match(layerSource, /sftpRetainedCleanupTimersRef/);
// Hidden UI parks browse channels; transfers keep pool / leased sessions.
// External editor temps must also block park (closeSftp deletes those files).
// Hidden UI parks browse channels only after the side panel closes;
// tool switches keep browse warm via ownerPanelOpen. Transfers keep pool /
// leased sessions. External editor temps must also block park (closeSftp
// deletes those files).
assert.match(stateSource, /shouldParkBrowseSessions/);
assert.match(stateSource, /activeExternalEditCount/);
assert.match(stateSource, /takeBrowseSessionsForClose/);
assert.match(stateSource, /shouldRestoreBrowseSessions/);
assert.match(slotsSource, /sftpRetainedAfterCloseTabIdsRef/);
assert.match(slotsSource, /sftpPaneClosedTabIdsRef/);
assert.match(slotsSource, /shouldKeepSftpBrowseSessionInteractive\(/);
});
@@ -65,6 +65,28 @@ export function shouldKeepSftpMountedAfterClose(params: {
|| (params.activeExternalEditCount ?? 0) > 0;
}
/**
* A different side-panel tool keeps SFTP warm only when the SFTP owner was
* never closed. A retained-after-close mount is kept for transfers/editor
* cleanup, but its browse session must still be allowed to park.
*/
export function shouldKeepSftpBrowseSessionInteractive(params: {
sidePanelOpen: boolean;
retainedAfterClose: boolean;
sftpPaneClosed: boolean;
}): boolean {
return params.sidePanelOpen
&& !params.retainedAfterClose
&& !params.sftpPaneClosed;
}
export function shouldMarkSftpPaneClosed(params: {
closingPaneTool: string | null | undefined;
closesWholePanel: boolean;
}): boolean {
return !params.closesWholePanel && params.closingPaneTool === 'sftp';
}
export function shouldCloseSftpSidePanel(params: {
shouldKeepOpen: boolean;
isOpen: boolean;
@@ -35,8 +35,11 @@ import {
type SidePanelLayout,
} from '../../domain/sidePanelLayout';
import { sidePanelHiddenNotesPanelClassName, sidePanelHiddenPanelClassName } from './terminalLayerSidePanelHiddenWrapper';
import { shouldKeepSftpBrowseSessionInteractive } from './sftpPanelLifecycle';
type SidePanelStableContext = Record<string, any>;
type SidePanelStableContext = Record<string, any> & {
sftpPaneClosedTabIdsRef: React.MutableRefObject<Set<string>>;
};
const navigatorPlatform = typeof navigator !== 'undefined' ? navigator.platform : '';
const EMPTY_VAULT_NOTES: never[] = [];
const EMPTY_VAULT_HOSTS: never[] = [];
@@ -61,10 +64,13 @@ function SidePanelSftpSlotInner({
tabId,
ctx,
isVisible,
ownerPanelOpen,
}: {
tabId: string;
ctx: SidePanelStableContext;
isVisible: boolean;
/** Side panel still open for this tab (may be showing another tool). */
ownerPanelOpen: boolean;
}) {
const live = useSidePanelLiveSnapshotForTab(tabId, isVisible);
@@ -195,6 +201,7 @@ function SidePanelSftpSlotInner({
onActiveExternalEditsChange={handleActiveExternalEditsChange}
showWorkspaceHostHeader={isVisible && !!live.activeWorkspace}
isVisible={isVisible}
ownerPanelOpen={ownerPanelOpen}
renderOverlays={isVisible}
pendingUpload={sftpPendingUploadsForTab.get(tabId) ?? null}
onPendingUploadHandled={handlePendingUploadHandledForTab}
@@ -648,6 +655,16 @@ export function SidePanelMountedContent({
activeTabStore.getActiveTabId,
);
const layouts = ctx.sidePanelLayouts as Map<string, SidePanelLayout>;
const openTabs = ctx.sidePanelOpenTabs as Map<string, SidePanelTab>;
const retainedAfterCloseTabIdsRef = ctx.sftpRetainedAfterCloseTabIdsRef as
| React.MutableRefObject<ReadonlySet<string>>
| undefined;
const sftpPaneClosedTabIdsRef = ctx.sftpPaneClosedTabIdsRef;
const isSftpOwnerPanelOpen = (tabId: string) => shouldKeepSftpBrowseSessionInteractive({
sidePanelOpen: openTabs.has(tabId),
retainedAfterClose: retainedAfterCloseTabIdsRef?.current.has(tabId) ?? false,
sftpPaneClosed: sftpPaneClosedTabIdsRef.current.has(tabId),
});
const isToolVisible = (tabId: string, tool: SidePanelTab) => (
activeTabId === tabId && sidePanelLayoutHasTool(layouts.get(tabId), tool)
);
@@ -666,7 +683,12 @@ export function SidePanelMountedContent({
portalKey={`sftp-${tabId}`}
target={portalTarget(tabId, 'sftp')}
>
<SidePanelSftpSlot tabId={tabId} ctx={ctx} isVisible={isToolVisible(tabId, 'sftp')} />
<SidePanelSftpSlot
tabId={tabId}
ctx={ctx}
isVisible={isToolVisible(tabId, 'sftp')}
ownerPanelOpen={isSftpOwnerPanelOpen(tabId)}
/>
</PersistentSidePanelPortal>
))}
{systemMountedTabIds.map((tabId: string) => {
@@ -379,3 +379,26 @@ test("terminal layer focus sidebar re-renders when dynamic tab title mode change
false,
);
});
test("terminal layer focus sidebar re-renders when host-append handler changes", () => {
const baseCtx = {
isFocusMode: true,
activeWorkspace: workspace(),
focusedSessionId: "session-1",
resolvedPreviewTheme: {},
sessionHostsMap: new Map(),
sessions: [],
terminalSettings: { dynamicTabTitleMode: "agent" },
onAppendHostToWorkspace: undefined as
| ((workspaceId: string, hostId: string) => void)
| undefined,
};
assert.equal(
terminalLayerFocusSidebarPropsEqual(
baseCtx,
{ ...baseCtx, onAppendHostToWorkspace: () => {} },
),
false,
);
});
@@ -242,6 +242,8 @@ const SIDE_PANEL_STABLE_CTX_KEYS = [
'sidePanelOpenTabs',
'sidePanelLayouts',
'sftpHostForTab',
'sftpPaneClosedTabIdsRef',
'sftpRetainedAfterCloseTabIdsRef',
'effectiveHosts',
'hosts',
// SFTP Connected picker reads live terminal sessions from stable ctx.
@@ -484,6 +486,7 @@ export function terminalLayerFocusSidebarPropsEqual(prev: Ctx, next: Ctx): boole
&& eq(prev, next, 't')
&& eq(prev, next, 'onReorderWorkspaceSessions')
&& eq(prev, next, 'onRequestAddToWorkspace')
&& eq(prev, next, 'onAppendHostToWorkspace')
&& eq(prev, next, 'handleCloseSession')
&& eq(prev, next, 'onCopySession')
&& eq(prev, next, 'onCopySessionToNewWindow')
@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps */
import { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
import type { MutableRefObject } from 'react';
import { terminalLayoutSuppressStore } from '../../application/state/terminalLayoutSuppressStore';
import { terminalCwdStore } from '../../application/state/terminalCwdStore';
@@ -15,6 +16,7 @@ import { collectSidePanelPanes, sidePanelLayoutHasTool } from '../../domain/side
import { collectSessionIds } from '../../domain/workspace';
import {
moveSidePanelTabMap,
moveSidePanelTabSet,
remapMountedSidePanelTabIds,
remapSidePanelTabMap,
type SidePanelTabRemap,
@@ -23,7 +25,9 @@ import { AI_PANEL_FORCE_HIDE_SHELL } from '../ai/aiPanelDiagnostics';
import { toast } from '../ui/toast';
import { getTerminalSidePanelShellWidth } from './TerminalLayerSidePanelSection';
type TerminalLayerEffectsContext = Record<string, any>;
type TerminalLayerEffectsContext = Record<string, any> & {
sftpPaneClosedTabIdsRef: MutableRefObject<Set<string>>;
};
type RuntimeStateRef<T> = { current: Map<string, T> };
@@ -100,7 +104,7 @@ export function pruneTerminalTabMemoryState(
export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
const { openPath } = useSftpBackend();
const { activeSidePanelTab, activeSidePanelLayout, activeTabId, activeTabIdRef, activeWorkspace, activityTrackedSessions, cancelAnimationFrame, ChunkedEscapeFilter, clearTopTabsPreviewVars, document, dropHint, effectiveHosts, filterTabsMap, focusedSessionId, getSessionActivityIdsToClear, handleToggleAiFromTopBar, handleToggleScriptsSidePanel, handleToggleSidePanel, hasNotifiableTerminalOutput, isComposeBarOpen, isFocusMode, isTerminalLayerVisible, lastSidePanelTabRef, Map, onConnectToHost, onSessionData, onSplitSessionRef, onToggleBroadcastRef, onToggleWorkspaceViewModeRef, prevFocusedSessionIdRef, refocusActiveTerminalSession, requestAnimationFrame, ResizeObserver, sessionActivityStore, sessions, Set, setAiMountedTabIds, setDropHint, setNotesMountedTabIds, setScriptsMountedTabIds, setSystemMountedTabIds, setSftpHostForTab, setSftpInitialLocationForTab, setSftpPendingUploadsForTab, setSidePanelOpenTabs, setSidePanelLayouts, setThemeMountedTabIds, setWorkspaceArea, shouldMeasureTerminalLayerLayout, sidePanelPosition, sidePanelWidth, sftpActiveHost, sftpHostForTab, shouldMarkSessionActivity, sidePanelOpenTabs, splitHorizontalHandlersRef, splitVerticalHandlersRef, toggleScriptsSidePanelRef, toggleSidePanelRef, validAIScopeTargetIds, validSessionActivityIds, window, workspaceBroadcastHandlersRef, workspaceFocusHandlersRef, workspaceInnerRef, workspaces } = ctx;
const { activeSidePanelTab, activeSidePanelLayout, activeTabId, activeTabIdRef, activeWorkspace, activityTrackedSessions, cancelAnimationFrame, ChunkedEscapeFilter, clearTopTabsPreviewVars, document, dropHint, effectiveHosts, filterTabsMap, focusedSessionId, getSessionActivityIdsToClear, handleToggleAiFromTopBar, handleToggleScriptsSidePanel, handleToggleSidePanel, hasNotifiableTerminalOutput, isComposeBarOpen, isFocusMode, isTerminalLayerVisible, lastSidePanelTabRef, Map, onConnectToHost, onSessionData, onSplitSessionRef, onToggleBroadcastRef, onToggleWorkspaceViewModeRef, prevFocusedSessionIdRef, refocusActiveTerminalSession, requestAnimationFrame, ResizeObserver, sessionActivityStore, sessions, Set, setAiMountedTabIds, setDropHint, setNotesMountedTabIds, setScriptsMountedTabIds, setSystemMountedTabIds, setSftpHostForTab, setSftpInitialLocationForTab, setSftpPendingUploadsForTab, setSidePanelOpenTabs, setSidePanelLayouts, setThemeMountedTabIds, setWorkspaceArea, shouldMeasureTerminalLayerLayout, sidePanelPosition, sidePanelWidth, sftpActiveHost, sftpHostForTab, sftpPaneClosedTabIdsRef, shouldMarkSessionActivity, sidePanelOpenTabs, splitHorizontalHandlersRef, splitVerticalHandlersRef, toggleScriptsSidePanelRef, toggleSidePanelRef, validAIScopeTargetIds, validSessionActivityIds, window, workspaceBroadcastHandlersRef, workspaceFocusHandlersRef, workspaceInnerRef, workspaces } = ctx;
const activeWorkspaceId = activeWorkspace?.id;
const activeWorkspaceViewMode = activeWorkspace?.viewMode;
@@ -205,6 +209,15 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
}
return next;
});
let sftpOwners = sftpHostForTab as ReadonlyMap<string, any>;
for (const remap of remaps) {
sftpPaneClosedTabIdsRef.current = moveSidePanelTabSet(
sftpPaneClosedTabIdsRef.current,
remap,
{ ownerTabIds: new Set(sftpOwners.keys()) },
);
sftpOwners = moveSidePanelTabMap(sftpOwners, remap);
}
setAiMountedTabIds((prev: string[]) => {
let next = prev;
for (const remap of remaps) {
@@ -495,6 +508,7 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
useEffect(() => {
const applySftpTargetOnTab = (tabId: string, host: any, targetDirectory: string) => {
sftpPaneClosedTabIdsRef.current.delete(tabId);
// Bump initialLocation even when the host is already selected so the
// path-navigation effect re-runs after reopen.
setSftpHostForTab((prev: Map<string, any>) => new Map(prev).set(tabId, host));
@@ -577,6 +591,7 @@ export function useTerminalLayerEffects(ctx: TerminalLayerEffectsContext) {
toast.error('Open a terminal tab first to browse this transfer', 'SFTP');
return;
}
sftpPaneClosedTabIdsRef.current.delete(currentTabId!);
setSidePanelOpenTabs((prev: Map<string, any>) => new Map(prev).set(currentTabId!, 'sftp'));
return;
}
+74 -26
View File
@@ -311,6 +311,21 @@ WindowControls.displayName = 'WindowControls';
type TranslateFn = ReturnType<typeof useI18n>['t'];
type RenderBulkCloseItems = (anchorId: string) => React.ReactNode;
/** 1-9 badge aligned with Cmd/Ctrl+[1...9] tab switching. */
export const TabShortcutNumberBadge: React.FC<{ number: number }> = memo(({ number }) => (
<span
className="inline-flex h-3.5 min-w-3.5 shrink-0 items-center justify-center rounded px-0.5 font-mono text-[10px] font-semibold leading-none tabular-nums"
style={{
color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
backgroundColor: 'color-mix(in srgb, var(--top-tabs-fg, hsl(var(--foreground))) 12%, transparent)',
}}
aria-hidden="true"
>
{number}
</span>
));
TabShortcutNumberBadge.displayName = 'TabShortcutNumberBadge';
const TOP_TAB_COMFORT_EDGE_RATIO = 0.22;
const TOP_TAB_COMFORT_EDGE_MIN = 72;
const TOP_TAB_COMFORT_EDGE_MAX = 160;
@@ -379,9 +394,10 @@ interface RootTopTabProps {
icon: React.ReactNode;
className?: string;
compact?: boolean;
shortcutNumber?: number;
}
export const RootTopTab: React.FC<RootTopTabProps> = memo(({ tabId, label, icon, className, compact = false }) => {
export const RootTopTab: React.FC<RootTopTabProps> = memo(({ tabId, label, icon, className, compact = false, shortcutNumber }) => {
const isActive = useIsTabActive(tabId);
// The Vaults tab is the app's persistent "home", so keep its selected state
// visually flat — no active background fill (the label/icon still brighten to
@@ -432,7 +448,7 @@ export const RootTopTab: React.FC<RootTopTabProps> = memo(({ tabId, label, icon,
}
}}
>
{icon}
{shortcutNumber != null ? <TabShortcutNumberBadge number={shortcutNumber} /> : icon}
<span className={cn('top-tab-root-label', compact && 'top-tab-root-label-compact')}>
{label}
</span>
@@ -457,6 +473,7 @@ interface PluginViewTopTabProps {
onTabDragLeave(e: React.DragEvent): void;
onTabDrop(e: React.DragEvent, tabId: string): void;
tabAnimationClass?: string;
shortcutNumber?: number;
}
export const PluginViewTopTab: React.FC<PluginViewTopTabProps> = memo(({
@@ -475,6 +492,7 @@ export const PluginViewTopTab: React.FC<PluginViewTopTabProps> = memo(({
onTabDragLeave,
onTabDrop,
tabAnimationClass,
shortcutNumber,
}) => {
const isActive = useIsTabActive(tab.id);
const close = useCallback((event?: React.MouseEvent) => {
@@ -510,7 +528,9 @@ export const PluginViewTopTab: React.FC<PluginViewTopTabProps> = memo(({
{showDropIndicatorBefore && isDraggingForReorder && <div className="absolute -left-0.5 bottom-1 top-1 w-0.5 rounded-full bg-primary" />}
{showDropIndicatorAfter && isDraggingForReorder && <div className="absolute -right-0.5 bottom-1 top-1 w-0.5 rounded-full bg-primary" />}
<div className="flex min-w-0 flex-1 items-center gap-2">
<PluginContributionIcon pluginId={tab.pluginId} icon={tab.icon} className="shrink-0" />
{shortcutNumber != null
? <TabShortcutNumberBadge number={shortcutNumber} />
: <PluginContributionIcon pluginId={tab.pluginId} icon={tab.icon} className="shrink-0" />}
<span className="truncate leading-5">{tab.title}</span>
</div>
<button onClick={close} className="flex h-5 w-5 shrink-0 items-center justify-center rounded hover:bg-muted" aria-label={t('tabs.closePluginViewAria', { title: tab.title })}><X size={12} /></button>
@@ -550,6 +570,7 @@ interface EditorTopTabProps {
onTabDragLeave: (e: React.DragEvent) => void;
onTabDrop: (e: React.DragEvent, targetTabId: string) => void;
tabAnimationClass?: string;
shortcutNumber?: number;
}
export const EditorTopTab: React.FC<EditorTopTabProps> = memo(({
@@ -569,6 +590,7 @@ export const EditorTopTab: React.FC<EditorTopTabProps> = memo(({
onTabDragLeave,
onTabDrop,
tabAnimationClass,
shortcutNumber,
}) => {
const isActive = useIsTabActive(tabId);
// Dirty is store-driven so App/TopTabs structure can stay presence-only.
@@ -640,11 +662,15 @@ export const EditorTopTab: React.FC<EditorTopTabProps> = memo(({
/>
)}
<div className="flex items-center gap-2 min-w-0 flex-1">
<FileIcon
size={14}
className="shrink-0"
style={{ color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
/>
{shortcutNumber != null ? (
<TabShortcutNumberBadge number={shortcutNumber} />
) : (
<FileIcon
size={14}
className="shrink-0"
style={{ color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
/>
)}
<span className="flex items-center gap-0.5 truncate leading-5">
{dirty && <span className="mr-0.5 text-primary">●</span>}
{editorTab.fileName}
@@ -688,6 +714,7 @@ interface SessionTopTabProps {
dynamicTabTitleMode?: DynamicTabTitleMode;
t: TranslateFn;
tabAnimationClass?: string;
shortcutNumber?: number;
}
export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
@@ -712,6 +739,7 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
dynamicTabTitleMode,
t,
tabAnimationClass,
shortcutNumber,
}) => {
// Per-session presentation: sibling title/provider updates do not re-render this tab.
const session = usePresentedSession(sessionProp);
@@ -784,14 +812,18 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
/>
)}
<div className="flex items-center gap-2 min-w-0 flex-1">
<SessionTabIcon
host={host}
session={session}
isActive={isActive}
protocol={session.protocol}
shellIcon={session.localShellIcon}
dynamicTabTitleMode={dynamicTabTitleMode}
/>
{shortcutNumber != null ? (
<TabShortcutNumberBadge number={shortcutNumber} />
) : (
<SessionTabIcon
host={host}
session={session}
isActive={isActive}
protocol={session.protocol}
shellIcon={session.localShellIcon}
dynamicTabTitleMode={dynamicTabTitleMode}
/>
)}
<span className="truncate leading-5">{tabTitle}</span>
<div className="flex-shrink-0">{sessionStatusDot(session.status, hasActivity)}</div>
</div>
@@ -854,6 +886,7 @@ interface WorkspaceTopTabProps {
shiftStyle: React.CSSProperties;
showDropIndicatorBefore: boolean;
showDropIndicatorAfter: boolean;
isHostDropTarget: boolean;
onTabDragStart: (e: React.DragEvent, tabId: string) => void;
onTabDragEnd: () => void;
onTabDragOver: (e: React.DragEvent, tabId: string) => void;
@@ -869,6 +902,7 @@ interface WorkspaceTopTabProps {
renderBulkCloseItems: RenderBulkCloseItems;
t: TranslateFn;
tabAnimationClass?: string;
shortcutNumber?: number;
}
/**
@@ -903,6 +937,7 @@ export const WorkspaceTopTab: React.FC<WorkspaceTopTabProps> = memo(({
shiftStyle,
showDropIndicatorBefore,
showDropIndicatorAfter,
isHostDropTarget,
onTabDragStart,
onTabDragEnd,
onTabDragOver,
@@ -915,6 +950,7 @@ export const WorkspaceTopTab: React.FC<WorkspaceTopTabProps> = memo(({
renderBulkCloseItems,
t,
tabAnimationClass,
shortcutNumber,
}) => {
const isActive = useIsTabActive(workspace.id);
const hasActivity = useAnySessionActivity(workspaceSessionIds);
@@ -935,6 +971,7 @@ export const WorkspaceTopTab: React.FC<WorkspaceTopTabProps> = memo(({
data-tab-id={workspace.id}
data-tab-type="workspace"
data-state={isActive ? 'active' : 'inactive'}
data-host-drop-active={isHostDropTarget ? 'true' : 'false'}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onMouseDown={handleTabMiddleMouseDown}
@@ -949,6 +986,7 @@ export const WorkspaceTopTab: React.FC<WorkspaceTopTabProps> = memo(({
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[150px] max-w-[260px] rounded-t-md overflow-hidden text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
"transition-transform duration-150",
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : "",
isHostDropTarget && "ring-1 ring-inset",
tabAnimationClass,
)}
style={{
@@ -986,11 +1024,15 @@ export const WorkspaceTopTab: React.FC<WorkspaceTopTabProps> = memo(({
/>
)}
<div className="flex items-center gap-2 truncate">
<LayoutGrid
size={14}
className="shrink-0"
style={{ color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
/>
{shortcutNumber != null ? (
<TabShortcutNumberBadge number={shortcutNumber} />
) : (
<LayoutGrid
size={14}
className="shrink-0"
style={{ color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
/>
)}
<span className="truncate leading-5" title={tabLabel}>{tabLabel}</span>
</div>
<div className="flex items-center gap-1.5 shrink-0">
@@ -1052,6 +1094,7 @@ interface LogViewTopTabProps {
onTabDrop: (e: React.DragEvent, targetTabId: string) => void;
t: TranslateFn;
tabAnimationClass?: string;
shortcutNumber?: number;
}
export const LogViewTopTab: React.FC<LogViewTopTabProps> = memo(({
@@ -1069,6 +1112,7 @@ export const LogViewTopTab: React.FC<LogViewTopTabProps> = memo(({
onTabDrop,
t,
tabAnimationClass,
shortcutNumber,
}) => {
const isActive = useIsTabActive(logView.id);
const isLocal = logView.log.protocol === 'local' || logView.log.hostname === 'localhost';
@@ -1135,11 +1179,15 @@ export const LogViewTopTab: React.FC<LogViewTopTabProps> = memo(({
/>
)}
<div className="flex items-center gap-2 min-w-0 flex-1">
<FileText
size={14}
className="shrink-0"
style={{ color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
/>
{shortcutNumber != null ? (
<TabShortcutNumberBadge number={shortcutNumber} />
) : (
<FileText
size={14}
className="shrink-0"
style={{ color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
/>
)}
<span className="truncate leading-5">
{t('tabs.logPrefix')} {isLocal ? t('tabs.logLocal') : logView.log.hostname}
</span>
+17 -1
View File
@@ -63,6 +63,21 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
const prepareGridLayoutAnimation = useVaultGridLayoutAnimation(hostListScrollRef);
const [focusedHostId, setFocusedHostId] = React.useState<string | null>(null);
const [focusedGroupPath, setFocusedGroupPath] = React.useState<string | null>(null);
const hostListFilterFocusKey = React.useMemo(
() => getVaultTreeAutoExpandKey(search, selectedTags) ?? "",
[search, selectedTags],
);
const [prevHostListFilterFocusKey, setPrevHostListFilterFocusKey] = React.useState(
hostListFilterFocusKey,
);
// Clear keyboard/selection focus as soon as search or tags change so the
// virtual list cannot steal DOM focus back from the search input on the
// same commit (useEffect would run too late).
if (hostListFilterFocusKey !== prevHostListFilterFocusKey) {
setPrevHostListFilterFocusKey(hostListFilterFocusKey);
setFocusedHostId(null);
setFocusedGroupPath(null);
}
const hostCollectionLayoutKey = [
displayedGroups.length,
hasHostsSidePanel ? "panel" : "full",
@@ -266,7 +281,8 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
const handleHostDragStart = React.useCallback((e: React.DragEvent, hostId: string) => {
e.dataTransfer.effectAllowed = "move";
// copyMove: vault reorder uses move; focus-sidebar append uses copy.
e.dataTransfer.effectAllowed = "copyMove";
e.dataTransfer.setData("host-id", hostId);
draggingHostIdRef.current = hostId;
setDraggingHostId(hostId);
+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>
);
@@ -7,6 +7,7 @@ import {
getNextRaggedRowPosition,
getNextVirtualHostIndex,
resolveVirtualFocusRequest,
shouldApplyVirtualHostDomFocus,
VirtualizedGroupedHostCollection,
VirtualizedHostCollection,
} from "./VirtualizedHostCollection.tsx";
@@ -26,6 +27,34 @@ test("virtual focus retries when an active item enters a collection", () => {
}), { status: "request", key: "host-1", index: 3 });
});
test("virtual host DOM focus does not steal from outside controls", () => {
const inside = { tagName: "BUTTON" } as unknown as Element;
const searchInput = { tagName: "INPUT" } as unknown as Element;
const body = { tagName: "BODY" } as unknown as Element;
const collectionRoot = {
contains(node: Node) {
return node === inside;
},
};
assert.equal(shouldApplyVirtualHostDomFocus({
collectionRoot,
activeElement: inside,
}), true);
assert.equal(shouldApplyVirtualHostDomFocus({
collectionRoot,
activeElement: searchInput,
}), false);
assert.equal(shouldApplyVirtualHostDomFocus({
collectionRoot,
activeElement: body,
}), true);
assert.equal(shouldApplyVirtualHostDomFocus({
collectionRoot: null,
activeElement: inside,
}), false);
});
test("host grids use the same fixed card-width column calculation", () => {
assert.equal(getVaultHostGridColumnCount(219), 1);
assert.equal(getVaultHostGridColumnCount(452), 2);
@@ -82,6 +82,23 @@ export function resolveVirtualFocusRequest({
return { status: "request", key, index };
}
/**
* Virtual collections may retry DOM focus when a remembered active item
* re-enters the filtered set. Never steal focus from controls outside the
* collection (e.g. the vault host search input).
*/
export function shouldApplyVirtualHostDomFocus(input: {
collectionRoot: { contains: (node: Node) => boolean } | null | undefined;
activeElement: Element | null | undefined;
}): boolean {
const { collectionRoot, activeElement } = input;
if (!collectionRoot) return false;
if (!activeElement) return true;
const tagName = activeElement.tagName;
if (tagName === "BODY" || tagName === "HTML") return true;
return collectionRoot.contains(activeElement);
}
export function VirtualizedHostCollection<T>({
items,
itemKey,
@@ -170,6 +187,13 @@ export function VirtualizedHostCollection<T>({
const focusRenderedItem = React.useCallback((key: string) => {
const root = rootRef.current;
if (!root) return false;
if (!shouldApplyVirtualHostDomFocus({
collectionRoot: root,
activeElement: typeof document === "undefined" ? null : document.activeElement,
})) {
pendingFocusKeyRef.current = null;
return true;
}
const wrapper = [...root.querySelectorAll<HTMLElement>("[data-vault-item-key]")]
.find((element) => element.dataset.vaultItemKey === key);
const focusTarget = wrapper?.querySelector<HTMLElement>(
@@ -450,6 +474,13 @@ export function VirtualizedGroupedHostCollection<T>({
const focusRenderedItem = React.useCallback((key: string) => {
const root = rootRef.current;
if (!root) return false;
if (!shouldApplyVirtualHostDomFocus({
collectionRoot: root,
activeElement: typeof document === "undefined" ? null : document.activeElement,
})) {
pendingFocusKeyRef.current = null;
return true;
}
const wrapper = [...root.querySelectorAll<HTMLElement>("[data-vault-item-key]")]
.find((element) => element.dataset.vaultItemKey === key);
const focusTarget = wrapper?.querySelector<HTMLElement>("[data-host-id]");
+97
View File
@@ -0,0 +1,97 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
appendHostFromWorkspaceDrop,
FOCUS_SIDEBAR_HOST_DRAG_TYPE,
FOCUS_SIDEBAR_SESSION_DRAG_TYPE,
readHostIdFromDataTransfer,
resolveFocusSidebarDragKind,
} from './focusSidebarHostDrop.ts';
test('resolveFocusSidebarDragKind prefers in-flight session reorder', () => {
assert.equal(
resolveFocusSidebarDragKind({
types: [FOCUS_SIDEBAR_HOST_DRAG_TYPE],
activeSessionDragId: 'session-1',
}),
'session-reorder',
);
});
test('resolveFocusSidebarDragKind detects session mime even without local state', () => {
assert.equal(
resolveFocusSidebarDragKind({
types: [FOCUS_SIDEBAR_SESSION_DRAG_TYPE],
activeSessionDragId: null,
}),
'session-reorder',
);
});
test('resolveFocusSidebarDragKind accepts vault or host-tree host drags', () => {
assert.equal(
resolveFocusSidebarDragKind({
types: [FOCUS_SIDEBAR_HOST_DRAG_TYPE],
activeSessionDragId: null,
}),
'host-append',
);
});
test('resolveFocusSidebarDragKind ignores unrelated drag payloads', () => {
assert.equal(
resolveFocusSidebarDragKind({
types: ['text/plain', 'tab-reorder-id'],
activeSessionDragId: null,
}),
null,
);
});
test('readHostIdFromDataTransfer returns trimmed host id', () => {
assert.equal(
readHostIdFromDataTransfer((type) => (type === FOCUS_SIDEBAR_HOST_DRAG_TYPE ? ' host-42 ' : '')),
'host-42',
);
});
test('readHostIdFromDataTransfer returns null when host id is missing', () => {
assert.equal(readHostIdFromDataTransfer(() => ''), null);
assert.equal(readHostIdFromDataTransfer(() => ' '), null);
});
test('appendHostFromWorkspaceDrop appends the dropped host to the target workspace once', () => {
const calls: Array<[string, string]> = [];
assert.equal(appendHostFromWorkspaceDrop({
types: [FOCUS_SIDEBAR_HOST_DRAG_TYPE],
getData: (type) => (type === FOCUS_SIDEBAR_HOST_DRAG_TYPE ? 'host-42' : ''),
workspaceId: 'workspace-7',
onAppendHostToWorkspace: (workspaceId, hostId) => calls.push([workspaceId, hostId]),
}), true);
assert.deepEqual(calls, [['workspace-7', 'host-42']]);
});
test('appendHostFromWorkspaceDrop leaves session and invalid drags alone', () => {
const calls: Array<[string, string]> = [];
const onAppendHostToWorkspace = (workspaceId: string, hostId: string) => {
calls.push([workspaceId, hostId]);
};
assert.equal(appendHostFromWorkspaceDrop({
types: [FOCUS_SIDEBAR_SESSION_DRAG_TYPE, FOCUS_SIDEBAR_HOST_DRAG_TYPE],
getData: (type) => (type === FOCUS_SIDEBAR_HOST_DRAG_TYPE ? 'host-42' : 'session-1'),
workspaceId: 'workspace-7',
onAppendHostToWorkspace,
}), false);
assert.equal(appendHostFromWorkspaceDrop({
types: [FOCUS_SIDEBAR_HOST_DRAG_TYPE],
getData: () => ' ',
workspaceId: 'workspace-7',
onAppendHostToWorkspace,
}), false);
assert.deepEqual(calls, []);
});
+55
View File
@@ -0,0 +1,55 @@
export const FOCUS_SIDEBAR_HOST_DRAG_TYPE = 'host-id';
export const FOCUS_SIDEBAR_SESSION_DRAG_TYPE = 'workspace-focus-session-id';
export type FocusSidebarDragKind = 'session-reorder' | 'host-append' | null;
export function dataTransferHasType(
types: ArrayLike<string> | readonly string[],
type: string,
): boolean {
return Array.from(types as ArrayLike<string>).includes(type);
}
/**
* Decide how the focus-mode workspace sidebar should treat an in-progress drag.
* Session reorder wins when this sidebar started the drag, so a host mime that
* happens to be present cannot steal reorder gestures.
*/
export function resolveFocusSidebarDragKind(input: {
types: ArrayLike<string> | readonly string[];
activeSessionDragId?: string | null;
}): FocusSidebarDragKind {
if (
input.activeSessionDragId
|| dataTransferHasType(input.types, FOCUS_SIDEBAR_SESSION_DRAG_TYPE)
) {
return 'session-reorder';
}
if (dataTransferHasType(input.types, FOCUS_SIDEBAR_HOST_DRAG_TYPE)) {
return 'host-append';
}
return null;
}
export function readHostIdFromDataTransfer(
getData: (type: string) => string,
): string | null {
const hostId = getData(FOCUS_SIDEBAR_HOST_DRAG_TYPE)?.trim();
return hostId || null;
}
export function appendHostFromWorkspaceDrop(input: {
types: ArrayLike<string> | readonly string[];
getData: (type: string) => string;
workspaceId: string;
activeSessionDragId?: string | null;
onAppendHostToWorkspace: (workspaceId: string, hostId: string) => void;
}): boolean {
if (resolveFocusSidebarDragKind(input) !== 'host-append') return false;
const hostId = readHostIdFromDataTransfer(input.getData);
if (!hostId) return false;
input.onAppendHostToWorkspace(input.workspaceId, hostId);
return true;
}
+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);
});
Loaded 100 of 143 files, more files were not shown because too many files have changed in this diff. Show more