Compare commits

...

28 Commits

Author SHA1 Message Date
陈大猫
ecadc1fc2d [codex] Enable sudo fallback for Docker panel (#1466)
Some checks failed
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
* Enable sudo fallback for Docker panel

* Prefer sudo for Docker panel commands

* Use pending saved sudo password immediately

* Try plain Docker before sudo fallback

* Detect Docker before sudo fallback

* Add sudo fallback for Docker popup commands

* Harden Docker popup sudo fallback
2026-06-14 10:47:21 +08:00
陈大猫
79ccf47655 fix editor tab theme toggle (#1467) 2026-06-14 09:54:13 +08:00
陈大猫
6ef0a4ad6b Fix settings localization gaps (#1465) 2026-06-14 09:00:49 +08:00
yabirthday
88142d2a92 fix: let Ctrl+C send SIGINT when no text is selected in terminal (#1461)
Some checks failed
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
* fix: let Ctrl+C send SIGINT when no text is selected in terminal

When the copy shortcut is customized to Ctrl+C (default Ctrl+Shift+C),
the terminal always consumed the event for copy even without a text
selection, preventing the standard Ctrl+C → SIGINT interrupt signal.

Added a guard in attachCustomKeyEventHandler: if the matched action is
'copy' and term.hasSelection() is false, return true to pass the event
through to xterm.js, which encodes it as \x03 (ETX) for normal SIGINT
delivery. When text IS selected, copy proceeds as before.

This change is platform-agnostic (renderer-side only) and does not
affect any other terminal actions or default key bindings.

* fix: gate copy passthrough on Ctrl+C/⌃C interrupt chord specifically

The previous fix passed ALL no-selection copy bindings through to xterm,
which would send unintended escape/control sequences to the remote
process if copy were bound to keys like F5 or Ctrl+L.

Now gate the passthrough on the exact interrupt chord: Ctrl+C (PC) or
⌃C (Mac) — key 'c' with only Ctrl pressed, no Shift/Alt/Meta. Any other
copy binding with no selection is consumed as a safe no-op.

* fix: preserve Ctrl-C passthrough for copy shortcut

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-06-14 01:49:21 +08:00
陈大猫
f5c3302329 feat: terminal rename, closeSession shortcut, and pane zoom (#1459)
* feat: auto-poll Docker capabilities while Docker tab is active

When the Docker tab is visible and hasDocker is not yet true,
poll refreshCapabilities() at the process refresh interval.
Stop polling once hasDocker becomes true, or when switching
to a different tab.

* fix: use resolvedTab instead of activeTab for Docker auto-poll condition

The auto-poll useEffect condition used activeTab, which stays stale
when Docker becomes unavailable. Changed to resolvedTab which reflects
the actual displayed tab. Also updated the dep array.

* fix: replace setInterval with setTimeout recursion in Docker tab probe

Replace setInterval-based polling with setTimeout recursion in the Docker
tab capability probe effect. This ensures the next probe only starts after
the previous one finishes, avoiding overlapping probes when SSH timeout
exceeds the polling interval.

- Add dockerPollTimerRef to track the timeout handle
- Use async pollOnce() that awaits refreshCapabilities() before scheduling next
- Use cancelled flag in cleanup to prevent scheduling after unmount
- Keep same dependency array for correctness

* fix: stabilize docker poll timer by using useRef for refreshCapabilities

refreshCapabilities() can return a new reference on every render, causing
the useEffect to re-run on every render — cleanup cancels the polling timer,
then the effect immediately calls pollOnce(), effectively bypassing the
configured timeout interval.

Fix: store refreshCapabilities in a useRef (refreshRef), use
refreshRef.current() inside pollOnce(), and replace refreshCapabilities
with refreshRef in the useEffect dependency array.

Closes #PR1456 Codex P2 review item.

* fix: delay auto-poll first probe by one interval to avoid overlap with tab-switch probe

When switching to the Docker tab, two mechanisms were triggering probes:
1. tab-switch effect (line 67-76): immediate probe via refreshCapabilities()
2. auto-poll effect: pollOnce() executing immediately on mount

This caused duplicate probes that waste SSH channel resources.

Fix: pollOnce() no longer fires on mount. Instead, the effect schedules the
first probe with setTimeout(pollOnce, capabilitiesTtlMs), so the first probe
happens after one full interval. Subsequent probes continue at interval pace
via the setTimeout recursion in pollOnce itself.

The tab-switch effect still fires the immediate probe (the correct one),
so responsiveness on tab switch is preserved.

* fix: reset cancelledRef in effect body to prevent permanent stalling of Docker polling

The cancelledRef was set to true in the cleanup function when dependencies
changed, but never reset when the effect re-ran. This caused pollOnce to
always early-return on subsequent timer ticks, permanently halting
Docker capability probing after the first dependency change.

* fix(system-manager): replace cancelledRef with closure variables for per-effect cancellation

Each effect generation now has its own  and  closure
variables instead of shared  / . This
prevents stale probes from surviving cleanup when the panel hides and
re-shows (Codex P2 review).

* fix: wrap refreshCapabilities in try/catch to keep polling on exception

If refreshCapabilities throws (instead of returning {success: false}),
the await would exit pollOnce without scheduling the next setTimeout,
silently killing Docker auto-detection polling.

* fix: add in-flight probe guard to prevent tab-switch and auto-poll concurrent probes

Add probingRef to track whether a capabilities probe is already in-flight.
- Tab-switch effect for Docker branch checks probingRef before starting a new probe
- Auto-poll pollOnce checks probingRef at entry and sets/clears it around the actual probe
- Tmux branch left unchanged as it has no auto-poll overlap risk

* fix: re-schedule next poll timer when probe is in-flight

When probingRef.current is true (tab-switch probe still running),
pollOnce was returning early without scheduling the next timer,
causing auto-poll to stop permanently afterward.

Now it schedules the next poll within the interval and returns,
so the polling loop keeps running until a slot where no probe is
active.

* fix: convert comments to ASCII-only English

- Line 105: translate Chinese comment to 'probe is in-flight, reschedule for next cycle'
- Line 113: replace em dash (U+2014) with ASCII dash

* feat: session inline rename, closeSession shortcut, pane zoom

* fix: sidebar inline rename with local state

* fix: add sessionDisplayName to terminalPropsAreEqual comparator

The Terminal component is wrapped with React.memo(…, terminalPropsAreEqual),
but the comparator was missing a check for sessionDisplayName. After renaming
a session, the pane title bar would show the old name until some other prop
changed and triggered a re-render.

Add prev.sessionDisplayName === next.sessionDisplayName to the comparator
so that display name changes cause the Terminal to re-render immediately.

* fix: add onStartSessionRename to TerminalLayerWorkspaceSection ctx destructuring and TerminalPanesHost props

* fix: add toggleWorkspaceViewMode to executeHotkeyActionImpl destructuring

The togglePaneZoom handler calls toggleWorkspaceViewMode() but it
wasn't destructured from getCtx(), causing a ReferenceError at runtime.

* fix: restore truncated ctx object in TerminalView render call

The TerminalView ctx object literal on line 1265 was truncated to
'showSele...' due to an editing tool truncation bug, causing
Parsing error: ',' expected on npm run lint / tsc --noEmit.

Restored the missing fields from the base commit:
showSelectionAIAction, snippets, status, statusDotTone, sudoHintRef,
sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef,
terminalBackend, terminalContextActions, terminalCwdTracker,
terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem

Kept the PR's new additions (isVisible, onRename, sessionDisplayName)
intact.

* fix: add toggleWorkspaceViewMode to executeHotkeyAction context and add terminal.menu.rename translations

- Add toggleWorkspaceViewMode to the context getter in executeHotkeyAction (App.tsx)
- Add terminal.menu.rename translation for en (Rename), zh-CN (重命名), ru (Переименовать)

* fix: validate focusedSessionId before closing in closeSession hotkey

When closeSession hotkey fires, workspace.focusedSessionId may reference
a session that was already closed by another trigger (e.g., mouse click
on tab close button). Collect alive session IDs from the workspace root
and fall back to the first living pane if the stored focusedSessionId
is stale.

* fix(auto-poll): check useSessionCapabilities probing state in pollOnce

When auto-poll timer fires before the initial probe (from
useSessionCapabilities) completes, probingRef.current is still false
because the initial probe doesn't set it — causing a second overlapping
probe.

Add  check so that any in-flight probe from any path
(initial/auto-poll/tab-switch) prevents auto-poll overlap.

PR #1459

* fix: address remaining Codex review issues

Co-authored-by: Cursor <cursoragent@cursor.com>

* feat: add detach session from workspace with toolbar button and context menu

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: use customName in pane header display name for renamed sessions

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: refine workspace terminal detach interactions

* fix: preserve workspace detach tab ordering

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 01:30:44 +08:00
陈大猫
bb02f8e162 fix docker availability flicker and add openEuler icon 2026-06-13 12:28:07 +08:00
陈大猫
d57dd664a2 feat: auto-poll Docker capabilities while Docker tab is active (#1456) 2026-06-13 11:41:50 +08:00
陈大猫
74ec6678bb fix(system): increase process list limit and improve Docker detection for openEuler (#1453) (#1455)
* fix(system): increase process list limit and improve Docker detection for openEuler

Root cause analysis for issue #1453:

1. Process list limit too low: The `head -n 200` pipeline capped the process
   list at 200 entries, causing the displayed count to mismatch `ps aux | wc -l`
   on systems with many processes (common on openEuler servers with Docker,
   databases, etc.). Increased limit from 200 to 2000 for both Linux/SSH
   (ps) and Windows (PowerShell) backends.

2. Docker detection failure on openEuler 24.03: The capability probe only
   relied on `docker info >/dev/null 2>&1`, which can fail even when Docker
   is running due to:
   - SSH exec channel environment differences vs interactive shell
   - Docker socket permission variations in non-interactive sessions
   - Different socket path configurations on openEuler

   Added a fallback: if `docker info` fails but the Docker socket exists at
   `/var/run/docker.sock`, Docker is still detected as available. This
   matches the behavior of other SSH terminal clients.

* fix(system): also add Docker socket fallback to fallback probe script for consistency

* fix(system): remove hardcoded process list limit, add capability probe TTL, auto-reprobe on tab switch

Three remaining issues from PR #1455:

1. Remove hardcoded `head -n 2000` / `Select-Object -First 2000`
   process list limits — virtual list handles rendering efficiently.

2. Add 60-second TTL to sessionCapabilitiesStore cache. `get()` returns
   undefined for expired entries, forcing re-probe on next access.
   `set()` always refreshes `probedAt`. Export CAPABILITIES_TTL_MS
   constant for future tuning.

3. Auto-trigger capability re-probe when switching to Docker/Tmux tab
   whose tool was previously reported unavailable — handles the case
   where Docker/Tmux was installed after the last probe.

* fix: replace Docker socket -S check with -r for permission accuracy; sync capabilities TTL with process refresh interval

- Change [ -S /var/run/docker.sock ] to [ -r /var/run/docker.sock ] in
  both the main capability probe script and the POSIX fallback (electron bridge).
  -r verifies the socket exists AND the current user has read permission,
  preventing false-positive Docker detection that leads to failed Docker ops.
- Remove hardcoded CAPABILITIES_TTL_MS (60s) from sessionCapabilitiesStore.
  Store now computes expiresAt internally in set(ttlMs) and checks it in get()
  without requiring a parameter at call sites.
- useSessionCapabilities and useSystemCapabilitiesWarmup accept a
  capabilitiesTtlMs parameter derived from
  terminalSettings.systemManagerProcessRefreshInterval (default 3s → 3 000ms).
- SystemManagerSidePanel passes the TTL from terminalSettings to the hook.
- TerminalLayerTabBridge passes TTL from stableRef settings to warmup hook.
- Fix missing refreshCapabilities destructuring in SystemManagerSidePanel.

* fix: restore process list safety cap (head -n 2000 / -First 2000)

Codex review flagged that removing the process list cap entirely could cause
timeout/maxBuffer issues on process-dense hosts. Restore head -n 2000 (POSIX)
and -First 2000 (Windows) as a safety guard with comments clarifying this is
NOT a functional limit — monitored processes still show accurate metrics.

* fix: hoist useRef/useEffect before early returns to fix React hook order violation

The useRef and useEffect for tab-switch re-probe were placed after early returns
for missing/disconnected sessions. When a session later connects, React discovers
new hooks that weren't registered before, causing hook order violation crashes.

Moved both hooks immediately after the resolvedTab computation, before any early
return path, satisfying React's Rules of Hooks.

* fix: change Docker detection from OR to AND (CLI + socket)

Both capability detection and fallback probe now require:
- docker CLI is on PATH (command -v docker)
- docker.sock is readable ([ -r /var/run/docker.sock ])

Previously used OR logic (docker info || socket readable),
which could report hasDocker=true even when docker CLI
was unavailable (e.g., non-login SSH shell).

Fixes #1453

* fix(system-monitor): prefer docker info, fallback to CLI+socket

Co-authored-by: Codex <codex@anthropic.com>

Changes:
- Line 12: Replace strict CLI+socket check with docker info first,
          falling back to CLI+socket check only if docker info fails.
- Line 139: Same fix in the fallback probe script.

This handles DOCKER_HOST, Docker contexts, and rootless Docker.

* fix: notify subscribers when TTL expires in sessionCapabilitiesStore.get()

When capabilitiesBySessionId.get() finds an expired entry, it deletes the
entry but did not notify session subscribers. This caused components to
stale capabilities until the next successful set() call.

Now get() calls notifySession() on expiry, matching the notification
behavior already present in delete().
2026-06-13 08:58:04 +08:00
陈大猫
b9e88cd99d Simplify SFTP conflict dialog
Some checks failed
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
Simplify the SFTP file conflict dialog and reduce visual noise.
2026-06-12 18:12:28 +08:00
陈大猫
32afade4f9 Fix SFTP type-mismatch upload conflicts (#1449) 2026-06-12 17:46:32 +08:00
lengyuqu
66de2db912 Fix CodeBuddy Windows CLI discovery (#1448) 2026-06-12 17:27:52 +08:00
陈大猫
0a38da8867 Implement ZMODEM drag-and-drop file upload support in terminal
Adds SFTP fallback when rz is unavailable and cleans up drag-drop upload edge cases.
2026-06-12 16:58:50 +08:00
bincxz
5e739f8293 Merge remote PR branch updates 2026-06-12 16:58:20 +08:00
bincxz
6f64245d10 Add SFTP fallback for missing rz uploads 2026-06-12 16:56:48 +08:00
陈大猫
d48ca65a1e Slim release package (#1446) 2026-06-12 16:38:45 +08:00
bincxz
285fcd55a9 Merge main into terminal drag-drop zmodem 2026-06-12 16:37:01 +08:00
陈大猫
05b713ab18 [codex] Add configurable middle-click terminal behavior (#1443)
* Add configurable middle-click terminal behavior

* Fix middle-click terminal behavior edge cases
2026-06-12 16:28:09 +08:00
shideqin
293b15f67a Merge branch 'main' into feature/terminal-drag-drop-zmodem
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 16:14:04 +08:00
shideqin
83aec35f2f Merge main into feature/terminal-drag-drop-zmodem
Resolve conflicts in terminal backend, Terminal, and TerminalView to keep ZMODEM drag-drop alongside serial YMODEM receive and terminal selection AI settings from main.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 16:11:45 +08:00
陈大猫
910ef72205 [codex] Fix known host fingerprint coverage (#1442)
* Fix known host fingerprint coverage

* Tighten SFTP host key verification
2026-06-12 16:09:29 +08:00
陈大猫
550a37b379 [codex] Add serial YMODEM receive (#1438)
* Add serial YMODEM receive

* Address YMODEM receive review feedback
2026-06-12 15:47:24 +08:00
陈大猫
2b396c14e3 [codex] Fix CentOS 7 process listing (#1440)
* Fix CentOS 7 process listing

* Tighten CentOS 7 process listing regression test
2026-06-12 14:43:10 +08:00
陈大猫
36724a3abd Add SFTP tab duplication menu
Fixes #1423
2026-06-12 14:40:22 +08:00
陈大猫
4459aa4ef3 Add terminal zoom disable setting
Adds a Shortcuts setting to disable terminal zoom shortcuts.
2026-06-12 14:33:17 +08:00
陈大猫
64a6986d01 Sync terminal selection AI preference (#1441)
Syncs the terminal selection Add to Conversation preference with AI settings so cloud sync, local backups, restores, and settings-only auto-sync detect the value.

Follow-up for #1436.
2026-06-12 14:28:33 +08:00
陈大猫
a301ecb2ca Add terminal selection AI toggle (#1436)
Adds a Settings > AI switch to hide the automatic Add to Conversation button on terminal selection while keeping the context menu action available.

Closes #1397
2026-06-12 14:16:09 +08:00
shideqin
f16429e30f Implement ZMODEM drag-and-drop file upload support in terminal
- Remove outdated SFTP upload message and replace it with ZMODEM-specific messages in English, Russian, and Chinese locales.
- Add a new function to handle ZMODEM drag-and-drop uploads in the terminal backend.
- Update terminal components to support ZMODEM drag-and-drop functionality.
- Enhance error handling for file uploads and provide user feedback for no files to upload.
- Introduce tests to verify ZMODEM upload behavior and fallback to SFTP for network devices.
2026-06-12 14:12:34 +08:00
陈大猫
46b9bf6ccb [codex] Hide managed startup commands from history
Hide Netcatty-managed Docker and tmux terminal launch commands from command history.

Validated locally with lint, full tests, and build. Multi-agent review completed with no remaining issues.
2026-06-12 14:00:16 +08:00
165 changed files with 8039 additions and 609 deletions

View File

@@ -194,7 +194,6 @@ function App({ settings }: { settings: SettingsState }) {
const keysRef = useRef(keys);
keysRef.current = keys;
const knownHostsRef = useRef(knownHosts);
knownHostsRef.current = knownHosts;
// Bridge the gap while useVaultState hydrates: its async init awaits
// hosts/keys/identities/proxyProfiles decryption before reading knownHosts,
// so the state is briefly [] at boot even when localStorage has entries.
@@ -205,6 +204,7 @@ function App({ settings }: { settings: SettingsState }) {
() => getEffectiveKnownHosts(knownHosts) ?? [],
[knownHosts],
);
knownHostsRef.current = effectiveKnownHosts;
const {
sessions,
@@ -216,6 +216,7 @@ function App({ settings }: { settings: SettingsState }) {
sessionRenameValue,
setSessionRenameValue,
startSessionRename,
renameSessionInline,
submitSessionRename,
resetSessionRename,
workspaceRenameTarget,
@@ -235,6 +236,7 @@ function App({ settings }: { settings: SettingsState }) {
createWorkspaceWithHosts,
createWorkspaceFromSessions,
addSessionToWorkspace,
removeSessionFromWorkspace,
appendHostToWorkspace,
appendLocalTerminalToWorkspace,
createWorkspaceFromTargets,
@@ -728,7 +730,7 @@ function App({ settings }: { settings: SettingsState }) {
);
// Shared hotkey action handler - used by both global handler and terminal callback
const executeHotkeyAction = useCallback((action: string, e: KeyboardEvent) => { return executeHotkeyActionImpl(() => ({ IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, action, activeTabStore, addConnectionLogRef, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, e, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, splitSessionWithCurrentShell, systemInfoRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces }), action, e); }, [orderedTabs, editorTabs, sessions, workspaces, isQuickSwitcherOpen, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast, settings, confirmIfBusyLocalTerminal]);
const executeHotkeyAction = useCallback((action: string, e: KeyboardEvent) => { return executeHotkeyActionImpl(() => ({ IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, action, activeTabStore, addConnectionLogRef, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, e, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, splitSessionWithCurrentShell, systemInfoRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, workspaces }), action, e); }, [orderedTabs, editorTabs, sessions, workspaces, isQuickSwitcherOpen, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast, toggleWorkspaceViewMode, settings, confirmIfBusyLocalTerminal]);
const handleWindowCommandCloseRequest = useCallback(async () => {
const openDialogs = Array.from(document.querySelectorAll<HTMLElement>('[role="dialog"][data-state="open"]'));
@@ -988,7 +990,7 @@ function App({ settings }: { settings: SettingsState }) {
logViews={logViews}
t={t}
/>
<AppView ctx={{ accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, clearSessionFontSizeOverride, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, copySessionToNewWindowWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost, handleEndSessionDrag, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateSessionFontSize, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />
<AppView ctx={{ accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, clearSessionFontSizeOverride, closeLogView, closeSession, closeTabsBatch, copySessionWithCurrentShell, copySessionToNewWindowWithCurrentShell, closeWorkspace, connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent, customGroups, currentTerminalTheme, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict, followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost, handleEndSessionDrag, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit, handleOpenQuickSwitcher, handleOpenSettings, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect, handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal, hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen, keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions, passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, renameSessionInline, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateSessionFontSize, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />
</>
);
}

View File

@@ -440,7 +440,7 @@ export async function closeTabsBatchImpl(getCtx: AppContextGetter, targetIds: st
}
export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string, e: KeyboardEvent) {
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, splitSessionWithCurrentShell, systemInfoRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces } = getCtx();
const { IS_DEV, MOVE_FOCUS_DEBOUNCE_MS, activeTabStore, addConnectionLogRef, closeSession, closeTabInFlightRef, closeWorkspace, collectSessionIds, confirmIfBusyLocalTerminal, createLocalTerminalWithCurrentShell, editorTabs, fromEditorTabId, handleOpenSettingsRef, handleRequestCloseEditorTabRef, isEditorTabId, isQuickSwitcherOpen, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, splitSessionWithCurrentShell, systemInfoRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, workspaces } = getCtx();
{
// Build complete tab list: vault + (sftp when visible) + sessions/workspaces + editor tabs.
// Hiding the SFTP tab must also remove it from keyboard cycling so nextTab
@@ -539,6 +539,40 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
break;
}
case 'closeSession': {
const currentId = activeTabStore.getActiveTabId();
if (!currentId || currentId === 'vault' || currentId === 'sftp') break;
if (closeTabInFlightRef.current) break;
const session = sessions.find((s) => s.id === currentId) ?? null;
const workspace = workspaces.find((w) => w.id === currentId) ?? null;
closeTabInFlightRef.current = true;
(async () => {
try {
// If active tab is a workspace, close the focused session (pane)
if (workspace) {
// Validate focusedSessionId is still valid — it can become stale
// if the previously focused session was already closed
const aliveIds = collectSessionIds(workspace.root);
const focusedId = aliveIds.includes(workspace.focusedSessionId)
? workspace.focusedSessionId
: aliveIds[0];
if (focusedId) {
const ok = await confirmIfBusyLocalTerminal([focusedId]);
if (ok) closeSession(focusedId);
}
} else if (session) {
// Standalone session tab — close the session
const ok = await confirmIfBusyLocalTerminal([session.id]);
if (ok) closeSession(session.id);
}
} finally {
closeTabInFlightRef.current = false;
}
})();
break;
}
case 'newTab':
case 'openLocal':
// Add connection log for local terminal
@@ -644,6 +678,15 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
}
break;
}
case 'togglePaneZoom': {
// Toggle workspace between split and focus (zoom) mode
const currentId = activeTabStore.getActiveTabId();
const activeWs = workspaces.find(w => w.id === currentId);
if (activeWs) {
toggleWorkspaceViewMode(activeWs.id);
}
break;
}
case 'moveFocus': {
// Debounce to prevent double-triggering when focus switches between terminals
const now = Date.now();

View File

@@ -8,6 +8,7 @@ import type { GroupConfig, Host, TerminalSession, TerminalTheme, Workspace } fro
import {
isHostTreeWorkTabSurface,
resolveWorkTabActiveHostId,
resolveWorkTabHostTreeTheme,
} from './workTabSurface';
interface AppHostTreeLayerProps {
@@ -20,7 +21,12 @@ interface AppHostTreeLayerProps {
editorTabs: readonly EditorTab[];
logViews: readonly LogView[];
orderedTabs: readonly string[];
resolvedPreviewTheme: TerminalTheme;
accentMode: 'theme' | 'custom';
currentTerminalTheme: TerminalTheme;
customAccent: string;
followAppTerminalTheme: boolean;
hostById: ReadonlyMap<string, Host>;
themeById: ReadonlyMap<string, TerminalTheme>;
onConnect: (host: Host) => void;
onCreateLocalTerminal?: () => void;
}
@@ -43,7 +49,12 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
editorTabs,
logViews,
orderedTabs,
resolvedPreviewTheme,
accentMode,
currentTerminalTheme,
customAccent,
followAppTerminalTheme,
hostById,
themeById,
onConnect,
onCreateLocalTerminal,
}) => {
@@ -67,6 +78,24 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
workspaces,
}), [activeTabId, editorTabs, sessions, workspaces]);
const hostTreeTheme = useMemo(() => resolveWorkTabHostTreeTheme({
activeHostId,
accentMode,
currentTerminalTheme,
customAccent,
followAppTerminalTheme,
hostById,
themeById,
}), [
activeHostId,
accentMode,
currentTerminalTheme,
customAccent,
followAppTerminalTheme,
hostById,
themeById,
]);
return (
<div
className="absolute left-0 top-0 bottom-0 flex min-h-0"
@@ -79,7 +108,7 @@ export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
hosts={hosts}
customGroups={customGroups}
groupConfigs={groupConfigs}
resolvedPreviewTheme={resolvedPreviewTheme}
resolvedPreviewTheme={hostTreeTheme}
activeHostId={activeHostId}
onConnect={onConnect}
onCreateLocalTerminal={onCreateLocalTerminal}

View File

@@ -42,13 +42,13 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleToggleTheme, handleUpdateHostFromTerminal,
hostById, hosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, orderedTabsWithEditors, orphanSessions,
passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename,
passphraseQueue, protocolSelectHost, proxyProfiles, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename,
resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen,
setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, updateSessionFontSize, clearSessionFontSizeOverride,
setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, startSessionRename,
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId,
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, themeById,
toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog,
updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources,
updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces,
@@ -134,6 +134,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
onStartSessionDrag={setDraggingSessionId}
onEndSessionDrag={handleEndSessionDrag}
onReorderTabs={reorderWorkTabs}
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
showSftpTab={settings.showSftpTab}
showHostTreeSidebar={settings.showHostTreeSidebar}
editorTabs={editorTabs}
@@ -152,7 +153,12 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
editorTabs={editorTabs}
logViews={logViews}
orderedTabs={orderedTabsWithEditors}
resolvedPreviewTheme={currentTerminalTheme}
accentMode={accentMode}
currentTerminalTheme={currentTerminalTheme}
customAccent={customAccent}
followAppTerminalTheme={followAppTerminalTheme}
hostById={hostById}
themeById={themeById}
onConnect={handleConnectToHost}
onCreateLocalTerminal={handleCreateLocalTerminal}
/>
@@ -214,9 +220,11 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
hosts={hosts}
keys={keys}
identities={identities}
knownHosts={effectiveKnownHosts}
proxyProfiles={proxyProfiles}
groupConfigs={groupConfigs}
updateHosts={updateHosts}
onAddKnownHost={handleAddKnownHost}
sftpDefaultViewMode={sftpDefaultViewMode}
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
sftpAutoSync={sftpAutoSync}
@@ -250,6 +258,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
terminalFontFamilyId={terminalFontFamilyId}
fontSize={terminalFontSize}
hotkeyScheme={hotkeyScheme}
disableTerminalFontZoom={settings.disableTerminalFontZoom}
keyBindings={keyBindings}
onHotkeyAction={handleHotkeyAction}
onUpdateTerminalThemeId={setTerminalThemeId}
@@ -278,6 +287,9 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
onSetWorkspaceFocusedSession={setWorkspaceFocusedSession}
onReorderWorkspaceSessions={reorderWorkspaceSessions}
onReorderTabs={reorderWorkTabs}
onCopySession={copySessionWithCurrentShell}
onCopySessionToNewWindow={copySessionToNewWindowWithCurrentShell}
onSplitSession={splitSessionWithCurrentShell}
onConnectToHost={handleConnectToHost}
onCreateLocalTerminal={handleCreateLocalTerminal}
@@ -304,6 +316,9 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
showHostTreeSidebar={settings.showHostTreeSidebar}
toggleScriptsSidePanelRef={toggleScriptsSidePanelRef}
toggleSidePanelRef={toggleSidePanelRef}
onStartSessionRename={startSessionRename}
onSubmitSessionRename={submitSessionRename}
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
/>
{/* Log Views - readonly terminal replays */}

View File

@@ -39,7 +39,7 @@ const baseInput = {
workspaceById: new Map<string, Workspace>(),
};
test("editor tabs use the theme from their owning host", () => {
test("editor tabs use the owning host terminal theme when follow-app terminal theme is off", () => {
const editorTab = {
id: "editor-1",
hostId: "host-1",
@@ -58,6 +58,26 @@ test("editor tabs use the theme from their owning host", () => {
assert.equal(resolved?.id, hostTheme.id);
});
test("editor tabs use the followed terminal theme when follow-app terminal theme is on", () => {
const editorTab = {
id: "editor-1",
hostId: "host-1",
sessionId: "sftp-1",
};
const resolved = resolveActiveChromeTheme({
...baseInput,
activeTabId: toEditorTabId(editorTab.id),
editorTabs: [editorTab as unknown as EditorTab],
followAppTerminalTheme: true,
hostById: new Map([
["host-1", { id: "host-1", theme: hostTheme.id } as unknown as Host],
]),
});
assert.equal(resolved?.id, currentTheme.id);
});
test("log tabs use the saved log theme when available", () => {
const resolved = resolveActiveChromeTheme({
...baseInput,

View File

@@ -54,22 +54,21 @@ export function resolveActiveChromeTheme({
}: ResolveActiveChromeThemeInput): TerminalTheme | null {
if (activeTabId === "vault" || activeTabId === "sftp") return null;
const resolveSessionTheme = (session: TerminalSession): TerminalTheme => {
const resolveHostTheme = (hostId: string): TerminalTheme => {
if (followAppTerminalTheme) return currentTerminalTheme;
const host = hostById.get(session.hostId) ?? null;
const host = hostById.get(hostId) ?? null;
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
const baseTheme = themeById.get(themeId) ?? currentTerminalTheme;
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
};
const resolveSessionTheme = (session: TerminalSession): TerminalTheme => resolveHostTheme(session.hostId);
if (isEditorTabId(activeTabId)) {
const editorTabId = fromEditorTabId(activeTabId);
const editorTab = editorTabs.find((tab) => tab.id === editorTabId);
if (!editorTab) return null;
const host = hostById.get(editorTab.hostId) ?? null;
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
const baseTheme = themeById.get(themeId) ?? currentTerminalTheme;
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
return resolveHostTheme(editorTab.hostId);
}
const logView = logViews.find((item) => item.id === activeTabId);

View File

@@ -6,10 +6,40 @@ import {
isHostTreeWorkTabSurface,
isRootPageTabId,
isTerminalContentTabSurface,
reorderWorkTabIds,
resolveWorkTabActiveHostId,
resolveWorkTabHostTreeTheme,
} from './workTabSurface';
import type { EditorTab } from '../state/editorTabStore';
import type { TerminalSession, Workspace } from '../../types';
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
const makeTheme = (id: string, type: TerminalTheme['type'], background: string): TerminalTheme => ({
id,
name: id,
type,
colors: {
background,
foreground: type === 'dark' ? '#ffffff' : '#000000',
cursor: '#888888',
selection: '#555555',
black: '#000000',
red: '#ff0000',
green: '#00ff00',
yellow: '#ffff00',
blue: '#0000ff',
magenta: '#ff00ff',
cyan: '#00ffff',
white: '#ffffff',
brightBlack: '#444444',
brightRed: '#ff5555',
brightGreen: '#55ff55',
brightYellow: '#ffff55',
brightBlue: '#5555ff',
brightMagenta: '#ff55ff',
brightCyan: '#55ffff',
brightWhite: '#ffffff',
},
});
test('work tab order keeps custom positions and appends new tabs', () => {
assert.deepEqual(
@@ -18,6 +48,29 @@ test('work tab order keeps custom positions and appends new tabs', () => {
);
});
test('work tab order removes duplicate ids before rendering', () => {
assert.deepEqual(
buildOrderedWorkTabIds(
['session-2', 'session-1', 'session-2', 'session-1'],
['session-1', 'session-2', 'session-3', 'session-3'],
),
['session-2', 'session-1', 'session-3'],
);
});
test('work tab order reorders with newly materialized tabs', () => {
assert.deepEqual(
reorderWorkTabIds(
['session-1', 'session-2', 'session-3'],
['session-1', 'session-2', 'session-3'],
'session-1',
'session-3',
'after',
),
['session-2', 'session-3', 'session-1'],
);
});
test('root pages are not work tab surfaces', () => {
assert.equal(isRootPageTabId('vault'), true);
assert.equal(isRootPageTabId('sftp'), true);
@@ -80,3 +133,73 @@ test('shared host tree resolves active host ids across work tab types', () => {
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'editor:file-1', sessions, workspaces, editorTabs }), 'host-3');
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'log-1', sessions, workspaces, editorTabs }), null);
});
test('shared host tree uses the active host theme when follow-app terminal theme is off', () => {
const currentTheme = makeTheme('app-dark', 'dark', '#111111');
const hostTheme = makeTheme('host-light', 'light', '#fafafa');
const host = {
id: 'host-1',
label: 'Host',
hostname: 'host.local',
username: 'root',
tags: [],
os: 'linux',
theme: hostTheme.id,
themeOverride: true,
} as Host;
const resolved = resolveWorkTabHostTreeTheme({
activeHostId: host.id,
accentMode: 'theme',
currentTerminalTheme: currentTheme,
customAccent: '#8b5cf6',
followAppTerminalTheme: false,
hostById: new Map([[host.id, host]]),
themeById: new Map([[currentTheme.id, currentTheme], [hostTheme.id, hostTheme]]),
});
assert.equal(resolved.id, hostTheme.id);
});
test('shared host tree uses the followed terminal theme when follow-app terminal theme is on', () => {
const currentTheme = makeTheme('app-light', 'light', '#ffffff');
const hostTheme = makeTheme('host-dark', 'dark', '#050505');
const host = {
id: 'host-1',
label: 'Host',
hostname: 'host.local',
username: 'root',
tags: [],
os: 'linux',
theme: hostTheme.id,
themeOverride: true,
} as Host;
const resolved = resolveWorkTabHostTreeTheme({
activeHostId: host.id,
accentMode: 'theme',
currentTerminalTheme: currentTheme,
customAccent: '#8b5cf6',
followAppTerminalTheme: true,
hostById: new Map([[host.id, host]]),
themeById: new Map([[currentTheme.id, currentTheme], [hostTheme.id, hostTheme]]),
});
assert.equal(resolved.id, currentTheme.id);
});
test('shared host tree falls back to the current terminal theme without an active host', () => {
const currentTheme = makeTheme('app-dark', 'dark', '#111111');
const resolved = resolveWorkTabHostTreeTheme({
activeHostId: null,
accentMode: 'theme',
currentTerminalTheme: currentTheme,
customAccent: '#8b5cf6',
followAppTerminalTheme: false,
hostById: new Map(),
themeById: new Map([[currentTheme.id, currentTheme]]),
});
assert.equal(resolved.id, currentTheme.id);
});

View File

@@ -2,8 +2,20 @@ import {
fromEditorTabId,
isEditorTabId,
} from '../state/activeTabStore';
import { applyCustomAccentToTerminalTheme, resolveHostTerminalThemeId } from '../../domain/terminalAppearance';
import type { EditorTab } from '../state/editorTabStore';
import type { TerminalSession, Workspace } from '../../types';
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
function uniqueTabIds(tabIds: readonly string[]): string[] {
const seen = new Set<string>();
const uniqueIds: string[] = [];
for (const tabId of tabIds) {
if (!tabId || seen.has(tabId)) continue;
seen.add(tabId);
uniqueIds.push(tabId);
}
return uniqueIds;
}
export function isRootPageTabId(activeTabId: string): boolean {
return activeTabId === 'vault' || activeTabId === 'sftp';
@@ -13,13 +25,42 @@ export function buildOrderedWorkTabIds(
tabOrder: readonly string[],
allTabIds: readonly string[],
): string[] {
const allTabIdSet = new Set(allTabIds);
const orderedIds = tabOrder.filter((id) => allTabIdSet.has(id));
const uniqueAllTabIds = uniqueTabIds(allTabIds);
const allTabIdSet = new Set(uniqueAllTabIds);
const orderedIds = uniqueTabIds(tabOrder.filter((id) => allTabIdSet.has(id)));
const orderedIdSet = new Set(orderedIds);
const newIds = allTabIds.filter((id) => !orderedIdSet.has(id));
const newIds = uniqueAllTabIds.filter((id) => !orderedIdSet.has(id));
return [...orderedIds, ...newIds];
}
export function reorderWorkTabIds(
tabOrder: readonly string[],
allTabIds: readonly string[],
draggedId: string,
targetId: string,
position: 'before' | 'after' = 'before',
): string[] {
if (draggedId === targetId) return buildOrderedWorkTabIds(tabOrder, allTabIds);
const currentOrder = buildOrderedWorkTabIds(tabOrder, allTabIds);
const draggedIndex = currentOrder.indexOf(draggedId);
const targetIndex = currentOrder.indexOf(targetId);
if (draggedIndex === -1 || targetIndex === -1) return [...tabOrder];
currentOrder.splice(draggedIndex, 1);
let nextTargetIndex = targetIndex;
if (draggedIndex < targetIndex) {
nextTargetIndex -= 1;
}
if (position === 'after') {
nextTargetIndex += 1;
}
currentOrder.splice(nextTargetIndex, 0, draggedId);
return currentOrder;
}
export function isHostTreeWorkTabSurface({
enabled,
activeTabId,
@@ -85,3 +126,28 @@ export function resolveWorkTabActiveHostId({
return null;
}
export function resolveWorkTabHostTreeTheme({
activeHostId,
accentMode,
currentTerminalTheme,
customAccent,
followAppTerminalTheme,
hostById,
themeById,
}: {
activeHostId: string | null;
accentMode: 'theme' | 'custom';
currentTerminalTheme: TerminalTheme;
customAccent: string;
followAppTerminalTheme: boolean;
hostById: ReadonlyMap<string, Host>;
themeById: ReadonlyMap<string, TerminalTheme>;
}): TerminalTheme {
if (!activeHostId || followAppTerminalTheme) return currentTerminalTheme;
const host = hostById.get(activeHostId) ?? null;
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
const baseTheme = themeById.get(themeId) ?? currentTerminalTheme;
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
}

View File

@@ -267,6 +267,11 @@ export const enAiMessages: Messages = {
'ai.chat.slashNoResults': 'No matching commands',
'ai.chat.slashEmptyHint': 'Add prompts in Settings → AI → Quick Messages.',
// AI Chat Shortcuts
'ai.chatShortcuts.title': 'Chat Shortcuts',
'ai.chatShortcuts.selectionAction': 'Show Add to Conversation when selecting terminal text',
'ai.chatShortcuts.selectionAction.description': 'Show a small AI button next to selected terminal text.',
// AI Error
'ai.codex.bridgeError': 'Codex main-process handlers are not loaded yet. Fully restart Netcatty, or restart the Electron dev process, then try again.',

View File

@@ -312,6 +312,15 @@ export const enCoreMessages: Messages = {
'settings.terminal.font.size.desc': 'Terminal text size',
'settings.terminal.font.weight': 'Font weight',
'settings.terminal.font.weight.desc': 'Weight for regular text (100-900)',
'settings.terminal.font.weight.thin': 'Thin',
'settings.terminal.font.weight.extraLight': 'Extra Light',
'settings.terminal.font.weight.light': 'Light',
'settings.terminal.font.weight.normal': 'Normal',
'settings.terminal.font.weight.medium': 'Medium',
'settings.terminal.font.weight.semiBold': 'Semi Bold',
'settings.terminal.font.weight.bold': 'Bold',
'settings.terminal.font.weight.extraBold': 'Extra Bold',
'settings.terminal.font.weight.black': 'Black',
'settings.terminal.font.weightBold': 'Bold font weight',
'settings.terminal.font.weightBold.desc': 'Weight for bold text (100-900)',
'settings.terminal.font.linePadding': 'Line padding',
@@ -341,6 +350,11 @@ export const enCoreMessages: Messages = {
'settings.terminal.behavior.middleClickPaste': 'Middle-click paste',
'settings.terminal.behavior.middleClickPaste.desc':
'Paste clipboard content on middle-click',
'settings.terminal.behavior.middleClick': 'Middle-click behavior',
'settings.terminal.behavior.middleClick.desc': 'Action when middle-clicking in terminal',
'settings.terminal.behavior.middleClick.menu': 'Show menu',
'settings.terminal.behavior.middleClick.paste': 'Paste',
'settings.terminal.behavior.middleClick.disabled': 'Do nothing',
'settings.terminal.behavior.bracketedPaste': 'Bracketed paste mode',
'settings.terminal.behavior.bracketedPaste.desc':
'Wrap pasted text with escape sequences so the shell can distinguish paste from typed input. Disable if you see ^[[200~ artifacts.',
@@ -476,6 +490,8 @@ export const enCoreMessages: Messages = {
'settings.shortcuts.scheme.disabled': 'Disabled',
'settings.shortcuts.scheme.mac': 'Mac (Cmd)',
'settings.shortcuts.scheme.pc': 'PC (Ctrl)',
'settings.shortcuts.disableTerminalFontZoom.label': 'Disable terminal zoom',
'settings.shortcuts.disableTerminalFontZoom.desc': 'Turn off terminal font zoom shortcuts, including Cmd/Ctrl + mouse wheel.',
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': 'Number keys skip pinned tabs',
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': 'When enabled, Cmd/Ctrl+[1...9] switches only work tabs (terminals, workspaces, editors), not the pinned Vault or SFTP tabs.',
'settings.shortcuts.section.custom': 'Custom Shortcuts',

View File

@@ -6,6 +6,7 @@ export const enTerminalMessages: Messages = {
'terminal.toolbar.openSftp': 'Open SFTP',
'terminal.toolbar.availableAfterConnect': 'Available after connect',
'terminal.toolbar.sendYmodem': 'Send with YMODEM',
'terminal.toolbar.receiveYmodem': 'Receive with YMODEM',
'terminal.toolbar.sftp': 'SFTP',
'terminal.toolbar.more': 'More actions',
'terminal.toolbar.scripts': 'Scripts',
@@ -50,6 +51,7 @@ export const enTerminalMessages: Messages = {
'terminal.composeBar.snippetClickHint': 'Click to insert · Shift+Click to send',
'terminal.toolbar.focus': 'Focus',
'terminal.toolbar.focusMode': 'Focus Mode',
'terminal.toolbar.detach': 'Detach to standalone tab',
'terminal.toolbar.encoding': 'Terminal Encoding',
'terminal.toolbar.encoding.utf8': 'UTF-8',
'terminal.toolbar.encoding.gb18030': 'GB18030',
@@ -88,7 +90,9 @@ export const enTerminalMessages: Messages = {
'terminal.dragDrop.localTitle': 'Drop to Insert Paths',
'terminal.dragDrop.localMessage': 'File paths will be inserted into the terminal',
'terminal.dragDrop.remoteTitle': 'Drop to Upload Files',
'terminal.dragDrop.remoteMessage': 'Files will be uploaded via SFTP',
'terminal.dragDrop.remoteZmodemMessage': 'Files will be uploaded via ZMODEM (PTY)',
'terminal.dragDrop.remoteSftpMessage': 'Files will be uploaded via SFTP',
'terminal.dragDrop.noFiles': 'No files to upload',
'terminal.dragDrop.notConnected': 'Cannot drop files - terminal is not connected',
'terminal.dragDrop.errorTitle': 'Drop Error',
'terminal.dragDrop.errorMessage': 'Failed to process dropped files',
@@ -103,15 +107,25 @@ export const enTerminalMessages: Messages = {
'terminal.menu.selectAll': 'Select All',
'terminal.menu.reconnect': 'Reconnect',
'terminal.menu.sendYmodem': 'Send with YMODEM',
'terminal.menu.receiveYmodem': 'Receive with YMODEM',
'terminal.menu.splitHorizontal': 'Split Horizontal',
'terminal.menu.splitVertical': 'Split Vertical',
'terminal.menu.clearBuffer': 'Clear Buffer',
'terminal.menu.closeTerminal': 'Close terminal',
'terminal.menu.rename': 'Rename',
'terminal.menu.detach': 'Detach from workspace',
'terminal.menu.detachSession': 'Detach {name}',
'terminal.ymodem.selectFile': 'Select file to send',
'terminal.ymodem.allFiles': 'All files',
'terminal.ymodem.started': 'YMODEM sending {fileName}',
'terminal.ymodem.complete': 'YMODEM sent {fileName}',
'terminal.ymodem.failed': 'YMODEM send failed',
'terminal.ymodem.selectReceiveDirectory': 'Select folder to save received files',
'terminal.ymodem.receiveStarted': 'YMODEM receiving...',
'terminal.ymodem.receiveComplete': 'YMODEM received {fileName}',
'terminal.ymodem.receiveCompleteMultiple': 'YMODEM received {count} files',
'terminal.ymodem.receiveEmpty': 'No YMODEM files received',
'terminal.ymodem.receiveFailed': 'YMODEM receive failed',
'terminal.ymodem.unavailable': 'YMODEM is unavailable',
'terminal.selection.addToAI': 'Add to Conversation',
'terminal.selection.addToAIDesc': 'Attach selected terminal output to the AI draft',

View File

@@ -258,6 +258,8 @@ export const enVaultMessages: Messages = {
'sftp.tabs.addTab': 'Add new tab',
'sftp.tabs.closeTab': 'Close tab',
'sftp.tabs.newTab': 'New Tab',
'sftp.tabs.copyDefaultPath': 'Copy tab (default path)',
'sftp.tabs.copyCurrentPath': 'Copy and go to current path',
'sftp.conflict.title': 'File Conflict',
'sftp.conflict.desc': 'A file with the same name already exists at the destination',
'sftp.conflict.alreadyExistsSuffix': 'already exists',
@@ -483,6 +485,7 @@ export const enVaultMessages: Messages = {
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
'hostDetails.distro.option.almalinux': 'AlmaLinux',
'hostDetails.distro.option.alinux': 'Alibaba Cloud Linux',
'hostDetails.distro.option.openeuler': 'openEuler',
'hostDetails.distro.option.oracle': 'Oracle Linux',
'hostDetails.distro.option.kali': 'Kali Linux',
'hostDetails.distro.option.cisco': 'Cisco',

View File

@@ -267,6 +267,11 @@ export const ruAiMessages: Messages = {
'ai.chat.slashNoResults': 'Нет подходящих команд',
'ai.chat.slashEmptyHint': 'Добавьте подсказки в Настройки → AI → Быстрые сообщения.',
// AI Chat Shortcuts
'ai.chatShortcuts.title': 'Быстрые действия чата',
'ai.chatShortcuts.selectionAction': 'Показывать «Добавить в чат» при выделении в терминале',
'ai.chatShortcuts.selectionAction.description': 'Показывать небольшую кнопку AI рядом с выделенным текстом терминала.',
// AI Error
'ai.codex.bridgeError': 'Обработчики главного процесса Codex ещё не загружены. Полностью перезапустите Netcatty или dev-процесс Electron и попробуйте снова.',

View File

@@ -312,6 +312,15 @@ export const ruCoreMessages: Messages = {
'settings.terminal.font.size.desc': 'Размер текста терминала',
'settings.terminal.font.weight': 'Толщина шрифта',
'settings.terminal.font.weight.desc': 'Толщина обычного текста (100-900)',
'settings.terminal.font.weight.thin': 'Тонкий',
'settings.terminal.font.weight.extraLight': 'Очень светлый',
'settings.terminal.font.weight.light': 'Светлый',
'settings.terminal.font.weight.normal': 'Обычный',
'settings.terminal.font.weight.medium': 'Средний',
'settings.terminal.font.weight.semiBold': 'Полужирный',
'settings.terminal.font.weight.bold': 'Жирный',
'settings.terminal.font.weight.extraBold': 'Очень жирный',
'settings.terminal.font.weight.black': 'Максимально жирный',
'settings.terminal.font.weightBold': 'Толщина жирного шрифта',
'settings.terminal.font.weightBold.desc': 'Толщина жирного текста (100-900)',
'settings.terminal.font.linePadding': 'Межстрочный отступ',
@@ -341,6 +350,11 @@ export const ruCoreMessages: Messages = {
'settings.terminal.behavior.middleClickPaste': 'Вставка средней кнопкой мыши',
'settings.terminal.behavior.middleClickPaste.desc':
'Вставлять содержимое буфера обмена по щелчку средней кнопкой',
'settings.terminal.behavior.middleClick': 'Поведение средней кнопки мыши',
'settings.terminal.behavior.middleClick.desc': 'Действие при щелчке средней кнопкой в терминале',
'settings.terminal.behavior.middleClick.menu': 'Показать меню',
'settings.terminal.behavior.middleClick.paste': 'Вставить',
'settings.terminal.behavior.middleClick.disabled': 'Ничего не делать',
'settings.terminal.behavior.bracketedPaste': 'Режим bracketed paste',
'settings.terminal.behavior.bracketedPaste.desc':
'Оборачивать вставляемый текст escape-последовательностями, чтобы оболочка отличала вставку от обычного ввода. Отключите, если видите артефакты вида ^[[200~.',
@@ -476,6 +490,8 @@ export const ruCoreMessages: Messages = {
'settings.shortcuts.scheme.disabled': 'Отключено',
'settings.shortcuts.scheme.mac': 'Mac (Cmd)',
'settings.shortcuts.scheme.pc': 'PC (Ctrl)',
'settings.shortcuts.disableTerminalFontZoom.label': 'Отключить масштаб терминала',
'settings.shortcuts.disableTerminalFontZoom.desc': 'Отключает быстрый масштаб текста в терминале, включая Cmd/Ctrl + колесо мыши.',
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': 'Цифры без закреплённых вкладок',
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': 'Если включено, Cmd/Ctrl+[1...9] переключает только рабочие вкладки (терминалы, рабочие области, редакторы), а не закреплённые Vault и SFTP.',
'settings.shortcuts.section.custom': 'Пользовательские сочетания',
@@ -493,6 +509,7 @@ export const ruCoreMessages: Messages = {
'settings.shortcuts.binding.next-tab': 'Следующая вкладка',
'settings.shortcuts.binding.prev-tab': 'Предыдущая вкладка',
'settings.shortcuts.binding.close-tab': 'Закрыть вкладку',
'settings.shortcuts.binding.close-session': 'Закрыть панель сессии',
'settings.shortcuts.binding.new-tab': 'Новая локальная вкладка',
'settings.shortcuts.binding.copy': 'Копировать из терминала',
'settings.shortcuts.binding.paste': 'Вставить в терминал',
@@ -500,9 +517,13 @@ export const ruCoreMessages: Messages = {
'settings.shortcuts.binding.select-all': 'Выделить всё содержимое терминала',
'settings.shortcuts.binding.clear-buffer': 'Очистить буфер терминала',
'settings.shortcuts.binding.search-terminal': 'Открыть поиск по терминалу',
'settings.shortcuts.binding.increase-terminal-font-size': 'Увеличить размер шрифта терминала',
'settings.shortcuts.binding.decrease-terminal-font-size': 'Уменьшить размер шрифта терминала',
'settings.shortcuts.binding.reset-terminal-font-size': 'Сбросить размер шрифта терминала',
'settings.shortcuts.binding.move-focus': 'Переместить фокус между разделёнными окнами',
'settings.shortcuts.binding.split-horizontal': 'Горизонтальное разделение',
'settings.shortcuts.binding.split-vertical': 'Вертикальное разделение',
'settings.shortcuts.binding.toggle-pane-zoom': 'Переключить масштаб панели',
'settings.shortcuts.binding.open-hosts': 'Открыть список хостов',
'settings.shortcuts.binding.open-local': 'Открыть локальный терминал',
'settings.shortcuts.binding.open-sftp': 'Открыть SFTP',

View File

@@ -27,6 +27,7 @@ export const ruTerminalMessages: Messages = {
'terminal.toolbar.openSftp': 'Открыть SFTP',
'terminal.toolbar.availableAfterConnect': 'Доступно после подключения',
'terminal.toolbar.sendYmodem': 'Отправить через YMODEM',
'terminal.toolbar.receiveYmodem': 'Получить через YMODEM',
'terminal.toolbar.sftp': 'SFTP',
'terminal.toolbar.more': 'Другие действия',
'terminal.toolbar.scripts': 'Скрипты',
@@ -71,6 +72,7 @@ export const ruTerminalMessages: Messages = {
'terminal.composeBar.snippetClickHint': 'Клик — вставить · Shift+клик — отправить',
'terminal.toolbar.focus': 'Фокус',
'terminal.toolbar.focusMode': 'Режим фокуса',
'terminal.toolbar.detach': 'Открепить в отдельную вкладку',
'terminal.toolbar.encoding': 'Кодировка терминала',
'terminal.toolbar.encoding.utf8': 'UTF-8',
'terminal.toolbar.encoding.gb18030': 'GB18030',
@@ -109,7 +111,9 @@ export const ruTerminalMessages: Messages = {
'terminal.dragDrop.localTitle': 'Перетащите для вставки путей',
'terminal.dragDrop.localMessage': 'Пути к файлам будут вставлены в терминал',
'terminal.dragDrop.remoteTitle': 'Перетащите для загрузки файлов',
'terminal.dragDrop.remoteMessage': 'Файлы будут загружены через SFTP',
'terminal.dragDrop.remoteZmodemMessage': 'Файлы будут загружены через ZMODEM (PTY)',
'terminal.dragDrop.remoteSftpMessage': 'Файлы будут загружены через SFTP',
'terminal.dragDrop.noFiles': 'Нет файлов для загрузки',
'terminal.dragDrop.notConnected': 'Нельзя перетащить файлы — терминал не подключён',
'terminal.dragDrop.errorTitle': 'Ошибка перетаскивания',
'terminal.dragDrop.errorMessage': 'Не удалось обработать перетащенные файлы',
@@ -124,15 +128,25 @@ export const ruTerminalMessages: Messages = {
'terminal.menu.selectAll': 'Выбрать всё',
'terminal.menu.reconnect': 'Переподключиться',
'terminal.menu.sendYmodem': 'Отправить через YMODEM',
'terminal.menu.receiveYmodem': 'Получить через YMODEM',
'terminal.menu.splitHorizontal': 'Разделить по горизонтали',
'terminal.menu.splitVertical': 'Разделить по вертикали',
'terminal.menu.clearBuffer': 'Очистить буфер',
'terminal.menu.closeTerminal': 'Закрыть терминал',
'terminal.menu.rename': 'Переименовать',
'terminal.menu.detach': 'Открепить из рабочей области',
'terminal.menu.detachSession': 'Открепить {name}',
'terminal.ymodem.selectFile': 'Выберите файл для отправки',
'terminal.ymodem.allFiles': 'Все файлы',
'terminal.ymodem.started': 'YMODEM отправляет {fileName}',
'terminal.ymodem.complete': 'YMODEM отправил {fileName}',
'terminal.ymodem.failed': 'Не удалось отправить через YMODEM',
'terminal.ymodem.selectReceiveDirectory': 'Выберите папку для полученных файлов',
'terminal.ymodem.receiveStarted': 'YMODEM получает...',
'terminal.ymodem.receiveComplete': 'YMODEM получил {fileName}',
'terminal.ymodem.receiveCompleteMultiple': 'YMODEM получил файлов: {count}',
'terminal.ymodem.receiveEmpty': 'Файлы YMODEM не получены',
'terminal.ymodem.receiveFailed': 'Не удалось получить через YMODEM',
'terminal.ymodem.unavailable': 'YMODEM недоступен',
'terminal.selection.addToAI': 'Добавить в чат',
'terminal.selection.addToAIDesc': 'Прикрепить выбранный вывод терминала к черновику AI',

View File

@@ -293,6 +293,8 @@ export const ruVaultMessages: Messages = {
'sftp.tabs.addTab': 'Добавить новую вкладку',
'sftp.tabs.closeTab': 'Закрыть вкладку',
'sftp.tabs.newTab': 'Новая вкладка',
'sftp.tabs.copyDefaultPath': 'Копировать вкладку (путь по умолчанию)',
'sftp.tabs.copyCurrentPath': 'Копировать и перейти к текущему пути',
'sftp.conflict.title': 'Конфликт файлов',
'sftp.conflict.desc': 'В месте назначения уже существует файл с таким именем',
'sftp.conflict.alreadyExistsSuffix': 'уже существует',
@@ -518,6 +520,7 @@ export const ruVaultMessages: Messages = {
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
'hostDetails.distro.option.almalinux': 'AlmaLinux',
'hostDetails.distro.option.alinux': 'Alibaba Cloud Linux',
'hostDetails.distro.option.openeuler': 'openEuler',
'hostDetails.distro.option.oracle': 'Oracle Linux',
'hostDetails.distro.option.kali': 'Kali Linux',
'hostDetails.distro.option.cisco': 'Cisco',

View File

@@ -0,0 +1,55 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DEFAULT_KEY_BINDINGS } from "../../../domain/models/keyBindings.ts";
import zhCN from "./zh-CN.ts";
import ru from "./ru.ts";
const LOCALIZED_SETTINGS_LOCALES = [
{ name: "zh-CN", messages: zhCN },
{ name: "ru", messages: ru },
];
test("localized settings include names for every default shortcut", () => {
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
const missing = DEFAULT_KEY_BINDINGS
.map((binding) => `settings.shortcuts.binding.${binding.id}`)
.filter((key) => !locale.messages[key]);
assert.deepEqual(missing, [], `${locale.name} is missing shortcut labels`);
}
});
test("localized settings include workspace focus indicator labels", () => {
const keys = [
"settings.terminal.section.workspaceFocus",
"settings.terminal.workspaceFocus.style",
"settings.terminal.workspaceFocus.style.desc",
"settings.terminal.workspaceFocus.dim",
"settings.terminal.workspaceFocus.border",
];
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
const missing = keys.filter((key) => !locale.messages[key]);
assert.deepEqual(missing, [], `${locale.name} is missing workspace focus labels`);
}
});
test("localized settings include terminal font weight option labels", () => {
const keys = [
"settings.terminal.font.weight.thin",
"settings.terminal.font.weight.extraLight",
"settings.terminal.font.weight.light",
"settings.terminal.font.weight.normal",
"settings.terminal.font.weight.medium",
"settings.terminal.font.weight.semiBold",
"settings.terminal.font.weight.bold",
"settings.terminal.font.weight.extraBold",
"settings.terminal.font.weight.black",
];
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
const missing = keys.filter((key) => !locale.messages[key]);
assert.deepEqual(missing, [], `${locale.name} is missing font weight labels`);
}
});

View File

@@ -267,6 +267,11 @@ export const zhCNAiMessages: Messages = {
'ai.chat.slashNoResults': '没有匹配的命令',
'ai.chat.slashEmptyHint': '可在 设置 → AI → 快捷消息 中添加常用提示词。',
// AI 聊天快捷入口
'ai.chatShortcuts.title': '聊天快捷入口',
'ai.chatShortcuts.selectionAction': '选中终端内容时显示“添加到对话”',
'ai.chatShortcuts.selectionAction.description': '在终端里选中文本后显示 AI 快捷按钮。',
// AI Error
'ai.codex.bridgeError': 'Codex 主进程处理器尚未加载。请完全重启 Netcatty 或重启 Electron 开发进程,然后重试。',

View File

@@ -2,6 +2,9 @@ import type { Messages } from '../types';
export const zhCNTerminalMessages: Messages = {
'terminal.sudoHint.pressEnter': '按 Enter 粘贴 sudo 密码',
'terminal.menu.rename': '重命名',
'terminal.toolbar.detach': '移出到独立标签',
'terminal.menu.detach': '从工作区移出',
'terminal.toolbar.timestampsEnable': '显示时间戳',
'terminal.toolbar.timestampsDisable': '隐藏时间戳',
'terminal.connection.protocol.et': 'EternalTerminal',
@@ -187,6 +190,15 @@ export const zhCNTerminalMessages: Messages = {
'settings.terminal.font.size.desc': '终端文字大小',
'settings.terminal.font.weight': '字重',
'settings.terminal.font.weight.desc': '常规文本字重 (100-900)',
'settings.terminal.font.weight.thin': '极细',
'settings.terminal.font.weight.extraLight': '特细',
'settings.terminal.font.weight.light': '细',
'settings.terminal.font.weight.normal': '常规',
'settings.terminal.font.weight.medium': '中等',
'settings.terminal.font.weight.semiBold': '半粗',
'settings.terminal.font.weight.bold': '粗',
'settings.terminal.font.weight.extraBold': '特粗',
'settings.terminal.font.weight.black': '黑体',
'settings.terminal.font.weightBold': '粗体字重',
'settings.terminal.font.weightBold.desc': '粗体文本字重 (100-900)',
'settings.terminal.font.linePadding': '行间距',
@@ -212,6 +224,11 @@ export const zhCNTerminalMessages: Messages = {
'settings.terminal.behavior.copyOnSelect.desc': '自动复制选中的文本。在 tmux/vim 鼠标模式下macOS 按住 OptionWindows/Linux 按住 Shift 拖选即可选中文本',
'settings.terminal.behavior.middleClickPaste': '中键粘贴',
'settings.terminal.behavior.middleClickPaste.desc': '中键点击时粘贴剪贴板内容',
'settings.terminal.behavior.middleClick': '中键行为',
'settings.terminal.behavior.middleClick.desc': '在终端中点击鼠标中键时执行的操作',
'settings.terminal.behavior.middleClick.menu': '显示菜单',
'settings.terminal.behavior.middleClick.paste': '粘贴',
'settings.terminal.behavior.middleClick.disabled': '无动作',
'settings.terminal.behavior.bracketedPaste': '括号粘贴模式',
'settings.terminal.behavior.bracketedPaste.desc':
'粘贴文本时使用转义序列包裹,以便终端区分粘贴和键入。如果出现 ^[[200~ 字样请关闭此选项。',
@@ -320,6 +337,13 @@ export const zhCNTerminalMessages: Messages = {
'settings.terminal.rendering.renderer.desc': '选择终端渲染技术。自动模式会在低内存设备上使用 DOM 渲染。更改将在新终端会话中生效。',
'settings.terminal.rendering.auto': '自动',
// Settings > Terminal > Workspace Focus Indicator
'settings.terminal.section.workspaceFocus': '工作区焦点提示',
'settings.terminal.workspaceFocus.style': '焦点提示样式',
'settings.terminal.workspaceFocus.style.desc': '在分屏视图中如何标识当前聚焦的窗格。',
'settings.terminal.workspaceFocus.dim': '淡化未聚焦窗格',
'settings.terminal.workspaceFocus.border': '为聚焦窗格显示边框',
// Settings > Terminal > Autocomplete
'settings.terminal.section.autocomplete': '自动补全',
'settings.terminal.autocomplete.enabled': '启用自动补全',
@@ -336,6 +360,8 @@ export const zhCNTerminalMessages: Messages = {
'settings.shortcuts.scheme.disabled': '禁用',
'settings.shortcuts.scheme.mac': 'Mac (Cmd)',
'settings.shortcuts.scheme.pc': 'PC (Ctrl)',
'settings.shortcuts.disableTerminalFontZoom.label': '禁用终端缩放',
'settings.shortcuts.disableTerminalFontZoom.desc': '关闭终端文字缩放快捷操作,包括 Cmd/Ctrl 加滚轮。',
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': '数字键跳过固定标签',
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': '开启后Cmd/Ctrl+[1...9] 仅在终端、工作区、编辑器等可关闭标签页之间切换,不包括固定的 Vault 和 SFTP 标签页。',
'settings.shortcuts.section.custom': '自定义快捷键',
@@ -352,18 +378,25 @@ export const zhCNTerminalMessages: Messages = {
'settings.shortcuts.binding.next-tab': '下一个标签页',
'settings.shortcuts.binding.prev-tab': '上一个标签页',
'settings.shortcuts.binding.close-tab': '关闭标签页',
'settings.shortcuts.binding.close-session': '关闭会话窗格',
'settings.shortcuts.binding.new-tab': '新建本地标签页',
'settings.shortcuts.binding.copy': '从终端复制',
'settings.shortcuts.binding.paste': '粘贴到终端',
'settings.shortcuts.binding.paste-selection': '将选区粘贴到终端',
'settings.shortcuts.binding.select-all': '全选终端内容',
'settings.shortcuts.binding.clear-buffer': '清空终端缓冲区',
'settings.shortcuts.binding.search-terminal': '打开终端搜索',
'settings.shortcuts.binding.increase-terminal-font-size': '增大终端字号',
'settings.shortcuts.binding.decrease-terminal-font-size': '减小终端字号',
'settings.shortcuts.binding.reset-terminal-font-size': '重置终端字号',
'settings.shortcuts.binding.move-focus': '在分屏间移动焦点',
'settings.shortcuts.binding.split-horizontal': '水平分屏',
'settings.shortcuts.binding.split-vertical': '垂直分屏',
'settings.shortcuts.binding.toggle-pane-zoom': '切换窗格缩放',
'settings.shortcuts.binding.open-hosts': '打开主机列表',
'settings.shortcuts.binding.open-local': '打开本地终端',
'settings.shortcuts.binding.open-sftp': '打开 SFTP',
'settings.shortcuts.binding.open-settings': '打开设置',
'settings.shortcuts.binding.port-forwarding': '打开端口转发',
'settings.shortcuts.binding.command-palette': '打开命令面板',
'settings.shortcuts.binding.quick-switch': '快速切换',
@@ -379,6 +412,9 @@ export const zhCNTerminalMessages: Messages = {
'settings.shortcuts.binding.sftp-delete': '删除文件',
'settings.shortcuts.binding.sftp-refresh': '刷新',
'settings.shortcuts.binding.sftp-new-folder': '新建文件夹',
'settings.shortcuts.binding.sftp-open': '打开文件 / 进入目录',
'settings.shortcuts.binding.sftp-go-parent': '转到上级目录',
'settings.shortcuts.binding.sftp-navigate-to': '转到选中的目录',
// Host Details (sub-panels)
'hostDetails.proxyPanel.title': '通过 HTTP/SOCKS5/命令代理',

View File

@@ -66,6 +66,7 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
'hostDetails.distro.option.almalinux': 'AlmaLinux',
'hostDetails.distro.option.alinux': '阿里云 Linux',
'hostDetails.distro.option.openeuler': 'openEuler',
'hostDetails.distro.option.oracle': 'Oracle Linux',
'hostDetails.distro.option.kali': 'Kali Linux',
'hostDetails.distro.option.cisco': '思科',
@@ -216,6 +217,7 @@ export const zhCNVaultMessages: Messages = {
'terminal.toolbar.openSftp': '打开 SFTP',
'terminal.toolbar.availableAfterConnect': '连接后可用',
'terminal.toolbar.sendYmodem': 'YMODEM 发送',
'terminal.toolbar.receiveYmodem': 'YMODEM 接收',
'terminal.toolbar.sftp': 'SFTP',
'terminal.toolbar.more': '更多操作',
'terminal.toolbar.scripts': '脚本',
@@ -243,6 +245,7 @@ export const zhCNVaultMessages: Messages = {
'terminal.composeBar.snippetClickHint': '单击插入 · Shift+单击直接发送',
'terminal.toolbar.focus': '聚焦',
'terminal.toolbar.focusMode': '聚焦模式',
'terminal.toolbar.detach': '移出到独立标签',
'terminal.toolbar.encoding': '终端编码',
'terminal.toolbar.encoding.utf8': 'UTF-8',
'terminal.toolbar.encoding.gb18030': 'GB18030',
@@ -281,7 +284,9 @@ export const zhCNVaultMessages: Messages = {
'terminal.dragDrop.localTitle': '拖放以插入路径',
'terminal.dragDrop.localMessage': '文件路径将被插入到终端',
'terminal.dragDrop.remoteTitle': '拖放以上传文件',
'terminal.dragDrop.remoteMessage': '文件将通过 SFTP 上传',
'terminal.dragDrop.remoteZmodemMessage': '文件将通过 ZMODEMPTY上传',
'terminal.dragDrop.remoteSftpMessage': '文件将通过 SFTP 上传',
'terminal.dragDrop.noFiles': '没有可上传的文件',
'terminal.dragDrop.notConnected': '无法拖放文件 - 终端未连接',
'terminal.dragDrop.errorTitle': '拖放错误',
'terminal.dragDrop.errorMessage': '处理拖放文件失败',
@@ -296,15 +301,25 @@ export const zhCNVaultMessages: Messages = {
'terminal.menu.selectAll': '全选',
'terminal.menu.reconnect': '重新连接',
'terminal.menu.sendYmodem': 'YMODEM 发送',
'terminal.menu.receiveYmodem': 'YMODEM 接收',
'terminal.menu.splitHorizontal': '水平分屏',
'terminal.menu.splitVertical': '垂直分屏',
'terminal.menu.clearBuffer': '清空缓冲区',
'terminal.menu.closeTerminal': '关闭终端',
'terminal.menu.rename': '重命名',
'terminal.menu.detach': '从工作区移出',
'terminal.menu.detachSession': '移出 {name}',
'terminal.ymodem.selectFile': '选择要发送的文件',
'terminal.ymodem.allFiles': '所有文件',
'terminal.ymodem.started': '正在通过 YMODEM 发送 {fileName}',
'terminal.ymodem.complete': 'YMODEM 已发送 {fileName}',
'terminal.ymodem.failed': 'YMODEM 发送失败',
'terminal.ymodem.selectReceiveDirectory': '选择接收文件保存位置',
'terminal.ymodem.receiveStarted': '正在通过 YMODEM 接收...',
'terminal.ymodem.receiveComplete': 'YMODEM 已接收 {fileName}',
'terminal.ymodem.receiveCompleteMultiple': 'YMODEM 已接收 {count} 个文件',
'terminal.ymodem.receiveEmpty': '没有接收到 YMODEM 文件',
'terminal.ymodem.receiveFailed': 'YMODEM 接收失败',
'terminal.ymodem.unavailable': 'YMODEM 当前不可用',
'terminal.selection.addToAI': '添加到对话',
'terminal.selection.addToAIDesc': '将选中的终端输出作为附件加入 AI 草稿',
@@ -676,6 +691,8 @@ export const zhCNVaultMessages: Messages = {
'sftp.tabs.addTab': '新建标签页',
'sftp.tabs.closeTab': '关闭标签页',
'sftp.tabs.newTab': '新标签页',
'sftp.tabs.copyDefaultPath': '复制标签页(默认路径)',
'sftp.tabs.copyCurrentPath': '复制并跳转到当前路径',
'sftp.conflict.title': '文件冲突',
'sftp.conflict.desc': '目标位置已存在同名文件',
'sftp.conflict.alreadyExistsSuffix': '已存在',

View File

@@ -1,31 +1,45 @@
import type { SessionCapabilities } from '../../domain/systemManager/types';
/** Internal entry: capabilities plus computed expiry timestamp. */
interface StoreEntry {
capabilities: SessionCapabilities;
expiresAt: number;
}
type Listener = () => void;
const capabilitiesBySessionId = new Map<string, SessionCapabilities>();
const capabilitiesBySessionId = new Map<string, StoreEntry>();
const listenersBySessionId = new Map<string, Set<Listener>>();
function isExpired(entry: StoreEntry): boolean {
return Date.now() > entry.expiresAt;
}
function notifySession(sessionId: string) {
listenersBySessionId.get(sessionId)?.forEach((listener) => listener());
}
export const sessionCapabilitiesStore = {
get(sessionId: string): SessionCapabilities | undefined {
return capabilitiesBySessionId.get(sessionId);
const entry = capabilitiesBySessionId.get(sessionId);
if (!entry) return undefined;
if (isExpired(entry)) {
capabilitiesBySessionId.delete(sessionId);
notifySession(sessionId);
return undefined;
}
return entry.capabilities;
},
set(sessionId: string, capabilities: SessionCapabilities) {
const prev = capabilitiesBySessionId.get(sessionId);
if (
prev
&& prev.targetOs === capabilities.targetOs
&& prev.hasTmux === capabilities.hasTmux
&& prev.hasDocker === capabilities.hasDocker
&& prev.probedAt === capabilities.probedAt
) {
return;
}
capabilitiesBySessionId.set(sessionId, capabilities);
set(sessionId: string, capabilities: SessionCapabilities, ttlMs: number) {
const entry: StoreEntry = {
capabilities: {
...capabilities,
probedAt: Date.now(),
},
expiresAt: Date.now() + ttlMs,
};
capabilitiesBySessionId.set(sessionId, entry);
notifySession(sessionId);
},

View File

@@ -0,0 +1,123 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { TerminalSession, Workspace } from "../../domain/models";
import {
closeSessionWorkspaceLayoutState,
detachSessionFromWorkspaceState,
replaceDissolvedWorkspaceTabOrder,
} from "./sessionWorkspaceDetach";
const session = (id: string, workspaceId = "ws-1"): TerminalSession => ({
id,
hostId: id,
hostLabel: id,
status: "connected",
workspaceId,
});
const workspace = (sessionIds: string[]): Workspace => ({
id: "ws-1",
title: "Workspace",
focusedSessionId: sessionIds[0],
focusSessionOrder: sessionIds,
root: sessionIds.length === 1
? { id: "pane-1", type: "pane", sessionId: sessionIds[0] }
: {
id: "split-1",
type: "split",
direction: "vertical",
children: sessionIds.map((sessionId, index) => ({
id: `pane-${index + 1}`,
type: "pane" as const,
sessionId,
})),
sizes: sessionIds.map(() => 1),
},
});
test("detach dissolves the original workspace when one session remains", () => {
const result = detachSessionFromWorkspaceState({
sessions: [session("s1"), session("s2")],
workspaces: [workspace(["s1", "s2"])],
sessionId: "s1",
});
assert.equal(result.changed, true);
assert.equal(result.activeTabId, "s1");
assert.deepEqual(result.sessions.map((s) => [s.id, s.workspaceId]), [
["s1", undefined],
["s2", undefined],
]);
assert.equal(result.workspaces.length, 0);
assert.equal(result.dissolvedWorkspaceId, "ws-1");
assert.deepEqual(result.replacementTabIds, ["s1", "s2"]);
});
test("detach preserves the other sessions in a multi-pane workspace", () => {
const result = detachSessionFromWorkspaceState({
sessions: [session("s1"), session("s2"), session("s3")],
workspaces: [workspace(["s1", "s2", "s3"])],
sessionId: "s2",
});
assert.equal(result.changed, true);
assert.deepEqual(result.sessions.map((s) => [s.id, s.workspaceId]), [
["s1", "ws-1"],
["s2", undefined],
["s3", "ws-1"],
]);
assert.deepEqual(result.workspaces[0].focusSessionOrder, ["s1", "s3"]);
assert.equal(result.workspaces[0].focusedSessionId, "s1");
assert.deepEqual(
result.workspaces[0].root.type === "split"
? result.workspaces[0].root.children.map((child) => child.type === "pane" ? child.sessionId : null)
: [],
["s1", "s3"],
);
});
test("dissolved workspace replacement preserves its tab position", () => {
assert.deepEqual(
replaceDissolvedWorkspaceTabOrder(["log-1", "ws-1", "session-3"], "ws-1", ["s1", "s2"]),
["log-1", "s1", "s2", "session-3"],
);
});
test("dissolved workspace replacement removes duplicate replacement ids", () => {
assert.deepEqual(
replaceDissolvedWorkspaceTabOrder(["s1", "ws-1", "session-3"], "ws-1", ["s1", "s2"]),
["s1", "s2", "session-3"],
);
});
test("dissolved workspace replacement is idempotent", () => {
const once = replaceDissolvedWorkspaceTabOrder(["log-1", "ws-1", "session-3"], "ws-1", ["s1", "s2"]);
assert.deepEqual(
replaceDissolvedWorkspaceTabOrder(once, "ws-1", ["s1", "s2"]),
once,
);
});
test("single remaining session preserves dissolved workspace tab position", () => {
assert.deepEqual(
replaceDissolvedWorkspaceTabOrder(["log-1", "ws-1", "session-3"], "ws-1", ["s2"]),
["log-1", "s2", "session-3"],
);
});
test("closing a workspace session dissolves the workspace when one terminal remains", () => {
const result = closeSessionWorkspaceLayoutState([workspace(["s1", "s2"])], "ws-1", "s1");
assert.equal(result.dissolvedWorkspaceId, "ws-1");
assert.equal(result.lastRemainingSessionId, "s2");
assert.deepEqual(result.workspaces, []);
assert.deepEqual(
replaceDissolvedWorkspaceTabOrder(
["log-1", result.dissolvedWorkspaceId!, "session-3"],
result.dissolvedWorkspaceId,
result.lastRemainingSessionId ? [result.lastRemainingSessionId] : undefined,
),
["log-1", "s2", "session-3"],
);
});

View File

@@ -0,0 +1,182 @@
import type { TerminalSession, Workspace } from "../../domain/models";
import { collectSessionIds, pruneWorkspaceNode } from "../../domain/workspace";
export type DetachSessionFromWorkspaceStateResult = {
changed: boolean;
sessions: TerminalSession[];
workspaces: Workspace[];
activeTabId?: string;
dissolvedWorkspaceId?: string;
replacementTabIds?: string[];
};
export type CloseSessionWorkspaceLayoutResult = {
workspaces: Workspace[];
removedWorkspaceId?: string;
dissolvedWorkspaceId?: string;
lastRemainingSessionId?: string;
};
type DetachSessionFromWorkspaceStateOptions = {
sessions: TerminalSession[];
workspaces: Workspace[];
sessionId: string;
};
export function replaceDissolvedWorkspaceTabOrder(
tabOrder: readonly string[],
workspaceId: string | undefined,
replacementTabIds: readonly string[] | undefined,
): string[] {
if (!workspaceId || !replacementTabIds?.length) return [...tabOrder];
const uniqueReplacementIds = replacementTabIds.filter((tabId, index, list) => (
tabId && list.indexOf(tabId) === index
));
if (uniqueReplacementIds.length === 0) return [...tabOrder];
if (!tabOrder.includes(workspaceId)) {
const hasAllReplacementIds = uniqueReplacementIds.every((tabId) => tabOrder.includes(tabId));
return hasAllReplacementIds ? [...tabOrder] : [
...tabOrder,
...uniqueReplacementIds.filter((tabId) => !tabOrder.includes(tabId)),
];
}
const replacementIdSet = new Set(uniqueReplacementIds);
let inserted = false;
const nextOrder: string[] = [];
for (const tabId of tabOrder) {
if (tabId === workspaceId) {
if (!inserted) {
nextOrder.push(...uniqueReplacementIds);
inserted = true;
}
continue;
}
if (!replacementIdSet.has(tabId)) {
nextOrder.push(tabId);
}
}
return nextOrder;
}
export function closeSessionWorkspaceLayoutState(
workspaces: readonly Workspace[],
workspaceId: string | undefined,
sessionId: string,
): CloseSessionWorkspaceLayoutResult {
if (!workspaceId) return { workspaces: [...workspaces] };
let removedWorkspaceId: string | undefined;
let dissolvedWorkspaceId: string | undefined;
let lastRemainingSessionId: string | undefined;
const nextWorkspaces = workspaces
.map((workspace) => {
if (workspace.id !== workspaceId) return workspace;
const prunedRoot = pruneWorkspaceNode(workspace.root, sessionId);
if (!prunedRoot) {
removedWorkspaceId = workspace.id;
return null;
}
const remainingSessionIds = collectSessionIds(prunedRoot);
if (remainingSessionIds.length === 1) {
dissolvedWorkspaceId = workspace.id;
lastRemainingSessionId = remainingSessionIds[0];
return null;
}
return { ...workspace, root: prunedRoot };
})
.filter((workspace): workspace is Workspace => Boolean(workspace));
return {
workspaces: nextWorkspaces,
removedWorkspaceId,
dissolvedWorkspaceId,
lastRemainingSessionId,
};
}
export function detachSessionFromWorkspaceState({
sessions,
workspaces,
sessionId,
}: DetachSessionFromWorkspaceStateOptions): DetachSessionFromWorkspaceStateResult {
const session = sessions.find((candidate) => candidate.id === sessionId);
if (!session?.workspaceId) {
return { changed: false, sessions, workspaces };
}
const workspaceId = session.workspaceId;
const targetWorkspace = workspaces.find((workspace) => workspace.id === workspaceId);
if (!targetWorkspace) {
return { changed: false, sessions, workspaces };
}
const prunedRoot = pruneWorkspaceNode(targetWorkspace.root, sessionId);
let nextSessions = sessions.map((candidate) => (
candidate.id === sessionId ? { ...candidate, workspaceId: undefined } : candidate
));
if (!prunedRoot) {
return {
changed: true,
sessions: nextSessions,
workspaces: workspaces.filter((workspace) => workspace.id !== workspaceId),
activeTabId: sessionId,
dissolvedWorkspaceId: workspaceId,
replacementTabIds: [sessionId],
};
}
const remainingSessionIds = collectSessionIds(prunedRoot);
if (remainingSessionIds.length === 1) {
nextSessions = nextSessions.map((candidate) => (
candidate.id === remainingSessionIds[0] ? { ...candidate, workspaceId: undefined } : candidate
));
return {
changed: true,
sessions: nextSessions,
workspaces: workspaces.filter((workspace) => workspace.id !== workspaceId),
activeTabId: sessionId,
dissolvedWorkspaceId: workspaceId,
replacementTabIds: [sessionId, ...remainingSessionIds],
};
}
const nextFocusedSessionId = remainingSessionIds.includes(targetWorkspace.focusedSessionId)
? targetWorkspace.focusedSessionId
: remainingSessionIds[0];
const nextFocusSessionOrder = (targetWorkspace.focusSessionOrder ?? [])
.filter((candidateId, index, list) => (
candidateId !== sessionId &&
remainingSessionIds.includes(candidateId) &&
list.indexOf(candidateId) === index
));
for (const remainingSessionId of remainingSessionIds) {
if (!nextFocusSessionOrder.includes(remainingSessionId)) {
nextFocusSessionOrder.push(remainingSessionId);
}
}
return {
changed: true,
sessions: nextSessions,
workspaces: workspaces.map((workspace) => (
workspace.id === workspaceId
? {
...workspace,
root: prunedRoot,
focusedSessionId: nextFocusedSessionId,
focusSessionOrder: nextFocusSessionOrder,
}
: workspace
)),
activeTabId: sessionId,
};
}

View File

@@ -12,6 +12,7 @@ import {
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
STORAGE_KEY_HOTKEY_RECORDING,
STORAGE_KEY_HOTKEY_SCHEME,
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
STORAGE_KEY_SESSION_LOGS_DIR,
STORAGE_KEY_SESSION_LOGS_ENABLED,
STORAGE_KEY_SESSION_LOGS_FORMAT,
@@ -73,6 +74,7 @@ interface UseSettingsIpcSyncParams {
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
setDisableTerminalFontZoomState: Dispatch<SetStateAction<boolean>>;
setSftpTransferConcurrencyState: Dispatch<SetStateAction<number>>;
}
@@ -105,6 +107,7 @@ export function useSettingsIpcSync({
setSftpDefaultViewMode,
setWorkspaceFocusStyleState,
setShowHostTreeSidebarState,
setDisableTerminalFontZoomState,
setSftpTransferConcurrencyState,
}: UseSettingsIpcSyncParams) {
// Listen for settings changes from other windows via IPC
@@ -228,6 +231,9 @@ export function useSettingsIpcSync({
if (key === STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR && typeof value === 'boolean') {
setShowHostTreeSidebarState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM && typeof value === 'boolean') {
setDisableTerminalFontZoomState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY && typeof value === 'number') {
setSftpTransferConcurrencyState((prev) => (prev === value ? prev : value));
}
@@ -258,6 +264,7 @@ export function useSettingsIpcSync({
setSftpFollowTerminalCwd,
setSftpDefaultViewMode,
setShowHostTreeSidebarState,
setDisableTerminalFontZoomState,
setSftpTransferConcurrencyState,
setTerminalFontFamilyId,
setTerminalFontSize,

View File

@@ -65,6 +65,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_DISABLE_TERMINAL_FONT_ZOOM = false;
// Editor defaults
export const DEFAULT_EDITOR_WORD_WRAP = false;

View File

@@ -29,6 +29,7 @@ import {
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_FONT_SIZE,
@@ -79,6 +80,7 @@ interface UseSettingsStorageSyncParams {
showSftpTab: boolean;
showHostTreeSidebar: boolean;
shellOnlyTabNumberShortcuts: boolean;
disableTerminalFontZoom: boolean;
editorWordWrap: boolean;
sessionLogsEnabled: boolean;
sessionLogsDir: string;
@@ -115,6 +117,7 @@ interface UseSettingsStorageSyncParams {
setShowSftpTabState: Dispatch<SetStateAction<boolean>>;
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
setShellOnlyTabNumberShortcutsState: Dispatch<SetStateAction<boolean>>;
setDisableTerminalFontZoomState: Dispatch<SetStateAction<boolean>>;
setEditorWordWrapState: Dispatch<SetStateAction<boolean>>;
setSessionLogsEnabled: Dispatch<SetStateAction<boolean>>;
setSessionLogsDir: Dispatch<SetStateAction<string>>;
@@ -136,7 +139,7 @@ export function useSettingsStorageSync({
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
@@ -145,7 +148,7 @@ export function useSettingsStorageSync({
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setShellOnlyTabNumberShortcutsState,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled,
setGlobalHotkeyEnabled, setWindowOpacity, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
@@ -159,7 +162,7 @@ export function useSettingsStorageSync({
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
});
@@ -169,7 +172,7 @@ export function useSettingsStorageSync({
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
};
@@ -389,6 +392,12 @@ export function useSettingsStorageSync({
setShellOnlyTabNumberShortcutsState(newValue);
}
}
if (e.key === STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.disableTerminalFontZoom) {
setDisableTerminalFontZoomState(newValue);
}
}
// Sync global hotkey enabled setting from other windows
if (e.key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && e.newValue !== null) {
const newValue = e.newValue === 'true';
@@ -458,6 +467,7 @@ export function useSettingsStorageSync({
setShowRecentHostsState,
setShowSftpTabState,
setShellOnlyTabNumberShortcutsState,
setDisableTerminalFontZoomState,
setTerminalFontFamilyId,
setTerminalFontSize,
setTerminalThemeDarkId,

View File

@@ -0,0 +1,56 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { RemoteSftpStartCache } from "./sftpConnectStartPath.ts";
import {
normalizeSftpInitialPath,
resolveRemoteSftpStartState,
} from "./sftpConnectStartPath.ts";
const cached: RemoteSftpStartCache = {
path: "/var/cache",
homeDir: "/home/deploy",
files: [],
filenameEncoding: "auto",
};
test("remote SFTP default-path duplication ignores the shared host cache", () => {
const state = resolveRemoteSftpStartState({
filenameEncoding: "auto",
ignoreSharedCache: true,
sharedHostCacheCandidate: cached,
});
assert.equal(state.initialPath, undefined);
assert.equal(state.sharedHostCache, null);
assert.equal(state.cachedStartPath, "/");
});
test("remote SFTP current-path duplication uses the requested path instead of stale cache", () => {
const state = resolveRemoteSftpStartState({
filenameEncoding: "auto",
initialPath: "/var/www/app",
sharedHostCacheCandidate: cached,
});
assert.equal(state.initialPath, "/var/www/app");
assert.equal(state.sharedHostCache, null);
assert.equal(state.cachedStartPath, "/var/www/app");
});
test("remote SFTP initial paths preserve meaningful whitespace", () => {
assert.equal(normalizeSftpInitialPath("/var/www/app "), "/var/www/app ");
const state = resolveRemoteSftpStartState({
filenameEncoding: "auto",
initialPath: "/var/www/app ",
sharedHostCacheCandidate: {
...cached,
path: "/var/www/app",
},
});
assert.equal(state.initialPath, "/var/www/app ");
assert.equal(state.sharedHostCache, null);
assert.equal(state.cachedStartPath, "/var/www/app ");
});

View File

@@ -0,0 +1,44 @@
import type { SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
export interface RemoteSftpStartCache {
path: string;
homeDir: string;
files: SftpFileEntry[];
filenameEncoding: SftpFilenameEncoding;
}
interface ResolveRemoteSftpStartStateParams {
filenameEncoding: SftpFilenameEncoding;
ignoreSharedCache?: boolean;
initialPath?: string;
sharedHostCacheCandidate: RemoteSftpStartCache | null;
}
export function normalizeSftpInitialPath(initialPath?: string): string | undefined {
return initialPath === undefined || initialPath.length === 0 ? undefined : initialPath;
}
export function resolveRemoteSftpStartState({
filenameEncoding,
ignoreSharedCache,
initialPath,
sharedHostCacheCandidate,
}: ResolveRemoteSftpStartStateParams): {
initialPath: string | undefined;
sharedHostCache: RemoteSftpStartCache | null;
cachedStartPath: string;
} {
const requestedInitialPath = normalizeSftpInitialPath(initialPath);
const sharedHostCache =
!ignoreSharedCache
&& sharedHostCacheCandidate?.filenameEncoding === filenameEncoding
&& (!requestedInitialPath || sharedHostCacheCandidate.path === requestedInitialPath)
? sharedHostCacheCandidate
: null;
return {
initialPath: requestedInitialPath,
sharedHostCache,
cachedStartPath: requestedInitialPath ?? sharedHostCache?.path ?? "/",
};
}

View File

@@ -1,6 +1,6 @@
import type { SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
interface SharedRemoteHostCacheEntry {
export interface SharedRemoteHostCacheEntry {
path: string;
homeDir: string;
files: SftpFileEntry[];

View File

@@ -1,4 +1,4 @@
import { SftpConnection, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
import { KnownHost, SftpConnection, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
export interface SftpPane {
id: string;
@@ -15,6 +15,22 @@ export interface SftpPane {
transferMutationToken: number;
}
export interface SftpHostKeyInfo {
hostname: string;
port: number;
keyType: string;
fingerprint: string;
publicKey?: string;
status?: "unknown" | "changed";
knownHostId?: string;
knownFingerprint?: string;
}
export interface SftpHostKeyVerificationState {
hostKeyInfo: SftpHostKeyInfo;
progressLogs: string[];
}
// Multi-tab state for left and right sides
export interface SftpSideTabs {
tabs: SftpPane[];
@@ -70,4 +86,6 @@ export interface SftpStateOptions {
* is honored for SFTP browsing too (not just the terminal session).
*/
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
knownHosts?: KnownHost[];
onAddKnownHost?: (knownHost: KnownHost) => void;
}

View File

@@ -1,16 +1,19 @@
import React, { useCallback, useEffect, useRef } from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import type { MutableRefObject } from "react";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import type { Host, Identity, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";
import type { SftpPane } from "./types";
import type { Host, Identity, KnownHost, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";
import type { SftpHostKeyInfo, SftpHostKeyVerificationState, SftpPane } from "./types";
import { useSftpDirectoryListing } from "./useSftpDirectoryListing";
import { useSftpHostCredentials } from "./useSftpHostCredentials";
import { buildCacheKey, getSharedRemoteHostCache, setSharedRemoteHostCache } from "./sharedRemoteHostCache";
import { resolveRemoteSftpStartState } from "./sftpConnectStartPath";
interface UseSftpConnectionsParams {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
knownHosts?: KnownHost[];
onAddKnownHost?: (knownHost: KnownHost) => void;
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
leftTabsRef: MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
rightTabsRef: MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
@@ -34,17 +37,61 @@ interface UseSftpConnectionsParams {
autoConnectLocalOnMount?: boolean;
}
export interface SftpConnectOptions {
forceNewTab?: boolean;
ignoreSharedCache?: boolean;
initialPath?: string;
onTabCreated?: (tabId: string) => void;
sourceSessionId?: string;
}
interface UseSftpConnectionsResult {
connect: (side: "left" | "right", host: Host | "local", options?: { forceNewTab?: boolean; onTabCreated?: (tabId: string) => void }) => Promise<void>;
connect: (side: "left" | "right", host: Host | "local", options?: SftpConnectOptions) => Promise<void>;
disconnect: (side: "left" | "right") => Promise<void>;
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
hostKeyVerification: SftpHostKeyVerificationState | null;
rejectHostKeyVerification: () => void;
acceptHostKeyVerification: () => void;
acceptAndSaveHostKeyVerification: () => void;
}
type HostKeyVerificationRequest = SftpHostKeyInfo & {
requestId: string;
sessionId?: string;
};
const toSftpHostKeyInfo = (request: HostKeyVerificationRequest): SftpHostKeyInfo => ({
hostname: request.hostname,
port: request.port || 22,
keyType: request.keyType,
fingerprint: request.fingerprint,
publicKey: request.publicKey,
status: request.status,
knownHostId: request.knownHostId,
knownFingerprint: request.knownFingerprint,
});
const createKnownHostFromSftpHostKeyInfo = (
hostKeyInfo: SftpHostKeyInfo,
now = Date.now(),
idSuffix = Math.random().toString(36).slice(2, 11),
): KnownHost => ({
id: hostKeyInfo.knownHostId || `kh-${now}-${idSuffix}`,
hostname: hostKeyInfo.hostname,
port: hostKeyInfo.port || 22,
keyType: hostKeyInfo.keyType,
publicKey: hostKeyInfo.publicKey || `SHA256:${hostKeyInfo.fingerprint}`,
fingerprint: hostKeyInfo.fingerprint,
discoveredAt: now,
});
export const useSftpConnections = ({
hosts,
keys,
identities,
knownHosts,
onAddKnownHost,
terminalSettings,
leftTabsRef,
rightTabsRef,
@@ -67,11 +114,79 @@ export const useSftpConnections = ({
createEmptyPane,
autoConnectLocalOnMount = true,
}: UseSftpConnectionsParams): UseSftpConnectionsResult => {
const getHostCredentials = useSftpHostCredentials({ hosts, keys, identities, terminalSettings });
const getHostCredentials = useSftpHostCredentials({ hosts, keys, identities, knownHosts, terminalSettings });
const { listLocalFiles, listRemoteFiles } = useSftpDirectoryListing();
const [hostKeyVerification, setHostKeyVerification] = useState<SftpHostKeyVerificationState | null>(null);
const hostKeyVerificationRef = useRef<(SftpHostKeyVerificationState & { requestId: string; sessionId: string }) | null>(null);
const activeHostKeySessionsRef = useRef<Map<string, { side: "left" | "right"; tabId: string }>>(new Map());
const setPendingHostKeyVerification = useCallback((
next: (SftpHostKeyVerificationState & { requestId: string; sessionId: string }) | null,
) => {
hostKeyVerificationRef.current = next;
setHostKeyVerification(next ? {
hostKeyInfo: next.hostKeyInfo,
progressLogs: next.progressLogs,
} : null);
}, []);
useEffect(() => {
const dispose = netcattyBridge.get()?.onHostKeyVerification?.((request: HostKeyVerificationRequest) => {
const sessionId = request.sessionId;
if (!sessionId) return;
const activeSession = activeHostKeySessionsRef.current.get(sessionId);
if (!activeSession) return;
const hostKeyInfo = toSftpHostKeyInfo(request);
const logLine = request.status === "changed"
? `Host key changed for ${request.hostname}. Waiting for confirmation...`
: `Host key verification required for ${request.hostname}.`;
updateTab(activeSession.side, activeSession.tabId, (prev) => ({
...prev,
connectionLogs: [...prev.connectionLogs, logLine],
}));
setPendingHostKeyVerification({
requestId: request.requestId,
sessionId,
hostKeyInfo,
progressLogs: [logLine],
});
});
return () => {
dispose?.();
};
}, [setPendingHostKeyVerification, updateTab]);
const respondToHostKeyVerification = useCallback((accept: boolean, addToKnownHosts = false) => {
const pending = hostKeyVerificationRef.current;
if (!pending) return;
if (accept && addToKnownHosts) {
onAddKnownHost?.(createKnownHostFromSftpHostKeyInfo(pending.hostKeyInfo));
}
void netcattyBridge.get()?.respondHostKeyVerification?.(
pending.requestId,
accept,
addToKnownHosts,
);
setPendingHostKeyVerification(null);
}, [onAddKnownHost, setPendingHostKeyVerification]);
const rejectHostKeyVerification = useCallback(() => {
respondToHostKeyVerification(false);
}, [respondToHostKeyVerification]);
const acceptHostKeyVerification = useCallback(() => {
respondToHostKeyVerification(true, false);
}, [respondToHostKeyVerification]);
const acceptAndSaveHostKeyVerification = useCallback(() => {
respondToHostKeyVerification(true, true);
}, [respondToHostKeyVerification]);
const connect = useCallback(
async (side: "left" | "right", host: Host | "local", options?: { forceNewTab?: boolean; onTabCreated?: (tabId: string) => void; sourceSessionId?: string }) => {
async (side: "left" | "right", host: Host | "local", options?: SftpConnectOptions) => {
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
let activeTabId: string | null = null;
@@ -101,6 +216,33 @@ export const useSftpConnections = ({
navSeqRef.current[side] += 1;
const connectRequestId = navSeqRef.current[side];
const getTargetPane = () => {
const tabs = side === "left" ? leftTabsRef.current.tabs : rightTabsRef.current.tabs;
return tabs.find((tab) => tab.id === activeTabId) ?? null;
};
const isTargetConnectionCurrent = () => {
const pane = getTargetPane();
if (!pane) return false;
if (pane.connection?.id === connectionId) return true;
return !pane.connection && navSeqRef.current[side] === connectRequestId;
};
const isTargetConnectionAtPath = (path: string) => {
const connection = getTargetPane()?.connection;
if (!connection) return navSeqRef.current[side] === connectRequestId;
return connection?.id === connectionId && connection.currentPath === path;
};
const closeSftpSessionForConnection = async () => {
const sftpId = sftpSessionsRef.current.get(connectionId);
sftpSessionsRef.current.delete(connectionId);
connectionCacheKeyMapRef.current.delete(connectionId);
clearCacheForConnection(connectionId);
if (!sftpId) return;
try {
await netcattyBridge.get()?.closeSftp(sftpId);
} catch {
// Ignore errors when closing stale SFTP sessions
}
};
lastConnectedHostRef.current[side] = host;
// Store the cache key for this connection so pane actions can look it up
@@ -147,13 +289,15 @@ export const useSftpConnections = ({
homeDir = isWindows ? "C:\\Users\\damao" : "/Users/damao";
}
const startPath = options?.initialPath || homeDir;
const connection: SftpConnection = {
id: connectionId,
hostId: "local",
hostLabel: "Local",
isLocal: true,
status: "connected",
currentPath: homeDir,
currentPath: startPath,
homeDir,
};
@@ -168,9 +312,9 @@ export const useSftpConnections = ({
}));
try {
const files = await listLocalFiles(homeDir);
if (navSeqRef.current[side] !== connectRequestId) return;
dirCacheRef.current.set(makeCacheKey(connectionId, homeDir, filenameEncoding), {
const files = await listLocalFiles(startPath);
if (!isTargetConnectionAtPath(startPath)) return;
dirCacheRef.current.set(makeCacheKey(connectionId, startPath, filenameEncoding), {
files,
timestamp: Date.now(),
});
@@ -182,7 +326,7 @@ export const useSftpConnections = ({
reconnecting: false,
}));
} catch (err) {
if (navSeqRef.current[side] !== connectRequestId) return;
if (!isTargetConnectionAtPath(startPath)) return;
reconnectingRef.current[side] = false;
updateTab(side, activeTabId, (prev) => ({
...prev,
@@ -193,12 +337,15 @@ export const useSftpConnections = ({
}
} else {
const hostCacheKey = buildCacheKey(host.id, host.hostname, host.port, host.protocol, host.sftpSudo, host.username);
const sharedHostCacheCandidate = getSharedRemoteHostCache(hostCacheKey);
const sharedHostCache =
sharedHostCacheCandidate?.filenameEncoding === filenameEncoding
? sharedHostCacheCandidate
: null;
const cachedStartPath = sharedHostCache?.path ?? "/";
const sharedHostCacheCandidate = options?.ignoreSharedCache
? null
: getSharedRemoteHostCache(hostCacheKey);
const { initialPath, sharedHostCache, cachedStartPath } = resolveRemoteSftpStartState({
filenameEncoding,
ignoreSharedCache: options?.ignoreSharedCache,
initialPath: options?.initialPath,
sharedHostCacheCandidate,
});
const connection: SftpConnection = {
id: connectionId,
@@ -230,6 +377,7 @@ export const useSftpConnections = ({
// Subscribe to SFTP connection progress events for auth logging
const sftpSessionId = `sftp-${connectionId}`;
activeHostKeySessionsRef.current.set(sftpSessionId, { side, tabId: activeTabId });
let unsubSftpProgress: (() => void) | undefined;
const bridge = netcattyBridge.get();
if (bridge?.onSftpConnectionProgress) {
@@ -264,7 +412,7 @@ export const useSftpConnections = ({
logLine = `${label} - ${status}${detail ? `: ${detail}` : ''}`;
}
// Only update if this is still the active request (avoids stale logs leaking)
if (navSeqRef.current[side] !== connectRequestId) return;
if (!isTargetConnectionCurrent()) return;
updateTab(side, activeTabId, (prev) => ({
...prev,
connectionLogs: [...prev.connectionLogs, logLine],
@@ -295,7 +443,7 @@ export const useSftpConnections = ({
if (hasKey) {
try {
const keyFirstCredentials = {
sessionId: `sftp-${connectionId}`,
sessionId: sftpSessionId,
...credentials,
sourceSessionId: options?.sourceSessionId,
};
@@ -306,7 +454,7 @@ export const useSftpConnections = ({
} catch (err) {
if (hasPassword && isAuthError(err)) {
sftpId = await openSftp({
sessionId: `sftp-${connectionId}`,
sessionId: sftpSessionId,
...credentials,
sourceSessionId: options?.sourceSessionId,
privateKey: undefined,
@@ -322,7 +470,7 @@ export const useSftpConnections = ({
}
} else {
sftpId = await openSftp({
sessionId: `sftp-${connectionId}`,
sessionId: sftpSessionId,
...credentials,
sourceSessionId: options?.sourceSessionId,
});
@@ -331,6 +479,10 @@ export const useSftpConnections = ({
if (!sftpId) throw new Error("Failed to open SFTP session");
sftpSessionsRef.current.set(connectionId, sftpId);
if (!isTargetConnectionCurrent()) {
await closeSftpSessionForConnection();
return;
}
let startPath = sharedHostCache?.path ?? "/";
let homeDir = sharedHostCache?.homeDir ?? startPath;
@@ -395,6 +547,10 @@ export const useSftpConnections = ({
}
}
if (initialPath) {
startPath = initialPath;
}
const provisionalCacheKey = sharedHostCache
? makeCacheKey(connectionId, startPath, filenameEncoding)
: null;
@@ -438,7 +594,10 @@ export const useSftpConnections = ({
throw new Error("Cannot list any remote directory");
}
}
if (navSeqRef.current[side] !== connectRequestId) return;
if (!isTargetConnectionCurrent()) {
await closeSftpSessionForConnection();
return;
}
dirCacheRef.current.set(makeCacheKey(connectionId, startPath, filenameEncoding), {
files,
timestamp: Date.now(),
@@ -469,7 +628,10 @@ export const useSftpConnections = ({
connectionLogs: [], // Clear after successful connect to avoid replay during navigation
}));
} catch (err) {
if (navSeqRef.current[side] !== connectRequestId) return;
if (!isTargetConnectionCurrent()) {
await closeSftpSessionForConnection();
return;
}
reconnectingRef.current[side] = false;
updateTab(side, activeTabId, (prev) => ({
...prev,
@@ -489,6 +651,10 @@ export const useSftpConnections = ({
reconnecting: false,
}));
} finally {
activeHostKeySessionsRef.current.delete(sftpSessionId);
if (hostKeyVerificationRef.current?.sessionId === sftpSessionId) {
setPendingHostKeyVerification(null);
}
unsubSftpProgress?.();
}
}
@@ -503,6 +669,7 @@ export const useSftpConnections = ({
makeCacheKey,
listLocalFiles,
listRemoteFiles,
setPendingHostKeyVerification,
],
);
@@ -588,5 +755,9 @@ export const useSftpConnections = ({
disconnect,
listLocalFiles,
listRemoteFiles,
hostKeyVerification,
rejectHostKeyVerification,
acceptHostKeyVerification,
acceptAndSaveHostKeyVerification,
};
};

View File

@@ -1,5 +1,6 @@
import { useCallback, useRef, useMemo, useState } from "react";
import { FileConflict, FileConflictAction, TransferStatus, SftpFilenameEncoding } from "../../../domain/models";
import { getSftpConflictTypeKey } from "../../../domain/sftpConflict";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { logger } from "../../../lib/logger";
import { notify } from "../../notification";
@@ -501,7 +502,7 @@ export const useSftpExternalOperations = (
newModified: number;
applyToAllCount: number;
}): Promise<FileConflictAction> => {
const conflictType = conflict.isDirectory ? "directory" : "file";
const conflictType = getSftpConflictTypeKey(conflict.isDirectory, conflict.existingType);
const defaultAction = conflictDefaults.get(conflictType);
if (defaultAction) return defaultAction;

View File

@@ -2,7 +2,7 @@ import test from "node:test";
import assert from "node:assert/strict";
import { buildSftpHostCredentials } from "./useSftpHostCredentials.ts";
import type { Host, SSHKey } from "../../../domain/models.ts";
import type { Host, KnownHost, SSHKey } from "../../../domain/models.ts";
const host = (overrides: Partial<Host> = {}): Host => ({
id: "host-1",
@@ -102,6 +102,28 @@ test("buildSftpHostCredentials passes reference keys as identity file paths", ()
assert.equal(credentials.passphrase, "saved-passphrase");
});
test("buildSftpHostCredentials forwards known hosts for SFTP host-key checks", () => {
const knownHosts: KnownHost[] = [{
id: "kh-1",
hostname: "example.com",
port: 22,
keyType: "ssh-ed25519",
publicKey: "SHA256:abc",
fingerprint: "abc",
discoveredAt: 1,
}];
const credentials = buildSftpHostCredentials({
host: host(),
hosts: [],
keys: [],
identities: [],
knownHosts,
});
assert.equal(credentials.knownHosts, knownHosts);
});
test("buildSftpHostCredentials passes jump host reference keys as identity file paths", () => {
const key: SSHKey = {
id: "jump-key",

View File

@@ -1,5 +1,5 @@
import { useCallback } from "react";
import type { Host, Identity, SSHKey, TerminalSettings } from "../../../domain/models";
import type { Host, Identity, KnownHost, SSHKey, TerminalSettings } from "../../../domain/models";
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "../../../domain/credentials";
import { resolveBridgeKeyAuth, resolveHostAuth } from "../../../domain/sshAuth";
import { resolveHostKeepalive } from "../../../domain/host";
@@ -14,6 +14,7 @@ interface UseSftpHostCredentialsParams {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
knownHosts?: KnownHost[];
terminalSettings?: Pick<TerminalSettings, 'keepaliveInterval' | 'keepaliveCountMax'>;
}
@@ -22,6 +23,7 @@ export const buildSftpHostCredentials = ({
hosts,
keys,
identities,
knownHosts,
terminalSettings,
}: UseSftpHostCredentialsParams & { host: Host }): NetcattySSHOptions => {
const globalKeepalive = terminalSettings ?? FALLBACK_KEEPALIVE;
@@ -165,6 +167,7 @@ export const buildSftpHostCredentials = ({
identityFilePaths: keyAuth.identityFilePaths,
keepaliveInterval: targetKeepalive.interval,
keepaliveCountMax: targetKeepalive.countMax,
knownHosts,
// Algorithm settings — must reach the SFTP bridge or hosts that need
// legacy mode / the ECDSA skip / advanced overrides would still hit
// the original negotiation failure when opening their SFTP pane,
@@ -179,9 +182,10 @@ export const useSftpHostCredentials = ({
hosts,
keys,
identities,
knownHosts,
terminalSettings,
}: UseSftpHostCredentialsParams) =>
useCallback(
(host: Host): NetcattySSHOptions => buildSftpHostCredentials({ host, hosts, keys, identities, terminalSettings }),
[hosts, identities, keys, terminalSettings],
(host: Host): NetcattySSHOptions => buildSftpHostCredentials({ host, hosts, keys, identities, knownHosts, terminalSettings }),
[hosts, identities, keys, knownHosts, terminalSettings],
);

View File

@@ -7,6 +7,12 @@ import {
TransferStatus,
TransferTask,
} from "../../../domain/models";
import {
canReplaceSftpConflict,
describeSftpExistingKind,
describeSftpIncomingKind,
getSftpConflictTypeKey,
} from "../../../domain/sftpConflict";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { logger } from "../../../lib/logger";
import { SftpPane } from "./types";
@@ -69,8 +75,14 @@ export const useSftpTransfers = ({
);
const conflictDefaultKey = useCallback(
(batchId: string | undefined, isDirectory: boolean) =>
`${batchId ?? "global"}:${isDirectory ? "directory" : "file"}`,
(batchId: string | undefined, isDirectory: boolean, existingType?: "file" | "directory" | "symlink") =>
`${batchId ?? "global"}:${getSftpConflictTypeKey(isDirectory, existingType)}`,
[],
);
const buildReplaceTypeMismatchError = useCallback(
(isDirectory: boolean, existingType: "file" | "directory" | "symlink" | undefined, targetPath: string) =>
`Cannot replace existing ${describeSftpExistingKind(existingType)} with ${describeSftpIncomingKind(isDirectory)}: ${targetPath}`,
[],
);
@@ -233,6 +245,33 @@ export const useSftpTransfers = ({
const existingStat = await statTargetPath(targetPane, targetSftpId, task.targetPath, targetEncoding);
if (existingStat) {
const applyToAllCount = task.batchId
? await (async () => {
const candidates = transfersRef.current.filter((candidate) =>
candidate.batchId === task.batchId &&
candidate.isDirectory === task.isDirectory &&
!candidate.parentTaskId &&
candidate.status !== "completed" &&
candidate.status !== "cancelled",
);
const matches = await Promise.all(candidates.map(async (candidate) => {
if (candidate.id === task.id) return true;
try {
const candidateStat = await statTargetPath(
targetPane,
targetSftpId,
candidate.targetPath,
targetEncoding,
);
return candidateStat?.type === existingStat.type;
} catch {
return false;
}
}));
return Math.max(1, matches.filter(Boolean).length);
})()
: 1;
return {
transferId: task.id,
batchId: task.batchId,
@@ -241,15 +280,7 @@ export const useSftpTransfers = ({
targetPath: task.targetPath,
isDirectory: task.isDirectory,
existingType: existingStat.type,
applyToAllCount: task.batchId
? transfersRef.current.filter((candidate) =>
candidate.batchId === task.batchId &&
candidate.isDirectory === task.isDirectory &&
!candidate.parentTaskId &&
candidate.status !== "completed" &&
candidate.status !== "cancelled",
).length
: 1,
applyToAllCount,
existingSize: existingStat.size,
newSize: sourceStat?.size || task.totalBytes || 0,
existingModified: existingStat.mtime,
@@ -271,7 +302,9 @@ export const useSftpTransfers = ({
const conflict = await conflictCheckPromise;
if (conflict) {
const defaultAction = conflictDefaultsRef.current.get(conflictDefaultKey(task.batchId, task.isDirectory));
const defaultAction = conflictDefaultsRef.current.get(
conflictDefaultKey(task.batchId, task.isDirectory, conflict.existingType),
);
if (defaultAction) {
if (defaultAction === "stop") {
await markBatchStopped(task);
@@ -285,6 +318,16 @@ export const useSftpTransfers = ({
return "cancelled";
}
if (defaultAction === "replace" && !canReplaceSftpConflict(task.isDirectory, conflict.existingType)) {
updateTask({
status: "failed",
endTime: Date.now(),
error: buildReplaceTypeMismatchError(task.isDirectory, conflict.existingType, task.targetPath),
retryable: false,
});
return "failed";
}
const duplicateTarget = defaultAction === "duplicate"
? await getDuplicateTarget(task, targetPane, targetSftpId, targetEncoding)
: null;
@@ -728,16 +771,19 @@ export const useSftpTransfers = ({
return;
}
const selectedConflictKey = conflictDefaultKey(task.batchId, task.isDirectory);
const selectedConflictKey = conflictDefaultKey(conflict.batchId, conflict.isDirectory, conflict.existingType);
const affectedConflicts = applyToAll
? conflictsRef.current.filter((candidate) =>
conflictDefaultKey(candidate.batchId, candidate.isDirectory) === selectedConflictKey,
conflictDefaultKey(candidate.batchId, candidate.isDirectory, candidate.existingType) === selectedConflictKey,
)
: [conflict];
const affectedConflictIds = new Set(affectedConflicts.map((candidate) => candidate.transferId));
const affectedTasks = affectedConflicts
.map((candidate) => transfersRef.current.find((transfer) => transfer.id === candidate.transferId))
.filter((candidate): candidate is TransferTask => Boolean(candidate));
const affectedConflictById = new Map<string, FileConflict>(
affectedConflicts.map((candidate): [string, FileConflict] => [candidate.transferId, candidate]),
);
if (applyToAll) {
conflictDefaultsRef.current.set(selectedConflictKey, action);
@@ -771,9 +817,11 @@ export const useSftpTransfers = ({
}
const updatedTasks: TransferTask[] = [];
const blockedReplaceTasks: Array<{ task: TransferTask; conflict: FileConflict }> = [];
for (const affectedTask of affectedTasks) {
let updatedTask = { ...affectedTask };
const affectedConflict = affectedConflictById.get(affectedTask.id);
if (action === "duplicate") {
const endpoints = resolveTaskEndpoints(affectedTask);
@@ -792,6 +840,13 @@ export const useSftpTransfers = ({
skipConflictCheck: true,
};
} else if (action === "replace") {
if (
affectedConflict &&
!canReplaceSftpConflict(affectedTask.isDirectory, affectedConflict.existingType)
) {
blockedReplaceTasks.push({ task: affectedTask, conflict: affectedConflict });
continue;
}
updatedTask = {
...affectedTask,
skipConflictCheck: true,
@@ -808,6 +863,28 @@ export const useSftpTransfers = ({
updatedTasks.push(updatedTask);
}
if (blockedReplaceTasks.length > 0) {
const blockedTaskIds = new Set(blockedReplaceTasks.map(({ task }) => task.id));
const blockedErrors = new Map(
blockedReplaceTasks.map(({ task, conflict }) => [
task.id,
buildReplaceTypeMismatchError(task.isDirectory, conflict.existingType, task.targetPath),
]),
);
setTransfers((prev) =>
prev.map((t) => blockedTaskIds.has(t.id)
? {
...t,
status: "failed" as TransferStatus,
endTime: Date.now(),
error: blockedErrors.get(t.id),
retryable: false,
}
: t,
),
);
}
const updatedTaskMap = new Map(updatedTasks.map((updatedTask) => [updatedTask.id, updatedTask]));
setTransfers((prev) =>
prev.map((t) => {

View File

@@ -0,0 +1,53 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildDockerLogsCommand } from '../../domain/systemManager/dockerShell.ts';
import { loadSanitizedShellHistory } from './shellHistoryPersistence.ts';
import type { ShellHistoryEntry } from '../../domain/models.ts';
const entry = (id: string, command: string): ShellHistoryEntry => ({
id,
command,
hostId: 'host-1',
hostLabel: 'Host',
sessionId: 'session-1',
timestamp: 1000,
});
test('loadSanitizedShellHistory removes persisted managed startup commands and writes back cleaned history', () => {
const stored = [
entry('managed', buildDockerLogsCommand('587abcdef123')),
entry('user', 'docker ps -a'),
];
let written: ShellHistoryEntry[] | null = null;
const loaded = loadSanitizedShellHistory({
read: () => stored,
write: (_key, value) => {
written = value;
return true;
},
});
assert.deepEqual(
loaded?.map((item) => item.command),
['docker ps -a'],
);
assert.deepEqual(written, loaded);
});
test('loadSanitizedShellHistory does not write when persisted history is already clean', () => {
const stored = [entry('user', 'docker ps -a')];
let writeCount = 0;
const loaded = loadSanitizedShellHistory({
read: () => stored,
write: () => {
writeCount += 1;
return true;
},
});
assert.deepEqual(loaded, stored);
assert.equal(writeCount, 0);
});

View File

@@ -0,0 +1,23 @@
import type { ShellHistoryEntry } from '../../domain/models';
import { sanitizeGlobalHistoryEntries } from '../../domain/globalHistory';
import { STORAGE_KEY_SHELL_HISTORY } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
type ShellHistoryStorage = {
read<T>(key: string): T | null;
write<T>(key: string, value: T): boolean;
};
export function loadSanitizedShellHistory(
storage: ShellHistoryStorage = localStorageAdapter,
storageKey = STORAGE_KEY_SHELL_HISTORY,
): ShellHistoryEntry[] | null {
const savedShellHistory = storage.read<ShellHistoryEntry[]>(storageKey);
if (!savedShellHistory) return null;
const cleanedShellHistory = sanitizeGlobalHistoryEntries(savedShellHistory);
if (cleanedShellHistory.length !== savedShellHistory.length) {
storage.write(storageKey, cleanedShellHistory);
}
return cleanedShellHistory;
}

View File

@@ -0,0 +1,62 @@
export const WORKSPACE_SESSION_DRAG_TYPE = 'application/x-netcatty-workspace-session';
type DataTransferLike = {
types: DOMStringList | readonly string[];
getData: (format: string) => string;
};
export function dataTransferHasType(dataTransfer: Pick<DataTransferLike, 'types'>, type: string): boolean {
return Array.prototype.includes.call(dataTransfer.types, type);
}
export function hasWorkspaceSessionDrag(dataTransfer: Pick<DataTransferLike, 'types'>): boolean {
return dataTransferHasType(dataTransfer, WORKSPACE_SESSION_DRAG_TYPE);
}
export function getWorkspaceSessionDragId(dataTransfer: DataTransferLike): string {
return dataTransfer.getData(WORKSPACE_SESSION_DRAG_TYPE) || dataTransfer.getData('session-id');
}
export function isPointInsideRect(
point: { clientX: number; clientY: number },
rect: Pick<DOMRect, 'left' | 'right' | 'top' | 'bottom'>,
): boolean {
return point.clientX >= rect.left
&& point.clientX <= rect.right
&& point.clientY >= rect.top
&& point.clientY <= rect.bottom;
}
export type TopTabInsertionTarget = {
tabId: string;
position: 'before' | 'after';
};
export function getTopTabInsertionTarget(
point: { clientX: number; clientY: number },
topTabsRoot: HTMLElement | null,
): TopTabInsertionTarget | null {
if (!topTabsRoot || !isPointInsideRect(point, topTabsRoot.getBoundingClientRect())) return null;
const tabs = Array.from(topTabsRoot.querySelectorAll<HTMLElement>('[data-tab-id]'))
.filter((tab) => tab.dataset.tabType !== 'root');
if (tabs.length === 0) return null;
for (const tab of tabs) {
const rect = tab.getBoundingClientRect();
const midpoint = rect.left + rect.width / 2;
const tabId = tab.dataset.tabId;
if (!tabId) continue;
if (point.clientX <= midpoint) {
return { tabId, position: 'before' };
}
if (point.clientX <= rect.right) {
return { tabId, position: 'after' };
}
}
const lastTab = tabs[tabs.length - 1];
const lastTabId = lastTab?.dataset.tabId;
return lastTabId ? { tabId: lastTabId, position: 'after' } : null;
}

View File

@@ -139,6 +139,86 @@ test("uploads picked folder files with their relative directory structure", asyn
]);
});
test("does not replace an existing directory when uploading a same-named file", async () => {
const file = new File(["local"], "dddd", { lastModified: 1234 });
const deletedPaths: string[] = [];
const uploadedPaths: string[] = [];
const results = await uploadFromFileList(
[file],
{
targetPath: "/target",
sftpId: "sftp-1",
isLocal: false,
bridge: {
mkdirSftp: async () => {},
statSftp: async (_sftpId, path) =>
path === "/target/dddd"
? { type: "directory", size: 0, lastModified: 1000 }
: null,
deleteSftp: async (_sftpId, path) => {
deletedPaths.push(path);
},
writeSftpBinary: async (_sftpId, path) => {
uploadedPaths.push(path);
},
},
joinPath: (base, name) => `${base}/${name}`,
resolveConflict: async () => "replace",
},
);
assert.deepEqual(deletedPaths, []);
assert.deepEqual(uploadedPaths, []);
assert.equal(results.length, 1);
assert.equal(results[0].fileName, "dddd");
assert.equal(results[0].success, false);
assert.match(results[0].error ?? "", /directory/i);
});
test("counts apply-to-all upload conflicts by incoming and existing type", async () => {
const files = [
new File(["local"], "existing-file", { lastModified: 1234 }),
new File(["local"], "existing-directory", { lastModified: 1234 }),
];
const conflictCounts: number[] = [];
const results = await uploadFromFileList(
files,
{
targetPath: "/target",
sftpId: "sftp-1",
isLocal: false,
bridge: {
mkdirSftp: async () => {},
statSftp: async (_sftpId, path) => {
if (path === "/target/existing-file") {
return { type: "file", size: 2, lastModified: 1000 };
}
if (path === "/target/existing-directory") {
return { type: "directory", size: 0, lastModified: 1000 };
}
return null;
},
writeSftpBinary: async () => {
throw new Error("skipped conflicts should not upload");
},
},
joinPath: (base, name) => `${base}/${name}`,
resolveConflict: async (conflict) => {
conflictCounts.push(conflict.applyToAllCount);
return "skip";
},
},
);
assert.deepEqual(conflictCounts, [1, 1]);
assert.deepEqual(results, [
{ fileName: "existing-file", success: false, cancelled: true },
{ fileName: "existing-directory", success: false, cancelled: true },
]);
});
test("uploads path-backed clipboard files through stream transfer", async () => {
const transfers: Array<{ sourcePath: string; targetPath: string; totalBytes?: number }> = [];
const taskTotals: number[] = [];

View File

@@ -15,6 +15,7 @@ import {
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
STORAGE_KEY_AI_WEB_SEARCH,
STORAGE_KEY_AI_QUICK_MESSAGES,
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
} from '../../infrastructure/config/storageKeys';
import type { AIQuickMessage } from '../../infrastructure/ai/quickMessages';
import { sanitizeQuickMessages } from '../../infrastructure/ai/quickMessages';
@@ -29,6 +30,7 @@ import { DEFAULT_COMMAND_BLOCKLIST } from '../../infrastructure/ai/types';
import { removeProviderReferences } from './aiProviderCleanup';
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
import { getAIBridge } from './aiStateSnapshots';
import { useStoredBoolean } from './useStoredBoolean';
function readPermissionMode(): AIPermissionMode {
const stored = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE);
@@ -75,6 +77,10 @@ export function useAISettingsState() {
const [quickMessages, setQuickMessagesRaw] = useState<AIQuickMessage[]>(() =>
sanitizeQuickMessages(localStorageAdapter.read<unknown>(STORAGE_KEY_AI_QUICK_MESSAGES)),
);
const [showTerminalSelectionAIAction, setShowTerminalSelectionAIAction] = useStoredBoolean(
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
true,
);
const setProviders = useCallback((value: ProviderConfig[] | ((prev: ProviderConfig[]) => ProviderConfig[])) => {
setProvidersRaw((prev) => {
@@ -307,6 +313,8 @@ export function useAISettingsState() {
setWebSearchConfig,
quickMessages,
setQuickMessages,
showTerminalSelectionAIAction,
setShowTerminalSelectionAIAction,
}), [
providers,
setProviders,
@@ -336,5 +344,7 @@ export function useAISettingsState() {
setWebSearchConfig,
quickMessages,
setQuickMessages,
showTerminalSelectionAIAction,
setShowTerminalSelectionAIAction,
]);
}

View File

@@ -23,6 +23,7 @@ export const getAppLevelActions = (): Set<string> => {
'nextTab',
'prevTab',
'closeTab',
'closeSession',
'newTab',
'openHosts',
'openSftp',
@@ -35,6 +36,7 @@ export const getAppLevelActions = (): Set<string> => {
'splitVertical',
'moveFocus',
'broadcast',
'togglePaneZoom',
'openLocal',
'openSettings',
]);

View File

@@ -17,8 +17,13 @@ SplitHint,
updateWorkspaceSplitSizes,
} from '../../domain/workspace';
import { clearSessionFontSizeOverride as clearSessionFontSizeOverrideFields } from '../../domain/terminalAppearance';
import { buildOrderedWorkTabIds } from '../app/workTabSurface';
import { buildOrderedWorkTabIds, reorderWorkTabIds } from '../app/workTabSurface';
import { activeTabStore } from './activeTabStore';
import {
closeSessionWorkspaceLayoutState,
detachSessionFromWorkspaceState,
replaceDissolvedWorkspaceTabOrder,
} from './sessionWorkspaceDetach';
import {
createCopiedTerminalSessionClone,
createSplitTerminalSessionClone,
@@ -122,33 +127,12 @@ export const useSessionState = () => {
const wsId = targetSession?.workspaceId;
setWorkspaces(prevWorkspaces => {
let removedWorkspaceId: string | null = null;
let nextWorkspaces = prevWorkspaces;
let dissolvedWorkspaceId: string | null = null;
let lastRemainingSessionId: string | null = null;
if (wsId) {
nextWorkspaces = prevWorkspaces
.map(ws => {
if (ws.id !== wsId) return ws;
const pruned = pruneWorkspaceNode(ws.root, sessionId);
if (!pruned) {
removedWorkspaceId = ws.id;
return null;
}
// Check if only 1 session remains - dissolve workspace
const remainingSessionIds = collectSessionIds(pruned);
if (remainingSessionIds.length === 1) {
dissolvedWorkspaceId = ws.id;
lastRemainingSessionId = remainingSessionIds[0];
return null;
}
return { ...ws, root: pruned };
})
.filter((ws): ws is Workspace => Boolean(ws));
}
const {
workspaces: nextWorkspaces,
removedWorkspaceId,
dissolvedWorkspaceId,
lastRemainingSessionId,
} = closeSessionWorkspaceLayoutState(prevWorkspaces, wsId, sessionId);
const remainingSessions = prevSessions.filter(s => s.id !== sessionId);
const fallbackWorkspace = nextWorkspaces[nextWorkspaces.length - 1];
@@ -162,6 +146,14 @@ export const useSessionState = () => {
return 'vault';
};
if (dissolvedWorkspaceId && lastRemainingSessionId) {
setTabOrder(prevTabOrder => replaceDissolvedWorkspaceTabOrder(
prevTabOrder,
dissolvedWorkspaceId,
[lastRemainingSessionId],
));
}
if (dissolvedWorkspaceId && currentActiveTabId === dissolvedWorkspaceId) {
setActiveTabId(getFallback());
} else if (currentActiveTabId === sessionId) {
@@ -205,20 +197,39 @@ export const useSessionState = () => {
const target = prevSessions.find(s => s.id === sessionId);
if (target) {
setSessionRenameTarget(target);
setSessionRenameValue(target.hostLabel);
setSessionRenameValue(target.customName || target.hostLabel);
}
return prevSessions;
});
}, []);
const submitSessionRename = useCallback(() => {
const renameSessionInline = useCallback((sessionId: string, name: string) => {
const trimmed = name.trim();
if (!trimmed) return;
setSessions(prev => prev.map(s => (
s.id === sessionId ? { ...s, customName: trimmed, hostLabel: trimmed } : s
)));
}, []);
const submitSessionRename = useCallback((sessionId?: string, name?: string) => {
if (sessionId !== undefined && name !== undefined) {
const trimmed = name.trim();
if (!trimmed) return;
setSessions(prev => prev.map(s => (
s.id === sessionId ? { ...s, customName: trimmed, hostLabel: trimmed } : s
)));
return;
}
setSessionRenameValue(prevValue => {
const name = prevValue.trim();
if (!name) return prevValue;
const trimmed = prevValue.trim();
if (!trimmed) return prevValue;
setSessionRenameTarget(prevTarget => {
if (!prevTarget) return prevTarget;
setSessions(prev => prev.map(s => s.id === prevTarget.id ? { ...s, hostLabel: name } : s));
setSessions(prev => prev.map(s => (
s.id === prevTarget.id ? { ...s, customName: trimmed, hostLabel: trimmed } : s
)));
return null;
});
@@ -888,6 +899,50 @@ export const useSessionState = () => {
[getOrderedWorkTabs],
);
const removeSessionFromWorkspace = useCallback((
sessionId: string,
tabInsertionTarget?: {
tabId: string;
position: 'before' | 'after';
additionalTabIds?: readonly string[];
},
) => {
setSessions(prevSessions => {
const result = detachSessionFromWorkspaceState({
sessions: prevSessions,
workspaces: workspacesRef.current,
sessionId,
});
if (!result.changed) return prevSessions;
setWorkspaces(result.workspaces);
setTabOrder(prevTabOrder => {
const replacedOrder = replaceDissolvedWorkspaceTabOrder(
prevTabOrder,
result.dissolvedWorkspaceId,
result.replacementTabIds,
);
if (!tabInsertionTarget) return replacedOrder;
const allTabIds = [
...result.sessions.filter(s => !s.workspaceId).map(s => s.id),
...result.workspaces.map(w => w.id),
...logViews.map(lv => lv.id),
...(tabInsertionTarget.additionalTabIds ?? []),
];
return reorderWorkTabIds(
replacedOrder,
allTabIds,
sessionId,
tabInsertionTarget.tabId,
tabInsertionTarget.position,
);
});
if (result.activeTabId) setActiveTabId(result.activeTabId);
return result.sessions;
});
}, [logViews, setActiveTabId]);
const reorderTabs = useCallback((
draggedId: string,
targetId: string,
@@ -896,39 +951,13 @@ export const useSessionState = () => {
) => {
if (draggedId === targetId) return;
setTabOrder(prevTabOrder => {
const allTabIds = [...baseWorkTabIds, ...additionalTabIds];
const allTabIdSet = new Set(allTabIds);
// Build current effective order: existing order + new tabs at end
const orderedIds = prevTabOrder.filter(id => allTabIdSet.has(id));
const orderedIdSet = new Set(orderedIds);
const newIds = allTabIds.filter(id => !orderedIdSet.has(id));
const currentOrder = [...orderedIds, ...newIds];
const draggedIndex = currentOrder.indexOf(draggedId);
const targetIndex = currentOrder.indexOf(targetId);
if (draggedIndex === -1 || targetIndex === -1) return prevTabOrder;
// Remove dragged item first
currentOrder.splice(draggedIndex, 1);
// Calculate new target index (adjusted after removal)
let newTargetIndex = targetIndex;
if (draggedIndex < targetIndex) {
newTargetIndex -= 1;
}
// Insert at the correct position
if (position === 'after') {
newTargetIndex += 1;
}
currentOrder.splice(newTargetIndex, 0, draggedId);
return currentOrder;
});
setTabOrder(prevTabOrder => reorderWorkTabIds(
prevTabOrder,
[...baseWorkTabIds, ...additionalTabIds],
draggedId,
targetId,
position,
));
}, [baseWorkTabIds]);
return {
@@ -942,6 +971,7 @@ export const useSessionState = () => {
sessionRenameValue,
setSessionRenameValue,
startSessionRename,
renameSessionInline,
submitSessionRename,
resetSessionRename,
workspaceRenameTarget,
@@ -962,6 +992,7 @@ export const useSessionState = () => {
createWorkspaceFromTargets,
createWorkspaceFromSessions,
addSessionToWorkspace,
removeSessionFromWorkspace,
appendHostToWorkspace,
appendLocalTerminalToWorkspace,
updateSplitSizes,

View File

@@ -47,6 +47,7 @@ import {
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
} from '../../infrastructure/config/storageKeys';
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
import {
@@ -89,6 +90,7 @@ import {
DEFAULT_SHOW_SFTP_TAB,
DEFAULT_SHOW_HOST_TREE_SIDEBAR,
DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
DEFAULT_DISABLE_TERMINAL_FONT_ZOOM,
DEFAULT_SSH_DEBUG_LOGS_ENABLED,
DEFAULT_TERMINAL_THEME,
DEFAULT_THEME,
@@ -244,6 +246,10 @@ export const useSettingsState = () => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
return stored ?? DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS;
});
const [disableTerminalFontZoom, setDisableTerminalFontZoomState] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM);
return stored ?? DEFAULT_DISABLE_TERMINAL_FONT_ZOOM;
});
const [sftpTransferConcurrency, setSftpTransferConcurrencyState] = useState<number>(() => {
const stored = localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY);
return stored != null && stored >= 1 && stored <= 16 ? stored : 4;
@@ -343,7 +349,14 @@ export const useSettingsState = () => {
const mergeIncomingTerminalSettings = useCallback((incoming: Partial<TerminalSettings>) => {
setTerminalSettingsState((prev) => {
const next = normalizeTerminalSettings({ ...prev, ...incoming });
const merged: Partial<TerminalSettings> = { ...prev, ...incoming };
if (
!Object.prototype.hasOwnProperty.call(incoming, 'middleClickBehavior') &&
Object.prototype.hasOwnProperty.call(incoming, 'middleClickPaste')
) {
delete merged.middleClickBehavior;
}
const next = normalizeTerminalSettings(merged);
if (areTerminalSettingsEqual(prev, next)) {
return prev;
}
@@ -544,6 +557,8 @@ export const useSettingsState = () => {
setShowHostTreeSidebarState(storedShowHostTreeSidebar ?? DEFAULT_SHOW_HOST_TREE_SIDEBAR);
const storedShellOnlyTabNumberShortcuts = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
setShellOnlyTabNumberShortcutsState(storedShellOnlyTabNumberShortcuts ?? DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
const storedDisableTerminalFontZoom = localStorageAdapter.readBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM);
setDisableTerminalFontZoomState(storedDisableTerminalFontZoom ?? DEFAULT_DISABLE_TERMINAL_FONT_ZOOM);
// Workspace focus style
const storedFocusStyle = readStoredString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
@@ -635,6 +650,7 @@ export const useSettingsState = () => {
setSftpDefaultViewMode,
setWorkspaceFocusStyleState,
setShowHostTreeSidebarState,
setDisableTerminalFontZoomState,
setSftpTransferConcurrencyState,
});
@@ -661,7 +677,7 @@ export const useSettingsState = () => {
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
@@ -670,7 +686,7 @@ export const useSettingsState = () => {
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setShellOnlyTabNumberShortcutsState,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled,
setGlobalHotkeyEnabled, setWindowOpacity, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
@@ -791,6 +807,13 @@ export const useSettingsState = () => {
notifySettingsChanged(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS, enabled);
}, [notifySettingsChanged]);
const setDisableTerminalFontZoom = useCallback((enabled: boolean) => {
setDisableTerminalFontZoomState(enabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM, enabled);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM, enabled);
}, [notifySettingsChanged]);
// Apply and persist custom CSS
useEffect(() => {
applyCustomCssToDocument(customCSS);
@@ -1031,6 +1054,8 @@ export const useSettingsState = () => {
setShowHostTreeSidebar,
shellOnlyTabNumberShortcuts,
setShellOnlyTabNumberShortcuts,
disableTerminalFontZoom,
setDisableTerminalFontZoom,
sftpTransferConcurrency,
setSftpTransferConcurrency,
// Editor Settings
@@ -1075,7 +1100,7 @@ export const useSettingsState = () => {
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
customKeyBindings, editorWordWrap,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
]),
};

View File

@@ -170,10 +170,21 @@ export const useSftpState = (
useSftpSessionCleanup(sftpSessionsRef);
useSftpFileWatch(options);
const { connect, disconnect, listLocalFiles, listRemoteFiles } = useSftpConnections({
const {
connect,
disconnect,
listLocalFiles,
listRemoteFiles,
hostKeyVerification,
rejectHostKeyVerification,
acceptHostKeyVerification,
acceptAndSaveHostKeyVerification,
} = useSftpConnections({
hosts,
keys,
identities,
knownHosts: options?.knownHosts,
onAddKnownHost: options?.onAddKnownHost,
terminalSettings: options?.terminalSettings,
leftTabsRef,
rightTabsRef,
@@ -402,6 +413,9 @@ export const useSftpState = (
resolveConflict: resolveAnyConflict,
getSftpIdForConnection,
reportSessionError: handleSessionError,
rejectHostKeyVerification,
acceptHostKeyVerification,
acceptAndSaveHostKeyVerification,
});
methodsRef.current = {
getFilteredFiles,
@@ -460,6 +474,9 @@ export const useSftpState = (
resolveConflict: resolveAnyConflict,
getSftpIdForConnection,
reportSessionError: handleSessionError,
rejectHostKeyVerification,
acceptHostKeyVerification,
acceptAndSaveHostKeyVerification,
};
// Create stable method wrappers that call through methodsRef
@@ -532,6 +549,9 @@ export const useSftpState = (
resolveConflict: (...args: Parameters<typeof resolveAnyConflict>) => methodsRef.current.resolveConflict(...args),
getSftpIdForConnection: (...args: Parameters<typeof getSftpIdForConnection>) => methodsRef.current.getSftpIdForConnection(...args),
reportSessionError: (...args: Parameters<typeof handleSessionError>) => methodsRef.current.reportSessionError(...args),
rejectHostKeyVerification: () => methodsRef.current.rejectHostKeyVerification(),
acceptHostKeyVerification: () => methodsRef.current.acceptHostKeyVerification(),
acceptAndSaveHostKeyVerification: () => methodsRef.current.acceptAndSaveHostKeyVerification(),
activeFileWatchCountRef,
}), [activeFileWatchCountRef]); // activeFileWatchCountRef is a stable ref
@@ -546,6 +566,7 @@ export const useSftpState = (
transfers,
activeTransfersCount,
conflicts,
hostKeyVerification,
// Stable methods - never change reference
...stableMethods,
@@ -566,6 +587,7 @@ export const useSftpState = (
transfers,
activeTransfersCount,
conflicts,
hostKeyVerification,
stableMethods,
]);
};

View File

@@ -180,17 +180,33 @@ export const useTerminalBackend = () => {
return !!bridge?.sendSerialYmodem;
}, []);
const serialYmodemReceiveAvailable = useCallback(() => {
const bridge = netcattyBridge.get();
return !!bridge?.receiveSerialYmodem;
}, []);
const selectFileAvailable = useCallback(() => {
const bridge = netcattyBridge.get();
return !!bridge?.selectFile;
}, []);
const selectDirectoryAvailable = useCallback(() => {
const bridge = netcattyBridge.get();
return !!bridge?.selectDirectory;
}, []);
const sendSerialYmodem = useCallback(async (sessionId: string, filePath: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.sendSerialYmodem) return { success: false, error: 'sendSerialYmodem unavailable' };
return bridge.sendSerialYmodem(sessionId, filePath);
}, []);
const receiveSerialYmodem = useCallback(async (sessionId: string, destinationDir: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.receiveSerialYmodem) return { success: false, error: 'receiveSerialYmodem unavailable' };
return bridge.receiveSerialYmodem(sessionId, destinationDir);
}, []);
const selectFile = useCallback(async (
title?: string,
defaultPath?: string,
@@ -201,6 +217,42 @@ export const useTerminalBackend = () => {
return bridge.selectFile(title, defaultPath, filters);
}, []);
const selectDirectory = useCallback(async (title?: string, defaultPath?: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.selectDirectory) return null;
return bridge.selectDirectory(title, defaultPath);
}, []);
const startZmodemDragDropUpload = useCallback(async (
sessionId: string,
files: Array<{
path?: string;
name: string;
remoteName: string;
data?: ArrayBuffer;
}>,
uploadCommand?: string,
) => {
const bridge = netcattyBridge.get();
if (!bridge?.startZmodemDragDropUpload) {
return { success: false, error: "startZmodemDragDropUpload unavailable" };
}
return bridge.startZmodemDragDropUpload(sessionId, files, uploadCommand);
}, []);
const cancelZmodem = useCallback((sessionId: string, options?: { interrupt?: boolean }) => {
const bridge = netcattyBridge.get();
bridge?.cancelZmodem?.(sessionId, options);
}, []);
const onZmodemEvent = useCallback((
sessionId: string,
cb: Parameters<NonNullable<NetcattyBridge["onZmodemEvent"]>>[1],
) => {
const bridge = netcattyBridge.get();
return bridge?.onZmodemEvent?.(sessionId, cb) ?? (() => {});
}, []);
const getSessionPwd = useCallback(async (sessionId: string, options?: { allowHomeFallback?: boolean }) => {
const bridge = netcattyBridge.get();
if (!bridge?.getSessionPwd) return { success: false, error: 'getSessionPwd unavailable' };
@@ -256,9 +308,16 @@ export const useTerminalBackend = () => {
startSerialSession,
listSerialPorts,
serialYmodemAvailable,
serialYmodemReceiveAvailable,
selectFileAvailable,
selectDirectoryAvailable,
sendSerialYmodem,
receiveSerialYmodem,
selectFile,
selectDirectory,
startZmodemDragDropUpload,
cancelZmodem,
onZmodemEvent,
execCommand,
getSessionPwd,
getSessionRemoteInfo,
@@ -297,9 +356,16 @@ export const useTerminalBackend = () => {
startSerialSession,
listSerialPorts,
serialYmodemAvailable,
serialYmodemReceiveAvailable,
selectFileAvailable,
selectDirectoryAvailable,
sendSerialYmodem,
receiveSerialYmodem,
selectFile,
selectDirectory,
startZmodemDragDropUpload,
cancelZmodem,
onZmodemEvent,
execCommand,
getSessionPwd,
getSessionRemoteInfo,

View File

@@ -36,8 +36,9 @@ import {
STORAGE_KEY_TERM_SETTINGS,
} from "../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
import { mergeGlobalHistoryOnAppend } from "../../domain/globalHistory";
import { mergeGlobalHistoryOnAppend, sanitizeGlobalHistoryEntries } from "../../domain/globalHistory";
import { getNextVaultOrder, normalizeVaultOrder } from "../../domain/vaultOrder";
import { loadSanitizedShellHistory } from "./shellHistoryPersistence";
import {
decryptGroupConfigs,
decryptHosts,
@@ -598,10 +599,10 @@ export const useVaultState = () => {
}
// Load shell history
const savedShellHistory = localStorageAdapter.read<ShellHistoryEntry[]>(
STORAGE_KEY_SHELL_HISTORY,
);
if (savedShellHistory) setShellHistory(savedShellHistory);
const savedShellHistory = loadSanitizedShellHistory();
if (savedShellHistory) {
setShellHistory(savedShellHistory);
}
// Load connection logs
const savedConnectionLogs = localStorageAdapter.read<ConnectionLog[]>(
@@ -729,7 +730,9 @@ export const useVaultState = () => {
}
if (key === STORAGE_KEY_SHELL_HISTORY) {
const next = safeParse<ShellHistoryEntry[]>(event.newValue) ?? [];
const next = sanitizeGlobalHistoryEntries(
safeParse<ShellHistoryEntry[]>(event.newValue) ?? [],
);
setShellHistory(next);
return;
}

View File

@@ -44,6 +44,7 @@ const {
hasCloudSyncEntityData,
hasMeaningfulCloudSyncData,
shouldPromptCloudVaultRecovery,
SYNCABLE_SETTING_STORAGE_KEYS,
} = await import("./syncPayload.ts");
const storageKeys = await import("../infrastructure/config/storageKeys.ts");
@@ -124,6 +125,7 @@ test("buildSyncPayload includes AI configuration settings", () => {
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP, JSON.stringify({ codex: "gpt-test" }));
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP, JSON.stringify({ catty: "openai-main" }));
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify(webSearch));
localStorage.setItem(storageKeys.STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION, "false");
const payload = buildSyncPayload(vault([]));
@@ -140,9 +142,18 @@ test("buildSyncPayload includes AI configuration settings", () => {
agentModelMap: { codex: "gpt-test" },
agentProviderMap: { catty: "openai-main" },
webSearchConfig: webSearch,
showTerminalSelectionAction: false,
});
});
test("terminal selection AI preference is syncable for auto-sync detection", () => {
assert.ok(
(SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(
storageKeys.STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
),
);
});
test("buildSyncPayload includes host tree sidebar visibility setting", () => {
localStorage.setItem(storageKeys.STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, "false");
@@ -215,6 +226,7 @@ test("applySyncPayload restores AI configuration settings", async () => {
agentModelMap: { claude: "claude-test" },
agentProviderMap: { catty: "anthropic-main" },
webSearchConfig: webSearch,
showTerminalSelectionAction: false,
},
},
syncedAt: 1,
@@ -234,6 +246,7 @@ test("applySyncPayload restores AI configuration settings", async () => {
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP)!), { claude: "claude-test" });
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP)!), { catty: "anthropic-main" });
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH)!), webSearch);
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION), "false");
});
test("applySyncPayload restores host tree sidebar visibility setting", async () => {
@@ -529,6 +542,7 @@ test("buildSyncPayload includes syncable terminal options from settings", () =>
localStorage.setItem(storageKeys.STORAGE_KEY_TERM_SETTINGS, JSON.stringify({
terminalEmulationType: "vt100",
altAsMeta: true,
middleClickBehavior: "context-menu",
showServerStats: false,
serverStatsRefreshInterval: 12,
rendererType: "dom",
@@ -541,6 +555,7 @@ test("buildSyncPayload includes syncable terminal options from settings", () =>
assert.deepEqual(payload.settings?.terminalSettings, {
terminalEmulationType: "vt100",
altAsMeta: true,
middleClickBehavior: "context-menu",
showServerStats: false,
serverStatsRefreshInterval: 12,
rendererType: "dom",
@@ -805,6 +820,42 @@ test("applySyncPayload writes incoming fallbackFont into local TERM_SETTINGS", a
assert.equal(parsed.fallbackFont, "Sarasa Mono SC");
});
test("applySyncPayload lets legacy middle-click paste update the new middle-click behavior", async () => {
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({
scrollback: 2000,
middleClickBehavior: "paste",
middleClickPaste: true,
}),
);
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: 1,
settings: {
terminalSettings: {
middleClickPaste: false,
},
},
} as SyncPayload;
await applySyncPayload(payload, {
importVaultData: () => {},
});
const raw = localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS);
assert.ok(raw, "TERM_SETTINGS should be written");
const parsed = JSON.parse(raw!);
assert.equal(parsed.scrollback, 2000);
assert.equal(parsed.middleClickBehavior, "disabled");
assert.equal(parsed.middleClickPaste, false);
});
test("applySyncPayload from legacy client (no fallbackFont) preserves local value", async () => {
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,

View File

@@ -67,6 +67,7 @@ import {
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_AI_PROVIDERS,
STORAGE_KEY_AI_ACTIVE_PROVIDER,
@@ -82,6 +83,7 @@ import {
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
STORAGE_KEY_AI_WEB_SEARCH,
STORAGE_KEY_AI_QUICK_MESSAGES,
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
STORAGE_KEY_PORT_FORWARDING,
} from '../infrastructure/config/storageKeys';
@@ -193,7 +195,7 @@ const SYNCABLE_TERMINAL_KEYS = [
'linePadding', 'cursorShape', 'cursorBlink', 'minimumContrastRatio',
'altAsMeta', 'optionArrowWordJump', 'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
'smoothScrolling',
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
'rightClickBehavior', 'middleClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
'keepaliveInterval', 'keepaliveCountMax', 'disableBracketedPaste', 'clearWipesScrollback',
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'showServerStats',
@@ -251,6 +253,7 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
STORAGE_KEY_AI_WEB_SEARCH,
STORAGE_KEY_AI_QUICK_MESSAGES,
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
] as const;
const isRecord = (value: unknown): value is Record<string, unknown> =>
@@ -416,6 +419,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 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);
if (showHostTreeSidebar != null) settings.showHostTreeSidebar = showHostTreeSidebar;
const workspaceFocusStyle = localStorageAdapter.readString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
@@ -457,6 +462,10 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
if (webSearchConfig) ai.webSearchConfig = stripDeviceBoundApiKey(webSearchConfig);
const quickMessages = readArraySetting(STORAGE_KEY_AI_QUICK_MESSAGES);
if (quickMessages) ai.quickMessages = sanitizeQuickMessages(quickMessages);
const showTerminalSelectionAction = localStorageAdapter.readBoolean(STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION);
if (showTerminalSelectionAction != null) {
ai.showTerminalSelectionAction = showTerminalSelectionAction;
}
if (Object.keys(ai).length > 0) settings.ai = ai;
return Object.keys(settings).length > 0 ? settings : undefined;
@@ -495,11 +504,27 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
try { existing = JSON.parse(raw); } catch { /* ignore */ }
}
const merged = { ...existing };
const hasIncomingMiddleClickBehavior = 'middleClickBehavior' in settings.terminalSettings;
const hasIncomingMiddleClickPaste = 'middleClickPaste' in settings.terminalSettings;
for (const key of SYNCABLE_TERMINAL_KEYS) {
if (key in settings.terminalSettings) {
merged[key] = settings.terminalSettings[key];
}
}
if (hasIncomingMiddleClickBehavior) {
const behavior = settings.terminalSettings.middleClickBehavior;
if (
behavior === 'context-menu' ||
behavior === 'paste' ||
behavior === 'disabled'
) {
merged.middleClickPaste = behavior === 'paste';
}
} else if (hasIncomingMiddleClickPaste) {
merged.middleClickBehavior = settings.terminalSettings.middleClickPaste === false
? 'disabled'
: 'paste';
}
localStorageAdapter.writeString(STORAGE_KEY_TERM_SETTINGS, JSON.stringify(merged));
}
@@ -553,6 +578,9 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
if (settings.shellOnlyTabNumberShortcuts != null) {
localStorageAdapter.writeBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS, settings.shellOnlyTabNumberShortcuts);
}
if (settings.disableTerminalFontZoom != null) {
localStorageAdapter.writeBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM, settings.disableTerminalFontZoom);
}
if (settings.showHostTreeSidebar != null) {
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, settings.showHostTreeSidebar);
}
@@ -594,6 +622,12 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
if (ai.quickMessages != null) {
localStorageAdapter.write(STORAGE_KEY_AI_QUICK_MESSAGES, sanitizeQuickMessages(ai.quickMessages));
}
if (ai.showTerminalSelectionAction != null) {
localStorageAdapter.writeBoolean(
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
ai.showTerminalSelectionAction,
);
}
// After all AI writes, reconcile per-agent bindings against the final
// provider list. Sync payloads can land with a new `providers` set but
// no `agentProviderMap`, or with a stale `agentProviderMap` that
@@ -635,6 +669,9 @@ function notifyAIStateAfterSync(ai: NonNullable<SyncPayload['settings']>['ai']):
}
if (ai.webSearchConfig !== undefined) touched.push(STORAGE_KEY_AI_WEB_SEARCH);
if (ai.quickMessages != null) touched.push(STORAGE_KEY_AI_QUICK_MESSAGES);
if (ai.showTerminalSelectionAction != null) {
touched.push(STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION);
}
for (const key of touched) {
emitAIStateChanged(key);
}

View File

@@ -19,6 +19,7 @@ export const DISTRO_LOGOS: Record<string, string> = {
kali: "/distro/kali.svg",
almalinux: "/distro/almalinux.svg",
alinux: "/distro/alinux.svg",
openeuler: "/distro/openeuler.svg",
// OS-level logos (used by local terminal tab icons)
macos: "/distro/macos.svg",
windows: "/distro/windows.svg",
@@ -50,6 +51,7 @@ export const DISTRO_COLORS: Record<string, string> = {
kali: "bg-[#0F6DB3]",
almalinux: "bg-[#173B66]",
alinux: "bg-[#FF6A00]",
openeuler: "bg-[#002FA7]",
// OS-level colors
macos: "bg-[#333333]",
windows: "bg-[#0078D4]",

View File

@@ -157,6 +157,8 @@ const SettingsAITabContainer: React.FC = () => {
setWebSearchConfig={aiState.setWebSearchConfig}
quickMessages={aiState.quickMessages}
setQuickMessages={aiState.setQuickMessages}
showTerminalSelectionAIAction={aiState.showTerminalSelectionAIAction}
setShowTerminalSelectionAIAction={aiState.setShowTerminalSelectionAIAction}
/>
</AITabErrorBoundary>
);
@@ -401,6 +403,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
setHotkeyScheme={settings.setHotkeyScheme}
shellOnlyTabNumberShortcuts={settings.shellOnlyTabNumberShortcuts}
setShellOnlyTabNumberShortcuts={settings.setShellOnlyTabNumberShortcuts}
disableTerminalFontZoom={settings.disableTerminalFontZoom}
setDisableTerminalFontZoom={settings.setDisableTerminalFontZoom}
keyBindings={settings.keyBindings}
updateKeyBinding={settings.updateKeyBinding}
resetKeyBinding={settings.resetKeyBinding}

View File

@@ -24,7 +24,7 @@ import { getParentPath, isConcreteTransferTargetPath } from "../application/stat
import { buildCacheKey } from "../application/state/sftp/sharedRemoteHostCache";
import { logger } from "../lib/logger";
import type { DropEntry } from "../lib/sftpFileUtils";
import { Host, Identity, SSHKey } from "../types";
import { Host, Identity, KnownHost, SSHKey } from "../types";
import type { TransferTask } from "../types";
import { toast } from "./ui/toast";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
@@ -47,7 +47,9 @@ interface SftpSidePanelProps {
writableHosts?: Host[];
keys: SSHKey[];
identities: Identity[];
knownHosts?: KnownHost[];
updateHosts: (hosts: Host[]) => void;
onAddKnownHost?: (knownHost: KnownHost) => void;
sftpDefaultViewMode: "list" | "tree";
/** The host to connect to (follows focused terminal) */
activeHost: Host | null;
@@ -87,7 +89,9 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
writableHosts,
keys,
identities,
knownHosts = [],
updateHosts,
onAddKnownHost,
sftpDefaultViewMode,
activeHost,
activeSessionId,
@@ -134,7 +138,9 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
defaultShowHiddenFiles: sftpShowHiddenFiles,
autoConnectLocalOnMount: false,
terminalSettings,
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles, terminalSettings]);
knownHosts,
onAddKnownHost,
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles, terminalSettings, knownHosts, onAddKnownHost]);
const sftp = useSftpState(hosts, keys, identities, sftpOptions);
const {
@@ -964,7 +970,9 @@ const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps):
prev.writableHosts === next.writableHosts &&
prev.keys === next.keys &&
prev.identities === next.identities &&
prev.knownHosts === next.knownHosts &&
prev.updateHosts === next.updateHosts &&
prev.onAddKnownHost === next.onAddKnownHost &&
prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
prev.activeHost === next.activeHost &&
prev.activeSessionId === next.activeSessionId &&

View File

@@ -24,7 +24,7 @@ import { HotkeyScheme, KeyBinding } from "../domain/models";
import { logger } from "../lib/logger";
import { useRenderTracker } from "../lib/useRenderTracker";
import { cn } from "../lib/utils";
import { Host, Identity, ProxyProfile, SSHKey, TransferTask } from "../types";
import { Host, Identity, KnownHost, ProxyProfile, SSHKey, TransferTask } from "../types";
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
@@ -54,9 +54,11 @@ interface SftpViewProps {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
knownHosts?: KnownHost[];
groupConfigs?: import('../domain/models').GroupConfig[];
proxyProfiles?: ProxyProfile[];
updateHosts: (hosts: Host[]) => void;
onAddKnownHost?: (knownHost: KnownHost) => void;
sftpDefaultViewMode: "list" | "tree";
sftpDoubleClickBehavior: "open" | "transfer";
sftpAutoSync: boolean;
@@ -73,9 +75,11 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
hosts,
keys,
identities,
knownHosts = [],
groupConfigs = [],
proxyProfiles = [],
updateHosts,
onAddKnownHost,
sftpDefaultViewMode,
sftpDoubleClickBehavior,
sftpAutoSync,
@@ -110,7 +114,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
useCompressedUpload: sftpUseCompressedUpload,
defaultShowHiddenFiles: sftpShowHiddenFiles,
terminalSettings,
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles, terminalSettings]);
knownHosts,
onAddKnownHost,
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles, terminalSettings, knownHosts, onAddKnownHost]);
// Pre-resolve group defaults so SFTP connections inherit group config
const effectiveHosts = useMemo(() => {
@@ -374,9 +380,11 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
handleReorderTabsRight,
handleMoveTabFromLeftToRight,
handleMoveTabFromRightToLeft,
handleDuplicateTabLeft,
handleDuplicateTabRight,
handleHostSelectLeft,
handleHostSelectRight,
} = useSftpViewTabs({ sftp, sftpRef });
} = useSftpViewTabs({ sftp, sftpRef, hosts: effectiveHosts });
const handleAddTabLeftWithFocus = useCallback(() => {
const tabId = handleAddTabLeft();
@@ -398,6 +406,26 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
handlePaneFocus("right", tabId);
}, [handlePaneFocus, handleSelectTabRight]);
const handleDuplicateTabLeftWithFocus = useCallback(
async (...args: Parameters<typeof handleDuplicateTabLeft>) => {
const tabId = await handleDuplicateTabLeft(...args);
if (tabId) {
handlePaneFocus("left", tabId);
}
},
[handleDuplicateTabLeft, handlePaneFocus],
);
const handleDuplicateTabRightWithFocus = useCallback(
async (...args: Parameters<typeof handleDuplicateTabRight>) => {
const tabId = await handleDuplicateTabRight(...args);
if (tabId) {
handlePaneFocus("right", tabId);
}
},
[handleDuplicateTabRight, handlePaneFocus],
);
return (
<SftpContextProvider
hosts={effectiveHosts}
@@ -444,6 +472,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
onAddTab={handleAddTabLeftWithFocus}
onReorderTabs={handleReorderTabsLeft}
onMoveTabToOtherSide={handleMoveTabFromRightToLeft}
onDuplicateTab={handleDuplicateTabLeftWithFocus}
/>
)}
<div className="relative flex-1 min-h-0">
@@ -504,6 +533,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
onAddTab={handleAddTabRightWithFocus}
onReorderTabs={handleReorderTabsRight}
onMoveTabToOtherSide={handleMoveTabFromLeftToRight}
onDuplicateTab={handleDuplicateTabRightWithFocus}
/>
)}
<div className="relative flex-1 min-h-0">
@@ -588,9 +618,11 @@ const sftpViewAreEqual = (prev: SftpViewProps, next: SftpViewProps): boolean =>
prev.hosts === next.hosts &&
prev.keys === next.keys &&
prev.identities === next.identities &&
prev.knownHosts === next.knownHosts &&
prev.groupConfigs === next.groupConfigs &&
prev.proxyProfiles === next.proxyProfiles &&
prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
prev.onAddKnownHost === next.onAddKnownHost &&
prev.sftpDoubleClickBehavior === next.sftpDoubleClickBehavior &&
prev.sftpAutoSync === next.sftpAutoSync &&
prev.sftpShowHiddenFiles === next.sftpShowHiddenFiles &&

View File

@@ -3,7 +3,7 @@ import { FitAddon } from "@xterm/addon-fit";
import { SerializeAddon } from "@xterm/addon-serialize";
import { SearchAddon } from "@xterm/addon-search";
import "@xterm/xterm/css/xterm.css";
import { Activity, Cpu, Clock3, Copy, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine, Sparkles } from "lucide-react";
import { Activity, Cpu, Clock3, Copy, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine, Sparkles, SquareArrowOutUpRight } from "lucide-react";
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { detectLocalOs } from "../lib/localShell";
@@ -25,6 +25,7 @@ import {
type TerminalHostUpdate,
} from "../domain/terminalAppearance";
import { classifyDistroId, shouldProbeSessionCwd } from "../domain/host";
import { supportsZmodemTerminalDragDrop } from "../lib/zmodemDragDrop";
import { resolveHostAuth } from "../domain/sshAuth";
import { useTerminalBackend } from "../application/state/useTerminalBackend";
import { useTerminalLayoutSuppressActive } from "../application/state/terminalLayoutSuppressStore";
@@ -117,6 +118,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
reuseConnectionFromSessionId,
serialConfig,
hotkeyScheme = "disabled",
disableTerminalFontZoom = false,
keyBindings = [],
onHotkeyAction,
onTerminalFontSizeChange,
@@ -147,7 +149,16 @@ const TerminalComponent: React.FC<TerminalProps> = ({
sessionLog,
sshDebugLogEnabled,
sudoAutofillPassword,
showSelectionAIAction = true,
onAddSelectionToAI,
sessionDisplayName,
onRename,
onDetach,
onStartSessionDrag,
onEndSessionDrag,
onDetachPointerDown,
onDetachDragStart,
onDetachDragEnd,
}) => {
const layoutSuppressActive = useTerminalLayoutSuppressActive();
const deferTerminalResize = isResizing || layoutSuppressActive;
@@ -220,9 +231,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}, [captureTerminalLogData]);
const hotkeySchemeRef = useRef(hotkeyScheme);
const disableTerminalFontZoomRef = useRef(disableTerminalFontZoom);
const keyBindingsRef = useRef(keyBindings);
const onHotkeyActionRef = useRef(onHotkeyAction);
hotkeySchemeRef.current = hotkeyScheme;
disableTerminalFontZoomRef.current = disableTerminalFontZoom;
keyBindingsRef.current = keyBindings;
onHotkeyActionRef.current = onHotkeyAction;
@@ -247,10 +260,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const terminalBackend = useTerminalBackend();
const {
resizeSession,
receiveSerialYmodem,
selectDirectory,
selectDirectoryAvailable,
selectFile,
selectFileAvailable,
sendSerialYmodem,
serialYmodemAvailable,
serialYmodemReceiveAvailable,
setSessionEncoding,
} = terminalBackend;
@@ -462,6 +479,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const detectedDeviceClass = classifyDistroId(host.distro);
const isNetworkDevice =
host.deviceType === 'network' || detectedDeviceClass === 'network-device';
const remoteDragDropUsesZmodem = supportsZmodemTerminalDragDrop(host, isNetworkDevice);
// Check if this is a local or serial connection (doesn't need connection dialog during connecting)
const isLocalConnection = host.protocol === "local";
@@ -961,6 +979,43 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
}, [isSerialConnection, selectFile, selectFileAvailable, sendSerialYmodem, serialYmodemAvailable, sessionId, t]);
const handleReceiveYmodem = useCallback(async () => {
if (!isSerialConnection || statusRef.current !== "connected") return;
if (!selectDirectoryAvailable() || !serialYmodemReceiveAvailable()) {
toast.error(t("terminal.ymodem.unavailable"));
return;
}
try {
const destinationDir = await selectDirectory(t("terminal.ymodem.selectReceiveDirectory"));
if (!destinationDir) return;
toast.info(t("terminal.ymodem.receiveStarted"));
const result = await receiveSerialYmodem(sessionRef.current || sessionId, destinationDir);
if (result.success) {
if (result.fileCount && result.fileCount > 1) {
toast.success(t("terminal.ymodem.receiveCompleteMultiple", { count: result.fileCount }));
} else if (result.fileName) {
toast.success(t("terminal.ymodem.receiveComplete", { fileName: result.fileName }));
} else {
toast.success(t("terminal.ymodem.receiveEmpty"));
}
} else {
toast.error(t("terminal.ymodem.receiveFailed"));
}
} catch {
toast.error(t("terminal.ymodem.receiveFailed"));
}
}, [
isSerialConnection,
receiveSerialYmodem,
selectDirectory,
selectDirectoryAvailable,
serialYmodemReceiveAvailable,
sessionId,
t,
]);
const handleCancelConnect = () => {
if (pendingHostKeyRequestId) {
void terminalBackend.respondHostKeyVerification(pendingHostKeyRequestId, false);
@@ -1117,6 +1172,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
} = useTerminalDragDrop({
host,
isLocalConnection,
isNetworkDevice,
onOpenSftp,
resolveSftpInitialPath,
scrollToBottomAfterProgrammaticInput,
@@ -1152,6 +1208,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onSnippetClick={(snippet) => { void executeSnippet(snippet); }}
onOpenSFTP={handleOpenSFTP}
onSendYmodem={isSerialConnection ? handleSendYmodem : undefined}
onReceiveYmodem={isSerialConnection ? handleReceiveYmodem : undefined}
onOpenScripts={onOpenScripts ?? (() => {})}
onOpenHistory={onOpenHistory}
onOpenTheme={onOpenTheme ?? (() => {})}
@@ -1169,6 +1226,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
compactToolbar,
executeSnippet,
handleOpenSFTP,
handleReceiveYmodem,
handleSendYmodem,
handleSetTerminalEncoding,
handleToggleSearch,
@@ -1208,9 +1266,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const effectiveComposeBarOpen = inWorkspace ? !!isWorkspaceComposeBarOpen : isComposeBarOpen;
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, paneLayoutKey, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef });
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isComposeBarOpen: effectiveComposeBarOpen, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, paneLayoutKey, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef });
return <TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onExpandToFocus, onOpenSystem, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, scrollToBottomAfterProgrammaticInput, searchMatchCount, selectionOverlayPosition, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, snippets, status, statusDotTone, sudoHintRef, sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />;
return <TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, remoteDragDropUsesZmodem, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, scrollToBottomAfterProgrammaticInput, searchMatchCount, selectionOverlayPosition, sessionDisplayName, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, showSelectionAIAction, snippets, status, statusDotTone, sudoHintRef, sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />;
};
const Terminal = memo(TerminalComponent, terminalPropsAreEqual);

View File

@@ -99,6 +99,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
terminalFontFamilyId,
fontSize = 14,
hotkeyScheme = 'disabled',
disableTerminalFontZoom = false,
keyBindings = [],
onHotkeyAction,
onUpdateTerminalThemeId,
@@ -123,6 +124,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onToggleWorkspaceViewMode,
onSetWorkspaceFocusedSession,
onReorderWorkspaceSessions,
onReorderTabs,
onCopySession,
onCopySessionToNewWindow,
onSplitSession,
onConnectToHost,
onCreateLocalTerminal,
@@ -149,6 +153,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
showHostTreeSidebar = true,
toggleScriptsSidePanelRef,
toggleSidePanelRef,
// Session rename props
onStartSessionRename,
onSubmitSessionRename,
onRemoveSessionFromWorkspace,
}) => {
const { t } = useI18n();
const terminalRendererCwdBySessionRef = useRef<Map<string, string>>(new Map());
@@ -1118,6 +1126,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
hosts,
hostsRef,
hotkeyScheme,
disableTerminalFontZoom,
identities,
isBroadcastEnabled,
isComposeBarOpen,
@@ -1136,10 +1145,18 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onCreateWorkspaceFromSessions,
onHotkeyAction,
onReorderWorkspaceSessions,
onReorderTabs,
onCopySession,
onCopySessionToNewWindow,
onRequestAddToWorkspace,
onSessionData,
onSetDraggingSessionId,
onSetWorkspaceFocusedSession,
onStartSessionRename,
onSubmitSessionRename,
onRemoveSessionFromWorkspace,
onStartSessionDrag: onSetDraggingSessionId,
onEndSessionDrag: () => onSetDraggingSessionId(null),
onSplitSession,
onSplitSessionRef,
onToggleBroadcastRef,

View File

@@ -7,9 +7,11 @@ import { useTerminalPopupWindow } from '../application/state/useTerminalPopupWin
import { useVaultState } from '../application/state/useVaultState';
import { useWindowControls } from '../application/state/useWindowControls';
import { shouldCloseTerminalPopupOnExit } from '../application/state/resolveTerminalSessionExitIntent';
import { upsertKnownHost } from '../domain/knownHosts';
import type { TerminalPopupPayload } from '../domain/systemManager/types';
import type { TerminalTheme } from '../domain/models';
import type { Host } from '../types';
import type { Host, KnownHost } from '../types';
import { getEffectiveKnownHosts } from '../infrastructure/syncHelpers';
import { cn } from '../lib/utils';
const Terminal = lazy(() => import('./Terminal'));
@@ -195,11 +197,22 @@ function TerminalPopupPageInner() {
const { close, setWindowTitle, onPopupConfig } = useTerminalPopupWindow();
const { notifyRendererReady, onWindowCommandCloseRequested } = useWindowControls();
const settings = useSettingsState();
const { isInitialized: vaultInitialized, hosts, keys, identities, knownHosts, snippets, snippetPackages } = useVaultState();
const { isInitialized: vaultInitialized, hosts, keys, identities, knownHosts, snippets, snippetPackages, updateKnownHosts } = useVaultState();
const [config, setConfig] = useState<TerminalPopupPayload | null>(null);
const [terminalReady, setTerminalReady] = useState(false);
const [startupError, setStartupError] = useState<string | null>(null);
const sessionId = useMemo(() => crypto.randomUUID(), []);
const knownHostsRef = React.useRef(knownHosts);
const effectiveKnownHosts = useMemo(
() => getEffectiveKnownHosts(knownHosts) ?? [],
[knownHosts],
);
knownHostsRef.current = effectiveKnownHosts;
const handleAddKnownHost = useCallback((knownHost: KnownHost) => {
const nextKnownHosts = upsertKnownHost(knownHostsRef.current, knownHost);
knownHostsRef.current = nextKnownHosts;
updateKnownHosts(nextKnownHosts);
}, [updateKnownHosts]);
const popupThemeVars = useMemo(
() => buildPopupThemeVars(settings.currentTerminalTheme),
[settings.currentTerminalTheme],
@@ -307,7 +320,8 @@ function TerminalPopupPageInner() {
snippetPackages={snippetPackages}
compactToolbar
lineTimestampsAvailable={false}
knownHosts={knownHosts}
knownHosts={effectiveKnownHosts}
onAddKnownHost={handleAddKnownHost}
isVisible
isFocused
fontFamilyId={settings.terminalFontFamilyId}
@@ -317,6 +331,7 @@ function TerminalPopupPageInner() {
accentMode={settings.accentMode}
customAccent={settings.customAccent}
terminalSettings={settings.terminalSettings}
disableTerminalFontZoom={settings.disableTerminalFontZoom}
sessionId={sessionId}
startupCommand={config.startupCommand}
reuseConnectionFromSessionId={reuseId}

View File

@@ -18,13 +18,23 @@ Object.defineProperty(globalThis, "requestAnimationFrame", {
const {
computeHostTreeTabGutter,
resolveWorkspaceSessionTabDropTarget,
shouldKeepHostTreeToggleSurface,
shouldShowHostTreeToggle,
} = await import("./TopTabs.tsx");
const {
WORKSPACE_SESSION_DRAG_TYPE,
dataTransferHasType,
getTopTabInsertionTarget,
getWorkspaceSessionDragId,
hasWorkspaceSessionDrag,
isPointInsideRect,
} = await import("../application/state/terminalDragData.ts");
const { activateLogViewTab } = await import("./top-tabs/TopTabItems.tsx");
const { activeTabStore } = await import("../application/state/activeTabStore.ts");
const indexCss = readFileSync(new URL("../index.css", import.meta.url), "utf8");
const topTabsSource = readFileSync(new URL("./TopTabs.tsx", import.meta.url), "utf8");
const terminalViewSource = readFileSync(new URL("./terminal/TerminalView.tsx", import.meta.url), "utf8");
test("host tree tab gutter fills the remaining sidebar width", () => {
assert.equal(computeHostTreeTabGutter(280, 120), 160);
@@ -93,6 +103,152 @@ test("quick switcher plus button exposes a custom CSS hook", () => {
assert.match(topTabsSource, /data-section="top-tabs-quick-switcher-toggle"/);
});
test("workspace session drag data is recognized with a dedicated drag type", () => {
const data = new Map([
[WORKSPACE_SESSION_DRAG_TYPE, "session-1"],
["session-id", "fallback-session"],
]);
const transfer = {
types: [WORKSPACE_SESSION_DRAG_TYPE, "text/plain"],
getData: (format: string) => data.get(format) ?? "",
};
assert.equal(hasWorkspaceSessionDrag(transfer), true);
assert.equal(getWorkspaceSessionDragId(transfer), "session-1");
});
test("workspace session drag id falls back to the legacy session id", () => {
const transfer = {
types: ["session-id"],
getData: (format: string) => (format === "session-id" ? "session-2" : ""),
};
assert.equal(dataTransferHasType(transfer, "session-id"), true);
assert.equal(hasWorkspaceSessionDrag(transfer), false);
assert.equal(getWorkspaceSessionDragId(transfer), "session-2");
});
test("point-in-rect detects pointer release inside the top tab bar", () => {
const rect = { left: 10, right: 110, top: 20, bottom: 60 };
assert.equal(isPointInsideRect({ clientX: 10, clientY: 20 }, rect), true);
assert.equal(isPointInsideRect({ clientX: 70, clientY: 40 }, rect), true);
assert.equal(isPointInsideRect({ clientX: 111, clientY: 40 }, rect), false);
assert.equal(isPointInsideRect({ clientX: 70, clientY: 61 }, rect), false);
});
test("top tab insertion target ignores fixed root tabs", () => {
const makeTab = (id: string, type: string, left: number, right: number) => ({
dataset: { tabId: id, tabType: type },
getBoundingClientRect: () => ({ left, right, top: 20, bottom: 60, width: right - left, height: 40 }),
});
const root = {
getBoundingClientRect: () => ({ left: 0, right: 400, top: 0, bottom: 80, width: 400, height: 80 }),
querySelectorAll: () => [
makeTab("vault", "root", 0, 80),
makeTab("workspace-1", "workspace", 90, 210),
makeTab("session-1", "session", 210, 330),
],
} as unknown as HTMLElement;
assert.deepEqual(getTopTabInsertionTarget({ clientX: 20, clientY: 40 }, root), {
tabId: "workspace-1",
position: "before",
});
assert.deepEqual(getTopTabInsertionTarget({ clientX: 180, clientY: 40 }, root), {
tabId: "workspace-1",
position: "after",
});
assert.deepEqual(getTopTabInsertionTarget({ clientX: 380, clientY: 40 }, root), {
tabId: "session-1",
position: "after",
});
assert.equal(getTopTabInsertionTarget({ clientX: 180, clientY: 120 }, root), null);
});
test("workspace session tab drop forwards the requested insertion target", () => {
assert.deepEqual(resolveWorkspaceSessionTabDropTarget({
targetTabId: "session-3",
position: "after",
draggedSessionId: "session-1",
draggedWorkspaceId: "workspace-1",
workspaces: [],
}), {
tabId: "session-3",
position: "after",
additionalTabIds: ["session-1", "session-3"],
});
});
test("workspace session tab drop targets the remaining terminal when its workspace dissolves", () => {
assert.deepEqual(resolveWorkspaceSessionTabDropTarget({
targetTabId: "workspace-1",
position: "before",
draggedSessionId: "session-1",
draggedWorkspaceId: "workspace-1",
workspaces: [{
id: "workspace-1",
title: "Workspace",
focusedSessionId: "session-1",
root: {
id: "split-1",
type: "split",
direction: "horizontal",
children: [
{ id: "pane-1", type: "pane", sessionId: "session-1" },
{ id: "pane-2", type: "pane", sessionId: "session-2" },
],
sizes: [1, 1],
},
}],
}), {
tabId: "session-2",
position: "before",
additionalTabIds: ["session-1", "session-2"],
});
});
test("workspace session tab-bar blank drop inserts after the last work tab", () => {
const makeTab = (id: string, type: string, left: number, right: number) => ({
dataset: { tabId: id, tabType: type },
getBoundingClientRect: () => ({ left, right, top: 20, bottom: 60, width: right - left, height: 40 }),
});
const root = {
getBoundingClientRect: () => ({ left: 0, right: 500, top: 0, bottom: 80, width: 500, height: 80 }),
querySelectorAll: () => [
makeTab("vault", "root", 0, 80),
makeTab("workspace-1", "workspace", 90, 210),
makeTab("session-3", "session", 210, 330),
],
} as unknown as HTMLElement;
const insertionTarget = getTopTabInsertionTarget({ clientX: 460, clientY: 40 }, root);
assert.deepEqual(insertionTarget, { tabId: "session-3", position: "after" });
assert.deepEqual(resolveWorkspaceSessionTabDropTarget({
targetTabId: insertionTarget!.tabId,
position: insertionTarget!.position,
draggedSessionId: "session-1",
draggedWorkspaceId: "workspace-1",
workspaces: [],
}), {
tabId: "session-3",
position: "after",
additionalTabIds: ["session-1", "session-3"],
});
});
test("terminal top bar hides server stats before they crowd the host title", () => {
assert.match(indexCss, /\.terminal-topbar\s*\{[\s\S]*container-type: inline-size/);
assert.match(indexCss, /@container \(max-width: 760px\) \{[\s\S]*\.terminal-server-stats\s*\{[\s\S]*display: none/);
assert.match(terminalViewSource, /terminal-topbar/);
assert.match(terminalViewSource, /terminal-title-cluster/);
assert.match(terminalViewSource, /onPointerDown=\{onDetachPointerDown\}/);
});
test("workspace session drag no longer uses a full tab-bar drop zone", () => {
assert.doesNotMatch(topTabsSource, /top-tabs-workspace-detach-drop-zone/);
});
test("host tree chrome enters after theme switch settles so root labels can animate", () => {
assert.match(topTabsSource, /hostTreeChromeReady/);
assert.match(topTabsSource, /scheduleAfterInstantThemeSwitch\(\(\) => \{\s*cancelHostTreeChromeReadyRef\.current = null;\s*setHostTreeChromeReady\(true\);/);

View File

@@ -4,7 +4,9 @@ import { fromEditorTabId, isEditorTabId, useActiveTabId } from '../application/s
import { isHostTreeWorkTabSurface } from '../application/app/workTabSurface';
import type { EditorTab } from '../application/state/editorTabStore';
import { buildWorkspaceActivityMap } from '../application/state/sessionActivity';
import { collectSessionIds } from '../domain/workspace';
import { useSessionActivityMap } from '../application/state/sessionActivityStore';
import { getTopTabInsertionTarget, getWorkspaceSessionDragId, hasWorkspaceSessionDrag } from '../application/state/terminalDragData';
import {
useTerminalHostTreeLayoutWidth,
useTerminalHostTreeOpen,
@@ -82,6 +84,34 @@ export function shouldKeepHostTreeToggleSurface({
return enabled && activeWorkTabCount > 0;
}
export function resolveWorkspaceSessionTabDropTarget({
targetTabId,
position,
draggedSessionId,
draggedWorkspaceId,
workspaces,
}: {
targetTabId: string;
position: 'before' | 'after';
draggedSessionId: string;
draggedWorkspaceId: string;
workspaces: readonly Workspace[];
}): { tabId: string; position: 'before' | 'after'; additionalTabIds: readonly string[] } {
const sourceWorkspace = workspaces.find((workspace) => workspace.id === draggedWorkspaceId);
const remainingSessionIds = sourceWorkspace
? collectSessionIds(sourceWorkspace.root).filter((sessionId) => sessionId !== draggedSessionId)
: [];
const stableTargetTabId = targetTabId === draggedWorkspaceId && remainingSessionIds.length === 1
? remainingSessionIds[0]
: targetTabId;
return {
tabId: stableTargetTabId,
position,
additionalTabIds: [draggedSessionId, stableTargetTabId],
};
}
interface TopTabsProps {
theme: 'dark' | 'light';
hosts: Host[];
@@ -109,6 +139,10 @@ interface TopTabsProps {
onStartSessionDrag: (sessionId: string) => void;
onEndSessionDrag: () => void;
onReorderTabs: (draggedId: string, targetId: string, position: 'before' | 'after') => void;
onRemoveSessionFromWorkspace: (
sessionId: string,
tabInsertionTarget?: { tabId: string; position: 'before' | 'after'; additionalTabIds?: readonly string[] },
) => void;
showSftpTab: boolean;
showHostTreeSidebar: boolean;
editorTabs: readonly EditorTab[];
@@ -143,6 +177,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onStartSessionDrag,
onEndSessionDrag,
onReorderTabs,
onRemoveSessionFromWorkspace,
showSftpTab,
showHostTreeSidebar,
editorTabs,
@@ -386,7 +421,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
useLayoutEffect(() => {
const syncGutter = () => updateHostTreeTabGutterRef.current();
syncGutter({ deferClose: true });
updateHostTreeTabGutterRef.current({ deferClose: true });
const rafId = window.requestAnimationFrame(() => syncGutter());
const settleTimer = window.setTimeout(syncGutter, 320);
const root = tabsContainerRef.current?.closest('[data-top-tabs-root]') as HTMLElement | null;
@@ -442,6 +477,11 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (hasWorkspaceSessionDrag(e.dataTransfer)) {
setDropIndicator(null);
return;
}
if (!draggedTabIdRef.current || draggedTabIdRef.current === tabId) {
return;
}
@@ -463,6 +503,26 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
const handleTabDrop = useCallback((e: React.DragEvent, targetTabId: string) => {
e.preventDefault();
if (hasWorkspaceSessionDrag(e.dataTransfer)) {
const draggedSessionId = getWorkspaceSessionDragId(e.dataTransfer);
const draggedSession = sessions.find((s) => s.id === draggedSessionId);
if (draggedSession?.workspaceId) {
const rect = e.currentTarget.getBoundingClientRect();
const position: 'before' | 'after' = e.clientX < rect.left + rect.width / 2 ? 'before' : 'after';
onRemoveSessionFromWorkspace(draggedSessionId, resolveWorkspaceSessionTabDropTarget({
targetTabId,
position,
draggedSessionId,
draggedWorkspaceId: draggedSession.workspaceId,
workspaces,
}));
setDropIndicator(null);
setIsDraggingForReorder(false);
onEndSessionDrag();
return;
}
}
const draggedId = e.dataTransfer.getData('tab-reorder-id') || draggedTabIdRef.current;
if (draggedId && draggedId !== targetTabId && dropIndicator) {
@@ -471,7 +531,33 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
setDropIndicator(null);
setIsDraggingForReorder(false);
}, [dropIndicator, onReorderTabs]);
}, [dropIndicator, onEndSessionDrag, onRemoveSessionFromWorkspace, onReorderTabs, sessions, workspaces]);
const handleTabBarDrop = useCallback((e: React.DragEvent) => {
if (!hasWorkspaceSessionDrag(e.dataTransfer)) return;
const draggedSessionId = getWorkspaceSessionDragId(e.dataTransfer);
if (!draggedSessionId) return;
const draggedSession = sessions.find((s) => s.id === draggedSessionId);
if (!draggedSession?.workspaceId) return;
e.preventDefault();
const root = e.currentTarget.closest('[data-top-tabs-root]') as HTMLElement | null;
const insertionTarget = getTopTabInsertionTarget(e, root);
onRemoveSessionFromWorkspace(
draggedSessionId,
insertionTarget
? resolveWorkspaceSessionTabDropTarget({
targetTabId: insertionTarget.tabId,
position: insertionTarget.position,
draggedSessionId,
draggedWorkspaceId: draggedSession.workspaceId,
workspaces,
})
: undefined,
);
setDropIndicator(null);
setIsDraggingForReorder(false);
onEndSessionDrag();
}, [onEndSessionDrag, onRemoveSessionFromWorkspace, sessions, workspaces]);
const handleScrollableTabClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement;
@@ -682,6 +768,14 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
const shiftStyle = tabShiftStyles[workspace.id] || emptyTabStyle;
const showDropIndicatorBefore = dropIndicator?.tabId === workspace.id && dropIndicator.position === 'before';
const showDropIndicatorAfter = dropIndicator?.tabId === workspace.id && dropIndicator.position === 'after';
const workspaceSessionIds = collectSessionIds(workspace.root);
const workspaceSessionLabels: Record<string, string> = {};
for (const sessionId of workspaceSessionIds) {
const wsSession = sessions.find((s) => s.id === sessionId);
if (wsSession) {
workspaceSessionLabels[sessionId] = wsSession.customName || wsSession.hostLabel;
}
}
return (
<WorkspaceTopTab
@@ -701,6 +795,8 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onTabDrop={handleTabDrop}
onRenameWorkspace={onRenameWorkspace}
onCloseWorkspace={onCloseWorkspace}
onDetachSessionFromWorkspace={(_workspaceId, sessionId) => onRemoveSessionFromWorkspace(sessionId)}
workspaceSessionLabels={workspaceSessionLabels}
renderBulkCloseItems={renderBulkCloseItems}
t={t}
tabAnimationClass={getTabAnimationClass(workspace.id)}
@@ -801,12 +897,18 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
style={dragRegionStyle}
// Add container-level drag handlers to prevent indicator loss
onDragOver={(e) => {
if (hasWorkspaceSessionDrag(e.dataTransfer)) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
return;
}
// Keep drop indicator active while dragging over the container
if (draggedTabIdRef.current && isDraggingForReorder && !dropIndicator) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
}
}}
onDrop={handleTabBarDrop}
>
{hasHostTreeToggleSurface && (
<div
@@ -871,6 +973,13 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
className="flex items-end gap-0 overflow-x-auto scrollbar-none app-drag max-w-full"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
onClick={handleScrollableTabClick}
onDragOver={(e) => {
if (hasWorkspaceSessionDrag(e.dataTransfer)) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
}
}}
onDrop={handleTabBarDrop}
>
{renderOrderedTabs()}
{/* Add new tab button - follows last tab when not overflowing */}

View File

@@ -0,0 +1,18 @@
import assert from "node:assert/strict";
import test from "node:test";
import * as terminalBehaviorSettings from "./tabs/TerminalBehaviorSettings.tsx";
const middleClickBehaviorOptions = (
terminalBehaviorSettings as {
MIDDLE_CLICK_BEHAVIOR_OPTIONS?: Array<{ value: string; labelKey: string }>;
}
).MIDDLE_CLICK_BEHAVIOR_OPTIONS;
test("middle-click settings expose only supported behaviors", () => {
assert.ok(Array.isArray(middleClickBehaviorOptions));
assert.deepEqual(
middleClickBehaviorOptions.map((option) => option.value),
["context-menu", "paste", "disabled"],
);
});

View File

@@ -8,13 +8,15 @@ interface ToggleProps {
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
ariaLabel?: string;
}
export const Toggle: React.FC<ToggleProps> = ({ checked, onChange, disabled }) => (
export const Toggle: React.FC<ToggleProps> = ({ checked, onChange, disabled, ariaLabel }) => (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={ariaLabel}
disabled={disabled}
onClick={() => onChange(!checked)}
className={cn(
@@ -69,7 +71,7 @@ export const Select: React.FC<SelectProps> = ({
</SelectPrimitive.Trigger>
<SelectPrimitive.Portal>
<SelectPrimitive.Content
className="z-[200000] max-h-80 w-max max-w-[var(--radix-select-content-available-width)] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1"
className="z-[200000] max-h-80 w-max max-w-[min(24rem,var(--radix-select-content-available-width))] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1"
position="popper"
sideOffset={4}
style={{ minWidth: "max(12rem, var(--radix-select-trigger-width))" }}
@@ -82,7 +84,7 @@ export const Select: React.FC<SelectProps> = ({
<SelectPrimitive.Item
key={opt.value}
value={opt.value}
className="relative flex w-full min-w-max cursor-default select-none items-center whitespace-nowrap rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
className="relative flex w-full min-w-0 cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
@@ -90,7 +92,7 @@ export const Select: React.FC<SelectProps> = ({
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>
<span className="flex items-center gap-2 whitespace-nowrap">
<span className="flex min-w-0 items-center gap-2 whitespace-normal break-words leading-snug">
{opt.icon}
{opt.label}
</span>

View File

@@ -21,7 +21,7 @@ import type { ManagedAgentKey } from "../../../infrastructure/ai/managedAgents";
import { PROVIDER_PRESETS } from "../../../infrastructure/ai/types";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { Button } from "../../ui/button";
import { Select, SettingCard, SettingsSection, SettingsTabContent, SettingRow } from "../settings-ui";
import { Select, SettingCard, SettingsSection, SettingsTabContent, SettingRow, Toggle } from "../settings-ui";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../ui/tabs";
import { AgentIconBadge } from "../../ai/AgentIconBadge";
import { canSendWithAgent } from "../../ai/agentSendEligibility";
@@ -140,6 +140,8 @@ interface SettingsAITabProps {
setWebSearchConfig: (config: WebSearchConfig | null) => void;
quickMessages: AIQuickMessage[];
setQuickMessages: (value: AIQuickMessage[] | ((prev: AIQuickMessage[]) => AIQuickMessage[])) => void;
showTerminalSelectionAIAction: boolean;
setShowTerminalSelectionAIAction: (value: boolean | ((prev: boolean) => boolean)) => void;
}
// ---------------------------------------------------------------------------
@@ -173,6 +175,8 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
setWebSearchConfig,
quickMessages,
setQuickMessages,
showTerminalSelectionAIAction,
setShowTerminalSelectionAIAction,
}) => {
const { t } = useI18n();
const [editingProviderId, setEditingProviderId] = useState<string | null>(null);
@@ -871,6 +875,21 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
</TabsContent>
<TabsContent value="tools" className="m-0 space-y-6">
<SettingsSection title={t('ai.chatShortcuts.title')}>
<SettingCard divided>
<SettingRow
label={t('ai.chatShortcuts.selectionAction')}
description={t('ai.chatShortcuts.selectionAction.description')}
>
<Toggle
checked={showTerminalSelectionAIAction}
onChange={setShowTerminalSelectionAIAction}
ariaLabel={t('ai.chatShortcuts.selectionAction')}
/>
</SettingRow>
</SettingCard>
</SettingsSection>
<SettingsSection title={t('ai.toolAccess.title')}>
<SettingCard>
<SettingRow description={t('ai.toolAccess.description')}>

View File

@@ -12,6 +12,8 @@ export default function SettingsShortcutsTab(props: {
setHotkeyScheme: (scheme: HotkeyScheme) => void;
shellOnlyTabNumberShortcuts: boolean;
setShellOnlyTabNumberShortcuts: (enabled: boolean) => void;
disableTerminalFontZoom: boolean;
setDisableTerminalFontZoom: (enabled: boolean) => void;
keyBindings: KeyBinding[];
updateKeyBinding?: (bindingId: string, scheme: "mac" | "pc", newKey: string) => void;
resetKeyBinding?: (bindingId: string, scheme?: "mac" | "pc") => void;
@@ -23,6 +25,8 @@ export default function SettingsShortcutsTab(props: {
setHotkeyScheme,
shellOnlyTabNumberShortcuts,
setShellOnlyTabNumberShortcuts,
disableTerminalFontZoom,
setDisableTerminalFontZoom,
keyBindings,
updateKeyBinding,
resetKeyBinding,
@@ -140,6 +144,15 @@ export default function SettingsShortcutsTab(props: {
className="w-32"
/>
</SettingRow>
<SettingRow
label={t("settings.shortcuts.disableTerminalFontZoom.label")}
description={t("settings.shortcuts.disableTerminalFontZoom.desc")}
>
<Toggle
checked={disableTerminalFontZoom}
onChange={setDisableTerminalFontZoom}
/>
</SettingRow>
<SettingRow
label={t("settings.shortcuts.shellOnlyTabNumberShortcuts.label")}
description={t("settings.shortcuts.shellOnlyTabNumberShortcuts.desc")}

View File

@@ -27,6 +27,19 @@ import { resolveFollowedTerminalThemeId, TERMINAL_THEME_AUTO } from "../../../do
import { KeywordHighlightRulesEditor, ThemePreviewButton } from "./SettingsTerminalTabControls";
import { TerminalBehaviorSettings } from "./TerminalBehaviorSettings";
const FONT_WEIGHT_OPTIONS = [
{ value: "100", labelKey: "settings.terminal.font.weight.thin" },
{ value: "200", labelKey: "settings.terminal.font.weight.extraLight" },
{ value: "300", labelKey: "settings.terminal.font.weight.light" },
{ value: "400", labelKey: "settings.terminal.font.weight.normal" },
{ value: "500", labelKey: "settings.terminal.font.weight.medium" },
{ value: "600", labelKey: "settings.terminal.font.weight.semiBold" },
{ value: "700", labelKey: "settings.terminal.font.weight.bold" },
{ value: "800", labelKey: "settings.terminal.font.weight.extraBold" },
{ value: "900", labelKey: "settings.terminal.font.weight.black" },
];
function SettingsTerminalTab(props: {
terminalThemeId: string;
setTerminalThemeId: (id: string) => void;
@@ -146,6 +159,13 @@ function SettingsTerminalTab(props: {
|| TERMINAL_THEMES[0];
}, [terminalThemeDarkId, terminalThemeLightId, lightUiThemeId, darkUiThemeId, terminalThemeId, customThemes]);
const fontWeightOptions = useMemo(() => (
FONT_WEIGHT_OPTIONS.map((option) => ({
value: option.value,
label: `${option.value} - ${t(option.labelKey)}`,
}))
), [t]);
const handleAutocompleteGhostTextChange = useCallback((enabled: boolean) => {
updateTerminalSetting("autocompleteGhostText", enabled);
if (enabled) {
@@ -516,17 +536,7 @@ function SettingsTerminalTab(props: {
>
<Select
value={String(terminalSettings.fontWeight)}
options={[
{ value: "100", label: "100 - Thin" },
{ value: "200", label: "200 - Extra Light" },
{ value: "300", label: "300 - Light" },
{ value: "400", label: "400 - Normal" },
{ value: "500", label: "500 - Medium" },
{ value: "600", label: "600 - Semi Bold" },
{ value: "700", label: "700 - Bold" },
{ value: "800", label: "800 - Extra Bold" },
{ value: "900", label: "900 - Black" },
]}
options={fontWeightOptions}
onChange={(v) => updateTerminalSetting("fontWeight", parseInt(v))}
className="w-40"
/>
@@ -538,17 +548,7 @@ function SettingsTerminalTab(props: {
>
<Select
value={String(terminalSettings.fontWeightBold)}
options={[
{ value: "100", label: "100 - Thin" },
{ value: "200", label: "200 - Extra Light" },
{ value: "300", label: "300 - Light" },
{ value: "400", label: "400 - Normal" },
{ value: "500", label: "500 - Medium" },
{ value: "600", label: "600 - Semi Bold" },
{ value: "700", label: "700 - Bold" },
{ value: "800", label: "800 - Extra Bold" },
{ value: "900", label: "900 - Black" },
]}
options={fontWeightOptions}
onChange={(v) => updateTerminalSetting("fontWeightBold", parseInt(v))}
className="w-40"
/>

View File

@@ -1,5 +1,5 @@
import React from "react";
import type { LinkModifier, RightClickBehavior, TerminalSettings } from "../../../domain/models";
import type { LinkModifier, MiddleClickBehavior, RightClickBehavior, TerminalSettings } from "../../../domain/models";
import { Input } from "../../ui/input";
import { Label } from "../../ui/label";
import { SectionHeader, Select, SettingRow, Toggle } from "../settings-ui";
@@ -12,6 +12,15 @@ interface TerminalBehaviorSettingsProps {
updateTerminalSetting: <K extends keyof TerminalSettings>(key: K, value: TerminalSettings[K]) => void;
}
export const MIDDLE_CLICK_BEHAVIOR_OPTIONS: Array<{
value: MiddleClickBehavior;
labelKey: string;
}> = [
{ value: "context-menu", labelKey: "settings.terminal.behavior.middleClick.menu" },
{ value: "paste", labelKey: "settings.terminal.behavior.middleClick.paste" },
{ value: "disabled", labelKey: "settings.terminal.behavior.middleClick.disabled" },
];
export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> = ({
t,
terminalSettings,
@@ -44,10 +53,18 @@ export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> =
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.middleClickPaste")}
description={t("settings.terminal.behavior.middleClickPaste.desc")}
label={t("settings.terminal.behavior.middleClick")}
description={t("settings.terminal.behavior.middleClick.desc")}
>
<Toggle checked={terminalSettings.middleClickPaste} onChange={(v) => updateTerminalSetting("middleClickPaste", v)} />
<Select
value={terminalSettings.middleClickBehavior}
options={MIDDLE_CLICK_BEHAVIOR_OPTIONS.map((option) => ({
value: option.value,
label: t(option.labelKey),
}))}
onChange={(v) => updateTerminalSetting("middleClickBehavior", v as MiddleClickBehavior)}
className="w-36"
/>
</SettingRow>
<SettingRow

View File

@@ -0,0 +1,25 @@
import test from "node:test";
import assert from "node:assert/strict";
import { canReplaceConflict } from "./SftpConflictDialog.tsx";
test("does not offer replace when a file upload conflicts with an existing directory", () => {
assert.equal(canReplaceConflict({
isDirectory: false,
existingType: "directory",
}), false);
});
test("does not offer replace when a directory upload conflicts with an existing file", () => {
assert.equal(canReplaceConflict({
isDirectory: true,
existingType: "file",
}), false);
});
test("offers replace when a file upload conflicts with an existing file", () => {
assert.equal(canReplaceConflict({
isDirectory: false,
existingType: "file",
}), true);
});

View File

@@ -5,6 +5,7 @@
import { AlertCircle } from 'lucide-react';
import React, { memo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { canReplaceSftpConflict, getSftpConflictTypeKey } from '../../domain/sftpConflict';
import { Button } from '../ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
import type { FileConflictAction } from '../../domain/models';
@@ -23,12 +24,53 @@ interface ConflictItem {
newModified: number;
}
export const canReplaceConflict = (conflict: Pick<ConflictItem, 'isDirectory' | 'existingType'>): boolean => {
return canReplaceSftpConflict(conflict.isDirectory, conflict.existingType);
};
const getConflictTypeKey = (conflict: Pick<ConflictItem, 'isDirectory' | 'existingType'>): string =>
getSftpConflictTypeKey(conflict.isDirectory, conflict.existingType);
interface SftpConflictDialogProps {
conflicts: ConflictItem[];
onResolve: (conflictId: string, action: FileConflictAction, applyToAll?: boolean) => void;
formatFileSize: (size: number) => string;
}
interface ConflictFileSummaryProps {
title: string;
sizeLabel: string;
modifiedLabel: string;
size: string;
modified: string;
}
const ConflictFileSummary: React.FC<ConflictFileSummaryProps> = ({
title,
sizeLabel,
modifiedLabel,
size,
modified,
}) => (
<div className="rounded-md border border-border/60 bg-secondary/25 px-4 py-3">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="text-sm font-medium text-foreground">
{title}
</div>
</div>
<dl className="space-y-2 text-sm">
<div className="grid grid-cols-[5.5rem_minmax(0,1fr)] gap-3">
<dt className="text-muted-foreground">{sizeLabel}</dt>
<dd className="min-w-0 text-foreground">{size}</dd>
</div>
<div className="grid grid-cols-[5.5rem_minmax(0,1fr)] gap-3">
<dt className="text-muted-foreground">{modifiedLabel}</dt>
<dd className="min-w-0 break-words leading-relaxed text-foreground">{modified}</dd>
</div>
</dl>
</div>
);
const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts, onResolve, formatFileSize }) => {
const { t } = useI18n();
const [applyToAll, setApplyToAll] = useState(false);
@@ -42,9 +84,10 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
const sameTypeConflictCount = Math.max(
conflict.applyToAllCount ?? 1,
conflicts.filter((item) => item.isDirectory === conflict.isDirectory).length,
conflicts.filter((item) => getConflictTypeKey(item) === getConflictTypeKey(conflict)).length,
);
const canMerge = conflict.isDirectory && conflict.existingType === 'directory';
const canReplace = canReplaceConflict(conflict);
const handleAction = (action: FileConflictAction) => {
onResolve(conflict.transferId, action, applyToAll);
@@ -53,55 +96,46 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
return (
<Dialog open={!!conflict} onOpenChange={() => handleAction('skip')}>
<DialogContent className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-yellow-500" />
<DialogContent className="gap-5 p-5 sm:max-w-[520px] sm:p-6">
<DialogHeader className="space-y-2 pr-8">
<DialogTitle className="flex items-center gap-3 text-xl leading-tight">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-border/70 text-muted-foreground">
<AlertCircle className="h-5 w-5" />
</span>
{t('sftp.conflict.title')}
</DialogTitle>
<DialogDescription>
<DialogDescription className="text-[15px] leading-6">
{t('sftp.conflict.desc')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="text-sm">
<span className="font-medium">{conflict.fileName}</span>
<span className="text-muted-foreground ml-1">{t('sftp.conflict.alreadyExistsSuffix')}</span>
<div className="space-y-4">
<div className="rounded-md border border-border/60 bg-muted/25 px-4 py-3 text-sm leading-6">
<div className="min-w-0 break-words">
<span className="font-medium text-foreground">{conflict.fileName}</span>
<span className="ml-1 text-muted-foreground">{t('sftp.conflict.alreadyExistsSuffix')}</span>
</div>
</div>
<div className="grid grid-cols-2 gap-4 text-xs">
<div className="p-3 rounded-lg bg-secondary/50 border border-border/60">
<div className="font-medium mb-2 text-muted-foreground">{t('sftp.conflict.existingFile')}</div>
<div className="space-y-1">
<div className="flex justify-between">
<span className="text-muted-foreground">{t('sftp.conflict.size')}</span>
<span>{formatFileSize(conflict.existingSize)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">{t('sftp.conflict.modified')}</span>
<span>{formatDate(conflict.existingModified)}</span>
</div>
</div>
</div>
<div className="p-3 rounded-lg bg-primary/10 border border-primary/30">
<div className="font-medium mb-2 text-primary">{t('sftp.conflict.newFile')}</div>
<div className="space-y-1">
<div className="flex justify-between">
<span className="text-muted-foreground">{t('sftp.conflict.size')}</span>
<span>{formatFileSize(conflict.newSize)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">{t('sftp.conflict.modified')}</span>
<span>{formatDate(conflict.newModified)}</span>
</div>
</div>
</div>
<div className="space-y-3">
<ConflictFileSummary
title={t('sftp.conflict.existingFile')}
sizeLabel={t('sftp.conflict.size')}
modifiedLabel={t('sftp.conflict.modified')}
size={formatFileSize(conflict.existingSize)}
modified={formatDate(conflict.existingModified)}
/>
<ConflictFileSummary
title={t('sftp.conflict.newFile')}
sizeLabel={t('sftp.conflict.size')}
modifiedLabel={t('sftp.conflict.modified')}
size={formatFileSize(conflict.newSize)}
modified={formatDate(conflict.newModified)}
/>
</div>
{sameTypeConflictCount > 1 && (
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<label className="flex cursor-pointer items-center gap-2 rounded-md border border-border/60 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
<input
type="checkbox"
checked={applyToAll}
@@ -113,25 +147,25 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
)}
</div>
<DialogFooter className="flex flex-wrap gap-2 sm:justify-end sm:space-x-0">
<DialogFooter className="flex flex-wrap gap-2 sm:items-center sm:justify-end sm:space-x-0">
<Button
variant="destructive"
variant="outline"
onClick={() => handleAction('stop')}
className="flex-1"
className="min-w-24 border-border/70 text-muted-foreground hover:text-destructive sm:mr-auto"
>
{t('sftp.conflict.action.stop')}
</Button>
<Button
variant="outline"
onClick={() => handleAction('skip')}
className="flex-1"
className="min-w-24"
>
{t('sftp.conflict.action.skip')}
</Button>
<Button
variant="outline"
onClick={() => handleAction('duplicate')}
className="flex-1"
className="min-w-24"
>
{t('sftp.conflict.action.duplicate')}
</Button>
@@ -140,18 +174,20 @@ const SftpConflictDialogInner: React.FC<SftpConflictDialogProps> = ({ conflicts,
variant="outline"
onClick={() => handleAction('merge')}
disabled={!canMerge}
className="flex-1"
className="min-w-24"
>
{t('sftp.conflict.action.merge')}
</Button>
)}
<Button
variant="default"
onClick={() => handleAction('replace')}
className="flex-1"
>
{t('sftp.conflict.action.replace')}
</Button>
{canReplace && (
<Button
variant="default"
onClick={() => handleAction('replace')}
className="min-w-28"
>
{t('sftp.conflict.action.replace')}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -7,6 +7,8 @@ import type { TransferTask } from "../../types";
import FileOpenerDialog from "../FileOpenerDialog";
import TextEditorModal from "../TextEditorModal";
import type { TextEditorModalSnapshot } from "../TextEditorModal";
import { TerminalHostKeyVerification } from "../terminal/TerminalHostKeyVerification";
import { Dialog, DialogContent, DialogTitle } from "../ui/dialog";
import { SftpConflictDialog } from "./SftpConflictDialog";
import { SftpHostPicker } from "./SftpHostPicker";
import { SftpPermissionsDialog } from "./SftpPermissionsDialog";
@@ -139,6 +141,27 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
formatFileSize={sftp.formatFileSize}
/>
<Dialog
open={!!sftp.hostKeyVerification}
onOpenChange={(open) => {
if (!open) sftp.rejectHostKeyVerification();
}}
>
<DialogContent className="max-w-lg" hideCloseButton>
<DialogTitle className="sr-only">Confirm host key</DialogTitle>
{sftp.hostKeyVerification && (
<TerminalHostKeyVerification
hostKeyInfo={sftp.hostKeyVerification.hostKeyInfo}
showLogs={sftp.hostKeyVerification.progressLogs.length > 0}
progressLogs={sftp.hostKeyVerification.progressLogs}
onClose={sftp.rejectHostKeyVerification}
onContinue={sftp.acceptHostKeyVerification}
onAddAndContinue={sftp.acceptAndSaveHostKeyVerification}
/>
)}
</DialogContent>
</Dialog>
<SftpPermissionsDialog
open={!!permissionsState}
onOpenChange={(open) => !open && setPermissionsState(null)}

View File

@@ -9,7 +9,7 @@
* - Drag-and-drop reordering of tabs
*/
import { HardDrive, Monitor, Plus, X } from "lucide-react";
import { Copy, HardDrive, Monitor, Plus, X } from "lucide-react";
import React, {
memo,
useCallback,
@@ -25,12 +25,27 @@ import { useRenderTracker } from "../../lib/useRenderTracker";
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
import { cn } from "../../lib/utils";
import { useActiveTabId } from "./SftpContext";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "../ui/context-menu";
import {
canDuplicateSftpTab,
isSftpTabKeyboardContextMenuShortcut,
isSftpTabKeyboardSelectShortcut,
shouldHandleSftpTabKeyboardEvent,
SFTP_TAB_DUPLICATE_MENU_ITEMS,
type SftpTabDuplicateMode,
} from "./sftpTabDuplication";
export interface SftpTab {
id: string;
label: string;
isLocal: boolean;
hostId: string | null;
canDuplicate?: boolean;
}
interface SftpTabBarProps {
@@ -46,6 +61,10 @@ interface SftpTabBarProps {
) => void;
/** Called when a tab is dragged to the other side */
onMoveTabToOtherSide?: (tabId: string) => void;
onDuplicateTab?: (
tabId: string,
mode: SftpTabDuplicateMode,
) => void | Promise<void>;
}
const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
@@ -56,6 +75,7 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
onAddTab,
onReorderTabs,
onMoveTabToOtherSide,
onDuplicateTab,
}) => {
// Subscribe to activeTabId from store (isolated subscription)
const activeTabId = useActiveTabId(side);
@@ -232,6 +252,35 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
[onAddTab],
);
const handleTabKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>, tabId: string) => {
if (!shouldHandleSftpTabKeyboardEvent(e.target, e.currentTarget)) {
return;
}
if (isSftpTabKeyboardSelectShortcut(e.key)) {
e.preventDefault();
onSelectTab(tabId);
return;
}
if (isSftpTabKeyboardContextMenuShortcut(e.key, e.shiftKey)) {
e.preventDefault();
const rect = e.currentTarget.getBoundingClientRect();
e.currentTarget.dispatchEvent(
new MouseEvent("contextmenu", {
bubbles: true,
cancelable: true,
button: 2,
clientX: rect.left + Math.min(rect.width / 2, 24),
clientY: rect.bottom,
}),
);
}
},
[onSelectTab],
);
// Cross-pane drag handlers
const handleCrossPaneDragOver = useCallback(
(e: React.DragEvent) => {
@@ -307,6 +356,7 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
>
{tabs.map((tab) => {
const isActive = activeTabId === tab.id;
const canDuplicateTab = canDuplicateSftpTab(tab, !!onDuplicateTab);
const isBeingDragged =
isDragging && draggedTabIdRef.current === tab.id;
const showDropIndicatorBefore =
@@ -317,71 +367,92 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
dropIndicator.position === "after";
return (
<div
key={tab.id}
data-tab-id={tab.id}
data-tab-type="sftp"
data-state={isActive ? 'active' : 'inactive'}
onClick={(e) => handleSelectTabClick(e, tab.id)}
onMouseDown={handleTabMiddleMouseDown}
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseTab(tab.id))}
draggable
onDragStart={(e) => handleTabDragStart(e, tab.id)}
onDragEnd={handleTabDragEnd}
onDragOver={(e) => handleTabDragOver(e, tab.id)}
onDrop={(e) => handleTabDrop(e, tab.id)}
className={cn(
"netcatty-tab relative px-3 min-w-[100px] max-w-[180px] text-xs font-medium cursor-pointer flex items-center justify-between gap-2 flex-shrink-0 border-r border-border/40",
"transition-[color,opacity,transform] duration-100 ease-out",
isActive
? "text-foreground border-b-2"
: "text-muted-foreground hover:text-foreground",
isBeingDragged && "opacity-50",
)}
style={
isActive
? { borderBottomColor: "hsl(var(--accent))" }
: undefined
}
>
{/* Drop indicator line - before */}
{showDropIndicatorBefore && isDragging && (
<div className="absolute left-0 top-1 bottom-1 w-0.5 bg-primary shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
)}
{/* Drop indicator line - after */}
{showDropIndicatorAfter && isDragging && (
<div className="absolute right-0 top-1 bottom-1 w-0.5 bg-primary shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
)}
<ContextMenu key={tab.id}>
<ContextMenuTrigger asChild>
<div
data-tab-id={tab.id}
data-tab-type="sftp"
data-state={isActive ? 'active' : 'inactive'}
tabIndex={0}
aria-haspopup="menu"
aria-label={tab.label}
onClick={(e) => handleSelectTabClick(e, tab.id)}
onKeyDown={(e) => handleTabKeyDown(e, tab.id)}
onMouseDown={handleTabMiddleMouseDown}
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseTab(tab.id))}
draggable
onDragStart={(e) => handleTabDragStart(e, tab.id)}
onDragEnd={handleTabDragEnd}
onDragOver={(e) => handleTabDragOver(e, tab.id)}
onDrop={(e) => handleTabDrop(e, tab.id)}
className={cn(
"netcatty-tab relative px-3 min-w-[100px] max-w-[180px] text-xs font-medium cursor-pointer flex items-center justify-between gap-2 flex-shrink-0 border-r border-border/40",
"transition-[color,opacity,transform] duration-100 ease-out focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 focus-visible:ring-inset",
isActive
? "text-foreground border-b-2"
: "text-muted-foreground hover:text-foreground",
isBeingDragged && "opacity-50",
)}
style={
isActive
? { borderBottomColor: "hsl(var(--accent))" }
: undefined
}
>
{/* Drop indicator line - before */}
{showDropIndicatorBefore && isDragging && (
<div className="absolute left-0 top-1 bottom-1 w-0.5 bg-primary shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
)}
{/* Drop indicator line - after */}
{showDropIndicatorAfter && isDragging && (
<div className="absolute right-0 top-1 bottom-1 w-0.5 bg-primary shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
)}
<div className="flex items-center gap-1.5 min-w-0 flex-1">
{tab.isLocal ? (
<Monitor
size={12}
className={cn(
"shrink-0",
isActive ? "text-primary" : "text-muted-foreground",
<div className="flex items-center gap-1.5 min-w-0 flex-1">
{tab.isLocal ? (
<Monitor
size={12}
className={cn(
"shrink-0",
isActive ? "text-primary" : "text-muted-foreground",
)}
/>
) : (
<HardDrive
size={12}
className={cn(
"shrink-0",
isActive ? "text-primary" : "text-muted-foreground",
)}
/>
)}
/>
) : (
<HardDrive
size={12}
className={cn(
"shrink-0",
isActive ? "text-primary" : "text-muted-foreground",
)}
/>
)}
<span className="truncate">{tab.label}</span>
</div>
<span className="truncate">{tab.label}</span>
</div>
<button
onClick={(e) => handleCloseTab(e, tab.id)}
className="p-0.5 hover:bg-destructive/10 hover:text-destructive transition-colors shrink-0"
aria-label={t("common.close")}
>
<X size={12} />
</button>
</div>
<button
onClick={(e) => handleCloseTab(e, tab.id)}
className="p-0.5 hover:bg-destructive/10 hover:text-destructive transition-colors shrink-0"
aria-label={t("common.close")}
>
<X size={12} />
</button>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
{SFTP_TAB_DUPLICATE_MENU_ITEMS.map((item) => (
<ContextMenuItem
key={item.mode}
disabled={!canDuplicateTab}
onClick={() => {
void onDuplicateTab?.(tab.id, item.mode);
}}
>
<Copy size={14} className="mr-2" />
{t(item.labelKey)}
</ContextMenuItem>
))}
</ContextMenuContent>
</ContextMenu>
);
})}
</div>
@@ -432,7 +503,8 @@ const sftpTabBarAreEqual = (
prevTab.id !== nextTab.id ||
prevTab.label !== nextTab.label ||
prevTab.isLocal !== nextTab.isLocal ||
prevTab.hostId !== nextTab.hostId
prevTab.hostId !== nextTab.hostId ||
prevTab.canDuplicate !== nextTab.canDuplicate
) {
return false;
}

View File

@@ -6,17 +6,22 @@ import { editorTabStore } from "../../../application/state/editorTabStore";
import type { EditorTab, EditorTabId } from "../../../application/state/editorTabStore";
import { releaseEditorTabSaveCoordinator, saveEditorTab } from "../../../application/state/editorTabSave";
import { promptUnsavedChanges } from "../../editor/UnsavedChangesDialog";
import {
getSftpTabDuplicateRequest,
type SftpTabDuplicateMode,
} from "../sftpTabDuplication";
interface UseSftpViewTabsParams {
sftp: SftpStateApi;
sftpRef: MutableRefObject<SftpStateApi>;
hosts?: Host[];
}
interface UseSftpViewTabsResult {
leftPanes: SftpStateApi["leftPane"][];
rightPanes: SftpStateApi["rightPane"][];
leftTabsInfo: { id: string; label: string; isLocal: boolean; hostId: string | null }[];
rightTabsInfo: { id: string; label: string; isLocal: boolean; hostId: string | null }[];
leftTabsInfo: { id: string; label: string; isLocal: boolean; hostId: string | null; canDuplicate: boolean }[];
rightTabsInfo: { id: string; label: string; isLocal: boolean; hostId: string | null; canDuplicate: boolean }[];
showHostPickerLeft: boolean;
showHostPickerRight: boolean;
hostSearchLeft: string;
@@ -35,15 +40,19 @@ interface UseSftpViewTabsResult {
handleReorderTabsRight: (draggedId: string, targetId: string, position: "before" | "after") => void;
handleMoveTabFromLeftToRight: (tabId: string) => void;
handleMoveTabFromRightToLeft: (tabId: string) => void;
handleDuplicateTabLeft: (tabId: string, mode: SftpTabDuplicateMode) => Promise<string | null>;
handleDuplicateTabRight: (tabId: string, mode: SftpTabDuplicateMode) => Promise<string | null>;
handleHostSelectLeft: (host: Host | "local") => void;
handleHostSelectRight: (host: Host | "local") => void;
}
export const useSftpViewTabs = ({ sftp, sftpRef }: UseSftpViewTabsParams): UseSftpViewTabsResult => {
export const useSftpViewTabs = ({ sftp, sftpRef, hosts = [] }: UseSftpViewTabsParams): UseSftpViewTabsResult => {
const [showHostPickerLeft, setShowHostPickerLeft] = useState(false);
const [showHostPickerRight, setShowHostPickerRight] = useState(false);
const [hostSearchLeft, setHostSearchLeft] = useState("");
const [hostSearchRight, setHostSearchRight] = useState("");
const hostsRef = React.useRef(hosts);
hostsRef.current = hosts;
const handleAddTabLeft = useCallback(() => {
const tabId = sftpRef.current.addTab("left");
@@ -132,6 +141,43 @@ export const useSftpViewTabs = ({ sftp, sftpRef }: UseSftpViewTabsParams): UseSf
sftpRef.current.moveTabToOtherSide("right", tabId);
}, [sftpRef]);
const handleDuplicateTab = useCallback(
async (side: "left" | "right", tabId: string, mode: SftpTabDuplicateMode) => {
const sideTabs = side === "left" ? sftpRef.current.leftTabs : sftpRef.current.rightTabs;
const pane = sideTabs.tabs.find((tab) => tab.id === tabId);
const request = getSftpTabDuplicateRequest(pane, mode);
if (!request) return null;
const host = request.kind === "local"
? "local"
: hostsRef.current.find((item) => item.id === request.hostId);
if (!host) return null;
let duplicatedTabId: string | null = null;
await sftpRef.current.connect(side, host, {
forceNewTab: true,
ignoreSharedCache: mode === "defaultPath",
initialPath: request.path,
onTabCreated: (createdTabId) => {
duplicatedTabId = createdTabId;
},
});
return duplicatedTabId;
},
[sftpRef],
);
const handleDuplicateTabLeft = useCallback(
(tabId: string, mode: SftpTabDuplicateMode) => handleDuplicateTab("left", tabId, mode),
[handleDuplicateTab],
);
const handleDuplicateTabRight = useCallback(
(tabId: string, mode: SftpTabDuplicateMode) => handleDuplicateTab("right", tabId, mode),
[handleDuplicateTab],
);
const handleHostSelectLeft = useCallback((host: Host | "local") => {
sftpRef.current.connect("left", host);
setShowHostPickerLeft(false);
@@ -149,6 +195,7 @@ export const useSftpViewTabs = ({ sftp, sftpRef }: UseSftpViewTabsParams): UseSf
label: pane.connection?.hostLabel || "New Tab",
isLocal: pane.connection?.isLocal || false,
hostId: pane.connection?.hostId || null,
canDuplicate: pane.connection?.status === "connected",
})),
[sftp.leftTabs.tabs],
);
@@ -160,6 +207,7 @@ export const useSftpViewTabs = ({ sftp, sftpRef }: UseSftpViewTabsParams): UseSf
label: pane.connection?.hostLabel || "New Tab",
isLocal: pane.connection?.isLocal || false,
hostId: pane.connection?.hostId || null,
canDuplicate: pane.connection?.status === "connected",
})),
[sftp.rightTabs.tabs],
);
@@ -187,6 +235,8 @@ export const useSftpViewTabs = ({ sftp, sftpRef }: UseSftpViewTabsParams): UseSf
handleReorderTabsRight,
handleMoveTabFromLeftToRight,
handleMoveTabFromRightToLeft,
handleDuplicateTabLeft,
handleDuplicateTabRight,
handleHostSelectLeft,
handleHostSelectRight,
};

View File

@@ -0,0 +1,114 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { SftpPane } from "../../application/state/sftp/types.ts";
import {
canDuplicateSftpTab,
getSftpTabDuplicateRequest,
isSftpTabKeyboardContextMenuShortcut,
isSftpTabKeyboardSelectShortcut,
shouldHandleSftpTabKeyboardEvent,
SFTP_TAB_DUPLICATE_MENU_ITEMS,
} from "./sftpTabDuplication.ts";
const connectedPane = (overrides: Partial<NonNullable<SftpPane["connection"]>> = {}): SftpPane => ({
id: "tab-1",
connection: {
id: "conn-1",
hostId: "host-1",
hostLabel: "Prod",
isLocal: false,
status: "connected",
currentPath: "/var/www/app",
homeDir: "/home/deploy",
...overrides,
},
files: [],
loading: false,
reconnecting: false,
error: null,
connectionLogs: [],
selectedFiles: new Set(),
filter: "",
filenameEncoding: "auto",
showHiddenFiles: false,
transferMutationToken: 0,
});
test("default-path SFTP tab duplication keeps only the remote host identity", () => {
assert.deepEqual(getSftpTabDuplicateRequest(connectedPane(), "defaultPath"), {
kind: "remote",
hostId: "host-1",
});
});
test("current-path SFTP tab duplication carries the active directory", () => {
assert.deepEqual(getSftpTabDuplicateRequest(connectedPane(), "currentPath"), {
kind: "remote",
hostId: "host-1",
path: "/var/www/app",
});
});
test("local SFTP tab duplication targets the local filesystem", () => {
assert.deepEqual(
getSftpTabDuplicateRequest(
connectedPane({
hostId: "local",
hostLabel: "Local",
isLocal: true,
currentPath: "/Users/damao/projects",
homeDir: "/Users/damao",
}),
"currentPath",
),
{
kind: "local",
path: "/Users/damao/projects",
},
);
});
test("SFTP tab duplication is unavailable before a tab is connected", () => {
assert.equal(getSftpTabDuplicateRequest({ ...connectedPane(), connection: null }, "defaultPath"), null);
assert.equal(
getSftpTabDuplicateRequest(connectedPane({ status: "connecting" }), "currentPath"),
null,
);
});
test("SFTP tab duplicate menu exposes separate default and current path actions", () => {
assert.deepEqual(
SFTP_TAB_DUPLICATE_MENU_ITEMS.map((item) => item.mode),
["defaultPath", "currentPath"],
);
assert.deepEqual(
SFTP_TAB_DUPLICATE_MENU_ITEMS.map((item) => item.labelKey),
["sftp.tabs.copyDefaultPath", "sftp.tabs.copyCurrentPath"],
);
});
test("SFTP tab duplicate menu is disabled without a connected tab and handler", () => {
assert.equal(canDuplicateSftpTab({ canDuplicate: true }, true), true);
assert.equal(canDuplicateSftpTab({ canDuplicate: true }, false), false);
assert.equal(canDuplicateSftpTab({ canDuplicate: false }, true), false);
assert.equal(canDuplicateSftpTab(connectedPane(), true), true);
assert.equal(canDuplicateSftpTab(connectedPane({ status: "connecting" }), true), false);
});
test("SFTP tab duplicate menu has keyboard shortcuts for selection and menu access", () => {
assert.equal(isSftpTabKeyboardSelectShortcut("Enter"), true);
assert.equal(isSftpTabKeyboardSelectShortcut(" "), true);
assert.equal(isSftpTabKeyboardSelectShortcut("Escape"), false);
assert.equal(isSftpTabKeyboardContextMenuShortcut("ContextMenu"), true);
assert.equal(isSftpTabKeyboardContextMenuShortcut("F10", true), true);
assert.equal(isSftpTabKeyboardContextMenuShortcut("F10", false), false);
});
test("SFTP tab keyboard shortcuts do not intercept nested close button events", () => {
const tab = new EventTarget();
const closeButton = new EventTarget();
assert.equal(shouldHandleSftpTabKeyboardEvent(tab, tab), true);
assert.equal(shouldHandleSftpTabKeyboardEvent(closeButton, tab), false);
});

View File

@@ -0,0 +1,73 @@
import type { SftpPane } from "../../application/state/sftp/types";
export type SftpTabDuplicateMode = "defaultPath" | "currentPath";
export type SftpTabDuplicateRequest =
| { kind: "local"; path?: string }
| { kind: "remote"; hostId: string; path?: string };
export const SFTP_TAB_DUPLICATE_MENU_ITEMS: ReadonlyArray<{
mode: SftpTabDuplicateMode;
labelKey: "sftp.tabs.copyDefaultPath" | "sftp.tabs.copyCurrentPath";
}> = Object.freeze([
{ mode: "defaultPath", labelKey: "sftp.tabs.copyDefaultPath" },
{ mode: "currentPath", labelKey: "sftp.tabs.copyCurrentPath" },
]);
export function canDuplicateSftpTab(
tab: Pick<SftpPane, "connection"> | { canDuplicate?: boolean } | null | undefined,
hasDuplicateHandler: boolean,
): boolean {
if (!hasDuplicateHandler || !tab) return false;
if ("connection" in tab) return tab.connection?.status === "connected";
return !!tab.canDuplicate;
}
export function isSftpTabKeyboardContextMenuShortcut(
key: string,
shiftKey = false,
): boolean {
return key === "ContextMenu" || (shiftKey && key === "F10");
}
export function isSftpTabKeyboardSelectShortcut(key: string): boolean {
return key === "Enter" || key === " ";
}
export function shouldHandleSftpTabKeyboardEvent(
target: EventTarget | null,
currentTarget: EventTarget | null,
): boolean {
return target === currentTarget;
}
export function getSftpTabDuplicateRequest(
pane: Pick<SftpPane, "connection"> | null | undefined,
mode: SftpTabDuplicateMode,
): SftpTabDuplicateRequest | null {
const connection = pane?.connection;
if (!connection || connection.status !== "connected") {
return null;
}
const path = mode === "currentPath" && connection.currentPath
? { path: connection.currentPath }
: {};
if (connection.isLocal) {
return {
kind: "local",
...path,
};
}
if (!connection.hostId) {
return null;
}
return {
kind: "remote",
hostId: connection.hostId,
...path,
};
}

View File

@@ -5,6 +5,7 @@ import { useSystemManagerBackend } from '../../application/state/useSystemManage
import type { TerminalSettings } from '../../domain/models';
import type { Host } from '../../domain/models/connection';
import type { SystemManagerSubTab } from '../../domain/systemManager/types';
import { resolveCapabilityPanelState } from '../../domain/systemManagerPanelState';
import { buildSystemManagerTabs } from '../../domain/systemManager/systemTarget';
import type { Snippet, TerminalSession } from '../../types';
import { cn } from '../../lib/utils';
@@ -50,7 +51,9 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
const sessionId = session?.id ?? null;
const isConnected = session?.status === 'connected';
const { capabilities, probing } = useSessionCapabilities(sessionId, isConnected, backend, isVisible);
const capabilitiesTtlMs = terminalSettings.systemManagerProcessRefreshInterval * 1000;
const { capabilities, refreshCapabilities } = useSessionCapabilities(sessionId, isConnected, backend, isVisible, capabilitiesTtlMs);
const availableTabs = useMemo(
() => buildSystemManagerTabs(sessionHost, capabilities, session),
@@ -60,6 +63,69 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
const [activeTab, setActiveTab] = useState<SystemManagerSubTab>('processes');
const resolvedTab = availableTabs.includes(activeTab) ? activeTab : 'processes';
// Must be defined before early returns to comply with React rules of hooks.
const prevTabRef = React.useRef(resolvedTab);
const probingRef = React.useRef(false);
React.useEffect(() => {
const prev = prevTabRef.current;
prevTabRef.current = resolvedTab;
if (prev === resolvedTab) return;
if (resolvedTab === 'docker' && capabilities?.hasDocker !== true) {
if (!probingRef.current) {
probingRef.current = true;
refreshCapabilities().finally(() => { probingRef.current = false; });
}
} else if (resolvedTab === 'tmux' && capabilities?.hasTmux !== true) {
void refreshCapabilities();
}
}, [resolvedTab, capabilities, refreshCapabilities]);
// Auto-poll for Docker capabilities while Docker tab is active and Docker not yet detected.
// Use setTimeout recursion so the next probe only starts after the previous one finishes,
// avoiding overlapping probes (e.g. SSH timeout 8s vs user-configured interval 2s).
// First poll is delayed by one interval to avoid overlapping with the tab-switch probe above.
//
// Use a ref to store refreshCapabilities so that if its reference changes on every render,
// the useEffect below is NOT re-run (which would cancel the timer and bypass the interval).
const refreshRef = React.useRef(refreshCapabilities);
refreshRef.current = refreshCapabilities;
// Auto-poll for Docker capabilities while Docker tab is active and Docker not yet detected.
// Each effect generation gets its own cancelled flag and timerId via closure,
// preventing stale probes from surviving cleanup (unlike cancelledRef which is shared).
// First poll is delayed by one interval to avoid overlapping with the tab-switch probe.
React.useEffect(() => {
if (!isVisible || resolvedTab !== 'docker' || capabilities?.hasDocker === true) return;
let cancelled = false;
let timerId: ReturnType<typeof setTimeout>;
const pollOnce = async () => {
if (cancelled) return;
if (probingRef.current) {
// probe is in-flight, reschedule for next cycle
timerId = setTimeout(pollOnce, capabilitiesTtlMs);
return;
}
probingRef.current = true;
try {
await refreshRef.current();
} catch {
// Transient error - keep polling next round
}
probingRef.current = false;
if (cancelled) return;
timerId = setTimeout(pollOnce, capabilitiesTtlMs);
};
timerId = setTimeout(pollOnce, capabilitiesTtlMs);
return () => {
cancelled = true;
if (timerId) clearTimeout(timerId);
};
}, [isVisible, resolvedTab, capabilities?.hasDocker, capabilitiesTtlMs]);
const workspaceHostHeader = showWorkspaceHostHeader && sessionHost ? (
<WorkspaceSidebarHostHeader
host={sessionHost}
@@ -93,10 +159,16 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
const tmuxReady = capabilities?.hasTmux === true;
const dockerReady = capabilities?.hasDocker === true;
const tmuxUnavailable = !probing && capabilities !== undefined && !tmuxReady;
const dockerUnavailable = !probing && capabilities !== undefined && !dockerReady;
const tmuxChecking = resolvedTab === 'tmux' && !tmuxReady && !tmuxUnavailable;
const dockerChecking = resolvedTab === 'docker' && !dockerReady && !dockerUnavailable;
const tmuxPanelState = resolveCapabilityPanelState({
isActive: resolvedTab === 'tmux',
ready: tmuxReady,
capabilitiesKnown: capabilities !== undefined,
});
const dockerPanelState = resolveCapabilityPanelState({
isActive: resolvedTab === 'docker',
ready: dockerReady,
capabilitiesKnown: capabilities !== undefined,
});
return (
<SystemPanelShell section="system-manager-panel">
@@ -129,15 +201,15 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
refreshIntervalSec={terminalSettings.systemManagerProcessRefreshInterval}
/>
</div>
{tmuxUnavailable && resolvedTab === 'tmux' ? (
{tmuxPanelState === 'unavailable' ? (
<div className="flex-1 min-h-0">
<SystemPanelEmpty icon={TerminalSquare} message={t('systemManager.tmux.unavailable')} />
</div>
) : tmuxChecking ? (
) : tmuxPanelState === 'checking' ? (
<div className="flex-1 min-h-0">
<SystemPanelChecking message={t('systemManager.common.checkingAvailability')} />
</div>
) : tmuxReady ? (
) : tmuxPanelState === 'ready' ? (
<div className={cn('flex-1 min-h-0 flex flex-col', resolvedTab !== 'tmux' && 'hidden')}>
<TmuxManagerTab
sessionId={sessionId}
@@ -150,15 +222,15 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
/>
</div>
) : null}
{dockerUnavailable && resolvedTab === 'docker' ? (
{dockerPanelState === 'unavailable' ? (
<div className="flex-1 min-h-0">
<SystemPanelEmpty icon={Box} message={t('systemManager.docker.unavailable')} />
</div>
) : dockerChecking ? (
) : dockerPanelState === 'checking' ? (
<div className="flex-1 min-h-0">
<SystemPanelChecking message={t('systemManager.common.checkingAvailability')} />
</div>
) : dockerReady ? (
) : dockerPanelState === 'ready' ? (
<div className={cn('flex-1 min-h-0 flex flex-col', resolvedTab !== 'docker' && 'hidden')}>
<DockerManagerTab
sessionId={sessionId}

View File

@@ -37,7 +37,11 @@ export function useSessionCapabilities(
isConnected: boolean,
backend: Backend,
enabled: boolean,
capabilitiesTtlMs: number,
) {
const ttlMsRef = useRef(capabilitiesTtlMs);
ttlMsRef.current = capabilitiesTtlMs;
const [capabilities, setCapabilities] = useState<SessionCapabilities | undefined>(
() => (sessionId ? sessionCapabilitiesStore.get(sessionId) : undefined),
);
@@ -63,7 +67,7 @@ export function useSessionCapabilities(
try {
const result = await backend.probeSystemCapabilities(sessionId);
if (result.success && result.capabilities) {
sessionCapabilitiesStore.set(sessionId, result.capabilities);
sessionCapabilitiesStore.set(sessionId, result.capabilities, ttlMsRef.current);
}
} finally {
setProbing(false);
@@ -84,10 +88,13 @@ export function useSystemCapabilitiesWarmup(
sessionIds: string[],
backend: Backend,
enabled: boolean,
capabilitiesTtlMs: number,
) {
const backendRef = useRef(backend);
backendRef.current = backend;
const inflightRef = useRef(new Set<string>());
const ttlMsRef = useRef(capabilitiesTtlMs);
ttlMsRef.current = capabilitiesTtlMs;
const sessionKey = enabled ? sessionIds.slice().sort().join(',') : '';
@@ -100,7 +107,7 @@ export function useSystemCapabilitiesWarmup(
void backendRef.current.probeSystemCapabilities(sessionId).then((result) => {
inflightRef.current.delete(sessionId);
if (result.success && result.capabilities) {
sessionCapabilitiesStore.set(sessionId, result.capabilities);
sessionCapabilitiesStore.set(sessionId, result.capabilities, ttlMsRef.current);
}
});
}

View File

@@ -0,0 +1,81 @@
import React, { useEffect, useRef, useState } from 'react';
import { cn } from '../../lib/utils';
type SessionInlineRenameInputProps = {
initialName: string;
onCommit: (name: string) => void;
onCancel: () => void;
className?: string;
style?: React.CSSProperties;
};
export const SessionInlineRenameInput: React.FC<SessionInlineRenameInputProps> = ({
initialName,
onCommit,
onCancel,
className,
style,
}) => {
const inputRef = useRef<HTMLInputElement>(null);
const [value, setValue] = useState(initialName);
const committedRef = useRef(false);
useEffect(() => {
const input = inputRef.current;
if (!input) return;
input.focus();
input.select();
}, []);
const commit = () => {
if (committedRef.current) return;
committedRef.current = true;
onCommit(value);
};
const cancel = () => {
if (committedRef.current) return;
committedRef.current = true;
onCancel();
};
return (
<input
ref={inputRef}
data-session-inline-rename="true"
value={value}
draggable={false}
onChange={(event) => setValue(event.target.value)}
onBlur={() => {
queueMicrotask(() => {
commit();
});
}}
onClick={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onDragStart={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === 'Enter') {
event.preventDefault();
commit();
}
if (event.key === 'Escape') {
event.preventDefault();
cancel();
}
}}
className={cn(
'min-w-0 flex-1 truncate select-text rounded-sm border border-primary/50 bg-background/80 px-1 py-0 text-sm font-medium outline-none ring-1 ring-primary/30',
className,
)}
style={style}
/>
);
};

View File

@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
import en from "../../application/i18n/locales/en.ts";
import zhCN from "../../application/i18n/locales/zh-CN.ts";
import { markMiddleClickContextMenuEvent } from "./runtime/middleClickBehavior.ts";
import * as terminalContextMenu from "./TerminalContextMenu.tsx";
import { shouldEnableYmodemAction } from "./TerminalView.tsx";
@@ -22,6 +23,30 @@ const shouldSuppressMouseTrackingContextMenu = (
}) => boolean;
}
).shouldSuppressMouseTrackingContextMenu;
const shouldShowAddSelectionToAIContextMenuAction = (
terminalContextMenu as {
shouldShowAddSelectionToAIContextMenuAction?: (onAddSelectionToAI?: () => void) => boolean;
}
).shouldShowAddSelectionToAIContextMenuAction;
const shouldOpenTerminalContextMenu = (
terminalContextMenu as {
shouldOpenTerminalContextMenu?: (options: {
event: { shiftKey?: boolean; nativeEvent: MouseEvent };
rightClickBehavior?: "context-menu" | "paste" | "select-word";
isAlternateScreen?: boolean;
showReconnectAction?: boolean;
}) => boolean;
}
).shouldOpenTerminalContextMenu;
const shouldRenderTerminalContextMenuContent = (
terminalContextMenu as {
shouldRenderTerminalContextMenuContent?: (options: {
isAlternateScreen?: boolean;
showReconnectAction?: boolean;
allowSuppressedMenuContent?: boolean;
}) => boolean;
}
).shouldRenderTerminalContextMenuContent;
test("shows reconnect only for reconnectable terminals with a handler", () => {
assert.equal(typeof shouldShowReconnectAction, "function");
@@ -49,11 +74,23 @@ test("localizes the reconnect context menu label", () => {
assert.equal(zhCN["terminal.menu.reconnect"], "重新连接");
});
test("shows add selection to AI context menu action when a handler exists", () => {
assert.equal(typeof shouldShowAddSelectionToAIContextMenuAction, "function");
if (typeof shouldShowAddSelectionToAIContextMenuAction !== "function") return;
assert.equal(shouldShowAddSelectionToAIContextMenuAction(() => {}), true);
assert.equal(shouldShowAddSelectionToAIContextMenuAction(), false);
});
test("localizes the YMODEM serial send actions", () => {
assert.equal(en["terminal.menu.sendYmodem"], "Send with YMODEM");
assert.equal(en["terminal.menu.receiveYmodem"], "Receive with YMODEM");
assert.equal(en["terminal.toolbar.sendYmodem"], "Send with YMODEM");
assert.equal(en["terminal.toolbar.receiveYmodem"], "Receive with YMODEM");
assert.equal(zhCN["terminal.menu.sendYmodem"], "YMODEM 发送");
assert.equal(zhCN["terminal.menu.receiveYmodem"], "YMODEM 接收");
assert.equal(zhCN["terminal.toolbar.sendYmodem"], "YMODEM 发送");
assert.equal(zhCN["terminal.toolbar.receiveYmodem"], "YMODEM 接收");
});
test("enables YMODEM action only for connected serial terminals", () => {
@@ -64,6 +101,16 @@ test("enables YMODEM action only for connected serial terminals", () => {
status: "connected",
handleSendYmodem: handler,
}), true);
assert.equal(shouldEnableYmodemAction({
isSerialConnection: true,
status: "connected",
handleReceiveYmodem: handler,
}), true);
assert.equal(shouldEnableYmodemAction({
isSerialConnection: true,
status: "disconnected",
handleReceiveYmodem: handler,
}), false);
assert.equal(shouldEnableYmodemAction({
isSerialConnection: true,
status: "disconnected",
@@ -99,3 +146,83 @@ test("allows reconnect menu while stale mouse tracking is still active", () => {
true,
);
});
test("opens a middle-click menu even when right-click is configured to paste", () => {
assert.equal(typeof shouldOpenTerminalContextMenu, "function");
if (typeof shouldOpenTerminalContextMenu !== "function") return;
assert.equal(
shouldOpenTerminalContextMenu({
event: {
shiftKey: false,
nativeEvent: markMiddleClickContextMenuEvent({} as MouseEvent),
},
rightClickBehavior: "paste",
}),
true,
);
assert.equal(
shouldOpenTerminalContextMenu({
event: {
shiftKey: false,
nativeEvent: {} as MouseEvent,
},
rightClickBehavior: "paste",
}),
false,
);
});
test("opens and renders middle-click menu while alternate-screen mouse tracking suppresses right-click menus", () => {
assert.equal(typeof shouldOpenTerminalContextMenu, "function");
assert.equal(typeof shouldRenderTerminalContextMenuContent, "function");
if (
typeof shouldOpenTerminalContextMenu !== "function" ||
typeof shouldRenderTerminalContextMenuContent !== "function"
) {
return;
}
assert.equal(
shouldOpenTerminalContextMenu({
event: {
shiftKey: false,
nativeEvent: markMiddleClickContextMenuEvent({} as MouseEvent),
},
rightClickBehavior: "paste",
isAlternateScreen: true,
showReconnectAction: false,
}),
true,
);
assert.equal(
shouldRenderTerminalContextMenuContent({
isAlternateScreen: true,
showReconnectAction: false,
allowSuppressedMenuContent: true,
}),
true,
);
assert.equal(
shouldOpenTerminalContextMenu({
event: {
shiftKey: false,
nativeEvent: {} as MouseEvent,
},
rightClickBehavior: "context-menu",
isAlternateScreen: true,
showReconnectAction: false,
}),
false,
);
assert.equal(
shouldRenderTerminalContextMenuContent({
isAlternateScreen: true,
showReconnectAction: false,
allowSuppressedMenuContent: false,
}),
false,
);
});

View File

@@ -5,15 +5,18 @@
import {
ClipboardPaste,
Copy,
Download,
Pencil,
RefreshCcw,
Sparkles,
SquareArrowOutUpRight,
SplitSquareHorizontal,
SplitSquareVertical,
Terminal as TerminalIcon,
Trash2,
Upload,
} from 'lucide-react';
import React, { useCallback, useRef } from 'react';
import React, { useCallback, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { KeyBinding, RightClickBehavior } from '../../domain/models';
import {
@@ -24,6 +27,7 @@ import {
ContextMenuShortcut,
ContextMenuTrigger,
} from '../ui/context-menu';
import { isMiddleClickContextMenuEvent } from './runtime/middleClickBehavior';
export interface TerminalContextMenuProps {
children: React.ReactNode;
@@ -40,11 +44,14 @@ export interface TerminalContextMenuProps {
onSplitHorizontal?: () => void;
onSplitVertical?: () => void;
onSendYmodem?: () => void;
onReceiveYmodem?: () => void;
isReconnectable?: boolean;
onReconnect?: () => void;
onClose?: () => void;
onSelectWord?: () => void;
onAddSelectionToAI?: () => void;
onRename?: () => void;
onDetach?: () => void;
}
export const shouldShowReconnectAction = ({
@@ -63,6 +70,44 @@ export const shouldSuppressMouseTrackingContextMenu = ({
showReconnectAction?: boolean;
}): boolean => Boolean(isAlternateScreen && !showReconnectAction);
export const shouldShowAddSelectionToAIContextMenuAction = (
onAddSelectionToAI?: () => void,
): boolean => Boolean(onAddSelectionToAI);
export const shouldRenderTerminalContextMenuContent = ({
isAlternateScreen,
showReconnectAction,
allowSuppressedMenuContent,
}: {
isAlternateScreen?: boolean;
showReconnectAction?: boolean;
allowSuppressedMenuContent?: boolean;
}): boolean =>
allowSuppressedMenuContent ||
!shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, showReconnectAction });
export const shouldOpenTerminalContextMenu = ({
event,
rightClickBehavior = 'context-menu',
isAlternateScreen,
showReconnectAction,
}: {
event: { shiftKey?: boolean; nativeEvent: MouseEvent };
rightClickBehavior?: RightClickBehavior;
isAlternateScreen?: boolean;
showReconnectAction?: boolean;
}): boolean => {
if (isMiddleClickContextMenuEvent(event.nativeEvent)) {
return true;
}
if (shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, showReconnectAction })) {
return false;
}
return Boolean(event.shiftKey || rightClickBehavior === 'context-menu');
};
export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
children,
hasSelection = false,
@@ -78,11 +123,14 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
onSplitHorizontal,
onSplitVertical,
onSendYmodem,
onReceiveYmodem,
isReconnectable,
onReconnect,
onClose,
onSelectWord,
onAddSelectionToAI,
onRename,
onDetach,
}) => {
const { t } = useI18n();
const isMac = hotkeyScheme === 'mac';
@@ -90,11 +138,13 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
// keep its `:focus-within`-driven opacity stable while focus is in the
// menu portal (otherwise the pane dims for the menu's lifetime).
const markedPaneRef = useRef<HTMLElement | null>(null);
const [allowSuppressedMenuContent, setAllowSuppressedMenuContent] = useState(false);
const handleOpenChange = useCallback((open: boolean) => {
if (!open) {
markedPaneRef.current?.removeAttribute('data-menu-open');
markedPaneRef.current = null;
setAllowSuppressedMenuContent(false);
}
}, []);
@@ -125,19 +175,28 @@ 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.
if (shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, showReconnectAction })) {
const shouldOpenMenu = shouldOpenTerminalContextMenu({
event: e,
rightClickBehavior,
isAlternateScreen,
showReconnectAction,
});
const isMiddleClickMenu = isMiddleClickContextMenuEvent(e.nativeEvent);
if (!shouldOpenMenu && shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, showReconnectAction })) {
e.preventDefault();
return;
}
// Shift+Right-Click or context-menu mode: let Radix open the menu
if (e.shiftKey || rightClickBehavior === 'context-menu') {
if (shouldOpenMenu) {
const pane = (e.target as HTMLElement | null)?.closest<HTMLElement>('.workspace-pane');
if (pane) {
markedPaneRef.current?.removeAttribute('data-menu-open');
pane.setAttribute('data-menu-open', '');
markedPaneRef.current = pane;
}
setAllowSuppressedMenuContent(isMiddleClickMenu);
return;
}
@@ -162,7 +221,11 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
>
{children}
</ContextMenuTrigger>
{!shouldSuppressMouseTrackingContextMenu({ isAlternateScreen, showReconnectAction }) && (
{shouldRenderTerminalContextMenuContent({
isAlternateScreen,
showReconnectAction,
allowSuppressedMenuContent,
}) && (
<ContextMenuContent className="w-max">
<ContextMenuItem onClick={onCopy} disabled={!hasSelection}>
<Copy size={14} className="mr-2" />
@@ -174,7 +237,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
{t('terminal.menu.paste')}
<ContextMenuShortcut>{pasteShortcut}</ContextMenuShortcut>
</ContextMenuItem>
{onAddSelectionToAI && (
{shouldShowAddSelectionToAIContextMenuAction(onAddSelectionToAI) && (
<ContextMenuItem onClick={onAddSelectionToAI} disabled={!hasSelection}>
<Sparkles size={14} className="mr-2" />
{t('terminal.menu.addSelectionToAI')}
@@ -203,13 +266,21 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
</>
)}
{onSendYmodem && (
{(onSendYmodem || onReceiveYmodem) && (
<>
<ContextMenuSeparator />
<ContextMenuItem onClick={onSendYmodem}>
<Upload size={14} className="mr-2" />
{t('terminal.menu.sendYmodem')}
</ContextMenuItem>
{onSendYmodem && (
<ContextMenuItem onClick={onSendYmodem}>
<Upload size={14} className="mr-2" />
{t('terminal.menu.sendYmodem')}
</ContextMenuItem>
)}
{onReceiveYmodem && (
<ContextMenuItem onClick={onReceiveYmodem}>
<Download size={14} className="mr-2" />
{t('terminal.menu.receiveYmodem')}
</ContextMenuItem>
)}
</>
)}
@@ -234,6 +305,26 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
<ContextMenuShortcut>{clearShortcut}</ContextMenuShortcut>
</ContextMenuItem>
{onRename && (
<>
<ContextMenuSeparator />
<ContextMenuItem onClick={onRename}>
<Pencil size={14} className="mr-2" />
{t('terminal.menu.rename')}
</ContextMenuItem>
</>
)}
{onDetach && (
<>
<ContextMenuSeparator />
<ContextMenuItem onClick={onDetach}>
<SquareArrowOutUpRight size={14} className="mr-2" />
{t('terminal.menu.detach')}
</ContextMenuItem>
</>
)}
{onClose && (
<>
<ContextMenuSeparator />

View File

@@ -47,16 +47,16 @@ export const TerminalServerStats: React.FC<TerminalServerStatsProps> = ({
if (!enabled || !isConnected || !serverStats.lastUpdated) return null;
return (
<div className="flex items-center gap-2.5 ml-2 text-[10px] opacity-80 flex-nowrap overflow-hidden min-w-0">
<div className="terminal-server-stats flex items-center gap-2 ml-1 text-[10px] opacity-80 flex-nowrap overflow-hidden min-w-0 shrink">
{/* CPU with HoverCard for per-core details */}
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild>
<button
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer min-w-0 shrink"
aria-label={t("terminal.serverStats.cpu")}
>
<Cpu size={10} className="flex-shrink-0" />
<span>
<span className="truncate">
{serverStats.cpu !== null ? `${serverStats.cpu}%` : '--'}
{serverStats.cpuCores !== null && ` (${serverStats.cpuCores}C)`}
</span>
@@ -121,11 +121,11 @@ export const TerminalServerStats: React.FC<TerminalServerStatsProps> = ({
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild>
<button
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer min-w-0 shrink"
aria-label={t("terminal.serverStats.memory")}
>
<MemoryStick size={10} className="flex-shrink-0" />
<span>
<span className="truncate">
{serverStats.memUsed !== null && serverStats.memTotal !== null
? `${(serverStats.memUsed / 1024).toFixed(1)}/${(serverStats.memTotal / 1024).toFixed(1)}G`
: '--'}
@@ -248,11 +248,12 @@ export const TerminalServerStats: React.FC<TerminalServerStatsProps> = ({
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild>
<button
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer min-w-0 shrink"
aria-label={t("terminal.serverStats.disk")}
>
<HardDrive size={10} className="flex-shrink-0" />
<span className={cn(
"truncate",
serverStats.diskPercent !== null && serverStats.diskPercent >= 90 && "text-red-400",
serverStats.diskPercent !== null && serverStats.diskPercent >= 80 && serverStats.diskPercent < 90 && "text-amber-400"
)}>
@@ -315,13 +316,13 @@ export const TerminalServerStats: React.FC<TerminalServerStatsProps> = ({
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild>
<button
className="flex items-center gap-1 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
className="flex items-center gap-1 hover:opacity-100 opacity-80 transition-opacity cursor-pointer min-w-0 shrink"
aria-label={t("terminal.serverStats.network")}
>
<ArrowDownToLine size={9} className="flex-shrink-0 text-emerald-400" />
<span>{formatNetSpeed(serverStats.netRxSpeed)}</span>
<span className="truncate">{formatNetSpeed(serverStats.netRxSpeed)}</span>
<ArrowUpFromLine size={9} className="flex-shrink-0 text-sky-400" />
<span>{formatNetSpeed(serverStats.netTxSpeed)}</span>
<span className="truncate">{formatNetSpeed(serverStats.netTxSpeed)}</span>
</button>
</HoverCardTrigger>
<HoverCardContent

View File

@@ -69,12 +69,15 @@ test("hides SFTP for local terminal sessions", () => {
test("shows YMODEM send only for connected serial sessions", () => {
const connectedSerial = renderToolbar(serialHost, "connected", {
onSendYmodem: () => {},
onReceiveYmodem: () => {},
});
const disconnectedSerial = renderToolbar(serialHost, "disconnected", {
onSendYmodem: () => {},
onReceiveYmodem: () => {},
});
const ssh = renderToolbar(sshHost, "connected", {
onSendYmodem: () => {},
onReceiveYmodem: () => {},
});
const local = renderToolbar({
...sshHost,
@@ -82,14 +85,20 @@ test("shows YMODEM send only for connected serial sessions", () => {
protocol: "local",
}, "connected", {
onSendYmodem: () => {},
onReceiveYmodem: () => {},
});
assert.equal(connectedSerial.includes('aria-label="Send with YMODEM"'), true);
assert.equal(connectedSerial.includes('aria-label="Receive with YMODEM"'), true);
assert.doesNotMatch(connectedSerial, /aria-label="Send with YMODEM"[^>]*disabled/);
assert.equal(disconnectedSerial.includes('aria-label="Available after connect"'), true);
assert.match(disconnectedSerial, /aria-label="Available after connect"[^>]*disabled/);
assert.equal(disconnectedSerial.includes('aria-label="Send with YMODEM - Available after connect"'), true);
assert.equal(disconnectedSerial.includes('aria-label="Receive with YMODEM - Available after connect"'), true);
assert.match(disconnectedSerial, /aria-label="Send with YMODEM - Available after connect"[^>]*disabled/);
assert.match(disconnectedSerial, /aria-label="Receive with YMODEM - Available after connect"[^>]*disabled/);
assert.equal(ssh.includes('aria-label="Send with YMODEM"'), false);
assert.equal(ssh.includes('aria-label="Receive with YMODEM"'), false);
assert.equal(local.includes('aria-label="Send with YMODEM"'), false);
assert.equal(local.includes('aria-label="Receive with YMODEM"'), false);
});
test("uses the terminal active button color for pressed toolbar actions", () => {

View File

@@ -2,7 +2,7 @@
* Terminal Toolbar
* Displays high-frequency terminal actions and close button in the terminal status bar.
*/
import { Check, ChevronRight, FolderInput, History, Languages, MoreVertical, X, Zap, Palette, Search, TextCursorInput, Upload } from 'lucide-react';
import { Check, ChevronRight, Download, FolderInput, History, Languages, MoreVertical, X, Zap, Palette, Search, TextCursorInput, Upload } from 'lucide-react';
import React, { useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Host, Snippet } from '../../types';
@@ -23,6 +23,7 @@ export interface TerminalToolbarProps {
onSnippetClick?: (snippet: Snippet) => void;
onOpenSFTP: () => void;
onSendYmodem?: () => void;
onReceiveYmodem?: () => void;
onOpenScripts: () => void;
onOpenHistory?: () => void;
onOpenTheme: () => void;
@@ -49,6 +50,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
onSnippetClick,
onOpenSFTP,
onSendYmodem,
onReceiveYmodem,
onOpenScripts,
onOpenHistory,
onOpenTheme,
@@ -86,6 +88,8 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
const encodingSwitchSupported = !isLocalTerminal && !isMoshSession && !isEtSession;
const hidesSftp = isLocalTerminal || isSerialTerminal;
const historySupported = !!onOpenHistory && !isLocalTerminal && !isSerialTerminal && host?.protocol !== 'telnet';
const unavailableYmodemSendLabel = `${t("terminal.toolbar.sendYmodem")} - ${t("terminal.toolbar.availableAfterConnect")}`;
const unavailableYmodemReceiveLabel = `${t("terminal.toolbar.receiveYmodem")} - ${t("terminal.toolbar.availableAfterConnect")}`;
const menuItemClass = "w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors";
const activeButtonStyle: React.CSSProperties = {
@@ -194,23 +198,43 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
)}
{isSerialTerminal && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon"
className={cn(buttonBase, status !== 'connected' && "opacity-50")}
aria-label={status === 'connected' ? t("terminal.toolbar.sendYmodem") : t("terminal.toolbar.availableAfterConnect")}
onClick={onSendYmodem}
disabled={status !== 'connected' || !onSendYmodem}
>
<Upload size={12} />
</Button>
</TooltipTrigger>
<TooltipContent>
{status === 'connected' ? t("terminal.toolbar.sendYmodem") : t("terminal.toolbar.availableAfterConnect")}
</TooltipContent>
</Tooltip>
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon"
className={cn(buttonBase, status !== 'connected' && "opacity-50")}
aria-label={status === 'connected' ? t("terminal.toolbar.sendYmodem") : unavailableYmodemSendLabel}
onClick={onSendYmodem}
disabled={status !== 'connected' || !onSendYmodem}
>
<Upload size={12} />
</Button>
</TooltipTrigger>
<TooltipContent>
{status === 'connected' ? t("terminal.toolbar.sendYmodem") : t("terminal.toolbar.availableAfterConnect")}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon"
className={cn(buttonBase, status !== 'connected' && "opacity-50")}
aria-label={status === 'connected' ? t("terminal.toolbar.receiveYmodem") : unavailableYmodemReceiveLabel}
onClick={onReceiveYmodem}
disabled={status !== 'connected' || !onReceiveYmodem}
>
<Download size={12} />
</Button>
</TooltipTrigger>
<TooltipContent>
{status === 'connected' ? t("terminal.toolbar.receiveYmodem") : t("terminal.toolbar.availableAfterConnect")}
</TooltipContent>
</Tooltip>
</>
)}
<Tooltip>

View File

@@ -4,6 +4,7 @@ import { readFileSync } from "node:fs";
import {
getLineTimestampToggleHostUpdate,
shouldShowSelectionAIOverlay,
shouldShowLineTimestampToolbarToggle,
} from "./TerminalView.tsx";
@@ -32,6 +33,38 @@ test("line timestamp toolbar toggle is hidden when timestamps are unavailable",
assert.equal(shouldShowLineTimestampToolbarToggle(true, undefined), false);
});
test("selection AI overlay honors the visibility preference", () => {
const overlayPosition = { left: 120, top: 80 };
const addSelection = () => {};
assert.equal(
shouldShowSelectionAIOverlay({
hasSelection: true,
selectionOverlayPosition: overlayPosition,
onAddSelectionToAI: addSelection,
}),
true,
);
assert.equal(
shouldShowSelectionAIOverlay({
hasSelection: true,
selectionOverlayPosition: overlayPosition,
onAddSelectionToAI: addSelection,
showSelectionAIAction: true,
}),
true,
);
assert.equal(
shouldShowSelectionAIOverlay({
hasSelection: true,
selectionOverlayPosition: overlayPosition,
onAddSelectionToAI: addSelection,
showSelectionAIAction: false,
}),
false,
);
});
test("popup terminals disable line timestamp controls", () => {
const source = readFileSync(new URL("../TerminalPopupPage.tsx", import.meta.url), "utf8");

View File

@@ -34,12 +34,33 @@ export function shouldEnableYmodemAction({
isSerialConnection,
status,
handleSendYmodem,
handleReceiveYmodem,
}: {
isSerialConnection?: boolean;
status?: string;
handleSendYmodem?: () => void;
handleReceiveYmodem?: () => void;
}): boolean {
return Boolean(isSerialConnection && status === "connected" && handleSendYmodem);
return Boolean(isSerialConnection && status === "connected" && (handleSendYmodem || handleReceiveYmodem));
}
export function shouldShowSelectionAIOverlay({
hasSelection,
selectionOverlayPosition,
onAddSelectionToAI,
showSelectionAIAction,
}: {
hasSelection: boolean;
selectionOverlayPosition?: { left: number; top: number } | null;
onAddSelectionToAI?: unknown;
showSelectionAIAction?: boolean;
}): boolean {
return Boolean(
showSelectionAIAction !== false
&& hasSelection
&& selectionOverlayPosition
&& onAddSelectionToAI,
);
}
/**
@@ -67,7 +88,13 @@ function terminalViewCtxEqual(
}
function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
const { Activity, Button, Clock3, Copy, Maximize2, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onCloseSession, onExpandToFocus, onOpenSystem, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, searchMatchCount, selectionOverlayPosition, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, snippets, status, statusDotTone, sudoHintRef, sudoHintText, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem } = ctx;
const { Activity, Button, Clock3, Copy, Maximize2, Radio, Sparkles, SquareArrowOutUpRight, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, remoteDragDropUsesZmodem, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onCloseSession, onDetach, onDetachPointerDown, onExpandToFocus, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, searchMatchCount, selectionOverlayPosition, sessionDisplayName, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, showSelectionAIAction, snippets, status, statusDotTone, sudoHintRef, sudoHintText, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem } = ctx;
const ymodemActionEnabled = shouldEnableYmodemAction({
isSerialConnection,
status,
handleSendYmodem,
handleReceiveYmodem,
});
const terminalContentTop = isSearchOpen ? "64px" : "30px";
const showLineTimestampGutter = lineTimestampsAvailable !== false && host.showLineTimestamps === true;
const lineTimestampColor = resolveTerminalTimestampGutterColor(effectiveTheme.colors);
@@ -100,11 +127,14 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
onSelectWord={terminalContextActions.onSelectWord}
onSplitHorizontal={onSplitHorizontal}
onSplitVertical={onSplitVertical}
onSendYmodem={shouldEnableYmodemAction({ isSerialConnection, status, handleSendYmodem }) ? handleSendYmodem : undefined}
onSendYmodem={ymodemActionEnabled ? handleSendYmodem : undefined}
onReceiveYmodem={ymodemActionEnabled ? handleReceiveYmodem : undefined}
isReconnectable={status === "disconnected"}
onReconnect={handleRetry}
onClose={inWorkspace ? () => onCloseSession?.(sessionId) : undefined}
onAddSelectionToAI={ctx.onAddSelectionToAI ? handleAddSelectionToAI : undefined}
onRename={onRename}
onDetach={inWorkspace ? onDetach : undefined}
>
<div
className={cn(
@@ -131,7 +161,9 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
<div className="text-sm text-muted-foreground">
{isLocalConnection
? t("terminal.dragDrop.localMessage")
: t("terminal.dragDrop.remoteMessage")
: remoteDragDropUsesZmodem
? t("terminal.dragDrop.remoteZmodemMessage")
: t("terminal.dragDrop.remoteSftpMessage")
}
</div>
</div>
@@ -140,7 +172,7 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
)}
<div className="absolute left-0 right-0 top-0 z-20 pointer-events-none">
<div
className="flex items-center gap-1 px-2 py-0.5 backdrop-blur-md pointer-events-auto min-w-0"
className="terminal-topbar flex items-center gap-1 px-2 py-0.5 backdrop-blur-md pointer-events-auto min-w-0"
onMouseDownCapture={handleTopOverlayMouseDownCapture}
style={{
backgroundColor: 'var(--terminal-ui-bg)',
@@ -153,14 +185,27 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
['--terminal-toolbar-btn-active' as never]: 'var(--terminal-ui-toolbar-btn-active)',
}}
>
<div className="flex items-center gap-1 text-[11px] font-semibold min-w-0">
<span className="whitespace-nowrap truncate">{host.label}</span>
<span
<div
className={cn(
"terminal-title-cluster flex items-center gap-1 text-[11px] font-semibold min-w-0 overflow-hidden shrink",
)}
>
<div
className={cn(
"inline-block h-2 w-2 rounded-full flex-shrink-0",
statusDotTone,
"flex items-center gap-1 min-w-0",
inWorkspace && onDetachPointerDown && "cursor-grab active:cursor-grabbing",
)}
/>
data-terminal-detach-drag-handle={inWorkspace && onDetachPointerDown ? "true" : undefined}
onPointerDown={onDetachPointerDown}
>
<span className="whitespace-nowrap truncate min-w-0 max-w-[12rem]">{sessionDisplayName || host.label}</span>
<span
className={cn(
"inline-block h-2 w-2 rounded-full flex-shrink-0",
statusDotTone,
)}
/>
</div>
{shouldShowLineTimestampToolbarToggle(lineTimestampsAvailable, onUpdateHost) && (
<Tooltip>
<TooltipTrigger asChild>
@@ -236,7 +281,7 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
isVisible={isVisible}
/>
)}
<div className="flex-1" />
<div className="flex-1 min-w-0" />
<div className="flex items-center gap-0.5 flex-shrink-0">
{inWorkspace && onToggleBroadcast && (
<Tooltip>
@@ -266,6 +311,22 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
</TooltipContent>
</Tooltip>
)}
{inWorkspace && onDetach && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
size="icon"
className="h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)] bg-transparent hover:bg-transparent"
onClick={onDetach}
aria-label={t('terminal.toolbar.detach')}
>
<SquareArrowOutUpRight size={12} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{t('terminal.toolbar.detach')}</TooltipContent>
</Tooltip>
)}
{inWorkspace && !isFocusMode && onExpandToFocus && (
<Tooltip>
<TooltipTrigger asChild>
@@ -326,7 +387,12 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
width={lineTimestampGutterWidth}
onWidthChange={handleLineTimestampGutterWidthChange}
/>
{hasSelection && selectionOverlayPosition && ctx.onAddSelectionToAI && handleAddSelectionToAI && (
{shouldShowSelectionAIOverlay({
hasSelection,
selectionOverlayPosition,
onAddSelectionToAI: ctx.onAddSelectionToAI,
showSelectionAIAction,
}) && handleAddSelectionToAI && (
<div
className="absolute z-30 pointer-events-none"
style={{

View File

@@ -3,6 +3,15 @@ import type React from "react";
import { useRef, useState } from "react";
import { logger } from "../../../lib/logger";
import {
buildZmodemDragDropFiles,
buildZmodemDragDropUploadCommand,
containsZmodemRzMissingMarker,
createZmodemRzMissingToken,
supportsZmodemDragDropSftpFallback,
supportsZmodemTerminalDragDrop,
type ZmodemDragDropFile,
} from "../../../lib/zmodemDragDrop";
import { extractDropEntries, type DropEntry } from "../../../lib/sftpFileUtils";
import type { Host, TerminalSession } from "../../../types";
import { toast } from "../../ui/toast";
@@ -14,6 +23,7 @@ import {
interface UseTerminalDragDropOptions {
host: Host;
isLocalConnection: boolean;
isNetworkDevice?: boolean;
onOpenSftp?: TerminalProps["onOpenSftp"];
resolveSftpInitialPath: (options?: { preferFreshBackend?: boolean }) => Promise<string | undefined>;
scrollToBottomAfterProgrammaticInput: (data: string) => void;
@@ -23,37 +33,112 @@ interface UseTerminalDragDropOptions {
t: (key: string) => string;
terminalBackend: {
writeToSession: (sessionId: string, data: string, options?: { automated?: boolean }) => void;
cancelZmodem?: (sessionId: string, options?: { interrupt?: boolean }) => void;
onSessionData?: (sessionId: string, cb: (chunk: string) => void) => () => void;
onZmodemEvent?: (
sessionId: string,
cb: (event: { type: string; transferType?: string }) => void,
) => () => void;
startZmodemDragDropUpload?: (
sessionId: string,
files: ZmodemDragDropFile[],
uploadCommand?: string,
) => Promise<{ success: boolean; error?: string }>;
};
rzMissingFallbackTimeoutMs?: number;
termRef: React.MutableRefObject<XTerm | null>;
}
const RZ_MISSING_FALLBACK_TIMEOUT_MS = 2500;
export async function resolveTerminalDropUploadInitialPath(
resolveSftpInitialPath: UseTerminalDragDropOptions["resolveSftpInitialPath"],
): Promise<string | undefined> {
return resolveSftpInitialPath({ preferFreshBackend: true });
}
function createRzMissingWatcher({
sessionId,
terminalBackend,
token,
timeoutMs = RZ_MISSING_FALLBACK_TIMEOUT_MS,
}: {
sessionId: string;
terminalBackend: Pick<UseTerminalDragDropOptions["terminalBackend"], "onSessionData" | "onZmodemEvent">;
token: string;
timeoutMs?: number;
}): { promise: Promise<"missing" | "detected" | "timeout">; stop: () => void } {
let settled = false;
let timeout: ReturnType<typeof setTimeout> | undefined;
let buffer = "";
let unsubscribeData: (() => void) | undefined;
let unsubscribeZmodem: (() => void) | undefined;
let settle: (result: "missing" | "detected" | "timeout") => void = () => {};
const cleanup = () => {
if (timeout) clearTimeout(timeout);
timeout = undefined;
unsubscribeData?.();
unsubscribeData = undefined;
unsubscribeZmodem?.();
unsubscribeZmodem = undefined;
};
const promise = new Promise<"missing" | "detected" | "timeout">((resolve) => {
settle = (result) => {
if (settled) return;
settled = true;
cleanup();
resolve(result);
};
unsubscribeData = terminalBackend.onSessionData?.(sessionId, (chunk) => {
buffer = `${buffer}${chunk}`.slice(-512);
if (containsZmodemRzMissingMarker(buffer, token)) {
settle("missing");
}
});
unsubscribeZmodem = terminalBackend.onZmodemEvent?.(sessionId, (event) => {
if (event.type === "detect" && event.transferType === "upload") {
settle("detected");
}
});
timeout = setTimeout(() => settle("timeout"), timeoutMs);
});
return {
promise,
stop: () => settle("detected"),
};
}
export async function handleTerminalDropEntries({
dropEntries,
host,
isLocalConnection,
isNetworkDevice = false,
onOpenSftp,
resolveSftpInitialPath,
scrollToBottomAfterProgrammaticInput,
sessionId,
sessionRef,
terminalBackend,
rzMissingFallbackTimeoutMs,
termRef,
}: Pick<
UseTerminalDragDropOptions,
| "host"
| "isLocalConnection"
| "isNetworkDevice"
| "onOpenSftp"
| "resolveSftpInitialPath"
| "scrollToBottomAfterProgrammaticInput"
| "sessionId"
| "sessionRef"
| "terminalBackend"
| "rzMissingFallbackTimeoutMs"
| "termRef"
> & {
dropEntries: DropEntry[];
@@ -74,7 +159,67 @@ export async function handleTerminalDropEntries({
return;
}
if (onOpenSftp) {
const requiresSftpForDirectoryDrop = dropEntries.some((entry) => (
entry.isDirectory || /[\\/]/.test(entry.relativePath)
));
if (
requiresSftpForDirectoryDrop
&& onOpenSftp
&& supportsZmodemDragDropSftpFallback(host)
) {
const initialPath = await resolveTerminalDropUploadInitialPath(resolveSftpInitialPath);
onOpenSftp(host, initialPath, dropEntries, sessionId);
} else if (supportsZmodemTerminalDragDrop(host, isNetworkDevice)) {
const files = await buildZmodemDragDropFiles(dropEntries);
if (files.length === 0) {
throw new Error("No files to upload");
}
if (!terminalBackend.startZmodemDragDropUpload) {
throw new Error("ZMODEM drag-drop upload is unavailable");
}
const shouldFallbackToSftpWhenRzMissing = Boolean(
onOpenSftp
&& supportsZmodemDragDropSftpFallback(host)
&& terminalBackend.onSessionData
&& terminalBackend.cancelZmodem,
);
const rzMissingToken = shouldFallbackToSftpWhenRzMissing
? createZmodemRzMissingToken()
: undefined;
const rzMissingWatcher = rzMissingToken
? createRzMissingWatcher({
sessionId,
terminalBackend,
token: rzMissingToken,
timeoutMs: rzMissingFallbackTimeoutMs,
})
: undefined;
const uploadCommand = rzMissingToken
? buildZmodemDragDropUploadCommand(rzMissingToken)
: undefined;
let result: { success: boolean; error?: string };
try {
result = await terminalBackend.startZmodemDragDropUpload(sessionId, files, uploadCommand);
} catch (error) {
rzMissingWatcher?.stop();
throw error;
}
if (!result.success) {
rzMissingWatcher?.stop();
throw new Error(result.error || "ZMODEM upload failed");
}
const fallbackResult = rzMissingWatcher ? await rzMissingWatcher.promise : "detected";
if (fallbackResult === "missing" || fallbackResult === "timeout") {
terminalBackend.cancelZmodem?.(sessionId, { interrupt: fallbackResult === "timeout" });
const initialPath = await resolveTerminalDropUploadInitialPath(resolveSftpInitialPath);
onOpenSftp?.(host, initialPath, dropEntries, sessionId);
}
} else if (onOpenSftp) {
const initialPath = await resolveTerminalDropUploadInitialPath(resolveSftpInitialPath);
onOpenSftp(host, initialPath, dropEntries, sessionId);
}
@@ -83,6 +228,7 @@ export async function handleTerminalDropEntries({
export function useTerminalDragDrop({
host,
isLocalConnection,
isNetworkDevice = false,
onOpenSftp,
resolveSftpInitialPath,
scrollToBottomAfterProgrammaticInput,
@@ -91,6 +237,7 @@ export function useTerminalDragDrop({
status,
t,
terminalBackend,
rzMissingFallbackTimeoutMs,
termRef,
}: UseTerminalDragDropOptions) {
const [isDraggingOver, setIsDraggingOver] = useState(false);
@@ -143,17 +290,22 @@ export function useTerminalDragDrop({
dropEntries,
host,
isLocalConnection,
isNetworkDevice,
onOpenSftp,
resolveSftpInitialPath,
scrollToBottomAfterProgrammaticInput,
sessionId,
sessionRef,
terminalBackend,
rzMissingFallbackTimeoutMs,
termRef,
});
} catch (error) {
logger.error("Failed to handle file drop", error);
toast.error(t("terminal.dragDrop.errorMessage"), t("terminal.dragDrop.errorTitle"));
const message = error instanceof Error && error.message === "No files to upload"
? t("terminal.dragDrop.noFiles")
: t("terminal.dragDrop.errorMessage");
toast.error(message, t("terminal.dragDrop.errorTitle"));
}
};

View File

@@ -161,6 +161,47 @@ test("startSSH forwards custom ProxyCommand to the SSH bridge", async () => {
});
});
test("startSSH forwards the saved sudo autofill password to the SSH bridge", async () => {
let capturedOptions: Record<string, unknown> | null = null;
const terminalBackend = {
backendAvailable: () => true,
telnetAvailable: () => true,
moshAvailable: () => true,
localAvailable: () => true,
serialAvailable: () => true,
execAvailable: () => true,
startSSHSession: async (options: Record<string, unknown>) => {
capturedOptions = options;
return "ssh-session";
},
startTelnetSession: async () => "telnet-session",
startMoshSession: async () => "mosh-session",
startLocalSession: async () => "local-session",
startSerialSession: async () => "serial-session",
execCommand: async () => ({}),
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
};
const ctx = createStarterContext({
host: {
id: "host-1",
label: "Target",
hostname: "target.example.test",
username: "alice",
password: "login-secret",
},
terminalBackend,
sudoAutofillPassword: "sudo-secret",
});
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
assert.equal(capturedOptions?.sudoAutofillPassword, "sudo-secret");
});
test("startSSH enables sudo autofill only with the host saved password", async () => {
let onData: ((data: string) => void) | null = null;
const sent: string[] = [];
@@ -255,7 +296,7 @@ test("startSSH does not use unsaved retry passwords for sudo autofill", async ()
assert.deepEqual(sent, []);
});
test("startSSH prefers latest sudo autofill password state over pending saved auth", async () => {
test("startSSH uses pending saved auth for sudo autofill on the first saved connection", async () => {
let onData: ((data: string) => void) | null = null;
const sent: string[] = [];
const terminalBackend = {
@@ -296,14 +337,15 @@ test("startSSH prefers latest sudo autofill password state over pending saved au
},
},
terminalBackend,
sudoAutofillPasswordRef: { current: undefined },
sudoAutofillPasswordRef: { current: "stale-secret" },
});
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
ctx.sudoAutofillRef.current?.armForCommand("sudo whoami");
onData?.("[sudo] password for alice: ");
ctx.sudoAutofillRef.current?.confirmFill();
assert.deepEqual(sent, []);
assert.deepEqual(sent, ["pending-secret\n"]);
});
test("startSSH does not use merged group default passwords for sudo autofill", async () => {

View File

@@ -53,13 +53,13 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
};
const resolveSavedSudoAutofillPassword = (): string | undefined => {
if (ctx.sudoAutofillPasswordRef) {
return sanitizeCredentialValue(ctx.sudoAutofillPasswordRef.current);
}
const pendingAuth = ctx.pendingAuthRef.current;
if (pendingAuth?.savedToHost && pendingAuth.password) {
return sanitizeCredentialValue(pendingAuth.password);
}
if (ctx.sudoAutofillPasswordRef) {
return sanitizeCredentialValue(ctx.sudoAutofillPasswordRef.current);
}
return sanitizeCredentialValue(ctx.sudoAutofillPassword);
};
@@ -401,6 +401,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
sshDebugLogEnabled: ctx.sshDebugLogEnabled,
identityFilePaths: attempt.password ? undefined : targetIdentityFilePaths,
knownHosts: ctx.knownHosts,
sudoAutofillPassword: resolveSavedSudoAutofillPassword(),
// Ask the bridge to reuse the source tab's authenticated connection
// (issue #1204). Only honored on the very first connect attempt; the
// bridge silently falls back to a fresh connection if the source is
@@ -764,6 +765,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
// Lets the stats companion verify the host key before sending a saved
// password (#1198), so it never discloses it to an unvetted host.
knownHosts: ctx.knownHosts,
sudoAutofillPassword: resolveSavedSudoAutofillPassword(),
cols: term.cols,
rows: term.rows,
charset: ctx.host.charset,
@@ -1002,6 +1004,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
knownHosts: ctx.knownHosts,
jumpHosts: jumpHosts.length > 0 ? jumpHosts : undefined,
agentForwarding: ctx.host.agentForwarding,
sudoAutofillPassword: resolveSavedSudoAutofillPassword(),
cols: term.cols,
rows: term.rows,
charset: ctx.host.charset,

View File

@@ -48,12 +48,20 @@ import { terminalAltKeyOptions } from "./altKeyOptions";
import { optionArrowWordJumpSequence } from "./optionArrowWordJump";
import { watchDevicePixelRatio } from "./rendererDprWatch";
import { shouldDeferWebglUntilVisible } from "./webglRendererPolicy";
import {
captureMiddleClickTerminalMouseEvent,
markMiddleClickContextMenuEvent,
resolveMiddleClickBehavior,
} from "./middleClickBehavior";
import { handleSerialLineModeInput } from "./serialLineInput";
import {
isTerminalFontSizeAction,
nextTerminalFontSizeForAction,
nextTerminalFontSizeForWheel,
shouldHandleTerminalFontSizeAction,
terminalFontSizeWheelListenerOptions,
} from "./terminalFontZoom";
import { shouldPassThroughCopyShortcut } from "./terminalCopyShortcut";
import {
markExpectedTerminalCursorPositionReport,
pasteTextIntoTerminal,
@@ -122,6 +130,7 @@ export type CreateXTermRuntimeContext = {
sessionRef: RefObject<string | null>;
hotkeySchemeRef: RefObject<"disabled" | "mac" | "pc">;
disableTerminalFontZoomRef: RefObject<boolean>;
keyBindingsRef: RefObject<KeyBinding[]>;
onHotkeyActionRef: RefObject<
((action: string, event: KeyboardEvent) => void) | undefined
@@ -173,6 +182,11 @@ export type CreateXTermRuntimeContext = {
// Autocomplete input handler — called on every character input
onAutocompleteInput?: (data: string) => void;
terminalContextActionsRef?: RefObject<{
onPaste?: () => void | Promise<void>;
onSelectWord?: () => void;
} | undefined>;
// Set to true while we're programmatically restoring a selection so that
// copy-on-select listeners can suppress redundant clipboard writes.
isRestoringSelectionRef?: RefObject<boolean>;
@@ -562,6 +576,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
event,
currentTerminalFontSize(),
isMac,
ctx.disableTerminalFontZoomRef.current,
);
if (nextFontSize === null) return;
event.preventDefault();
@@ -684,6 +699,17 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
}
if (terminalActions.has(action)) {
if (
isTerminalFontSizeAction(action)
&& !shouldHandleTerminalFontSizeAction(action, ctx.disableTerminalFontZoomRef.current)
) {
return true;
}
// When copy is bound specifically to Ctrl+C and there is no text
// selected, pass the event through so xterm can send SIGINT.
if (shouldPassThroughCopyShortcut(action, term.hasSelection(), e)) {
return true;
}
e.preventDefault();
e.stopPropagation();
switch (action) {
@@ -731,7 +757,11 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
case "decreaseTerminalFontSize":
case "resetTerminalFontSize": {
applyTerminalFontSize(
nextTerminalFontSizeForAction(action, currentTerminalFontSize()),
nextTerminalFontSizeForAction(
action,
currentTerminalFontSize(),
ctx.disableTerminalFontZoomRef.current,
),
);
break;
}
@@ -783,29 +813,35 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
return true;
});
let cleanupMiddleClick: (() => void) | null = null;
const middleClickPaste = settings?.middleClickPaste ?? true;
if (middleClickPaste) {
const handleMiddleClick = async (e: MouseEvent) => {
if (e.button !== 1) return;
e.preventDefault();
try {
const text = await navigator.clipboard.readText();
if (text && ctx.sessionRef.current) {
pasteTextIntoTerminal(term, text, {
scrollOnPaste: shouldScrollOnTerminalPaste(ctx.terminalSettingsRef.current),
onPasteData: broadcastUserPasteData,
});
}
} catch (err) {
logger.warn("[Terminal] Failed to paste from clipboard:", err);
}
};
const handleMiddleClick = (e: MouseEvent) => {
if (e.button !== 1) return;
e.preventDefault();
ctx.container.addEventListener("auxclick", handleMiddleClick);
cleanupMiddleClick = () =>
ctx.container.removeEventListener("auxclick", handleMiddleClick);
}
const behavior = resolveMiddleClickBehavior(ctx.terminalSettingsRef.current);
if (behavior === "disabled") return;
if (behavior === "paste") {
void ctx.terminalContextActionsRef?.current?.onPaste?.();
return;
}
const contextMenuEvent = markMiddleClickContextMenuEvent(new MouseEvent("contextmenu", {
bubbles: true,
cancelable: true,
clientX: e.clientX,
clientY: e.clientY,
screenX: e.screenX,
screenY: e.screenY,
button: 2,
buttons: 0,
view: window,
}));
ctx.container.dispatchEvent(contextMenuEvent);
};
ctx.container.addEventListener("mousedown", captureMiddleClickTerminalMouseEvent, true);
ctx.container.addEventListener("mouseup", captureMiddleClickTerminalMouseEvent, true);
ctx.container.addEventListener("auxclick", handleMiddleClick);
fitAddon.fit();
term.focus();
@@ -1093,7 +1129,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
handleFontSizeWheel,
terminalFontSizeWheelListenerOptions,
);
cleanupMiddleClick?.();
ctx.container.removeEventListener("auxclick", handleMiddleClick);
ctx.container.removeEventListener("mousedown", captureMiddleClickTerminalMouseEvent, true);
ctx.container.removeEventListener("mouseup", captureMiddleClickTerminalMouseEvent, true);
stopDprWatch();
keywordHighlighter.dispose();
eraseScrollbackDisposable.dispose();

View File

@@ -0,0 +1,74 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
isMiddleClickContextMenuEvent,
markMiddleClickContextMenuEvent,
captureMiddleClickTerminalMouseEvent,
resolveMiddleClickBehavior,
shouldInterceptMouseTrackingContextMenu,
} from "./middleClickBehavior";
test("resolveMiddleClickBehavior uses the explicit middle-click behavior", () => {
assert.equal(resolveMiddleClickBehavior({ middleClickBehavior: "context-menu" }), "context-menu");
assert.equal(resolveMiddleClickBehavior({ middleClickBehavior: "disabled" }), "disabled");
});
test("resolveMiddleClickBehavior ignores unsupported middle-click behavior values", () => {
assert.equal(
resolveMiddleClickBehavior({ middleClickBehavior: "select-word" as never }),
"paste",
);
});
test("resolveMiddleClickBehavior falls back to the legacy middle-click paste flag", () => {
assert.equal(resolveMiddleClickBehavior({ middleClickPaste: true }), "paste");
assert.equal(resolveMiddleClickBehavior({ middleClickPaste: false }), "disabled");
assert.equal(resolveMiddleClickBehavior(undefined), "paste");
});
test("middle-click context menu events are identifiable", () => {
const event = {} as MouseEvent;
assert.equal(isMiddleClickContextMenuEvent(event), false);
assert.equal(isMiddleClickContextMenuEvent(markMiddleClickContextMenuEvent(event)), true);
});
test("mouse-tracking context menu capture lets middle-click menu events pass through", () => {
assert.equal(
shouldInterceptMouseTrackingContextMenu({
event: markMiddleClickContextMenuEvent({} as MouseEvent),
mouseTracking: true,
status: "connected",
}),
false,
);
assert.equal(
shouldInterceptMouseTrackingContextMenu({
event: {} as MouseEvent,
mouseTracking: true,
status: "connected",
}),
true,
);
});
test("middle-click terminal mouse down/up events are captured before xterm sees them", () => {
const calls: string[] = [];
const middleClickEvent = {
button: 1,
preventDefault: () => calls.push("preventDefault"),
stopImmediatePropagation: () => calls.push("stopImmediatePropagation"),
} as unknown as MouseEvent;
assert.equal(captureMiddleClickTerminalMouseEvent(middleClickEvent), true);
assert.deepEqual(calls, ["preventDefault", "stopImmediatePropagation"]);
calls.length = 0;
assert.equal(captureMiddleClickTerminalMouseEvent({
button: 0,
preventDefault: () => calls.push("preventDefault"),
stopImmediatePropagation: () => calls.push("stopImmediatePropagation"),
} as unknown as MouseEvent), false);
assert.deepEqual(calls, []);
});

View File

@@ -0,0 +1,54 @@
import type { MiddleClickBehavior, TerminalSettings } from "../../../domain/models";
type MiddleClickSettings = Partial<Pick<TerminalSettings, "middleClickBehavior" | "middleClickPaste">>;
const MIDDLE_CONTEXT_MENU_EVENT_KEY = "__netcattyMiddleContextMenu";
type MiddleClickContextMenuEvent = MouseEvent & {
[MIDDLE_CONTEXT_MENU_EVENT_KEY]?: boolean;
};
export interface MouseTrackingContextMenuCaptureState {
event: MouseEvent;
mouseTracking: boolean;
status?: string | null;
}
export const resolveMiddleClickBehavior = (
settings?: MiddleClickSettings | null,
): MiddleClickBehavior => {
const behavior = settings?.middleClickBehavior;
if (
behavior === "context-menu" ||
behavior === "paste" ||
behavior === "disabled"
) {
return behavior;
}
return settings?.middleClickPaste === false ? "disabled" : "paste";
};
export const markMiddleClickContextMenuEvent = (event: MouseEvent): MouseEvent => {
Object.defineProperty(event, MIDDLE_CONTEXT_MENU_EVENT_KEY, {
value: true,
configurable: true,
});
return event;
};
export const isMiddleClickContextMenuEvent = (event: MouseEvent): boolean =>
(event as MiddleClickContextMenuEvent)[MIDDLE_CONTEXT_MENU_EVENT_KEY] === true;
export const shouldInterceptMouseTrackingContextMenu = ({
event,
mouseTracking,
status,
}: MouseTrackingContextMenuCaptureState): boolean =>
mouseTracking && status === "connected" && !isMiddleClickContextMenuEvent(event);
export const captureMiddleClickTerminalMouseEvent = (event: MouseEvent): boolean => {
if (event.button !== 1) return false;
event.preventDefault();
event.stopImmediatePropagation();
return true;
};

View File

@@ -0,0 +1,56 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
isPlainCtrlCInterruptChord,
shouldPassThroughCopyShortcut,
} from "./terminalCopyShortcut.ts";
const keyboardEvent = (
key: string,
code: string,
modifiers: Partial<KeyboardEvent> = {},
): KeyboardEvent => ({
key,
code,
ctrlKey: false,
shiftKey: false,
altKey: false,
metaKey: false,
...modifiers,
}) as KeyboardEvent;
test("plain Ctrl+C copy with no selection passes through for SIGINT", () => {
const event = keyboardEvent("c", "KeyC", { ctrlKey: true });
assert.equal(isPlainCtrlCInterruptChord(event), true);
assert.equal(shouldPassThroughCopyShortcut("copy", false, event), true);
});
test("copy shortcut does not pass through when text is selected", () => {
const event = keyboardEvent("c", "KeyC", { ctrlKey: true });
assert.equal(shouldPassThroughCopyShortcut("copy", true, event), false);
});
test("copy shortcut does not pass through for shifted or alternate chords", () => {
assert.equal(
shouldPassThroughCopyShortcut("copy", false, keyboardEvent("C", "KeyC", { ctrlKey: true, shiftKey: true })),
false,
);
assert.equal(
shouldPassThroughCopyShortcut("copy", false, keyboardEvent("l", "KeyL", { ctrlKey: true })),
false,
);
assert.equal(
shouldPassThroughCopyShortcut("paste", false, keyboardEvent("c", "KeyC", { ctrlKey: true })),
false,
);
});
test("plain Ctrl+C copy passthrough follows the physical C key on non-Latin layouts", () => {
const event = keyboardEvent("\u0441", "KeyC", { ctrlKey: true });
assert.equal(isPlainCtrlCInterruptChord(event), true);
assert.equal(shouldPassThroughCopyShortcut("copy", false, event), true);
});

View File

@@ -0,0 +1,20 @@
type CopyShortcutKeyEvent = Pick<
KeyboardEvent,
"key" | "code" | "ctrlKey" | "shiftKey" | "altKey" | "metaKey"
>;
export function isPlainCtrlCInterruptChord(e: CopyShortcutKeyEvent): boolean {
return e.ctrlKey
&& !e.shiftKey
&& !e.altKey
&& !e.metaKey
&& (e.key.toLowerCase() === "c" || e.code === "KeyC");
}
export function shouldPassThroughCopyShortcut(
action: string,
hasSelection: boolean,
e: CopyShortcutKeyEvent,
): boolean {
return action === "copy" && !hasSelection && isPlainCtrlCInterruptChord(e);
}

View File

@@ -4,6 +4,7 @@ import test from 'node:test';
import {
nextTerminalFontSizeForAction,
nextTerminalFontSizeForWheel,
shouldHandleTerminalFontSizeAction,
terminalFontSizeWheelListenerOptions,
} from './terminalFontZoom.ts';
@@ -16,6 +17,20 @@ test('terminal font size actions step and reset within bounds', () => {
assert.equal(nextTerminalFontSizeForAction('copy', 14), null);
});
test('terminal font size actions return null when terminal font zoom is disabled', () => {
assert.equal(nextTerminalFontSizeForAction('increaseTerminalFontSize', 14, true), null);
assert.equal(nextTerminalFontSizeForAction('decreaseTerminalFontSize', 14, true), null);
assert.equal(nextTerminalFontSizeForAction('resetTerminalFontSize', 18, true), null);
});
test('terminal font size actions are not handled when terminal font zoom is disabled', () => {
assert.equal(shouldHandleTerminalFontSizeAction('increaseTerminalFontSize', false), true);
assert.equal(shouldHandleTerminalFontSizeAction('decreaseTerminalFontSize', false), true);
assert.equal(shouldHandleTerminalFontSizeAction('resetTerminalFontSize', false), true);
assert.equal(shouldHandleTerminalFontSizeAction('increaseTerminalFontSize', true), false);
assert.equal(shouldHandleTerminalFontSizeAction('copy', true), false);
});
test('wheel adjusts terminal font size with the platform modifier only', () => {
assert.equal(nextTerminalFontSizeForWheel({ ctrlKey: true, metaKey: false, deltaY: -1 }, 14, false), 15);
assert.equal(nextTerminalFontSizeForWheel({ ctrlKey: true, metaKey: false, deltaY: 1 }, 14, false), 13);
@@ -27,6 +42,11 @@ test('wheel adjusts terminal font size with the platform modifier only', () => {
assert.equal(nextTerminalFontSizeForWheel({ ctrlKey: true, metaKey: false, deltaY: 0 }, 14, false), null);
});
test('wheel zoom returns null when terminal font zoom is disabled', () => {
assert.equal(nextTerminalFontSizeForWheel({ ctrlKey: true, metaKey: false, deltaY: -1 }, 14, false, true), null);
assert.equal(nextTerminalFontSizeForWheel({ ctrlKey: false, metaKey: true, deltaY: -1 }, 14, true, true), null);
});
test('wheel font-size listener runs before xterm consumes terminal scrolling', () => {
assert.equal(terminalFontSizeWheelListenerOptions.capture, true);
assert.equal(terminalFontSizeWheelListenerOptions.passive, false);

View File

@@ -6,6 +6,12 @@ import {
type WheelLike = Pick<WheelEvent, "ctrlKey" | "metaKey" | "deltaY">;
const TERMINAL_FONT_SIZE_ACTIONS = new Set([
"increaseTerminalFontSize",
"decreaseTerminalFontSize",
"resetTerminalFontSize",
]);
export const terminalFontSizeWheelListenerOptions = {
passive: false,
capture: true,
@@ -14,10 +20,20 @@ export const terminalFontSizeWheelListenerOptions = {
export const clampTerminalFontSize = (fontSize: number): number =>
Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, fontSize));
export const isTerminalFontSizeAction = (action: string): boolean =>
TERMINAL_FONT_SIZE_ACTIONS.has(action);
export const shouldHandleTerminalFontSizeAction = (
action: string,
disabled = false,
): boolean => isTerminalFontSizeAction(action) && !disabled;
export const nextTerminalFontSizeForAction = (
action: string,
currentFontSize: number,
disabled = false,
): number | null => {
if (disabled) return null;
switch (action) {
case "increaseTerminalFontSize":
return clampTerminalFontSize(currentFontSize + 1);
@@ -34,7 +50,9 @@ export const nextTerminalFontSizeForWheel = (
event: WheelLike,
currentFontSize: number,
isMac: boolean,
disabled = false,
): number | null => {
if (disabled) return null;
const hasZoomModifier = isMac
? event.metaKey && !event.ctrlKey
: event.ctrlKey && !event.metaKey;

View File

@@ -23,7 +23,166 @@ const dropEntries: DropEntry[] = [
},
];
test("remote terminal drop opens SFTP upload with a freshly resolved cwd", async () => {
test("remote SSH terminal drop triggers ZMODEM drag-drop upload", async () => {
let uploadedFiles: unknown;
let uploadedSessionId: string | undefined;
await handleTerminalDropEntries({
dropEntries: [
{
file: {
name: "report.txt",
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
} as File,
relativePath: "report.txt",
isDirectory: false,
},
],
host,
isLocalConnection: false,
resolveSftpInitialPath: async () => "/srv/app/current",
scrollToBottomAfterProgrammaticInput: () => {},
sessionId: "session-1",
sessionRef: { current: "session-1" },
terminalBackend: {
writeToSession: () => {},
startZmodemDragDropUpload: async (sessionId, files) => {
uploadedSessionId = sessionId;
uploadedFiles = files;
return { success: true };
},
},
termRef: { current: null },
});
assert.equal(uploadedSessionId, "session-1");
assert.equal(Array.isArray(uploadedFiles), true);
const files = uploadedFiles as Array<{ name: string; remoteName: string; data?: ArrayBuffer }>;
assert.equal(files.length, 1);
assert.equal(files[0].name, "report.txt");
assert.equal(files[0].remoteName, "report.txt");
assert.ok(files[0].data);
});
test("remote SSH terminal drop stays on ZMODEM when rz starts", async () => {
let openedSftp = false;
let zmodemCallback: ((event: { type: string; transferType?: string }) => void) | undefined;
await handleTerminalDropEntries({
dropEntries: [
{
file: {
name: "report.txt",
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
} as File,
relativePath: "report.txt",
isDirectory: false,
},
],
host,
isLocalConnection: false,
onOpenSftp: () => {
openedSftp = true;
},
resolveSftpInitialPath: async () => "/srv/app/current",
scrollToBottomAfterProgrammaticInput: () => {},
sessionId: "session-1",
sessionRef: { current: "session-1" },
terminalBackend: {
writeToSession: () => {},
cancelZmodem: () => {},
onSessionData: () => () => {},
onZmodemEvent: (_sessionId, cb) => {
zmodemCallback = cb;
return () => {
zmodemCallback = undefined;
};
},
startZmodemDragDropUpload: async (_sessionId, _files, uploadCommand) => {
assert.match(uploadCommand ?? "", /NetcattyRzMissing=/);
zmodemCallback?.({ type: "detect", transferType: "upload" });
return { success: true };
},
},
termRef: { current: null },
});
assert.equal(openedSftp, false);
});
test("serial terminal drop does not wrap rz with an SSH shell fallback", async () => {
let uploadCommandSeen: string | undefined;
await handleTerminalDropEntries({
dropEntries: [
{
file: {
name: "report.txt",
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
} as File,
relativePath: "report.txt",
isDirectory: false,
},
],
host: { ...host, protocol: "serial" } as Host,
isLocalConnection: false,
onOpenSftp: () => {},
resolveSftpInitialPath: async () => "/srv/app/current",
scrollToBottomAfterProgrammaticInput: () => {},
sessionId: "session-1",
sessionRef: { current: "session-1" },
terminalBackend: {
writeToSession: () => {},
cancelZmodem: () => {},
onSessionData: () => () => {},
startZmodemDragDropUpload: async (_sessionId, _files, uploadCommand) => {
uploadCommandSeen = uploadCommand;
return { success: true };
},
},
termRef: { current: null },
});
assert.equal(uploadCommandSeen, undefined);
});
test("telnet terminal drop does not wrap rz with an SSH shell fallback", async () => {
let uploadCommandSeen: string | undefined;
await handleTerminalDropEntries({
dropEntries: [
{
file: {
name: "report.txt",
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
} as File,
relativePath: "report.txt",
isDirectory: false,
},
],
host: { ...host, protocol: "telnet" } as Host,
isLocalConnection: false,
onOpenSftp: () => {},
resolveSftpInitialPath: async () => "/srv/app/current",
scrollToBottomAfterProgrammaticInput: () => {},
sessionId: "session-1",
sessionRef: { current: "session-1" },
terminalBackend: {
writeToSession: () => {},
cancelZmodem: () => {},
onSessionData: () => () => {},
startZmodemDragDropUpload: async (_sessionId, _files, uploadCommand) => {
uploadCommandSeen = uploadCommand;
return { success: true };
},
},
termRef: { current: null },
});
assert.equal(uploadCommandSeen, undefined);
});
test("network device drop falls back to SFTP upload with a freshly resolved cwd", async () => {
let receivedOptions: { preferFreshBackend?: boolean } | undefined;
let openedPath: string | undefined;
let openedEntries: DropEntry[] | undefined;
@@ -33,6 +192,7 @@ test("remote terminal drop opens SFTP upload with a freshly resolved cwd", async
dropEntries,
host,
isLocalConnection: false,
isNetworkDevice: true,
onOpenSftp: (_host, initialPath, pendingUploadEntries, sourceSessionId) => {
openedPath = initialPath;
openedEntries = pendingUploadEntries;
@@ -57,6 +217,163 @@ test("remote terminal drop opens SFTP upload with a freshly resolved cwd", async
assert.equal(openedSessionId, "session-1");
});
test("remote SSH terminal drop falls back to SFTP when rz is unavailable", async () => {
let receivedOptions: { preferFreshBackend?: boolean } | undefined;
let openedPath: string | undefined;
let openedEntries: DropEntry[] | undefined;
let openedSessionId: string | undefined;
let dataCallback: ((chunk: string) => void) | undefined;
let cancelled: { sessionId: string; interrupt?: boolean } | undefined;
await handleTerminalDropEntries({
dropEntries: [
{
file: {
name: "report.txt",
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
} as File,
relativePath: "report.txt",
isDirectory: false,
},
],
host,
isLocalConnection: false,
onOpenSftp: (_host, initialPath, pendingUploadEntries, sourceSessionId) => {
openedPath = initialPath;
openedEntries = pendingUploadEntries;
openedSessionId = sourceSessionId;
},
resolveSftpInitialPath: async (options) => {
receivedOptions = options;
return "/srv/app/current";
},
scrollToBottomAfterProgrammaticInput: () => {},
sessionId: "session-1",
sessionRef: { current: "session-1" },
terminalBackend: {
writeToSession: () => {},
onSessionData: (_sessionId: string, cb: (chunk: string) => void) => {
dataCallback = cb;
return () => {
dataCallback = undefined;
};
},
cancelZmodem: (sessionId: string, options?: { interrupt?: boolean }) => {
cancelled = { sessionId, interrupt: options?.interrupt };
},
startZmodemDragDropUpload: async (_sessionId, _files, uploadCommand) => {
assert.match(uploadCommand ?? "", /NetcattyRzMissing=/);
assert.equal((uploadCommand ?? "").includes("\u001b]1337;NetcattyRzMissing="), false);
const token = uploadCommand?.match(/NetcattyRzMissing=([A-Za-z0-9_-]+)/)?.[1];
assert.ok(token);
dataCallback?.(`\u001b]1337;NetcattyRzMissing=${token}\u0007`);
return { success: true };
},
},
termRef: { current: null },
});
assert.deepEqual(receivedOptions, { preferFreshBackend: true });
assert.equal(openedPath, "/srv/app/current");
assert.equal(openedEntries?.length, 1);
assert.equal(openedEntries?.[0].relativePath, "report.txt");
assert.equal(openedSessionId, "session-1");
assert.deepEqual(cancelled, { sessionId: "session-1", interrupt: false });
});
test("remote SSH terminal drop falls back to SFTP when rz never starts", async () => {
let receivedOptions: { preferFreshBackend?: boolean } | undefined;
let openedPath: string | undefined;
let openedEntries: DropEntry[] | undefined;
let cancelled: { sessionId: string; interrupt?: boolean } | undefined;
await handleTerminalDropEntries({
dropEntries: [
{
file: {
name: "report.txt",
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
} as File,
relativePath: "report.txt",
isDirectory: false,
},
],
host,
isLocalConnection: false,
onOpenSftp: (_host, initialPath, pendingUploadEntries) => {
openedPath = initialPath;
openedEntries = pendingUploadEntries;
},
resolveSftpInitialPath: async (options) => {
receivedOptions = options;
return "/srv/app/current";
},
scrollToBottomAfterProgrammaticInput: () => {},
sessionId: "session-1",
sessionRef: { current: "session-1" },
terminalBackend: {
writeToSession: () => {},
onSessionData: () => () => {},
cancelZmodem: (sessionId: string, options?: { interrupt?: boolean }) => {
cancelled = { sessionId, interrupt: options?.interrupt };
},
startZmodemDragDropUpload: async () => ({ success: true }),
},
rzMissingFallbackTimeoutMs: 1,
termRef: { current: null },
});
assert.deepEqual(receivedOptions, { preferFreshBackend: true });
assert.equal(openedPath, "/srv/app/current");
assert.equal(openedEntries?.length, 1);
assert.deepEqual(cancelled, { sessionId: "session-1", interrupt: true });
});
test("remote SSH folder drop uses SFTP to preserve directory structure", async () => {
let openedEntries: DropEntry[] | undefined;
let zmodemStarted = false;
const folderEntries: DropEntry[] = [
{
file: null,
relativePath: "docs",
isDirectory: true,
},
{
file: {
name: "guide.txt",
arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer,
} as File,
relativePath: "docs/guide.txt",
isDirectory: false,
},
];
await handleTerminalDropEntries({
dropEntries: folderEntries,
host,
isLocalConnection: false,
onOpenSftp: (_host, _initialPath, pendingUploadEntries) => {
openedEntries = pendingUploadEntries;
},
resolveSftpInitialPath: async () => "/srv/app/current",
scrollToBottomAfterProgrammaticInput: () => {},
sessionId: "session-1",
sessionRef: { current: "session-1" },
terminalBackend: {
writeToSession: () => {},
startZmodemDragDropUpload: async () => {
zmodemStarted = true;
return { success: true };
},
},
termRef: { current: null },
});
assert.equal(zmodemStarted, false);
assert.equal(openedEntries, folderEntries);
});
test("fresh cwd resolution falls back to the renderer cwd when backend probe has no real cwd", async () => {
const cwd = await resolvePreferredTerminalCwd({
rendererCwd: "/srv/app/current",

View File

@@ -1,3 +1,4 @@
import type { DragEvent, PointerEvent } from "react";
import { Terminal as XTerm } from "@xterm/xterm";
import { logger } from "../../lib/logger";
@@ -17,6 +18,14 @@ import type {
export const MAX_CONNECTION_LOG_DATA_CHARS = 1_000_000;
/**
* Get the display name for a terminal session.
* Uses customName if set, otherwise falls back to hostLabel.
*/
export function getSessionDisplayName(session: TerminalSession): string {
return session.customName || session.hostLabel || '';
}
/**
* Extract unique root paths from drop entries for local terminal path insertion.
* For nested files, extracts the root folder path; for single files, uses the full path.
@@ -119,6 +128,7 @@ export interface TerminalProps {
reuseConnectionFromSessionId?: string;
serialConfig?: SerialConfig;
hotkeyScheme?: "disabled" | "mac" | "pc";
disableTerminalFontZoom?: boolean;
keyBindings?: KeyBinding[];
onHotkeyAction?: (action: string, event: KeyboardEvent) => void;
onTerminalFontSizeChange?: (fontSize: number) => void;
@@ -167,7 +177,19 @@ export interface TerminalProps {
sessionLog?: { enabled: boolean; directory: string; format: string; timestampsEnabled?: boolean };
sshDebugLogEnabled?: boolean;
sudoAutofillPassword?: string;
showSelectionAIAction?: boolean;
onAddSelectionToAI?: (sessionId: string, selection: string) => void;
/** Override display name for the pane title bar (customName || hostLabel) */
sessionDisplayName?: string;
/** Open rename dialog for this session */
onRename?: () => void;
/** Detach this session from its workspace to a standalone tab */
onDetach?: () => void;
onStartSessionDrag?: (sessionId: string) => void;
onEndSessionDrag?: () => void;
onDetachPointerDown?: (e: PointerEvent<HTMLElement>) => void;
onDetachDragStart?: (e: DragEvent) => void;
onDetachDragEnd?: (e: DragEvent) => void;
}
export function formatNetSpeed(bytesPerSec: number): string {

View File

@@ -0,0 +1,25 @@
import assert from "node:assert/strict";
import test from "node:test";
import { terminalPropsAreEqual } from "./terminalMemo.ts";
import type { TerminalProps } from "./terminalHelpers.ts";
const baseProps = {
host: {},
keys: [],
identities: [],
snippets: [],
isVisible: true,
fontFamilyId: "default",
fontSize: 14,
terminalTheme: {},
sessionId: "session-1",
showSelectionAIAction: true,
} as unknown as TerminalProps;
test("terminal memo refreshes when selection AI action visibility changes", () => {
assert.equal(
terminalPropsAreEqual(baseProps, { ...baseProps, showSelectionAIAction: false }),
false,
);
});

View File

@@ -33,17 +33,20 @@ export const terminalPropsAreEqual = (
&& prev.customAccent === next.customAccent
&& prev.terminalSettings === next.terminalSettings
&& prev.sessionId === next.sessionId
&& prev.sessionDisplayName === next.sessionDisplayName
&& prev.startupCommand === next.startupCommand
&& prev.noAutoRun === next.noAutoRun
&& prev.reuseConnectionFromSessionId === next.reuseConnectionFromSessionId
&& prev.serialConfig === next.serialConfig
&& prev.hotkeyScheme === next.hotkeyScheme
&& prev.disableTerminalFontZoom === next.disableTerminalFontZoom
&& prev.keyBindings === next.keyBindings
&& prev.isBroadcastEnabled === next.isBroadcastEnabled
&& prev.isWorkspaceComposeBarOpen === next.isWorkspaceComposeBarOpen
&& prev.sessionLog === next.sessionLog
&& prev.sshDebugLogEnabled === next.sshDebugLogEnabled
&& prev.sudoAutofillPassword === next.sudoAutofillPassword
&& prev.showSelectionAIAction === next.showSelectionAIAction
&& prev.onHotkeyAction === next.onHotkeyAction
&& prev.onTerminalFontSizeChange === next.onTerminalFontSizeChange
&& prev.onStatusChange === next.onStatusChange
@@ -69,4 +72,11 @@ export const terminalPropsAreEqual = (
&& prev.onBroadcastInput === next.onBroadcastInput
&& prev.onSnippetExecutorChange === next.onSnippetExecutorChange
&& prev.onAddSelectionToAI === next.onAddSelectionToAI
&& prev.onRename === next.onRename
&& prev.onDetach === next.onDetach
&& prev.onStartSessionDrag === next.onStartSessionDrag
&& prev.onEndSessionDrag === next.onEndSessionDrag
&& prev.onDetachPointerDown === next.onDetachPointerDown
&& prev.onDetachDragStart === next.onDetachDragStart
&& prev.onDetachDragEnd === next.onDetachDragEnd
);

View File

@@ -62,3 +62,18 @@ test("allows native focus for contenteditable regions", () => {
assert.equal(shouldPreserveTerminalFocusOnMouseDown(editableTarget as unknown as EventTarget), false);
});
test("allows native drag start from the terminal detach drag handle", () => {
const dragHandleTarget = {
tagName: "span",
isContentEditable: false,
closest(selector: string) {
return selector.includes("data-terminal-detach-drag-handle") ? { tagName: "DIV" } : null;
},
getAttribute() {
return null;
},
};
assert.equal(shouldPreserveTerminalFocusOnMouseDown(dragHandleTarget as unknown as EventTarget), false);
});

View File

@@ -6,6 +6,7 @@ type FocusTargetLike = {
};
const EDITABLE_SELECTOR = 'input, textarea, select, [contenteditable=""], [contenteditable="true"], [role="textbox"]';
const NATIVE_POINTER_SELECTOR = `${EDITABLE_SELECTOR}, [data-terminal-detach-drag-handle="true"]`;
/**
* The terminal's top overlay sits above the xterm textarea. Pointer clicks on
@@ -31,12 +32,16 @@ export const shouldPreserveTerminalFocusOnMouseDown = (target: EventTarget | nul
if (typeof candidate.getAttribute === "function") {
const contentEditable = candidate.getAttribute("contenteditable");
const role = candidate.getAttribute("role");
const detachDragHandle = candidate.getAttribute("data-terminal-detach-drag-handle");
if (contentEditable === "" || contentEditable === "true" || role === "textbox") {
return false;
}
if (detachDragHandle === "true") {
return false;
}
}
if (typeof candidate.closest === "function" && candidate.closest(EDITABLE_SELECTOR)) {
if (typeof candidate.closest === "function" && candidate.closest(NATIVE_POINTER_SELECTOR)) {
return false;
}

View File

@@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps */
import { useRef } from 'react';
import { resolveFontWeightBold } from '../../lib/fontWeightAvailability';
import { shouldInterceptMouseTrackingContextMenu } from './runtime/middleClickBehavior';
type TerminalEffectsContext = Record<string, any>;
@@ -48,7 +49,7 @@ export function resolveSelectionOverlayPosition(term: any, container: HTMLElemen
}
export function useTerminalEffects(ctx: TerminalEffectsContext) {
const { CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isComposeBarOpen, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, paneLayoutKey, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, onSnippetShortkeyRef, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef } = ctx;
const { CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBootActiveRef, isBroadcastEnabledRef, isComposeBarOpen, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, paneLayoutKey, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, onSnippetShortkeyRef, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef } = ctx;
// Remember the last layout we successfully refit while visible so revisiting
// the same workspace tab does not replay expensive force-fit/WebGL recovery.
@@ -239,6 +240,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
terminalBackend,
sessionRef,
hotkeySchemeRef,
disableTerminalFontZoomRef,
keyBindingsRef,
onHotkeyActionRef,
onTerminalFontSizeChange,
@@ -268,6 +270,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
// Autocomplete integration
onAutocompleteKeyEvent: (e: KeyboardEvent) => autocompleteKeyEventRef.current?.(e) ?? true,
onAutocompleteInput: (data: string) => autocompleteInputRef.current?.(data),
terminalContextActionsRef,
isRestoringSelectionRef,
// Defer WebGL context creation for panes that mount hidden (e.g. the
// background tabs of a batch connect) until they first become visible.
@@ -958,8 +961,13 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
if (!el) return;
const handleContextMenuCapture = (e: MouseEvent) => {
if (!mouseTrackingRef.current) return;
if (statusRef.current !== 'connected') return;
if (!shouldInterceptMouseTrackingContextMenu({
event: e,
mouseTracking: mouseTrackingRef.current,
status: statusRef.current,
})) {
return;
}
e.preventDefault();
e.stopImmediatePropagation();

View File

@@ -7,7 +7,10 @@ import { STORAGE_KEY_WORKSPACE_FOCUS_SIDEBAR_WIDTH } from '../../infrastructure/
import { cn } from '../../lib/utils';
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
import { DistroAvatar } from '../DistroAvatar';
import { SessionInlineRenameInput } from '../terminal/SessionInlineRenameInput';
import { SessionTabContextMenuContent } from '../top-tabs/SessionTabContextMenuContent';
import { Button } from '../ui/button';
import { ContextMenu, ContextMenuTrigger } from '../ui/context-menu';
import { Input } from '../ui/input';
import { ScrollArea } from '../ui/scroll-area';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
@@ -17,8 +20,13 @@ interface TerminalFocusSidebarProps {
focusedSessionId: string | undefined;
onReorderWorkspaceSessions?: (workspaceId: string, draggedSessionId: string, targetSessionId: string, position: 'before' | 'after') => void;
onRequestAddToWorkspace?: (workspaceId: string) => void;
onCloseSession: (sessionId: string) => void;
onCopySession?: (sessionId: string) => void;
onCopySessionToNewWindow?: (sessionId: string) => void;
onDetachSessionFromWorkspace?: (sessionId: string) => void;
onSetWorkspaceFocusedSession?: (workspaceId: string, sessionId: string) => void;
onToggleWorkspaceViewMode?: (workspaceId: string) => void;
onSubmitSessionRename: (sessionId: string, name: string) => void;
resolvedPreviewTheme: TerminalTheme;
sessionHostsMap: Map<string, Host>;
sessions: TerminalSession[];
@@ -40,6 +48,15 @@ type WorkspaceFocusSessionRowProps = {
session: TerminalSession;
host: Host | undefined;
isSelected: boolean;
isRenaming: boolean;
renameValue: string;
onStartRename: (sessionId: string) => void;
onSubmitRename: (name: string) => void;
onCancelRename: () => void;
onCloseSession: (sessionId: string) => void;
onCopySession?: (sessionId: string) => void;
onCopySessionToNewWindow?: (sessionId: string) => void;
onDetachSessionFromWorkspace?: (sessionId: string) => void;
isDragging: boolean;
dropPosition: 'before' | 'after' | null;
theme: FocusSidebarTheme;
@@ -48,12 +65,22 @@ type WorkspaceFocusSessionRowProps = {
onDragOver: (event: DragEvent, sessionId: string) => void;
onDrop: (event: DragEvent, sessionId: string) => void;
onDragEnd: () => void;
t: (key: string) => string;
};
const WorkspaceFocusSessionRow = memo<WorkspaceFocusSessionRowProps>(({
session,
host,
isSelected,
isRenaming,
renameValue,
onStartRename,
onSubmitRename,
onCancelRename,
onCloseSession,
onCopySession,
onCopySessionToNewWindow,
onDetachSessionFromWorkspace,
isDragging,
dropPosition,
theme,
@@ -62,6 +89,7 @@ const WorkspaceFocusSessionRow = memo<WorkspaceFocusSessionRowProps>(({
onDragOver,
onDrop,
onDragEnd,
t,
}) => {
const {
termFg,
@@ -83,80 +111,121 @@ const WorkspaceFocusSessionRow = memo<WorkspaceFocusSessionRowProps>(({
const rowFg = isSelected ? termFg : unselectedFg;
return (
<div
data-workspace-focus-session-id={session.id}
draggable
role="button"
tabIndex={0}
className={cn(
'relative flex w-full select-none items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm font-normal outline-none transition-colors hover:text-inherit focus-visible:ring-1',
isDragging && 'opacity-50',
)}
style={{
backgroundColor: restBg,
color: rowFg,
boxShadow: dropPosition
? `inset 0 ${dropPosition === 'before' ? '2px' : '-2px'} 0 ${termFg}`
: undefined,
}}
onDragStart={(event) => onDragStart(event, session.id)}
onDragOver={(event) => onDragOver(event, session.id)}
onDragLeave={(event) => {
event.stopPropagation();
}}
onDrop={(event) => onDrop(event, session.id)}
onDragEnd={onDragEnd}
onMouseEnter={(event) => {
event.currentTarget.style.backgroundColor = hoverBg;
}}
onMouseLeave={(event) => {
event.currentTarget.style.backgroundColor = restBg;
}}
onClick={() => onSelect(session.id)}
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
onSelect(session.id);
}}
>
<div className="relative flex h-6 w-6 shrink-0 items-center justify-center self-center">
{host ? (
<DistroAvatar
host={host}
fallback={session.hostLabel}
size="sm"
className="!h-6 !w-6"
/>
) : (
<Server size={14} style={{ color: mutedFg }} />
)}
<Circle
size={5}
className={cn('absolute bottom-0 right-0 fill-current', statusColor)}
/>
</div>
<div className="flex h-6 flex-1 min-w-0 flex-col justify-center self-center text-left">
<div className={cn('truncate text-xs leading-none', isSelected ? 'font-semibold' : 'font-medium')}>
{session.hostLabel}
<ContextMenu>
<ContextMenuTrigger asChild>
<div
data-workspace-focus-session-id={session.id}
draggable
role="button"
tabIndex={0}
className={cn(
'relative flex w-full select-none items-center justify-start gap-2 rounded-md px-2 py-1.5 text-sm font-normal outline-none transition-colors hover:text-inherit focus-visible:ring-1',
isDragging && 'opacity-50',
)}
style={{
backgroundColor: restBg,
color: rowFg,
boxShadow: dropPosition
? `inset 0 ${dropPosition === 'before' ? '2px' : '-2px'} 0 ${termFg}`
: undefined,
}}
onContextMenu={() => onSelect(session.id)}
onDragStart={(event) => onDragStart(event, session.id)}
onDragOver={(event) => onDragOver(event, session.id)}
onDragLeave={(event) => {
event.stopPropagation();
}}
onDrop={(event) => onDrop(event, session.id)}
onDragEnd={onDragEnd}
onMouseEnter={(event) => {
event.currentTarget.style.backgroundColor = hoverBg;
}}
onMouseLeave={(event) => {
event.currentTarget.style.backgroundColor = restBg;
}}
onClick={() => onSelect(session.id)}
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
onSelect(session.id);
}}
>
<div className="relative flex h-6 w-6 shrink-0 items-center justify-center self-center">
{host ? (
<DistroAvatar
host={host}
fallback={session.hostLabel}
size="sm"
className="!h-6 !w-6"
/>
) : (
<Server size={14} style={{ color: mutedFg }} />
)}
<Circle
size={5}
className={cn('absolute bottom-0 right-0 fill-current', statusColor)}
/>
</div>
<div className="flex h-6 flex-1 min-w-0 flex-col justify-center self-center text-left">
{isRenaming ? (
<SessionInlineRenameInput
initialName={renameValue}
onCommit={onSubmitRename}
onCancel={onCancelRename}
className="h-5 text-xs leading-none"
/>
) : (
<>
<div
className={cn('truncate text-xs leading-none', isSelected ? 'font-semibold' : 'font-medium')}
onDoubleClick={(e) => {
e.stopPropagation();
onStartRename(session.id);
}}
>
{session.customName || session.hostLabel}
</div>
<div className="mt-0.5 truncate text-[10px] leading-none" style={{ color: mutedFg }}>
{session.username}@{session.hostname}
</div>
</>
)}
</div>
</div>
<div className="mt-0.5 truncate text-[10px] leading-none" style={{ color: mutedFg }}>
{session.username}@{session.hostname}
</div>
</div>
</div>
</ContextMenuTrigger>
<SessionTabContextMenuContent
sessionId={session.id}
onCloseSession={onCloseSession}
onCopySession={onCopySession}
onCopySessionToNewWindow={onCopySessionToNewWindow}
onDetachSession={onDetachSessionFromWorkspace}
onRenameSession={onStartRename}
t={t}
/>
</ContextMenu>
);
}, (prev, next) => (
prev.session === next.session
&& prev.host === next.host
&& prev.isSelected === next.isSelected
&& prev.isRenaming === next.isRenaming
&& prev.renameValue === next.renameValue
&& prev.isDragging === next.isDragging
&& prev.dropPosition === next.dropPosition
&& prev.theme === next.theme
&& prev.onSelect === next.onSelect
&& prev.onStartRename === next.onStartRename
&& prev.onSubmitRename === next.onSubmitRename
&& prev.onCancelRename === next.onCancelRename
&& prev.onCloseSession === next.onCloseSession
&& prev.onCopySession === next.onCopySession
&& prev.onCopySessionToNewWindow === next.onCopySessionToNewWindow
&& prev.onDetachSessionFromWorkspace === next.onDetachSessionFromWorkspace
&& prev.onDragStart === next.onDragStart
&& prev.onDragOver === next.onDragOver
&& prev.onDrop === next.onDrop
&& prev.onDragEnd === next.onDragEnd
&& prev.t === next.t
));
WorkspaceFocusSessionRow.displayName = 'WorkspaceFocusSessionRow';
@@ -165,8 +234,13 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
focusedSessionId,
onReorderWorkspaceSessions,
onRequestAddToWorkspace,
onCloseSession,
onCopySession,
onCopySessionToNewWindow,
onDetachSessionFromWorkspace,
onSetWorkspaceFocusedSession,
onToggleWorkspaceViewMode,
onSubmitSessionRename,
resolvedPreviewTheme,
sessionHostsMap,
sessions,
@@ -182,6 +256,9 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
STORAGE_KEY_WORKSPACE_FOCUS_SIDEBAR_WIDTH, 224, { min: 160, max: 480 },
);
const [sidebarRenameSessionId, setSidebarRenameSessionId] = useState<string | null>(null);
const [sidebarRenameValue, setSidebarRenameValue] = useState('');
const theme = useMemo<FocusSidebarTheme>(() => {
const termBg = resolvedPreviewTheme.colors.background;
const termFg = resolvedPreviewTheme.colors.foreground;
@@ -208,7 +285,8 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
const term = focusSidebarSearch.trim().toLowerCase();
if (!term) return workspaceSessions;
return workspaceSessions.filter((session) => (
session.hostLabel?.toLowerCase().includes(term)
session.customName?.toLowerCase().includes(term)
|| session.hostLabel?.toLowerCase().includes(term)
|| session.hostname?.toLowerCase().includes(term)
|| session.username?.toLowerCase().includes(term)
));
@@ -349,6 +427,25 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
onSetWorkspaceFocusedSession?.(activeWorkspace.id, sessionId);
}, [activeWorkspace.id, onSetWorkspaceFocusedSession]);
const handleLocalStartRename = useCallback((sessionId: string) => {
const session = sessions.find((s) => s.id === sessionId);
if (!session) return;
setSidebarRenameSessionId(sessionId);
setSidebarRenameValue(session.customName || session.hostLabel || '');
}, [sessions]);
const handleLocalSubmitRename = useCallback((name: string) => {
if (!sidebarRenameSessionId) return;
onSubmitSessionRename(sidebarRenameSessionId, name);
setSidebarRenameSessionId(null);
setSidebarRenameValue('');
}, [sidebarRenameSessionId, onSubmitSessionRename]);
const handleLocalCancelRename = useCallback(() => {
setSidebarRenameSessionId(null);
setSidebarRenameValue('');
}, []);
return (
<div
className="flex-shrink-0 flex flex-col relative"
@@ -426,6 +523,15 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
session={session}
host={sessionHostsMap.get(session.id)}
isSelected={session.id === focusedSessionId}
isRenaming={sidebarRenameSessionId === session.id}
renameValue={sidebarRenameValue}
onStartRename={handleLocalStartRename}
onSubmitRename={handleLocalSubmitRename}
onCancelRename={handleLocalCancelRename}
onCloseSession={onCloseSession}
onCopySession={onCopySession}
onCopySessionToNewWindow={onCopySessionToNewWindow}
onDetachSessionFromWorkspace={onDetachSessionFromWorkspace}
isDragging={focusSidebarDragSessionId === session.id}
dropPosition={
focusSidebarDropIndicator?.sessionId === session.id
@@ -438,6 +544,7 @@ const TerminalFocusSidebarInner: React.FC<TerminalFocusSidebarProps> = ({
onDragOver={handleFocusSidebarDragOver}
onDrop={handleFocusSidebarDrop}
onDragEnd={handleFocusSidebarDragEnd}
t={t}
/>
))}
</div>
@@ -451,6 +558,11 @@ function terminalFocusSidebarPropsEqual(
next: TerminalFocusSidebarProps,
): boolean {
if (prev.focusedSessionId !== next.focusedSessionId) return false;
if (prev.onSubmitSessionRename !== next.onSubmitSessionRename) return false;
if (prev.onCloseSession !== next.onCloseSession) return false;
if (prev.onCopySession !== next.onCopySession) return false;
if (prev.onCopySessionToNewWindow !== next.onCopySessionToNewWindow) return false;
if (prev.onDetachSessionFromWorkspace !== next.onDetachSessionFromWorkspace) return false;
if (prev.resolvedPreviewTheme !== next.resolvedPreviewTheme) return false;
if (prev.sessionHostsMap !== next.sessionHostsMap) return false;
if (prev.sessions !== next.sessions) return false;

View File

@@ -15,8 +15,13 @@ function TerminalLayerFocusSidebarSectionInner({ ctx }: { ctx: FocusSidebarConte
focusedSessionId={ctx.focusedSessionId}
onReorderWorkspaceSessions={ctx.onReorderWorkspaceSessions}
onRequestAddToWorkspace={ctx.onRequestAddToWorkspace}
onCloseSession={ctx.handleCloseSession}
onCopySession={ctx.onCopySession}
onCopySessionToNewWindow={ctx.onCopySessionToNewWindow}
onDetachSessionFromWorkspace={ctx.onRemoveSessionFromWorkspace}
onSetWorkspaceFocusedSession={ctx.onSetWorkspaceFocusedSession}
onToggleWorkspaceViewMode={ctx.onToggleWorkspaceViewMode}
onSubmitSessionRename={ctx.onSubmitSessionRename}
resolvedPreviewTheme={ctx.resolvedPreviewTheme}
sessionHostsMap={ctx.sessionHostsMap}
sessions={ctx.sessions}

View File

@@ -89,6 +89,7 @@ function TerminalLayerSidePanelTabBody({ ctx }: { ctx: SidePanelContext }) {
handleCloseSidePanel,
handleHistoryPaste,
handleHistoryRun,
handleAddKnownHost,
handleOpenHistory,
handleFontFamilyChangeForFocusedSession,
handleFontFamilyResetForFocusedSession,
@@ -116,6 +117,7 @@ function TerminalLayerSidePanelTabBody({ ctx }: { ctx: SidePanelContext }) {
identities,
keyBindings,
keys,
knownHosts,
mountedAiTabIds,
mountedSftpTabIds,
scriptsMountedTabIds,
@@ -460,7 +462,9 @@ function TerminalLayerSidePanelTabBody({ ctx }: { ctx: SidePanelContext }) {
writableHosts={hosts}
keys={keys}
identities={identities}
knownHosts={knownHosts}
updateHosts={updateHosts}
onAddKnownHost={handleAddKnownHost}
sftpDefaultViewMode={sftpDefaultViewMode}
activeHost={panelActiveHost}
activeSessionId={isVisibleSftpPanel ? activeTerminalSessionIdForSftp : null}

Some files were not shown because too many files have changed in this diff Show More