Compare commits

..

11 Commits

Author SHA1 Message Date
sakuradairong
850d038c5a fix: cap unlimited terminal scrollback
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
test / lint-and-test (push) Has been cancelled
2026-06-19 11:05:06 +08:00
Ryanisgood
52bc48f73a 为主机添加可自定义图标和颜色 (#1504) 2026-06-17 23:32:36 +08:00
Ryanisgood
46755465f9 feat: add SFTP current path copy action (#1506) 2026-06-17 23:31:54 +08:00
陈大猫
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
97 changed files with 3969 additions and 316 deletions

View File

@@ -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

@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { executeHotkeyActionImpl, handleGlobalHotkeyKeyDownImpl } from './app/AppHandlers.ts';
import { executeHotkeyActionImpl, getLogHostVisualSnapshot, handleGlobalHotkeyKeyDownImpl } from './app/AppHandlers.ts';
import { matchesKeyBinding } from '../domain/models.ts';
import { DEFAULT_KEY_BINDINGS } from '../domain/models/keyBindings.ts';
@@ -169,3 +169,27 @@ test('quick switch hotkey toggles the quick switcher open state', () => {
executeHotkeyActionImpl(() => ({ ...baseCtx, isQuickSwitcherOpen: true }), 'quickSwitch', event);
assert.equal(isQuickSwitcherOpen, false);
});
test('connection log host snapshot includes custom host icon fields', () => {
assert.deepEqual(
getLogHostVisualSnapshot({
id: 'host-1',
label: 'Database',
hostname: 'db.example.com',
username: 'root',
tags: [],
os: 'linux',
distro: 'ubuntu',
iconMode: 'custom',
iconId: 'database',
iconColor: 'blue',
}),
{
hostOs: 'linux',
hostDistro: 'ubuntu',
hostIconMode: 'custom',
hostIconId: 'database',
hostIconColor: 'blue',
},
);
});

View File

@@ -3,16 +3,23 @@ import type React from 'react';
import type { Host, HostProtocol } from '../../types';
import type { PassphraseRequest } from '../../components/PassphraseModal';
import { getEffectiveHostDistro } from '../../domain/host';
import { sanitizeHostIconFields } from '../../domain/hostIcon';
import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
type AppContextGetter = () => Record<string, any>;
const TERMINAL_PASSTHROUGH_ACTIONS = getTerminalPassthroughActions();
const getLogHostVisualSnapshot = (host: Host) => ({
hostOs: host.os,
hostDistro: getEffectiveHostDistro(host) || undefined,
});
export const getLogHostVisualSnapshot = (host: Host) => {
const icon = sanitizeHostIconFields(host);
return {
hostOs: host.os,
hostDistro: getEffectiveHostDistro(host) || undefined,
hostIconMode: icon.iconMode,
hostIconId: icon.iconId,
hostIconColor: icon.iconColor,
};
};
export function handleTrayJumpToSessionImpl(getCtx: AppContextGetter, sessionId: string) {
const { sessions, setActiveTabId, setWorkspaceFocusedSession } = getCtx();
@@ -440,7 +447,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 +546,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 +685,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}
/>
@@ -281,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}
@@ -307,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

@@ -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',

View File

@@ -51,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',
@@ -111,6 +112,9 @@ export const enTerminalMessages: Messages = {
'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}',

View File

@@ -151,6 +151,9 @@ export const enVaultMessages: Messages = {
'sftp.moveTo.pathNotFound': 'Directory not found or inaccessible',
'sftp.context.download': 'Download',
'sftp.context.copyToOtherPane': 'Copy to other pane',
'sftp.copyCurrentPath': 'Copy current path',
'sftp.copyCurrentPath.success': 'Current path copied',
'sftp.copyCurrentPath.error': 'Could not copy current path',
'sftp.viewMode.label': 'View mode',
'sftp.viewMode.list': 'List view',
'sftp.viewMode.tree': 'Tree view',
@@ -464,7 +467,52 @@ export const enVaultMessages: Messages = {
'hostDetails.section.portCredentials': 'Port & Credentials',
'hostDetails.section.appearance': 'Appearance',
'hostDetails.distro.title': 'Linux Distribution',
'hostDetails.distro.desc': 'Auto-detect on connect, or override the distro icon manually.',
'hostDetails.distro.desc': 'Controls the automatic host icon. A custom Host Icon overrides this display.',
'hostDetails.icon.title': 'Host Icon',
'hostDetails.icon.desc': 'Use automatic distro icons with optional color, or choose a built-in icon.',
'hostDetails.icon.mode.auto': 'Automatic',
'hostDetails.icon.mode.custom': 'Custom',
'hostDetails.icon.reset': 'Reset host icon',
'hostDetails.icon.showLibrary': 'Show icon library',
'hostDetails.icon.hideLibrary': 'Hide icon library',
'hostDetails.icon.autoUsesDistro': 'Use Linux Distribution icon and selected color for this host.',
'hostDetails.icon.customOverridesDistro': 'Built-in icon replaces Linux Distribution for this host.',
'hostDetails.icon.option.server': 'Server',
'hostDetails.icon.option.terminal': 'Terminal',
'hostDetails.icon.option.database': 'Database',
'hostDetails.icon.option.cloud': 'Cloud',
'hostDetails.icon.option.router': 'Router',
'hostDetails.icon.option.shield': 'Shield',
'hostDetails.icon.option.code': 'Code',
'hostDetails.icon.option.box': 'Box',
'hostDetails.icon.option.globe': 'Globe',
'hostDetails.icon.option.cpu': 'CPU',
'hostDetails.icon.option.hard-drive': 'Storage',
'hostDetails.icon.option.network': 'Network',
'hostDetails.icon.option.wifi': 'Wireless',
'hostDetails.icon.option.lock': 'Lock',
'hostDetails.icon.option.key': 'Key',
'hostDetails.icon.option.monitor': 'Monitor',
'hostDetails.icon.option.container': 'Container',
'hostDetails.icon.option.activity': 'Activity',
'hostDetails.icon.option.zap': 'Fast',
'hostDetails.icon.option.server-cog': 'Server settings',
'hostDetails.icon.color.blue': 'Blue',
'hostDetails.icon.color.green': 'Green',
'hostDetails.icon.color.red': 'Red',
'hostDetails.icon.color.amber': 'Amber',
'hostDetails.icon.color.purple': 'Purple',
'hostDetails.icon.color.cyan': 'Cyan',
'hostDetails.icon.color.orange': 'Orange',
'hostDetails.icon.color.slate': 'Slate',
'hostDetails.icon.color.violet': 'Violet',
'hostDetails.icon.color.pink': 'Pink',
'hostDetails.icon.color.rose': 'Rose',
'hostDetails.icon.color.lime': 'Lime',
'hostDetails.icon.color.teal': 'Teal',
'hostDetails.icon.color.sky': 'Sky',
'hostDetails.icon.color.indigo': 'Indigo',
'hostDetails.icon.color.zinc': 'Zinc',
'hostDetails.distro.mode': 'Source',
'hostDetails.distro.mode.auto': 'Auto-detect',
'hostDetails.distro.mode.manual': 'Manual override',
@@ -485,6 +533,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

@@ -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': 'Межстрочный отступ',
@@ -500,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': 'Вставить в терминал',
@@ -507,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

@@ -72,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',
@@ -132,6 +133,9 @@ export const ruTerminalMessages: Messages = {
'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}',

View File

@@ -186,6 +186,9 @@ export const ruVaultMessages: Messages = {
'sftp.moveTo.pathNotFound': 'Каталог не найден или недоступен',
'sftp.context.download': 'Скачать',
'sftp.context.copyToOtherPane': 'Копировать в другую панель',
'sftp.copyCurrentPath': 'Копировать текущий путь',
'sftp.copyCurrentPath.success': 'Текущий путь скопирован',
'sftp.copyCurrentPath.error': 'Не удалось скопировать текущий путь',
'sftp.viewMode.label': 'Режим просмотра',
'sftp.viewMode.list': 'Список',
'sftp.viewMode.tree': 'Дерево',
@@ -499,7 +502,52 @@ export const ruVaultMessages: Messages = {
'hostDetails.section.portCredentials': 'Порт и учётные данные',
'hostDetails.section.appearance': 'Внешний вид',
'hostDetails.distro.title': 'Дистрибутив Linux',
'hostDetails.distro.desc': 'Автоопределение при подключении или ручное переопределение значка дистрибутива.',
'hostDetails.distro.desc': 'Управляет автоматическим значком хоста. Свой значок хоста переопределяет это отображение.',
'hostDetails.icon.title': 'Значок хоста',
'hostDetails.icon.desc': 'Используйте автоматический значок дистрибутива с отдельным цветом или выберите встроенный значок.',
'hostDetails.icon.mode.auto': 'Авто',
'hostDetails.icon.mode.custom': 'Свой',
'hostDetails.icon.reset': 'Сбросить значок',
'hostDetails.icon.showLibrary': 'Показать библиотеку значков',
'hostDetails.icon.hideLibrary': 'Скрыть библиотеку значков',
'hostDetails.icon.autoUsesDistro': 'Использует значок дистрибутива Linux и выбранный цвет для этого хоста.',
'hostDetails.icon.customOverridesDistro': 'Встроенный значок заменяет значок дистрибутива Linux для этого хоста.',
'hostDetails.icon.option.server': 'Сервер',
'hostDetails.icon.option.terminal': 'Терминал',
'hostDetails.icon.option.database': 'База данных',
'hostDetails.icon.option.cloud': 'Облако',
'hostDetails.icon.option.router': 'Маршрутизатор',
'hostDetails.icon.option.shield': 'Защита',
'hostDetails.icon.option.code': 'Код',
'hostDetails.icon.option.box': 'Узел',
'hostDetails.icon.option.globe': 'Глобус',
'hostDetails.icon.option.cpu': 'CPU',
'hostDetails.icon.option.hard-drive': 'Хранилище',
'hostDetails.icon.option.network': 'Сеть',
'hostDetails.icon.option.wifi': 'Wi-Fi',
'hostDetails.icon.option.lock': 'Замок',
'hostDetails.icon.option.key': 'Ключ',
'hostDetails.icon.option.monitor': 'Монитор',
'hostDetails.icon.option.container': 'Контейнер',
'hostDetails.icon.option.activity': 'Активность',
'hostDetails.icon.option.zap': 'Быстрый',
'hostDetails.icon.option.server-cog': 'Настройки сервера',
'hostDetails.icon.color.blue': 'Синий',
'hostDetails.icon.color.green': 'Зеленый',
'hostDetails.icon.color.red': 'Красный',
'hostDetails.icon.color.amber': 'Янтарный',
'hostDetails.icon.color.purple': 'Фиолетовый',
'hostDetails.icon.color.cyan': 'Голубой',
'hostDetails.icon.color.orange': 'Оранжевый',
'hostDetails.icon.color.slate': 'Серый',
'hostDetails.icon.color.violet': 'Фиолетово-синий',
'hostDetails.icon.color.pink': 'Розовый',
'hostDetails.icon.color.rose': 'Розово-красный',
'hostDetails.icon.color.lime': 'Лаймовый',
'hostDetails.icon.color.teal': 'Бирюзовый',
'hostDetails.icon.color.sky': 'Небесный',
'hostDetails.icon.color.indigo': 'Индиго',
'hostDetails.icon.color.zinc': 'Цинковый',
'hostDetails.distro.mode': 'Источник',
'hostDetails.distro.mode.auto': 'Автоопределение',
'hostDetails.distro.mode.manual': 'Ручное переопределение',
@@ -520,6 +568,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,77 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DEFAULT_KEY_BINDINGS } from "../../../domain/models/keyBindings.ts";
import { HOST_ICON_COLORS, HOST_ICON_IDS } from "../../../domain/hostIcon.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`);
}
});
test("localized vault messages include host icon labels", () => {
const keys = [
"hostDetails.icon.title",
"hostDetails.icon.desc",
"hostDetails.icon.mode.auto",
"hostDetails.icon.mode.custom",
"hostDetails.icon.reset",
"hostDetails.icon.showLibrary",
"hostDetails.icon.hideLibrary",
"hostDetails.icon.autoUsesDistro",
"hostDetails.icon.customOverridesDistro",
...HOST_ICON_IDS.map((id) => `hostDetails.icon.option.${id}`),
...HOST_ICON_COLORS.map((color) => `hostDetails.icon.color.${color.id}`),
];
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
const missing = keys.filter((key) => !locale.messages[key]);
assert.deepEqual(missing, [], `${locale.name} is missing host icon labels`);
}
});

View File

@@ -573,6 +573,9 @@ export const zhCNCoreMessages: Messages = {
'sftp.moveTo.pathNotFound': '目录不存在或无法访问',
'sftp.context.download': '下载',
'sftp.context.copyToOtherPane': '复制到另一侧',
'sftp.copyCurrentPath': '复制当前路径',
'sftp.copyCurrentPath.success': '已复制当前路径',
'sftp.copyCurrentPath.error': '无法复制当前路径',
'sftp.viewMode.label': '视图模式',
'sftp.viewMode.list': '列表视图',
'sftp.viewMode.tree': '树形视图',

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': '行间距',
@@ -325,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': '启用自动补全',
@@ -359,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': '快速切换',
@@ -386,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

@@ -45,7 +45,52 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.section.portCredentials': '端口与凭据',
'hostDetails.section.appearance': '外观',
'hostDetails.distro.title': 'Linux 发行版',
'hostDetails.distro.desc': '可在连接后自动探测,也可以手动覆盖图标所用的发行版。',
'hostDetails.distro.desc': '控制自动主机图标。自定义主机图标会覆盖此显示。',
'hostDetails.icon.title': '主机图标',
'hostDetails.icon.desc': '使用自动发行版图标并可单独改色,或选择内置图标。',
'hostDetails.icon.mode.auto': '自动',
'hostDetails.icon.mode.custom': '自定义',
'hostDetails.icon.reset': '重置主机图标',
'hostDetails.icon.showLibrary': '展开图标库',
'hostDetails.icon.hideLibrary': '收起图标库',
'hostDetails.icon.autoUsesDistro': '使用 Linux 发行版图标和所选颜色显示此主机。',
'hostDetails.icon.customOverridesDistro': '内置图标会替换此主机的 Linux 发行版图标。',
'hostDetails.icon.option.server': '服务器',
'hostDetails.icon.option.terminal': '终端',
'hostDetails.icon.option.database': '数据库',
'hostDetails.icon.option.cloud': '云主机',
'hostDetails.icon.option.router': '路由器',
'hostDetails.icon.option.shield': '安全',
'hostDetails.icon.option.code': '代码',
'hostDetails.icon.option.box': '节点',
'hostDetails.icon.option.globe': '公网',
'hostDetails.icon.option.cpu': '计算',
'hostDetails.icon.option.hard-drive': '存储',
'hostDetails.icon.option.network': '网络',
'hostDetails.icon.option.wifi': '无线',
'hostDetails.icon.option.lock': '锁定',
'hostDetails.icon.option.key': '密钥',
'hostDetails.icon.option.monitor': '显示器',
'hostDetails.icon.option.container': '容器',
'hostDetails.icon.option.activity': '活动',
'hostDetails.icon.option.zap': '高速',
'hostDetails.icon.option.server-cog': '服务器设置',
'hostDetails.icon.color.blue': '蓝色',
'hostDetails.icon.color.green': '绿色',
'hostDetails.icon.color.red': '红色',
'hostDetails.icon.color.amber': '琥珀色',
'hostDetails.icon.color.purple': '紫色',
'hostDetails.icon.color.cyan': '青色',
'hostDetails.icon.color.orange': '橙色',
'hostDetails.icon.color.slate': '石板灰',
'hostDetails.icon.color.violet': '紫罗兰',
'hostDetails.icon.color.pink': '粉色',
'hostDetails.icon.color.rose': '玫瑰红',
'hostDetails.icon.color.lime': '青柠',
'hostDetails.icon.color.teal': '蓝绿色',
'hostDetails.icon.color.sky': '天蓝',
'hostDetails.icon.color.indigo': '靛蓝',
'hostDetails.icon.color.zinc': '锌灰',
'hostDetails.distro.mode': '来源',
'hostDetails.distro.mode.auto': '自动探测',
'hostDetails.distro.mode.manual': '手动覆盖',
@@ -66,6 +111,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': '思科',
@@ -244,6 +290,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',
@@ -304,6 +351,9 @@ export const zhCNVaultMessages: Messages = {
'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}',

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

@@ -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

@@ -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

@@ -0,0 +1,64 @@
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
import type { ConnectionLog } from "../types.ts";
import ConnectionLogsManager from "./ConnectionLogsManager.tsx";
import { TooltipProvider } from "./ui/tooltip.tsx";
const baseLog: ConnectionLog = {
id: "log-1",
hostId: "host-1",
hostLabel: "Database",
hostname: "db.example.com",
username: "root",
protocol: "ssh",
hostOs: "linux",
hostDistro: "ubuntu",
startTime: 1_700_000_000_000,
localUsername: "alice",
localHostname: "workstation",
saved: false,
};
const renderLogs = (log: ConnectionLog) =>
renderToStaticMarkup(
<I18nProvider locale="en">
<TooltipProvider>
<ConnectionLogsManager
logs={[log]}
hosts={[]}
onToggleSaved={() => {}}
onDelete={() => {}}
onClearUnsaved={() => {}}
onOpenLogView={() => {}}
/>
</TooltipProvider>
</I18nProvider>,
);
test("ConnectionLogsManager renders saved custom host icon snapshots", () => {
const markup = renderLogs({
...baseLog,
hostIconMode: "custom",
hostIconId: "database",
hostIconColor: "blue",
});
assert.match(markup, /background-color:#2563EB/i);
assert.doesNotMatch(markup, /bg-\[#E95420\]/);
});
test("ConnectionLogsManager renders saved distro icon snapshots with custom colors", () => {
const markup = renderLogs({
...baseLog,
hostIconMode: "auto",
hostIconColor: "violet",
});
assert.match(markup, /background-color:#7C3AED/i);
assert.match(markup, /src="\/distro\/ubuntu.svg"/);
assert.doesNotMatch(markup, /bg-\[#E95420\]/);
});

View File

@@ -9,6 +9,7 @@ import {
} from "lucide-react";
import React, { memo, useCallback, useMemo, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { resolveHostIconAppearance } from "../domain/hostIcon";
import { cn } from "../lib/utils";
import { ConnectionLog, Host } from "../types";
import { DistroAvatar } from "./DistroAvatar";
@@ -67,7 +68,12 @@ const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) =
const { t, resolvedLocale } = useI18n();
const isLocal = log.protocol === "local" || log.hostname === "localhost";
const isSerial = log.protocol === "serial";
const hasPersistedHostIcon = !isLocal && !isSerial && !!log.hostDistro;
const customHostIcon = resolveHostIconAppearance({
iconMode: log.hostIconMode,
iconId: log.hostIconId,
iconColor: log.hostIconColor,
});
const hasPersistedHostIcon = !isLocal && !isSerial && (!!log.hostDistro || !!customHostIcon);
return (
<div
@@ -101,6 +107,9 @@ const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) =
os: log.hostOs ?? "linux",
distro: log.hostDistro,
distroMode: "auto",
iconMode: log.hostIconMode,
iconId: log.hostIconId,
iconColor: log.hostIconColor,
}}
fallback={(log.hostOs ?? "linux")[0].toUpperCase()}
size="log"

View File

@@ -0,0 +1,49 @@
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { DistroAvatar } from "./DistroAvatar.tsx";
import type { Host } from "../types.ts";
const baseHost: Pick<Host, "distro" | "manualDistro" | "distroMode" | "os" | "protocol" | "iconMode" | "iconId" | "iconColor"> = {
os: "linux",
protocol: "ssh",
};
test("DistroAvatar renders custom host icon before distro color", () => {
const markup = renderToStaticMarkup(
<DistroAvatar
host={{ ...baseHost, distro: "ubuntu", iconMode: "custom", iconId: "database", iconColor: "blue" }}
fallback="DB"
/>,
);
assert.match(markup, /background-color:#2563EB/i);
assert.doesNotMatch(markup, /bg-\[#E95420\]/);
});
test("DistroAvatar keeps serial hosts on the USB icon", () => {
const markup = renderToStaticMarkup(
<DistroAvatar
host={{ ...baseHost, protocol: "serial", iconMode: "custom", iconId: "database", iconColor: "blue" }}
fallback="S"
/>,
);
assert.match(markup, /bg-amber-600/);
assert.doesNotMatch(markup, /background-color:#2563EB/i);
});
test("DistroAvatar keeps distro icon and applies custom palette color when icon mode is automatic", () => {
const markup = renderToStaticMarkup(
<DistroAvatar
host={{ ...baseHost, distro: "ubuntu", iconMode: "auto", iconId: "database", iconColor: "blue" }}
fallback="U"
/>,
);
assert.match(markup, /background-color:#2563EB/i);
assert.match(markup, /src="\/distro\/ubuntu.svg"/);
assert.doesNotMatch(markup, /bg-\[#E95420\]/);
});

View File

@@ -1,8 +1,10 @@
import { Server, Usb } from "lucide-react";
import React, { memo } from "react";
import { getEffectiveHostDistro } from "../domain/host";
import { resolveHostIconAppearance, resolveHostIconColorAppearance } from "../domain/hostIcon";
import { cn } from "../lib/utils";
import { Host } from "../types";
import { renderHostIconGlyph } from "./hostIconRenderer";
export const DISTRO_LOGOS: Record<string, string> = {
ubuntu: "/distro/ubuntu.svg",
@@ -19,6 +21,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 +53,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]",
@@ -69,7 +73,7 @@ export const DISTRO_COLORS: Record<string, string> = {
type DistroAvatarProps = {
host: Pick<Host, "distro" | "manualDistro" | "distroMode" | "os"> &
Partial<Pick<Host, "protocol">>;
Partial<Pick<Host, "protocol" | "iconMode" | "iconId" | "iconColor">>;
fallback: string;
className?: string;
/** xs matches top tab bar icons (h-4 rounded rect) */
@@ -123,15 +127,33 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
);
}
const customAppearance = resolveHostIconAppearance(host);
const customColor = resolveHostIconColorAppearance(host);
if (customAppearance) {
return (
<div
className={cn(
"shrink-0 rounded flex items-center justify-center text-white",
containerClass,
className,
)}
style={{ backgroundColor: customAppearance.colorHex }}
>
{renderHostIconGlyph(customAppearance.iconId, iconSize)}
</div>
);
}
if (logo && !errored) {
return (
<div
className={cn(
"shrink-0 rounded flex items-center justify-center overflow-hidden",
containerClass,
bg,
!customColor && bg,
className,
)}
style={customColor ? { backgroundColor: customColor.colorHex } : undefined}
>
<img
src={logo}

View File

@@ -3,6 +3,7 @@ import { ChevronDown, Eye, EyeOff, FileKey, FolderLock, FolderOpen, Key, KeyRoun
import type { Host } from "../types";
import { cn } from "../lib/utils";
import { DistroAvatar } from "./DistroAvatar";
import { HostIconPicker } from "./HostIconPicker";
import { Button } from "./ui/button";
import { Combobox } from "./ui/combobox";
import { HostDetailsSection, HostDetailsSettingRow } from "./host-details";
@@ -71,6 +72,28 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
</div>
</HostDetailsSection>
<HostDetailsSection
icon={<DistroAvatar host={form as Host} fallback="H" size="sm" />}
title={t("hostDetails.icon.title")}
hint={t("hostDetails.icon.desc")}
>
<HostIconPicker
iconMode={form.iconMode}
iconId={form.iconId}
iconColor={form.iconColor}
onChange={(next) => {
update("iconMode", next.iconMode);
update("iconId", next.iconId);
update("iconColor", next.iconColor);
}}
onReset={() => {
update("iconMode", undefined);
update("iconId", undefined);
update("iconColor", undefined);
}}
/>
</HostDetailsSection>
<HostDetailsSection
icon={<KeyRound size={14} className="text-muted-foreground" />}
title={t("hostDetails.section.portCredentials")}

View File

@@ -296,3 +296,19 @@ test("HostDetailsPanel does not offer to disable telnet when telnet is the prima
assert.ok(telnetHeader);
assert.doesNotMatch(telnetHeader[0], /hover:text-destructive/);
});
test("HostDetailsPanel shows host icon customization in the connection settings", () => {
const markup = renderHostDetails({
...hostWithMissingProxyProfile,
proxyProfileId: undefined,
iconMode: "custom",
iconId: "database",
iconColor: "blue",
});
assert.match(markup, /Host Icon/);
assert.match(markup, /Database/);
assert.match(markup, /Violet/);
assert.match(markup, /Built-in icon replaces Linux Distribution/);
assert.match(markup, /IP or Hostname/);
});

View File

@@ -0,0 +1,70 @@
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
import { HostIconPicker } from "./HostIconPicker.tsx";
import { TooltipProvider } from "./ui/tooltip.tsx";
const renderPicker = (props: Partial<React.ComponentProps<typeof HostIconPicker>> = {}) =>
renderToStaticMarkup(
<I18nProvider locale="en">
<TooltipProvider>
<HostIconPicker
iconMode={props.iconMode}
iconId={props.iconId}
iconColor={props.iconColor}
onChange={() => {}}
onReset={() => {}}
/>
</TooltipProvider>
</I18nProvider>,
);
test("HostIconPicker renders automatic mode without selected custom defaults", () => {
const markup = renderPicker();
assert.match(markup, /Automatic/);
assert.doesNotMatch(markup, /aria-pressed="true"[^>]*Database/);
});
test("HostIconPicker renders custom choices and reset when custom", () => {
const markup = renderPicker({ iconMode: "custom", iconId: "database", iconColor: "blue" });
assert.match(markup, /Database/);
assert.match(markup, /Globe/);
assert.match(markup, /Show icon library/);
assert.doesNotMatch(markup, /Server settings/);
assert.match(markup, /grid-cols-5/);
assert.match(markup, /Blue/);
assert.match(markup, /Reset/);
assert.match(markup, /Built-in icon replaces Linux Distribution for this host/);
});
test("HostIconPicker shows two rows of color swatches in automatic mode", () => {
const markup = renderPicker({ iconMode: "auto", iconColor: "violet" });
assert.match(markup, /Violet/);
assert.match(markup, /grid-cols-8/);
assert.match(markup, /Use Linux Distribution icon and selected color/);
});
test("HostIconPicker does not expose image upload", () => {
const markup = renderPicker({ iconMode: "custom", iconId: "database", iconColor: "blue" });
assert.doesNotMatch(markup, /upload/i);
assert.doesNotMatch(markup, /choose file/i);
});
test("HostIconPicker normalizes invalid incoming custom values only for editing", () => {
const markup = renderPicker({
iconMode: "custom",
iconId: "bad" as React.ComponentProps<typeof HostIconPicker>["iconId"],
iconColor: "bad" as React.ComponentProps<typeof HostIconPicker>["iconColor"],
});
assert.match(markup, /Server/);
assert.match(markup, /Blue/);
assert.match(markup, /Built-in icon replaces Linux Distribution/);
});

View File

@@ -0,0 +1,164 @@
import { RotateCcw } from "lucide-react";
import React from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import {
DEFAULT_HOST_ICON_COLOR,
DEFAULT_HOST_ICON_ID,
HOST_ICON_COLORS,
HOST_ICON_IDS,
isHostIconColorId,
isHostIconId,
normalizeHostIconSelection,
} from "../domain/hostIcon";
import type { HostIconColorId, HostIconId, HostIconMode } from "../domain/models";
import { cn } from "../lib/utils";
import { renderHostIconGlyph } from "./hostIconRenderer";
import { Button } from "./ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
type HostIconPickerProps = {
iconMode?: HostIconMode;
iconId?: HostIconId;
iconColor?: HostIconColorId;
onChange: (next: { iconMode?: HostIconMode; iconId?: HostIconId; iconColor?: HostIconColorId }) => void;
onReset: () => void;
};
export const HostIconPicker: React.FC<HostIconPickerProps> = ({
iconMode,
iconId,
iconColor,
onChange,
onReset,
}) => {
const { t } = useI18n();
const [expanded, setExpanded] = React.useState(false);
const custom = iconMode === "custom";
const normalizedSelection = normalizeHostIconSelection({ iconMode, iconId, iconColor });
const selectedIconId = custom && isHostIconId(normalizedSelection.iconId)
? normalizedSelection.iconId
: DEFAULT_HOST_ICON_ID;
const hasCustomColor = isHostIconColorId(normalizedSelection.iconColor);
const selectedColor = hasCustomColor ? normalizedSelection.iconColor : DEFAULT_HOST_ICON_COLOR;
const selectedColorHex =
HOST_ICON_COLORS.find((color) => color.id === selectedColor)?.hex || HOST_ICON_COLORS[0].hex;
const setCustom = () => onChange({ iconMode: "custom", iconId: selectedIconId, iconColor: selectedColor });
const updateIcon = (nextIconId: HostIconId) =>
onChange({ iconMode: "custom", iconId: nextIconId, iconColor: selectedColor });
const updateColor = (nextColor: HostIconColorId) => {
if (custom) {
onChange({ iconMode: "custom", iconId: selectedIconId, iconColor: nextColor });
return;
}
onChange({ iconMode: "auto", iconColor: nextColor });
};
const visibleIconIds = custom && !expanded ? HOST_ICON_IDS.slice(0, 10) : HOST_ICON_IDS;
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<Button
type="button"
variant={custom ? "ghost" : "secondary"}
size="sm"
className="h-8 flex-1"
onClick={onReset}
>
{t("hostDetails.icon.mode.auto")}
</Button>
<Button
type="button"
variant={custom ? "secondary" : "ghost"}
size="sm"
className="h-8 flex-1"
onClick={setCustom}
>
{t("hostDetails.icon.mode.custom")}
</Button>
{(custom || hasCustomColor) && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={onReset}
aria-label={t("hostDetails.icon.reset")}
>
<RotateCcw size={14} />
</Button>
)}
</div>
{custom && (
<div className="space-y-2">
<div className="grid grid-cols-5 gap-2">
{visibleIconIds.map((optionIconId) => {
const selected = selectedIconId === optionIconId;
return (
<Tooltip key={optionIconId}>
<TooltipTrigger asChild>
<button
type="button"
aria-label={t(`hostDetails.icon.option.${optionIconId}`)}
aria-pressed={selected}
className={cn(
"flex h-9 items-center justify-center rounded-md border text-muted-foreground transition-colors hover:bg-secondary",
selected ? "border-primary bg-primary/10 text-primary" : "border-border/60 bg-background/60",
)}
onClick={() => updateIcon(optionIconId)}
>
{renderHostIconGlyph(optionIconId, "h-4 w-4")}
<span className="sr-only">{t(`hostDetails.icon.option.${optionIconId}`)}</span>
</button>
</TooltipTrigger>
<TooltipContent>{t(`hostDetails.icon.option.${optionIconId}`)}</TooltipContent>
</Tooltip>
);
})}
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 w-full text-xs"
onClick={() => setExpanded((value) => !value)}
>
{t(expanded ? "hostDetails.icon.hideLibrary" : "hostDetails.icon.showLibrary")}
</Button>
</div>
)}
<div className="grid grid-cols-8 gap-2">
{HOST_ICON_COLORS.map((color) => {
const selected = hasCustomColor && selectedColor === color.id;
return (
<Tooltip key={color.id}>
<TooltipTrigger asChild>
<button
type="button"
aria-label={t(`hostDetails.icon.color.${color.id}`)}
aria-pressed={selected}
className={cn(
"h-7 rounded-md border transition-transform hover:scale-105",
selected ? "border-primary ring-2 ring-primary/30" : "border-border/60",
)}
style={{ backgroundColor: color.hex }}
onClick={() => updateColor(color.id)}
>
<span className="sr-only">{t(`hostDetails.icon.color.${color.id}`)}</span>
</button>
</TooltipTrigger>
<TooltipContent>{t(`hostDetails.icon.color.${color.id}`)}</TooltipContent>
</Tooltip>
);
})}
</div>
<div className="flex items-center gap-2 rounded-md border border-border/60 bg-secondary/40 px-2.5 py-2 text-xs text-muted-foreground">
<span className="h-4 w-4 rounded" style={{ backgroundColor: hasCustomColor ? selectedColorHex : undefined }} />
<span>{t(custom ? "hostDetails.icon.customOverridesDistro" : "hostDetails.icon.autoUsesDistro")}</span>
</div>
</div>
);
};

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";
@@ -149,8 +149,16 @@ const TerminalComponent: React.FC<TerminalProps> = ({
sessionLog,
sshDebugLogEnabled,
sudoAutofillPassword,
showSelectionAIAction,
showSelectionAIAction = true,
onAddSelectionToAI,
sessionDisplayName,
onRename,
onDetach,
onStartSessionDrag,
onEndSessionDrag,
onDetachPointerDown,
onDetachDragStart,
onDetachDragEnd,
}) => {
const layoutSuppressActive = useTerminalLayoutSuppressActive();
const deferTerminalResize = isResizing || layoutSuppressActive;
@@ -1260,7 +1268,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
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, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, remoteDragDropUsesZmodem, 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, showSelectionAIAction, 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

@@ -124,6 +124,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onToggleWorkspaceViewMode,
onSetWorkspaceFocusedSession,
onReorderWorkspaceSessions,
onReorderTabs,
onCopySession,
onCopySessionToNewWindow,
onSplitSession,
onConnectToHost,
onCreateLocalTerminal,
@@ -150,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());
@@ -1138,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

@@ -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,161 @@ test("quick switcher plus button exposes a custom CSS hook", () => {
assert.match(topTabsSource, /data-section="top-tabs-quick-switcher-toggle"/);
});
test("SessionTabIcon checks custom host icon appearance before distro logos", () => {
const source = readFileSync(new URL("./top-tabs/TopTabItems.tsx", import.meta.url), "utf8");
assert.match(source, /resolveHostIconAppearance\(host\)/);
assert.ok(
source.indexOf("resolveHostIconAppearance(host)") < source.indexOf("getEffectiveHostDistro(host)"),
"custom host icon should be checked before distro fallback",
);
});
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,75 @@
import {
Activity,
Box,
Cloud,
Code2,
Container,
Cpu,
Database,
Globe2,
HardDrive,
KeyRound,
Lock,
Monitor,
Network,
Router,
Server,
ServerCog,
Shield,
SquareTerminal,
Wifi,
Zap,
} from "lucide-react";
import React from "react";
import type { HostIconId } from "../domain/models";
const HOST_ICON_COMPONENTS = {
server: Server,
terminal: SquareTerminal,
database: Database,
cloud: Cloud,
router: Router,
shield: Shield,
code: Code2,
box: Box,
globe: Globe2,
cpu: Cpu,
"hard-drive": HardDrive,
network: Network,
wifi: Wifi,
lock: Lock,
key: KeyRound,
monitor: Monitor,
container: Container,
activity: Activity,
zap: Zap,
"server-cog": ServerCog,
} as const satisfies Record<HostIconId, React.ComponentType<{ className?: string; size?: number }>>;
export const HOST_ICON_LABEL_KEYS: Record<HostIconId, string> = {
server: "hostDetails.icon.option.server",
terminal: "hostDetails.icon.option.terminal",
database: "hostDetails.icon.option.database",
cloud: "hostDetails.icon.option.cloud",
router: "hostDetails.icon.option.router",
shield: "hostDetails.icon.option.shield",
code: "hostDetails.icon.option.code",
box: "hostDetails.icon.option.box",
globe: "hostDetails.icon.option.globe",
cpu: "hostDetails.icon.option.cpu",
"hard-drive": "hostDetails.icon.option.hard-drive",
network: "hostDetails.icon.option.network",
wifi: "hostDetails.icon.option.wifi",
lock: "hostDetails.icon.option.lock",
key: "hostDetails.icon.option.key",
monitor: "hostDetails.icon.option.monitor",
container: "hostDetails.icon.option.container",
activity: "hostDetails.icon.option.activity",
zap: "hostDetails.icon.option.zap",
"server-cog": "hostDetails.icon.option.server-cog",
};
export const renderHostIconGlyph = (iconId: HostIconId, className?: string): React.ReactNode => {
const Icon = HOST_ICON_COMPONENTS[iconId] || Server;
return <Icon className={className} />;
};

View File

@@ -71,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))" }}
@@ -84,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>
@@ -92,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

@@ -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

@@ -6,6 +6,8 @@ import { renderToStaticMarkup } from "react-dom/server";
import {
getSftpBookmarkButtonLabelKey,
getNextSftpViewMode,
copySftpCurrentPathToClipboard,
getNextSftpToolbarDisplayPath,
getSftpViewModeToggleTarget,
getSftpViewModeToggleLabelKey,
shouldToggleSftpBookmarkFromButton,
@@ -139,6 +141,137 @@ test("toolbar renders one view-mode toggle instead of separate list and tree but
assert.match(markup, /aria-label="Bookmarked paths"/);
});
test("toolbar exposes copy-current-path action for the active directory", () => {
const pane: SftpPane = {
id: "pane-1",
connection: {
id: "conn-1",
hostId: "host-1",
name: "Example",
currentPath: "/var/www/app",
homeDir: "/home/app",
isLocal: false,
},
files: [],
loading: false,
reconnecting: false,
error: null,
connectionLogs: [],
selectedFiles: new Set(),
filter: "",
filenameEncoding: "auto",
showHiddenFiles: false,
transferMutationToken: 0,
};
const markup = renderToStaticMarkup(
React.createElement(SftpPaneToolbar, {
t: (key: string) => ({
"sftp.copyCurrentPath": "Copy current path",
"sftp.viewMode.switchToTree": "Switch to tree view",
"sftp.bookmark.list": "Bookmarked paths",
}[key] ?? key),
pane,
onNavigateTo: () => {},
onSetFilter: () => {},
onSetFilenameEncoding: () => {},
onRefresh: () => {},
showFilterBar: false,
setShowFilterBar: () => {},
filterInputRef: { current: null },
isEditingPath: false,
editingPathValue: "",
setEditingPathValue: () => {},
setShowPathSuggestions: () => {},
showPathSuggestions: false,
setPathSuggestionIndex: () => {},
pathSuggestions: [],
pathSuggestionIndex: -1,
pathInputRef: { current: null },
pathDropdownRef: { current: null },
handlePathBlur: () => {},
handlePathKeyDown: () => {},
handlePathDoubleClick: () => {},
handlePathSubmit: () => {},
startTransition: (callback: () => void) => callback(),
getNextUntitledName: () => "untitled",
setNewFileName: () => {},
setFileNameError: () => {},
setShowNewFileDialog: () => {},
setShowNewFolderDialog: () => {},
setNewFolderName: () => {},
bookmarks: [],
isCurrentPathBookmarked: false,
onToggleBookmark: () => {},
onAddGlobalBookmark: () => {},
isCurrentPathGlobalBookmarked: false,
onNavigateToBookmark: () => {},
onDeleteBookmark: () => {},
showHiddenFiles: false,
onToggleShowHiddenFiles: () => {},
viewMode: "list",
onSetViewMode: () => {},
}),
);
assert.match(markup, /aria-label="Copy current path"/);
});
test("copy-current-path action writes the displayed path and reports success", async () => {
let copiedText = "";
let successMessage = "";
await copySftpCurrentPathToClipboard({
currentPath: "/srv/current",
writeText: async (text) => {
copiedText = text;
},
onSuccess: (message) => {
successMessage = message;
},
onError: () => {},
t: (key) => ({
"sftp.copyCurrentPath.success": "Current path copied",
}[key] ?? key),
});
assert.equal(copiedText, "/srv/current");
assert.equal(successMessage, "Current path copied");
});
test("copy-current-path action reports clipboard failures", async () => {
let errorMessage = "";
await copySftpCurrentPathToClipboard({
currentPath: "/srv/current",
writeText: async () => {
throw new Error("denied");
},
onSuccess: () => {},
onError: (message) => {
errorMessage = message;
},
t: (key) => ({
"sftp.copyCurrentPath.error": "Could not copy current path",
}[key] ?? key),
});
assert.equal(errorMessage, "Could not copy current path");
});
test("toolbar display path keeps the previous confirmed path while loading the same connection", () => {
assert.equal(
getNextSftpToolbarDisplayPath({
previousDisplayPath: "/srv/old",
previousConnectionId: "conn-1",
connectionId: "conn-1",
currentPath: "/srv/new",
loading: true,
}),
"/srv/old",
);
});
test("bookmark list renders saved paths as selectable rows", () => {
const markup = renderToStaticMarkup(
React.createElement(

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Bookmark, Check, Eye, EyeOff, FilePlus, Folder, FolderPlus, FolderSync, Globe, Home, Languages, List, ListTree, MoreHorizontal, RefreshCw, Search, TerminalSquare, Trash2, X } from "lucide-react";
import { Bookmark, Check, ClipboardCopy, Eye, EyeOff, FilePlus, Folder, FolderPlus, FolderSync, Globe, Home, Languages, List, ListTree, MoreHorizontal, RefreshCw, Search, TerminalSquare, Trash2, X } from "lucide-react";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from "../ui/popover";
@@ -10,6 +10,7 @@ import { SftpBreadcrumb } from "./SftpBreadcrumb";
import type { SftpFilenameEncoding } from "../../types";
import type { SftpPane } from "../../application/state/sftp/types";
import type { SftpBookmark } from "../../domain/models";
import { toast } from "../ui/toast";
type SftpPaneViewMode = "list" | "tree";
@@ -43,6 +44,46 @@ export const getSftpBookmarkButtonLabelKey = ({
? "sftp.bookmark.add"
: "sftp.bookmark.list";
export const copySftpCurrentPathToClipboard = async ({
currentPath,
writeText,
onSuccess,
onError,
t,
}: {
currentPath: string;
writeText: (text: string) => Promise<void>;
onSuccess: (message: string) => void;
onError: (message: string) => void;
t: (key: string) => string;
}) => {
if (!currentPath) return;
try {
await writeText(currentPath);
onSuccess(t("sftp.copyCurrentPath.success"));
} catch {
onError(t("sftp.copyCurrentPath.error"));
}
};
export const getNextSftpToolbarDisplayPath = ({
previousDisplayPath,
previousConnectionId,
connectionId,
currentPath,
loading,
}: {
previousDisplayPath: string;
previousConnectionId: string | undefined;
connectionId: string | undefined;
currentPath: string | undefined;
loading: boolean;
}): string => {
const connectionChanged = connectionId !== previousConnectionId;
return connectionChanged || !loading ? currentPath ?? "" : previousDisplayPath;
};
interface SftpPaneToolbarProps {
t: (key: string, params?: Record<string, unknown>) => string;
pane: SftpPane;
@@ -208,12 +249,17 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
const prevDisplayConnectionIdRef = useRef(pane.connection?.id);
useEffect(() => {
const connectionChanged = pane.connection?.id !== prevDisplayConnectionIdRef.current;
const previousConnectionId = prevDisplayConnectionIdRef.current;
prevDisplayConnectionIdRef.current = pane.connection?.id;
// Sync immediately on connection change; otherwise defer until loading completes
if (connectionChanged || !pane.loading) {
setDisplayPath(pane.connection?.currentPath ?? "");
}
setDisplayPath((previousDisplayPath) =>
getNextSftpToolbarDisplayPath({
previousDisplayPath,
previousConnectionId,
connectionId: pane.connection?.id,
currentPath: pane.connection?.currentPath,
loading: pane.loading,
})
);
}, [pane.connection?.currentPath, pane.connection?.id, pane.loading]);
// Observe the overall toolbar width to decide whether to collapse action buttons
@@ -248,6 +294,16 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
}
}, [showFilterBar, setShowFilterBar, filterInputRef]);
const handleCopyCurrentPath = useCallback(async () => {
await copySftpCurrentPathToClipboard({
currentPath: displayPath,
writeText: (text) => navigator.clipboard.writeText(text),
onSuccess: (message) => toast.success(message, "SFTP"),
onError: (message) => toast.error(message, "SFTP"),
t,
});
}, [displayPath, t]);
const isRemote = !pane.connection?.isLocal;
const viewModeToggleTarget = getSftpViewModeToggleTarget(viewMode);
const viewModeToggleLabel = t(viewModeToggleTarget.labelKey);
@@ -297,6 +353,21 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
aria-label={t("sftp.copyCurrentPath")}
disabled={!displayPath}
onClick={handleCopyCurrentPath}
>
<ClipboardCopy size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.copyCurrentPath")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button

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

@@ -6,8 +6,10 @@ import {
ClipboardPaste,
Copy,
Download,
Pencil,
RefreshCcw,
Sparkles,
SquareArrowOutUpRight,
SplitSquareHorizontal,
SplitSquareVertical,
Terminal as TerminalIcon,
@@ -48,6 +50,8 @@ export interface TerminalContextMenuProps {
onClose?: () => void;
onSelectWord?: () => void;
onAddSelectionToAI?: () => void;
onRename?: () => void;
onDetach?: () => void;
}
export const shouldShowReconnectAction = ({
@@ -125,6 +129,8 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
onClose,
onSelectWord,
onAddSelectionToAI,
onRename,
onDetach,
}) => {
const { t } = useI18n();
const isMac = hotkeyScheme === 'mac';
@@ -299,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

@@ -88,7 +88,7 @@ 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, 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, onExpandToFocus, onOpenSystem, onSplitHorizontal, onSplitVertical, onToggleBroadcast, onUpdateHost, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, searchMatchCount, selectionOverlayPosition, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, showSelectionAIAction, 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,
@@ -133,6 +133,8 @@ function TerminalViewInner({ ctx }: { ctx: TerminalViewContext }) {
onReconnect={handleRetry}
onClose={inWorkspace ? () => onCloseSession?.(sessionId) : undefined}
onAddSelectionToAI={ctx.onAddSelectionToAI ? handleAddSelectionToAI : undefined}
onRename={onRename}
onDetach={inWorkspace ? onDetach : undefined}
>
<div
className={cn(
@@ -170,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)',
@@ -183,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>
@@ -266,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>
@@ -296,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>

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

@@ -15,6 +15,7 @@ import { fontStore } from "../../../application/state/fontStore";
import { KeywordHighlighter } from "../keywordHighlight";
import {
XTERM_PERFORMANCE_CONFIG,
resolveXTermScrollback,
type XTermPlatform,
resolveXTermPerformanceConfig,
} from "../../../infrastructure/config/xtermPerformance";
@@ -61,6 +62,7 @@ import {
shouldHandleTerminalFontSizeAction,
terminalFontSizeWheelListenerOptions,
} from "./terminalFontZoom";
import { shouldPassThroughCopyShortcut } from "./terminalCopyShortcut";
import {
markExpectedTerminalCursorPositionReport,
pasteTextIntoTerminal,
@@ -269,11 +271,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
const cursorStyle = settings?.cursorShape ?? "block";
const cursorBlink = settings?.cursorBlink ?? true;
// xterm.js treats scrollback=0 as "no scrollback buffer", which breaks mouse
// wheel scrolling (events become arrow-key sequences). The UI uses 0 to mean
// "no limit", so map it to a large value instead.
const rawScrollback = settings?.scrollback ?? 10000;
const scrollback = rawScrollback === 0 ? 999999 : rawScrollback;
const scrollback = resolveXTermScrollback(rawScrollback);
const drawBoldTextInBrightColors = settings?.drawBoldInBrightColors ?? true;
const fontWeight = resolveHostTerminalFontWeight(ctx.host, settings?.fontWeight ?? 400);
const fontWeightBold = settings?.fontWeightBold ?? 700;
@@ -704,6 +703,11 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
) {
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) {

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

@@ -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.
@@ -170,6 +179,17 @@ export interface TerminalProps {
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

@@ -33,6 +33,7 @@ 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
@@ -71,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 { resolveXTermScrollback } from '../../infrastructure/config/xtermPerformance';
import { shouldInterceptMouseTrackingContextMenu } from './runtime/middleClickBehavior';
type TerminalEffectsContext = Record<string, any>;
@@ -465,7 +466,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
if (terminalSettings) {
applyUserCursorPreference(termRef.current, terminalSettings);
termRef.current.options.scrollback = terminalSettings.scrollback === 0 ? 999999 : terminalSettings.scrollback;
termRef.current.options.scrollback = resolveXTermScrollback(terminalSettings.scrollback);
termRef.current.options.fontWeight = effectiveFontWeight as
| 100
| 200

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

@@ -4,9 +4,10 @@ import { activeTabStore } from '../../application/state/activeTabStore';
import { useTerminalLayoutSuppressActive } from '../../application/state/terminalLayoutSuppressStore';
import type { TerminalSessionExitEvent } from '../../application/state/resolveTerminalSessionExitIntent';
import { createTerminalSelectionAttachment } from '../../application/state/terminalSelectionAttachment';
import { getTopTabInsertionTarget, isPointInsideRect, WORKSPACE_SESSION_DRAG_TYPE } from '../../application/state/terminalDragData';
import { useAIState } from '../../application/state/useAIState';
import { useStoredBoolean } from '../../application/state/useStoredBoolean';
import { SplitDirection } from '../../domain/workspace';
import { collectSessionIds, SplitDirection } from '../../domain/workspace';
import { KeyBinding, TerminalSettings } from '../../domain/models';
import { STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION } from '../../infrastructure/config/storageKeys';
import { cn } from '../../lib/utils';
@@ -501,6 +502,13 @@ export interface TerminalLayerProps {
onToggleWorkspaceViewMode?: (workspaceId: string) => void;
onSetWorkspaceFocusedSession?: (workspaceId: string, sessionId: string) => void;
onReorderWorkspaceSessions?: (workspaceId: string, draggedSessionId: string, targetSessionId: string, position: 'before' | 'after') => void;
onReorderTabs?: (draggedId: string, targetId: string, position: 'before' | 'after', additionalTabIds?: readonly string[]) => void;
onCopySession?: (sessionId: string) => void;
onCopySessionToNewWindow?: (sessionId: string) => void;
onRemoveSessionFromWorkspace?: (
sessionId: string,
tabInsertionTarget?: { tabId: string; position: 'before' | 'after'; additionalTabIds?: readonly string[] },
) => void;
onSplitSession?: (sessionId: string, direction: SplitDirection) => void;
onConnectToHost: (host: Host) => void;
onCreateLocalTerminal?: () => void;
@@ -530,6 +538,9 @@ export interface TerminalLayerProps {
showHostTreeSidebar?: boolean;
toggleScriptsSidePanelRef?: React.MutableRefObject<(() => void) | null>;
toggleSidePanelRef?: React.MutableRefObject<(() => void) | null>;
// Session rename
onStartSessionRename?: (sessionId: string) => void;
onSubmitSessionRename?: (sessionId?: string, name?: string) => void;
}
interface TerminalPaneProps {
@@ -597,6 +608,14 @@ interface TerminalPaneProps {
) => void;
onAddSelectionToAI?: (sessionId: string, selection: string) => void;
showSelectionAIAction: boolean;
onStartSessionRename?: (sessionId: string) => void;
onRemoveSessionFromWorkspace?: (
sessionId: string,
tabInsertionTarget?: { tabId: string; position: 'before' | 'after'; additionalTabIds?: readonly string[] },
) => void;
onReorderTabs?: (draggedId: string, targetId: string, position: 'before' | 'after', additionalTabIds?: readonly string[]) => void;
onStartSessionDrag?: (sessionId: string) => void;
onEndSessionDrag?: () => void;
}
const getPaneThemePreviewId = (props: TerminalPaneProps): string | null => (
@@ -684,7 +703,12 @@ const terminalPanePropsAreEqual = (
prev.onToggleWorkspaceComposeBar === next.onToggleWorkspaceComposeBar &&
prev.onSnippetExecutorChange === next.onSnippetExecutorChange &&
prev.onAddSelectionToAI === next.onAddSelectionToAI &&
prev.showSelectionAIAction === next.showSelectionAIAction
prev.showSelectionAIAction === next.showSelectionAIAction &&
prev.onStartSessionRename === next.onStartSessionRename &&
prev.onRemoveSessionFromWorkspace === next.onRemoveSessionFromWorkspace &&
prev.onReorderTabs === next.onReorderTabs &&
prev.onStartSessionDrag === next.onStartSessionDrag &&
prev.onEndSessionDrag === next.onEndSessionDrag
);
const TerminalPane: React.FC<TerminalPaneProps> = memo(({
@@ -743,6 +767,11 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
onSnippetExecutorChange,
onAddSelectionToAI,
showSelectionAIAction,
onStartSessionRename,
onRemoveSessionFromWorkspace,
onReorderTabs,
onStartSessionDrag,
onEndSessionDrag,
}) => {
const layoutSuppressActive = useTerminalLayoutSuppressActive();
const deferPaneLayoutUpdate = isResizing || layoutSuppressActive;
@@ -855,6 +884,192 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
}
onOpenSystem?.();
}, [activeWorkspaceId, isFocusMode, onOpenSystem, onSetWorkspaceFocusedSession, session.id]);
const handleRename = useCallback(() => {
onStartSessionRename?.(session.id);
}, [onStartSessionRename, session.id]);
const handleDetach = useCallback(() => {
onRemoveSessionFromWorkspace?.(session.id);
}, [onRemoveSessionFromWorkspace, session.id]);
const handleDetachDragStart = useCallback((e: React.DragEvent) => {
if (!inActiveWorkspace) return;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData(WORKSPACE_SESSION_DRAG_TYPE, session.id);
e.dataTransfer.setData('session-id', session.id);
e.dataTransfer.setData('text/plain', session.id);
onStartSessionDrag?.(session.id);
}, [inActiveWorkspace, onStartSessionDrag, session.id]);
const handleDetachDragEnd = useCallback(() => {
onEndSessionDrag?.();
}, [onEndSessionDrag]);
const handleDetachPointerDown = useCallback((e: React.PointerEvent<HTMLElement>) => {
if (!inActiveWorkspace || e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
const startPoint = { clientX: e.clientX, clientY: e.clientY };
const dragLabel = session.customName || session.hostLabel;
let dragStarted = false;
let ghostEl: HTMLDivElement | null = null;
let insertEl: HTMLDivElement | null = null;
const ensureDragElements = () => {
if (!ghostEl) {
ghostEl = document.createElement('div');
ghostEl.textContent = dragLabel;
ghostEl.style.position = 'fixed';
ghostEl.style.left = '0';
ghostEl.style.top = '0';
ghostEl.style.zIndex = '2147483647';
ghostEl.style.pointerEvents = 'none';
ghostEl.style.maxWidth = '220px';
ghostEl.style.padding = '5px 10px';
ghostEl.style.borderRadius = '7px';
ghostEl.style.border = '1px solid color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 60%, transparent)';
ghostEl.style.background = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 90%, transparent)';
ghostEl.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
ghostEl.style.boxShadow = '0 12px 28px rgba(0, 0, 0, 0.28)';
ghostEl.style.fontSize = '12px';
ghostEl.style.fontWeight = '600';
ghostEl.style.whiteSpace = 'nowrap';
ghostEl.style.overflow = 'hidden';
ghostEl.style.textOverflow = 'ellipsis';
document.body.appendChild(ghostEl);
}
if (!insertEl) {
insertEl = document.createElement('div');
insertEl.style.position = 'fixed';
insertEl.style.zIndex = '2147483646';
insertEl.style.pointerEvents = 'none';
insertEl.style.width = '2px';
insertEl.style.borderRadius = '999px';
insertEl.style.background = 'var(--top-tabs-accent, hsl(var(--accent)))';
insertEl.style.boxShadow = '0 0 10px color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 70%, transparent)';
insertEl.style.display = 'none';
document.body.appendChild(insertEl);
}
};
const removeDragElements = () => {
ghostEl?.remove();
insertEl?.remove();
ghostEl = null;
insertEl = null;
};
const updateDragElements = (event: PointerEvent) => {
ensureDragElements();
if (ghostEl) {
ghostEl.style.transform = `translate(${event.clientX + 12}px, ${event.clientY + 10}px)`;
}
const topTabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
const insertionTarget = getTopTabInsertionTarget(event, topTabsRoot);
if (!topTabsRoot || !insertionTarget || !insertEl) {
if (insertEl) insertEl.style.display = 'none';
return insertionTarget;
}
const targetTab = Array.from(topTabsRoot.querySelectorAll<HTMLElement>('[data-tab-id]'))
.find((tab) => tab.dataset.tabId === insertionTarget.tabId);
if (!targetTab) {
insertEl.style.display = 'none';
return insertionTarget;
}
const targetRect = targetTab.getBoundingClientRect();
const rootRect = topTabsRoot.getBoundingClientRect();
const lineX = insertionTarget.position === 'before' ? targetRect.left : targetRect.right;
insertEl.style.display = 'block';
insertEl.style.left = `${lineX - 1}px`;
insertEl.style.top = `${Math.max(rootRect.top + 5, targetRect.top + 3)}px`;
insertEl.style.height = `${Math.max(18, Math.min(rootRect.bottom - rootRect.top - 8, targetRect.height - 4))}px`;
return insertionTarget;
};
const resolveStableInsertionTarget = (insertionTarget: ReturnType<typeof getTopTabInsertionTarget>) => {
if (!insertionTarget || insertionTarget.tabId !== session.workspaceId) return insertionTarget;
const sourceWorkspace = session.workspaceId ? workspaceById.get(session.workspaceId) : undefined;
if (!sourceWorkspace) return insertionTarget;
const remainingSessionIds = collectSessionIds(sourceWorkspace.root)
.filter((candidateId) => candidateId !== session.id);
if (remainingSessionIds.length !== 1) return insertionTarget;
return {
tabId: remainingSessionIds[0],
position: insertionTarget.position,
};
};
const startDragIfNeeded = (event: PointerEvent) => {
if (dragStarted) return;
const dx = event.clientX - startPoint.clientX;
const dy = event.clientY - startPoint.clientY;
if (Math.hypot(dx, dy) < 4) return;
dragStarted = true;
onStartSessionDrag?.(session.id);
updateDragElements(event);
};
const cleanup = () => {
document.removeEventListener('pointermove', handlePointerMove, true);
document.removeEventListener('pointerup', handlePointerUp, true);
document.removeEventListener('pointercancel', handlePointerCancel, true);
removeDragElements();
if (dragStarted) onEndSessionDrag?.();
};
const handlePointerMove = (event: PointerEvent) => {
startDragIfNeeded(event);
if (dragStarted) updateDragElements(event);
};
const handlePointerCancel = () => {
cleanup();
};
const handlePointerUp = (event: PointerEvent) => {
startDragIfNeeded(event);
const topTabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
const insertionTarget = dragStarted ? updateDragElements(event) : null;
const shouldDetach = dragStarted && !!topTabsRoot && isPointInsideRect(event, topTabsRoot.getBoundingClientRect());
cleanup();
if (shouldDetach) {
const stableInsertionTarget = resolveStableInsertionTarget(insertionTarget);
if (onRemoveSessionFromWorkspace) {
onRemoveSessionFromWorkspace(
session.id,
stableInsertionTarget
? {
tabId: stableInsertionTarget.tabId,
position: stableInsertionTarget.position,
additionalTabIds: [session.id, stableInsertionTarget.tabId],
}
: undefined,
);
} else if (stableInsertionTarget) {
onReorderTabs?.(session.id, stableInsertionTarget.tabId, stableInsertionTarget.position, [
session.id,
stableInsertionTarget.tabId,
]);
}
}
};
document.addEventListener('pointermove', handlePointerMove, true);
document.addEventListener('pointerup', handlePointerUp, true);
document.addEventListener('pointercancel', handlePointerCancel, true);
}, [
inActiveWorkspace,
onEndSessionDrag,
onRemoveSessionFromWorkspace,
onReorderTabs,
onStartSessionDrag,
session.customName,
session.hostLabel,
session.id,
session.workspaceId,
workspaceById,
]);
const handleTerminalFontSizeChange = useCallback((nextFontSize: number) => {
onTerminalFontSizeChange?.(session.id, nextFontSize);
}, [onTerminalFontSizeChange, session.id]);
@@ -931,8 +1146,16 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
sessionLog={sessionLog}
sshDebugLogEnabled={sshDebugLogEnabled}
sudoAutofillPassword={sudoAutofillPassword}
sessionDisplayName={session.customName || session.hostLabel}
showSelectionAIAction={showSelectionAIAction}
onAddSelectionToAI={onAddSelectionToAI}
onRename={handleRename}
onDetach={inActiveWorkspace ? handleDetach : undefined}
onStartSessionDrag={inActiveWorkspace ? onStartSessionDrag : undefined}
onEndSessionDrag={inActiveWorkspace ? onEndSessionDrag : undefined}
onDetachPointerDown={inActiveWorkspace ? handleDetachPointerDown : undefined}
onDetachDragStart={inActiveWorkspace ? handleDetachDragStart : undefined}
onDetachDragEnd={inActiveWorkspace ? handleDetachDragEnd : undefined}
/>
</div>
);
@@ -998,6 +1221,11 @@ interface TerminalPanesHostProps {
executor: SnippetExecutor | null,
) => void;
onAddSelectionToAI?: (sessionId: string, selection: string) => void;
onStartSessionRename?: (sessionId: string) => void;
onRemoveSessionFromWorkspace?: TerminalPaneProps['onRemoveSessionFromWorkspace'];
onReorderTabs?: (draggedId: string, targetId: string, position: 'before' | 'after', additionalTabIds?: readonly string[]) => void;
onStartSessionDrag?: (sessionId: string) => void;
onEndSessionDrag?: () => void;
}
const terminalPanesHostPropsAreEqual = (
@@ -1057,6 +1285,11 @@ const terminalPanesHostPropsAreEqual = (
if (prev.onToggleWorkspaceComposeBar !== next.onToggleWorkspaceComposeBar) return false;
if (prev.onSnippetExecutorChange !== next.onSnippetExecutorChange) return false;
if (prev.onAddSelectionToAI !== next.onAddSelectionToAI) return false;
if (prev.onStartSessionRename !== next.onStartSessionRename) return false;
if (prev.onRemoveSessionFromWorkspace !== next.onRemoveSessionFromWorkspace) return false;
if (prev.onReorderTabs !== next.onReorderTabs) return false;
if (prev.onStartSessionDrag !== next.onStartSessionDrag) return false;
if (prev.onEndSessionDrag !== next.onEndSessionDrag) return false;
if (prev.workspaceRectsById === next.workspaceRectsById) return true;

View File

@@ -155,6 +155,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
systemWarmupSessionIds,
systemBackend,
systemWarmupSessionIds.length > 0,
(s.terminalSettings?.systemManagerProcessRefreshInterval ?? 3) * 1000,
);
useEffect(() => {
@@ -391,8 +392,16 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
onCreateLocalTerminal: s.onCreateLocalTerminal,
onHotkeyAction: s.onHotkeyAction,
onReorderWorkspaceSessions: s.onReorderWorkspaceSessions,
onReorderTabs: s.onReorderTabs,
onCopySession: s.onCopySession,
onCopySessionToNewWindow: s.onCopySessionToNewWindow,
onRequestAddToWorkspace: s.onRequestAddToWorkspace,
onSetWorkspaceFocusedSession: s.onSetWorkspaceFocusedSession,
onStartSessionRename: s.onStartSessionRename,
onSubmitSessionRename: s.onSubmitSessionRename,
onRemoveSessionFromWorkspace: s.onRemoveSessionFromWorkspace,
onStartSessionDrag: s.onStartSessionDrag,
onEndSessionDrag: s.onEndSessionDrag,
onSplitSession: s.onSplitSession,
onToggleWorkspaceViewMode: s.onToggleWorkspaceViewMode,
Palette: s.Palette,

View File

@@ -83,6 +83,11 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
TerminalComposeBar,
Array,
cn,
onStartSessionRename,
onRemoveSessionFromWorkspace,
onReorderTabs,
onStartSessionDrag,
onEndSessionDrag,
} = ctx;
return (
@@ -180,6 +185,11 @@ function TerminalLayerWorkspaceSectionInner({ ctx }: { ctx: WorkspaceContext })
onToggleWorkspaceComposeBar={handleToggleWorkspaceComposeBar}
onSnippetExecutorChange={handleSnippetExecutorChange}
onAddSelectionToAI={handleAddSelectionToAI}
onStartSessionRename={onStartSessionRename}
onRemoveSessionFromWorkspace={onRemoveSessionFromWorkspace}
onReorderTabs={onReorderTabs}
onStartSessionDrag={onStartSessionDrag}
onEndSessionDrag={onEndSessionDrag}
/>
{!isFocusMode && activeResizers.map((handle: any) => {
const isVertical = handle.direction === 'vertical';

View File

@@ -137,6 +137,9 @@ export type TerminalLayerStableSnapshot = {
onConnectToHost: TerminalLayerProps['onConnectToHost'];
onCreateLocalTerminal: TerminalLayerProps['onCreateLocalTerminal'];
onReorderWorkspaceSessions: TerminalLayerProps['onReorderWorkspaceSessions'];
onReorderTabs: TerminalLayerProps['onReorderTabs'];
onCopySession: TerminalLayerProps['onCopySession'];
onCopySessionToNewWindow: TerminalLayerProps['onCopySessionToNewWindow'];
onRequestAddToWorkspace: TerminalLayerProps['onRequestAddToWorkspace'];
onSetWorkspaceFocusedSession: TerminalLayerProps['onSetWorkspaceFocusedSession'];
onToggleWorkspaceViewMode: TerminalLayerProps['onToggleWorkspaceViewMode'];

View File

@@ -284,6 +284,11 @@ const WORKSPACE_CTX_KEYS = [
'setResizing',
'Array',
'cn',
'onStartSessionRename',
'onRemoveSessionFromWorkspace',
'onReorderTabs',
'onStartSessionDrag',
'onEndSessionDrag',
] as const;
export function terminalLayerSidePanelCtxEqual(prev: Ctx, next: Ctx): boolean {
@@ -337,6 +342,11 @@ export function terminalLayerFocusSidebarPropsEqual(prev: Ctx, next: Ctx): boole
&& eq(prev, next, 't')
&& eq(prev, next, 'onReorderWorkspaceSessions')
&& eq(prev, next, 'onRequestAddToWorkspace')
&& eq(prev, next, 'handleCloseSession')
&& eq(prev, next, 'onCopySession')
&& eq(prev, next, 'onCopySessionToNewWindow')
&& eq(prev, next, 'onRemoveSessionFromWorkspace')
&& eq(prev, next, 'onSetWorkspaceFocusedSession')
&& eq(prev, next, 'onToggleWorkspaceViewMode');
&& eq(prev, next, 'onToggleWorkspaceViewMode')
&& eq(prev, next, 'onSubmitSessionRename');
}

View File

@@ -39,6 +39,7 @@ export const terminalLayerAreEqual = (
prev.onToggleWorkspaceViewMode === next.onToggleWorkspaceViewMode &&
prev.onSetWorkspaceFocusedSession === next.onSetWorkspaceFocusedSession &&
prev.onReorderWorkspaceSessions === next.onReorderWorkspaceSessions &&
prev.onReorderTabs === next.onReorderTabs &&
prev.onSplitSession === next.onSplitSession &&
prev.onConnectToHost === next.onConnectToHost &&
prev.onCreateLocalTerminal === next.onCreateLocalTerminal &&

View File

@@ -0,0 +1,55 @@
import React from 'react';
import type { useI18n } from '../../application/i18n/I18nProvider';
import { ContextMenuContent, ContextMenuItem } from '../ui/context-menu';
type TranslateFn = ReturnType<typeof useI18n>['t'];
interface SessionTabContextMenuContentProps {
sessionId: string;
onCloseSession: (sessionId: string) => void;
onCopySession?: (sessionId: string) => void;
onCopySessionToNewWindow?: (sessionId: string) => void;
onDetachSession?: (sessionId: string) => void;
onRenameSession: (sessionId: string) => void;
renderBulkCloseItems?: (anchorId: string) => React.ReactNode;
t: TranslateFn;
}
export function SessionTabContextMenuContent({
sessionId,
onCloseSession,
onCopySession,
onCopySessionToNewWindow,
onDetachSession,
onRenameSession,
renderBulkCloseItems,
t,
}: SessionTabContextMenuContentProps) {
return (
<ContextMenuContent>
<ContextMenuItem onClick={() => onRenameSession(sessionId)}>
{t('common.rename')}
</ContextMenuItem>
{onCopySession && (
<ContextMenuItem onClick={() => onCopySession(sessionId)}>
{t('tabs.copyTab')}
</ContextMenuItem>
)}
{onCopySessionToNewWindow && (
<ContextMenuItem onClick={() => onCopySessionToNewWindow(sessionId)}>
{t('tabs.copyTabToNewWindow')}
</ContextMenuItem>
)}
{onDetachSession && (
<ContextMenuItem onClick={() => onDetachSession(sessionId)}>
{t('terminal.menu.detach')}
</ContextMenuItem>
)}
<ContextMenuItem className="text-destructive" onClick={() => onCloseSession(sessionId)}>
{t('common.close')}
</ContextMenuItem>
{renderBulkCloseItems?.(sessionId)}
</ContextMenuContent>
);
}

View File

@@ -6,13 +6,16 @@ import type { LogView } from '../../application/state/logViewState';
import { useWindowControls } from '../../application/state/useWindowControls';
import { useI18n } from '../../application/i18n/I18nProvider';
import { getEffectiveHostDistro } from '../../domain/host';
import { resolveHostIconAppearance, resolveHostIconColorAppearance } from '../../domain/hostIcon';
import { cn } from '../../lib/utils';
import { Host, TerminalSession, Workspace } from '../../types';
import { DISTRO_LOGOS, DISTRO_COLORS } from '../DistroAvatar';
import { getShellIconPath, isMonochromeShellIcon } from '../../lib/useDiscoveredShells';
import { handleTabMiddleClickClose, handleTabMiddleMouseDown } from '../../lib/tabInteractions';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '../ui/context-menu';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from '../ui/context-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { SessionTabContextMenuContent } from './SessionTabContextMenuContent';
import { renderHostIconGlyph } from '../hostIconRenderer';
// File extensions that render the code-file icon instead of the plain text icon.
const CODE_EXTENSIONS_RE = /\.(js|jsx|ts|tsx|py|rb|go|rs|c|cpp|cs|java|php|sh|bash|zsh|fish|lua|r|scala|swift|kt|html|css|scss|less|json|yaml|yml|toml|xml|sql|graphql|gql|md|mdx|conf|ini|env|tf|hcl|dockerfile)$/i;
@@ -77,14 +80,26 @@ const SessionTabIcon: React.FC<{ host: Host | undefined; isActive: boolean; prot
);
}
if (host) {
const customAppearance = resolveHostIconAppearance(host);
if (customAppearance) {
return (
<div className={cn(boxBase, "text-white")} style={{ backgroundColor: customAppearance.colorHex }}>
{renderHostIconGlyph(customAppearance.iconId, iconSize)}
</div>
);
}
}
// Try distro logo with brand background color
if (host) {
const distro = getEffectiveHostDistro(host);
const logo = DISTRO_LOGOS[distro];
if (logo) {
const bg = DISTRO_COLORS[distro] || DISTRO_COLORS.default;
const customColor = resolveHostIconColorAppearance(host);
return (
<div className={cn(boxBase, bg)}>
<div className={cn(boxBase, !customColor && bg)} style={customColor ? { backgroundColor: customColor.colorHex } : undefined}>
<img
src={logo}
alt={distro || host.os}
@@ -555,7 +570,7 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
)}
<div className="flex items-center gap-2 min-w-0 flex-1">
<SessionTabIcon host={host} isActive={isActive} protocol={session.protocol} shellIcon={session.localShellIcon} />
<span className="truncate">{session.hostLabel}</span>
<span className="truncate">{session.customName || session.hostLabel}</span>
<div className="flex-shrink-0">{sessionStatusDot(session.status, hasActivity)}</div>
</div>
<button
@@ -567,21 +582,15 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
</button>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => onRenameSession(session.id)}>
{t('common.rename')}
</ContextMenuItem>
<ContextMenuItem onClick={() => onCopySession(session.id)}>
{t('tabs.copyTab')}
</ContextMenuItem>
<ContextMenuItem onClick={() => onCopySessionToNewWindow(session.id)}>
{t('tabs.copyTabToNewWindow')}
</ContextMenuItem>
<ContextMenuItem className="text-destructive" onClick={() => onCloseSession(session.id)}>
{t('common.close')}
</ContextMenuItem>
{renderBulkCloseItems(session.id)}
</ContextMenuContent>
<SessionTabContextMenuContent
sessionId={session.id}
onCloseSession={onCloseSession}
onCopySession={onCopySession}
onCopySessionToNewWindow={onCopySessionToNewWindow}
onRenameSession={onRenameSession}
renderBulkCloseItems={renderBulkCloseItems}
t={t}
/>
</ContextMenu>
);
});
@@ -603,6 +612,8 @@ interface WorkspaceTopTabProps {
onTabDrop: (e: React.DragEvent, targetTabId: string) => void;
onRenameWorkspace: (workspaceId: string) => void;
onCloseWorkspace: (workspaceId: string) => void;
onDetachSessionFromWorkspace?: (workspaceId: string, sessionId: string) => void;
workspaceSessionLabels?: Record<string, string>;
renderBulkCloseItems: RenderBulkCloseItems;
t: TranslateFn;
tabAnimationClass?: string;
@@ -624,6 +635,8 @@ export const WorkspaceTopTab: React.FC<WorkspaceTopTabProps> = memo(({
onTabDrop,
onRenameWorkspace,
onCloseWorkspace,
onDetachSessionFromWorkspace,
workspaceSessionLabels,
renderBulkCloseItems,
t,
tabAnimationClass,
@@ -715,6 +728,17 @@ export const WorkspaceTopTab: React.FC<WorkspaceTopTabProps> = memo(({
<ContextMenuItem onClick={() => onRenameWorkspace(workspace.id)}>
{t('common.rename')}
</ContextMenuItem>
{onDetachSessionFromWorkspace && workspaceSessionLabels && Object.entries(workspaceSessionLabels).map(([sessionId, label]) => (
<ContextMenuItem
key={sessionId}
onClick={() => onDetachSessionFromWorkspace(workspace.id, sessionId)}
>
{t('terminal.menu.detachSession', { name: label })}
</ContextMenuItem>
))}
{onDetachSessionFromWorkspace && workspaceSessionLabels && Object.keys(workspaceSessionLabels).length > 0 && (
<ContextMenuSeparator />
)}
<ContextMenuItem className="text-destructive" onClick={() => onCloseWorkspace(workspace.id)}>
{t('common.close')}
</ContextMenuItem>

View File

@@ -23,7 +23,9 @@ const makeHost = (overrides: Partial<Host> = {}): Host => ({
hostname: "127.0.0.1",
port: 22,
username: "root",
authType: "password",
authMethod: "password",
tags: [],
os: "linux",
createdAt: 1,
protocol: "ssh",
...overrides,
@@ -136,6 +138,41 @@ test("migrateHostsFromLegacyLineTimestamps fills only missing host choices", ()
]);
});
test("sanitizeHost preserves valid custom host icon fields", () => {
const sanitized = sanitizeHost(makeHost({
iconMode: "custom",
iconId: "database",
iconColor: "blue",
}));
assert.equal(sanitized.iconMode, "custom");
assert.equal(sanitized.iconId, "database");
assert.equal(sanitized.iconColor, "blue");
});
test("sanitizeHost preserves automatic host icon color fields", () => {
const sanitized = sanitizeHost(makeHost({
iconMode: "auto",
iconColor: "violet",
}));
assert.equal(sanitized.iconMode, "auto");
assert.equal(sanitized.iconId, undefined);
assert.equal(sanitized.iconColor, "violet");
});
test("sanitizeHost removes invalid custom host icon fields", () => {
const sanitized = sanitizeHost(makeHost({
iconMode: "custom",
iconId: "bad",
iconColor: "blue",
} as unknown as Partial<Host>));
assert.equal(sanitized.iconMode, undefined);
assert.equal(sanitized.iconId, undefined);
assert.equal(sanitized.iconColor, undefined);
});
test("preserves a concurrent terminal timestamp toggle when host details did not edit it", () => {
const openedHost = makeHost({ showLineTimestamps: false });
const latestHost = makeHost({ showLineTimestamps: true });
@@ -243,6 +280,12 @@ test("normalizeDistroId matches Alibaba Cloud Linux PRETTY_NAME/NAME fallback",
);
});
test("normalizeDistroId maps openEuler before the generic Linux fallback", () => {
assert.equal(normalizeDistroId("openeuler"), "openeuler");
assert.equal(normalizeDistroId("openEuler"), "openeuler");
assert.notEqual(normalizeDistroId("openeuler"), "linux");
});
test("shouldProbeSessionCwd allows the probe on a plain Linux host", () => {
assert.equal(
shouldProbeSessionCwd({ isNetworkDevice: false, remoteSshVersion: "OpenSSH_9.6" }),

View File

@@ -1,4 +1,5 @@
import { Host, TerminalSettings } from './models';
import { sanitizeHostIconFields } from './hostIcon';
import { migrateDeprecatedFontOverride } from '../infrastructure/config/fonts';
export type HostLabelRenameResult =
@@ -47,6 +48,7 @@ export const LINUX_DISTRO_OPTIONS = [
'oracle',
'kali',
'alinux',
'openeuler',
] as const;
/**
@@ -86,6 +88,7 @@ export const normalizeDistroId = (value?: string) => {
if (v.includes('almalinux')) return 'almalinux';
if (v.includes('oracle')) return 'oracle';
if (v.includes('kali')) return 'kali';
if (v.includes('openeuler') || v.includes('open euler')) return 'openeuler';
// Alibaba Cloud Linux: os-release ID is `alinux` (older branding: Aliyun
// Linux / `aliyun`). Must come before the generic `linux` fallback because
// 'alinux'.includes('linux') is true and would otherwise resolve to 'linux'.
@@ -324,6 +327,7 @@ export const sanitizeHost = (host: Host): Host => {
: host.distroMode === 'auto'
? 'auto'
: undefined;
const cleanHostIcon = sanitizeHostIconFields(host);
const migrated = migrateDeprecatedFontOverride(host);
const cleanNotes = host.notes?.trim() || undefined;
return {
@@ -332,6 +336,10 @@ export const sanitizeHost = (host: Host): Host => {
distro: cleanDistro,
distroMode: cleanDistroMode,
manualDistro: cleanManualDistro || undefined,
iconMode: undefined,
iconId: undefined,
iconColor: undefined,
...cleanHostIcon,
notes: cleanNotes,
};
};

78
domain/hostIcon.test.ts Normal file
View File

@@ -0,0 +1,78 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
DEFAULT_HOST_ICON_COLOR,
DEFAULT_HOST_ICON_ID,
HOST_ICON_COLORS,
clearHostIconAppearance,
isHostIconColorId,
isHostIconId,
normalizeHostIconSelection,
resolveHostIconAppearance,
sanitizeHostIconFields,
} from "./hostIcon.ts";
test("resolveHostIconAppearance returns null for automatic hosts", () => {
assert.equal(resolveHostIconAppearance({}), null);
assert.equal(resolveHostIconAppearance({ iconMode: "auto", iconId: "database", iconColor: "blue" }), null);
});
test("automatic hosts may keep a custom palette color without a custom icon", () => {
assert.deepEqual(sanitizeHostIconFields({ iconMode: "auto", iconColor: "violet" }), {
iconMode: "auto",
iconColor: "violet",
});
});
test("resolveHostIconAppearance returns validated custom icon and color", () => {
assert.deepEqual(
resolveHostIconAppearance({ iconMode: "custom", iconId: "database", iconColor: "blue" }),
{ iconId: "database", colorId: "blue", colorHex: "#2563EB" },
);
});
test("resolveHostIconAppearance ignores invalid custom data", () => {
assert.equal(
resolveHostIconAppearance({ iconMode: "custom", iconId: "bad", iconColor: "blue" } as unknown as Parameters<typeof resolveHostIconAppearance>[0]),
null,
);
assert.equal(
resolveHostIconAppearance({ iconMode: "custom", iconId: "server", iconColor: "#123456" } as unknown as Parameters<typeof resolveHostIconAppearance>[0]),
null,
);
});
test("normalizeHostIconSelection creates a complete UI custom selection", () => {
assert.deepEqual(normalizeHostIconSelection({ iconMode: "custom" }), {
iconMode: "custom",
iconId: DEFAULT_HOST_ICON_ID,
iconColor: DEFAULT_HOST_ICON_COLOR,
});
});
test("sanitizeHostIconFields clears incomplete or invalid stored custom data", () => {
assert.deepEqual(sanitizeHostIconFields({ iconMode: "custom" }), {});
assert.deepEqual(
sanitizeHostIconFields({ iconMode: "custom", iconId: "bad", iconColor: "blue" } as unknown as Parameters<typeof sanitizeHostIconFields>[0]),
{},
);
});
test("clearHostIconAppearance removes custom icon fields", () => {
assert.deepEqual(
clearHostIconAppearance({ iconMode: "custom", iconId: "database", iconColor: "blue", label: "DB" }),
{ label: "DB" },
);
});
test("host icon validators accept only curated IDs and color IDs", () => {
assert.equal(isHostIconId("server"), true);
assert.equal(isHostIconId("globe"), true);
assert.equal(isHostIconId("server-cog"), true);
assert.equal(isHostIconId("uploaded-file"), false);
assert.equal(isHostIconColorId(HOST_ICON_COLORS[0].id), true);
assert.equal(isHostIconColorId("violet"), true);
assert.equal(HOST_ICON_COLORS.length, 16);
assert.equal(isHostIconColorId("#2563EB"), false);
});

120
domain/hostIcon.ts Normal file
View File

@@ -0,0 +1,120 @@
import type { Host, HostIconColorId, HostIconId, HostIconMode } from "./models";
export const DEFAULT_HOST_ICON_ID: HostIconId = "server";
export const DEFAULT_HOST_ICON_COLOR: HostIconColorId = "blue";
export const HOST_ICON_IDS = [
"server",
"terminal",
"database",
"cloud",
"router",
"shield",
"code",
"box",
"globe",
"cpu",
"hard-drive",
"network",
"wifi",
"lock",
"key",
"monitor",
"container",
"activity",
"zap",
"server-cog",
] as const satisfies readonly HostIconId[];
export const HOST_ICON_COLORS = [
{ id: "blue", hex: "#2563EB" },
{ id: "green", hex: "#16A34A" },
{ id: "red", hex: "#DC2626" },
{ id: "amber", hex: "#B45309" },
{ id: "purple", hex: "#9333EA" },
{ id: "cyan", hex: "#0891B2" },
{ id: "orange", hex: "#EA580C" },
{ id: "slate", hex: "#475569" },
{ id: "violet", hex: "#7C3AED" },
{ id: "pink", hex: "#DB2777" },
{ id: "rose", hex: "#E11D48" },
{ id: "lime", hex: "#65A30D" },
{ id: "teal", hex: "#0D9488" },
{ id: "sky", hex: "#0284C7" },
{ id: "indigo", hex: "#4F46E5" },
{ id: "zinc", hex: "#52525B" },
] as const satisfies readonly { id: HostIconColorId; hex: string }[];
export type HostIconAppearance = {
iconId: HostIconId;
colorId: HostIconColorId;
colorHex: string;
};
export type HostIconColorAppearance = {
colorId: HostIconColorId;
colorHex: string;
};
export const isHostIconMode = (value: unknown): value is HostIconMode =>
value === "auto" || value === "custom";
export const isHostIconId = (value: unknown): value is HostIconId =>
typeof value === "string" && (HOST_ICON_IDS as readonly string[]).includes(value);
export const isHostIconColorId = (value: unknown): value is HostIconColorId =>
typeof value === "string" && HOST_ICON_COLORS.some((color) => color.id === value);
const resolveColorHex = (colorId: HostIconColorId): string =>
HOST_ICON_COLORS.find((color) => color.id === colorId)?.hex || HOST_ICON_COLORS[0].hex;
export const resolveHostIconColorAppearance = (
host: Partial<Pick<Host, "iconColor">>,
): HostIconColorAppearance | null => {
if (!isHostIconColorId(host.iconColor)) return null;
return {
colorId: host.iconColor,
colorHex: resolveColorHex(host.iconColor),
};
};
export const resolveHostIconAppearance = (
host: Partial<Pick<Host, "iconMode" | "iconId" | "iconColor">>,
): HostIconAppearance | null => {
if (host.iconMode !== "custom") return null;
if (!isHostIconId(host.iconId) || !isHostIconColorId(host.iconColor)) return null;
return {
iconId: host.iconId,
colorId: host.iconColor,
colorHex: resolveColorHex(host.iconColor),
};
};
export const normalizeHostIconSelection = <T extends Partial<Pick<Host, "iconMode" | "iconId" | "iconColor">>>(
host: T,
): Pick<Host, "iconMode" | "iconId" | "iconColor"> => {
if (host.iconMode !== "custom") {
const iconColor = isHostIconColorId(host.iconColor) ? host.iconColor : undefined;
return iconColor ? { iconMode: "auto", iconColor } : {};
}
const iconId = isHostIconId(host.iconId) ? host.iconId : DEFAULT_HOST_ICON_ID;
const iconColor = isHostIconColorId(host.iconColor) ? host.iconColor : DEFAULT_HOST_ICON_COLOR;
return { iconMode: "custom", iconId, iconColor };
};
export const sanitizeHostIconFields = <T extends Partial<Pick<Host, "iconMode" | "iconId" | "iconColor">>>(
host: T,
): Pick<Host, "iconMode" | "iconId" | "iconColor"> => {
if (host.iconMode !== "custom") {
return isHostIconColorId(host.iconColor) ? { iconMode: "auto", iconColor: host.iconColor } : {};
}
if (!isHostIconId(host.iconId) || !isHostIconColorId(host.iconColor)) return {};
return { iconMode: "custom", iconId: host.iconId, iconColor: host.iconColor };
};
export const clearHostIconAppearance = <T extends Record<string, unknown>>(
host: T,
): Omit<T, "iconMode" | "iconId" | "iconColor"> => {
const { iconMode: _iconMode, iconId: _iconId, iconColor: _iconColor, ...rest } = host;
return rest;
};

View File

@@ -49,6 +49,45 @@ export interface EnvVar {
// Protocol type for connections
export type HostProtocol = 'ssh' | 'telnet' | 'mosh' | 'et' | 'local' | 'serial';
export type HostIconMode = 'auto' | 'custom';
export type HostIconId =
| 'server'
| 'terminal'
| 'database'
| 'cloud'
| 'router'
| 'shield'
| 'code'
| 'box'
| 'globe'
| 'cpu'
| 'hard-drive'
| 'network'
| 'wifi'
| 'lock'
| 'key'
| 'monitor'
| 'container'
| 'activity'
| 'zap'
| 'server-cog';
export type HostIconColorId =
| 'blue'
| 'green'
| 'red'
| 'amber'
| 'purple'
| 'cyan'
| 'orange'
| 'slate'
| 'violet'
| 'pink'
| 'rose'
| 'lime'
| 'teal'
| 'sky'
| 'indigo'
| 'zinc';
// Serial port configuration
export type SerialParity = 'none' | 'even' | 'odd' | 'mark' | 'space';
@@ -131,6 +170,9 @@ export interface Host {
distro?: string; // detected distro id (e.g., ubuntu, debian)
distroMode?: 'auto' | 'manual'; // whether distro icon comes from detection or manual override
manualDistro?: string; // manually selected distro id when distroMode='manual'
iconMode?: HostIconMode; // Optional host icon mode. Missing/auto preserves distro detection.
iconId?: HostIconId; // Curated icon override used when iconMode='custom'
iconColor?: HostIconColorId; // Palette color used with automatic or custom host icons
// Multi-protocol support
protocols?: ProtocolConfig[]; // Multiple protocol configurations
telnetPort?: number; // Telnet-specific port (for quick access)

View File

@@ -1,4 +1,6 @@
// Known Hosts - discovered from system SSH known_hosts file
import type { HostIconColorId, HostIconId, HostIconMode } from './connection';
export interface KnownHost {
id: string;
hostname: string; // The host pattern from known_hosts
@@ -46,6 +48,9 @@ export interface ConnectionLog {
protocol: 'ssh' | 'telnet' | 'local' | 'mosh' | 'et' | 'serial';
hostOs?: 'linux' | 'windows' | 'macos'; // Snapshot of the connected host OS for log icons
hostDistro?: string; // Snapshot of the connected host distro/vendor icon id
hostIconMode?: HostIconMode; // Snapshot of the host icon mode for log icons
hostIconId?: HostIconId; // Snapshot of the built-in host icon id
hostIconColor?: HostIconColorId; // Snapshot of the host icon color id
startTime: number; // Connection start timestamp
endTime?: number; // Connection end timestamp (undefined if still active)
localUsername: string; // System username of the local user

View File

@@ -197,6 +197,7 @@ export const DEFAULT_KEY_BINDINGS: KeyBinding[] = [
{ id: 'next-tab', action: 'nextTab', label: 'Next Tab', mac: '⌘ + Shift + ]', pc: 'Ctrl + Tab', category: 'tabs' },
{ id: 'prev-tab', action: 'prevTab', label: 'Previous Tab', mac: '⌘ + Shift + [', pc: 'Ctrl + Shift + Tab', category: 'tabs' },
{ id: 'close-tab', action: 'closeTab', label: 'Close Tab', mac: '⌘ + W', pc: 'Ctrl + W', category: 'tabs' },
{ id: 'close-session', action: 'closeSession', label: 'Close Session Pane', mac: '⌘ + Shift + W', pc: 'Ctrl + Shift + W', category: 'tabs' },
{ id: 'new-tab', action: 'newTab', label: 'New Local Tab', mac: '⌘ + T', pc: 'Ctrl + T', category: 'tabs' },
// Terminal Operations
@@ -214,6 +215,7 @@ export const DEFAULT_KEY_BINDINGS: KeyBinding[] = [
{ id: 'move-focus', action: 'moveFocus', label: 'Move focus between Split View panes', mac: '⌘ + ⌥ + arrows', pc: 'Ctrl + Alt + arrows', category: 'navigation' },
{ id: 'split-horizontal', action: 'splitHorizontal', label: 'Split Horizontal', mac: '⌘ + D', pc: 'Ctrl + Shift + D', category: 'navigation' },
{ id: 'split-vertical', action: 'splitVertical', label: 'Split Vertical', mac: '⌘ + Shift + D', pc: 'Ctrl + Shift + E', category: 'navigation' },
{ id: 'toggle-pane-zoom', action: 'togglePaneZoom', label: 'Toggle Pane Zoom', mac: '⌘ + Shift + Enter', pc: 'Ctrl + Shift + Enter', category: 'navigation' },
// App Features
{ id: 'open-hosts', action: 'openHosts', label: 'Open Hosts Page', mac: 'Disabled', pc: 'Disabled', category: 'app' },

View File

@@ -391,4 +391,6 @@ export interface TerminalSession {
// Per-pane font size override (workspace splits only; not persisted to vault hosts).
fontSize?: number;
fontSizeOverride?: boolean;
/** User-assigned display name for this terminal session (overrides hostLabel in UI) */
customName?: string;
}

View File

@@ -9,7 +9,7 @@ export function isNetcattyAiHistoryCommand(command: string): boolean {
}
const NETCATTY_MANAGED_STARTUP_COMMAND =
/^printf '\\033\[H\\033\[2J\\033\[3J';\s*exec\s+(?:docker\s+(?:exec|logs)\b|tmux\s+attach\b)/;
/^(?:sh\s+-c\s+.*printf .*\\033\[H\\033\[2J\\033\[3J.*_nc_docker_err=.*\bdocker\s+inspect\b|printf '\\033\[H\\033\[2J\\033\[3J';\s*(?:_nc_docker_err=.*\bdocker\s+inspect\b|exec\s+(?:docker\s+(?:exec|logs)\b|tmux\s+attach\b)))/;
/** True when a shell history line came from a Netcatty-managed terminal launch. */
export function isNetcattyManagedStartupHistoryCommand(command: string): boolean {

View File

@@ -0,0 +1,30 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { buildDockerExecShellCommand, buildDockerLogsCommand } from './dockerShell.ts';
test('buildDockerExecShellCommand probes plain Docker before sudo fallback', () => {
const command = buildDockerExecShellCommand('587abcdef123');
assert.match(command, /^sh -c /);
assert.match(command, /printf .*\\033\[H\\033\[2J\\033\[3J/);
assert.match(command, /docker inspect 587abcdef123/);
assert.match(command, /exec docker exec -it 587abcdef123/);
assert.match(command, /exec sudo docker exec -it 587abcdef123/);
assert.match(command, /permission\\ denied.*docker.sock.*docker.sock.*permission\\ denied/);
assert.doesNotMatch(command, /sudo -S/);
assert.equal(command.includes('\n'), false);
});
test('buildDockerLogsCommand probes plain Docker before sudo fallback', () => {
const command = buildDockerLogsCommand('587abcdef123');
assert.match(command, /^sh -c /);
assert.match(command, /printf .*\\033\[H\\033\[2J\\033\[3J/);
assert.match(command, /docker inspect 587abcdef123/);
assert.match(command, /exec docker logs -f --tail 200 587abcdef123/);
assert.match(command, /exec sudo docker logs -f --tail 200 587abcdef123/);
assert.match(command, /permission\\ denied.*docker.sock.*docker.sock.*permission\\ denied/);
assert.doesNotMatch(command, /sudo -S/);
assert.equal(command.includes('\n'), false);
});

View File

@@ -5,15 +5,48 @@ export function sanitizeDockerContainerId(id: string): string {
const CLEAR_STARTUP_OUTPUT = "printf '\\033[H\\033[2J\\033[3J';";
function shQuote(value: string): string {
return `'${String(value).replace(/'/g, `'"'"'`)}'`;
}
function buildDockerCommandWithSudoFallback(containerId: string, dockerArgs: string): string {
const plainCommand = `docker ${dockerArgs}`;
const sudoCommand = `sudo ${plainCommand}`;
const script = [
CLEAR_STARTUP_OUTPUT,
`_nc_docker_err=$(docker inspect ${containerId} 2>&1 >/dev/null);`,
'_nc_docker_status=$?;',
`if [ "$_nc_docker_status" -eq 0 ]; then exec ${plainCommand}; fi;`,
'_nc_docker_lc=$(printf \'%s\' "$_nc_docker_err" | tr \'[:upper:]\' \'[:lower:]\');',
'case "$_nc_docker_lc" in',
[
'*permission\\ denied*docker\\ daemon*',
'*docker\\ daemon*permission\\ denied*',
'*permission\\ denied*docker.sock*',
'*docker.sock*permission\\ denied*',
'*permission\\ denied*/var/run/docker.sock*',
'*/var/run/docker.sock*permission\\ denied*',
'*permission\\ denied*connect\\ to\\ the\\ docker\\ daemon*',
'*connect\\ to\\ the\\ docker\\ daemon*permission\\ denied*',
].join('|') + `) exec ${sudoCommand} ;;`,
'*) printf \'%s\\n\' "$_nc_docker_err" >&2; exit "$_nc_docker_status" ;;',
'esac',
].join(' ');
return `sh -c ${shQuote(script)}`;
}
/** Interactive shell into a container — prefer bash, fall back to sh. */
export function buildDockerExecShellCommand(containerId: string): string {
const safeId = sanitizeDockerContainerId(containerId);
if (!safeId) return 'echo "Invalid container id"';
return `${CLEAR_STARTUP_OUTPUT} exec docker exec -it ${safeId} sh -c 'command -v bash >/dev/null 2>&1 && exec bash || exec sh'`;
return buildDockerCommandWithSudoFallback(
safeId,
`exec -it ${safeId} sh -c 'command -v bash >/dev/null 2>&1 && exec bash || exec sh'`,
);
}
export function buildDockerLogsCommand(containerId: string): string {
const safeId = sanitizeDockerContainerId(containerId);
if (!safeId) return 'echo "Invalid container id"';
return `${CLEAR_STARTUP_OUTPUT} exec docker logs -f --tail 200 ${safeId}`;
return buildDockerCommandWithSudoFallback(safeId, `logs -f --tail 200 ${safeId}`);
}

View File

@@ -0,0 +1,37 @@
import assert from "node:assert/strict";
import test from "node:test";
import { resolveCapabilityPanelState } from "./systemManagerPanelState.ts";
test("keeps unavailable state visible while a known-missing capability is refreshed", () => {
assert.equal(
resolveCapabilityPanelState({
isActive: true,
ready: false,
capabilitiesKnown: true,
}),
"unavailable",
);
});
test("shows checking only before capabilities are known", () => {
assert.equal(
resolveCapabilityPanelState({
isActive: true,
ready: false,
capabilitiesKnown: false,
}),
"checking",
);
});
test("hides inactive capability panels", () => {
assert.equal(
resolveCapabilityPanelState({
isActive: false,
ready: false,
capabilitiesKnown: true,
}),
"hidden",
);
});

View File

@@ -0,0 +1,16 @@
export type CapabilityPanelState = "hidden" | "checking" | "unavailable" | "ready";
export function resolveCapabilityPanelState({
isActive,
ready,
capabilitiesKnown,
}: {
isActive: boolean;
ready: boolean;
capabilitiesKnown: boolean;
}): CapabilityPanelState {
if (!isActive) return "hidden";
if (ready) return "ready";
if (capabilitiesKnown) return "unavailable";
return "checking";
}

View File

@@ -38,6 +38,9 @@ function createStartSessionApi(ctx) {
hostname: options.host || options.hostname || '',
username: options.username || '',
label: options.label || '',
systemManagerSudoPassword: typeof options.sudoAutofillPassword === 'string' && options.sudoAutofillPassword.length > 0
? options.sudoAutofillPassword
: undefined,
lastIdlePrompt: '',
lastIdlePromptAt: 0,
_promptTrackTail: '',

View File

@@ -19,6 +19,37 @@ function sanitizeImageRef(ref) {
return trimmed || null;
}
function isSuccessfulCommandResult(result) {
return result?.success && (result.code === 0 || result.code === null || result.code === undefined);
}
function dockerCommandError(result, fallback) {
return (result?.stderr || result?.error || "").trim() || fallback;
}
function isDockerSocketPermissionError(result) {
const text = `${result?.stderr || ""}\n${result?.stdout || ""}\n${result?.error || ""}`.toLowerCase();
if (!text.includes("permission denied")) return false;
return text.includes("docker daemon")
|| text.includes("docker.sock")
|| text.includes("/var/run/docker.sock")
|| text.includes("connect to the docker daemon");
}
function getSessionSudoPassword(session) {
return typeof session?.systemManagerSudoPassword === "string" && session.systemManagerSudoPassword.length > 0
? session.systemManagerSudoPassword
: null;
}
function buildDockerCommand(args) {
return `docker ${args}`.trim();
}
function buildSudoDockerCommand(args) {
return `sudo -S -p '' ${buildDockerCommand(args)}`;
}
function parseDockerContainers(stdout) {
const containers = [];
for (const line of (stdout || "").split("\n")) {
@@ -132,15 +163,35 @@ function summarizeContainerInspect(info) {
};
}
function createDockerOpsApi({ execOnSession }) {
function createDockerOpsApi({ execOnSession, getSession }) {
async function runDocker(event, sessionId, args, timeoutMs = 15000) {
const cmd = `docker ${args}`;
const cmd = buildDockerCommand(args);
const result = await execOnSession(event, sessionId, cmd, timeoutMs);
if (isSuccessfulCommandResult(result)) return result;
const sudoPassword = getSessionSudoPassword(getSession?.(sessionId));
if (sudoPassword && isDockerSocketPermissionError(result)) {
const sudoResult = await execOnSession(
event,
sessionId,
buildSudoDockerCommand(args),
timeoutMs,
{ stdin: `${sudoPassword}\n` },
);
if (isSuccessfulCommandResult(sudoResult)) return sudoResult;
return {
success: false,
error: dockerCommandError(sudoResult, `sudo docker exited with code ${sudoResult?.code}`),
stderr: sudoResult?.stderr,
};
}
if (!result.success) return result;
if (result.code !== 0 && result.code !== null && result.code !== undefined) {
return {
success: false,
error: (result.stderr || "").trim() || `docker exited with code ${result.code}`,
error: dockerCommandError(result, `docker exited with code ${result.code}`),
stderr: result.stderr,
};
}
@@ -148,23 +199,13 @@ function createDockerOpsApi({ execOnSession }) {
}
async function listContainers(event, sessionId) {
const result = await execOnSession(
event,
sessionId,
"docker ps -a --format '{{json .}}'",
12000,
);
const result = await runDocker(event, sessionId, "ps -a --format '{{json .}}'", 12000);
if (!result.success) return { success: false, error: result.error };
return { success: true, containers: parseDockerContainers(result.stdout) };
}
async function listImages(event, sessionId) {
const result = await execOnSession(
event,
sessionId,
"docker images --format '{{json .}}'",
12000,
);
const result = await runDocker(event, sessionId, "images --format '{{json .}}'", 12000);
if (!result.success) return { success: false, error: result.error };
return { success: true, images: parseDockerImages(result.stdout) };
}
@@ -174,10 +215,10 @@ function createDockerOpsApi({ execOnSession }) {
if (!sessionId) return { success: false, error: "Missing sessionId" };
const ids = Array.isArray(payload?.ids) ? payload.ids.filter(Boolean) : [];
const idArg = ids.map((id) => sanitizeDockerId(id)).filter(Boolean).join(" ");
const result = await execOnSession(
const result = await runDocker(
event,
sessionId,
`docker stats --no-stream --format '{{json .}}' ${idArg}`.trim(),
`stats --no-stream --format '{{json .}}' ${idArg}`.trim(),
15000,
);
if (!result.success) return { success: false, error: result.error };
@@ -188,7 +229,7 @@ function createDockerOpsApi({ execOnSession }) {
const { sessionId, containerId } = payload || {};
if (!sessionId || !containerId) return { success: false, error: "Missing params" };
const safeId = sanitizeDockerId(containerId);
const result = await execOnSession(event, sessionId, `docker inspect ${safeId}`, 10000);
const result = await runDocker(event, sessionId, `inspect ${safeId}`, 10000);
if (!result.success) return { success: false, error: result.error };
try {
const parsed = JSON.parse(result.stdout || "[]");
@@ -203,7 +244,7 @@ function createDockerOpsApi({ execOnSession }) {
const { sessionId, imageId } = payload || {};
if (!sessionId || !imageId) return { success: false, error: "Missing params" };
const safeId = sanitizeDockerId(imageId);
const result = await execOnSession(event, sessionId, `docker image inspect ${safeId}`, 10000);
const result = await runDocker(event, sessionId, `image inspect ${safeId}`, 10000);
if (!result.success) return { success: false, error: result.error };
try {
const parsed = JSON.parse(result.stdout || "[]");

View File

@@ -0,0 +1,186 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { createDockerOpsApi } = require("./dockerOps.cjs");
test("listContainers uses plain docker first even when a saved session password exists", async () => {
const calls = [];
const dockerOps = createDockerOpsApi({
getSession: () => ({ systemManagerSudoPassword: "host-secret" }),
execOnSession: async (_event, sessionId, command, timeoutMs, execOptions) => {
calls.push({ sessionId, command, timeoutMs, execOptions });
return {
success: true,
stdout: '{"ID":"abc123","Names":"web","Image":"nginx","State":"running"}\n',
stderr: "",
code: 0,
};
},
});
const result = await dockerOps.listContainers(null, "s1");
assert.equal(result.success, true);
assert.equal(result.containers.length, 1);
assert.equal(calls.length, 1);
assert.equal(
calls[0].command,
"docker ps -a --format '{{json .}}'",
);
assert.equal(calls[0].execOptions, undefined);
});
test("listContainers falls back to sudo when plain docker hits socket permission denial", async () => {
const calls = [];
const dockerOps = createDockerOpsApi({
getSession: () => ({ systemManagerSudoPassword: "host-secret" }),
execOnSession: async (_event, sessionId, command, timeoutMs, execOptions) => {
calls.push({ sessionId, command, timeoutMs, execOptions });
if (calls.length === 1) {
return {
success: true,
stdout: "",
stderr: "permission denied while trying to connect to the Docker daemon socket",
code: 1,
};
}
return {
success: true,
stdout: '{"ID":"abc123","Names":"web","Image":"nginx","State":"running"}\n',
stderr: "",
code: 0,
};
},
});
const result = await dockerOps.listContainers(null, "s1");
assert.equal(result.success, true);
assert.equal(result.containers.length, 1);
assert.equal(calls.length, 2);
assert.equal(calls[0].command, "docker ps -a --format '{{json .}}'");
assert.equal(calls[0].execOptions, undefined);
assert.equal(
calls[1].command,
"sudo -S -p '' docker ps -a --format '{{json .}}'",
);
assert.deepEqual(calls[1].execOptions, { stdin: "host-secret\n" });
});
test("listContainers uses plain docker when no saved password exists", async () => {
const calls = [];
const dockerOps = createDockerOpsApi({
getSession: () => ({}),
execOnSession: async (_event, sessionId, command, timeoutMs, execOptions) => {
calls.push({ sessionId, command, timeoutMs, execOptions });
return {
success: true,
stdout: "",
stderr: "Got permission denied while trying to connect to the Docker daemon socket",
code: 1,
};
},
});
const result = await dockerOps.listContainers(null, "s1");
assert.equal(result.success, false);
assert.match(result.error, /permission denied/i);
assert.equal(calls.length, 1);
});
test("listContainers does not retry with transport auth passwords that were not saved for sudo autofill", async () => {
const calls = [];
const dockerOps = createDockerOpsApi({
getSession: () => ({
moshStatsAuth: { password: "interactive-mosh-password" },
etStatsAuth: { password: "interactive-et-password" },
}),
execOnSession: async (_event, sessionId, command, timeoutMs, execOptions) => {
calls.push({ sessionId, command, timeoutMs, execOptions });
return {
success: true,
stdout: "",
stderr: "permission denied while trying to connect to the Docker daemon socket",
code: 1,
};
},
});
const result = await dockerOps.listContainers(null, "s1");
assert.equal(result.success, false);
assert.match(result.error, /permission denied/i);
assert.equal(calls.length, 1);
});
test("listContainers retries with explicit sudo autofill password on mosh or et sessions", async () => {
const calls = [];
const dockerOps = createDockerOpsApi({
getSession: () => ({
systemManagerSudoPassword: "saved-secret",
moshStatsAuth: { password: "transport-secret" },
}),
execOnSession: async (_event, sessionId, command, timeoutMs, execOptions) => {
calls.push({ sessionId, command, timeoutMs, execOptions });
if (calls.length === 1) {
return {
success: true,
stdout: "",
stderr: "dial unix /var/run/docker.sock: connect: permission denied",
code: 1,
};
}
return {
success: true,
stdout: '{"ID":"abc123","Names":"web","Image":"nginx","State":"running"}\n',
stderr: "",
code: 0,
};
},
});
const result = await dockerOps.listContainers(null, "s1");
assert.equal(result.success, true);
assert.equal(calls.length, 2);
assert.equal(
calls[1].command,
"sudo -S -p '' docker ps -a --format '{{json .}}'",
);
assert.deepEqual(calls[1].execOptions, { stdin: "saved-secret\n" });
});
test("docker image actions retry with sudo and send saved passwords through stdin", async () => {
const calls = [];
const dockerOps = createDockerOpsApi({
getSession: () => ({ systemManagerSudoPassword: "pa'ss" }),
execOnSession: async (_event, sessionId, command, timeoutMs, execOptions) => {
calls.push({ sessionId, command, timeoutMs, execOptions });
if (calls.length === 1) {
return {
success: true,
stdout: "",
stderr: "dial unix /var/run/docker.sock: connect: permission denied",
code: 1,
};
}
return { success: true, stdout: "deleted\n", stderr: "", code: 0 };
},
});
const result = await dockerOps.imageAction(null, {
sessionId: "s1",
action: "rm",
imageId: "sha256:abc123",
});
assert.equal(result.success, true);
assert.equal(calls.length, 2);
assert.equal(
calls[1].command,
"sudo -S -p '' docker rmi sha256abc123",
);
assert.deepEqual(calls[1].execOptions, { stdin: "pa'ss\n" });
});

View File

@@ -78,7 +78,7 @@ function createExecOnSessionApi(ctx) {
return conn;
}
function execOnConnection(conn, command, timeoutMs) {
function execOnConnection(conn, command, timeoutMs, execOptions = {}) {
return new Promise((resolve) => {
let settled = false;
let activeStream = null;
@@ -106,6 +106,10 @@ function createExecOnSessionApi(ctx) {
if (stream.stderr) {
stream.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
}
if (typeof execOptions.stdin === "string") {
stream.write(execOptions.stdin);
stream.end();
}
stream.on("close", (code) => {
settle({ success: true, stdout, stderr, code: code ?? 0 });
});
@@ -116,7 +120,7 @@ function createExecOnSessionApi(ctx) {
});
}
async function execOnSshSession(session, sessionId, command, timeoutMs, event, allowCompanionRetry = true) {
async function execOnSshSession(session, sessionId, command, timeoutMs, event, execOptions = {}, allowCompanionRetry = true) {
if (session?.type === "et") {
if (typeof execOnEtSession !== "function") {
return { success: false, error: "ET command executor unavailable" };
@@ -124,6 +128,7 @@ function createExecOnSessionApi(ctx) {
return execOnEtSession(session, command, timeoutMs, {
requireTrustedHost: true,
knownHosts: session.etStatsAuth?.knownHosts,
stdin: execOptions.stdin,
});
}
@@ -135,7 +140,7 @@ function createExecOnSessionApi(ctx) {
return { success: false, error: "Session not found or not connected" };
}
const result = await execOnConnection(conn, command, timeoutMs);
const result = await execOnConnection(conn, command, timeoutMs, execOptions);
if (
allowCompanionRetry
&& !result.success
@@ -143,18 +148,18 @@ function createExecOnSessionApi(ctx) {
&& isTransportExecError(result.error)
) {
session.moshStatsConn = null;
return execOnSshSession(session, sessionId, command, timeoutMs, event, false);
return execOnSshSession(session, sessionId, command, timeoutMs, event, execOptions, false);
}
return result;
}
async function execOnLocalMachine(command, timeoutMs) {
async function execOnLocalMachine(command, timeoutMs, execOptions = {}) {
const { execFile } = require("node:child_process");
const platform = process.platform;
if (platform === "win32") {
return new Promise((resolve) => {
execFile(
const child = execFile(
"powershell.exe",
["-NoProfile", "-NonInteractive", "-Command", command],
{ timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024 },
@@ -166,11 +171,14 @@ function createExecOnSessionApi(ctx) {
resolve({ success: true, stdout: String(stdout || ""), stderr: String(stderr || ""), code: err?.code ?? 0 });
},
);
if (typeof execOptions.stdin === "string") {
child.stdin?.end(execOptions.stdin);
}
});
}
return new Promise((resolve) => {
execFile(
const child = execFile(
"sh",
["-c", command],
{ timeout: timeoutMs, maxBuffer: 10 * 1024 * 1024 },
@@ -182,10 +190,13 @@ function createExecOnSessionApi(ctx) {
resolve({ success: true, stdout: String(stdout || ""), stderr: String(stderr || ""), code: err?.code ?? 0 });
},
);
if (typeof execOptions.stdin === "string") {
child.stdin?.end(execOptions.stdin);
}
});
}
async function execOnSessionInner(event, sessionId, command, timeoutMs = 8000) {
async function execOnSessionInner(event, sessionId, command, timeoutMs = 8000, execOptions = {}) {
const session = getSession(sessionId);
if (!session) {
execQueues.delete(sessionId);
@@ -193,18 +204,18 @@ function createExecOnSessionApi(ctx) {
}
if (session.protocol === "local" || session.type === "local") {
return execOnLocalMachine(command, timeoutMs);
return execOnLocalMachine(command, timeoutMs, execOptions);
}
if (session.conn || session.type === "mosh" || session.type === "et") {
return execOnSshSession(session, sessionId, command, timeoutMs, event);
return execOnSshSession(session, sessionId, command, timeoutMs, event, execOptions);
}
return { success: false, error: "Session not supported for system management" };
}
async function execOnSession(event, sessionId, command, timeoutMs = 8000) {
return enqueueExec(sessionId, () => execOnSessionInner(event, sessionId, command, timeoutMs));
async function execOnSession(event, sessionId, command, timeoutMs = 8000, execOptions = {}) {
return enqueueExec(sessionId, () => execOnSessionInner(event, sessionId, command, timeoutMs, execOptions));
}
function isLocalSession(sessionId) {

View File

@@ -0,0 +1,38 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const { createExecOnSessionApi } = require("./execOnSession.cjs");
test("execOnSession closes ssh exec stdin after writing provided input", async () => {
const writes = [];
let ended = false;
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.write = (data) => {
writes.push(data);
return true;
};
stream.end = () => {
ended = true;
};
const conn = {
exec(_command, callback) {
callback(null, stream);
process.nextTick(() => stream.emit("close", 0));
},
};
const execApi = createExecOnSessionApi({
sessions: { get: () => ({ conn, type: "ssh" }) },
});
const result = await execApi.execOnSession(null, "s1", "sudo -S -p '' docker ps", 1000, {
stdin: "secret\n",
});
assert.equal(result.success, true);
assert.deepEqual(writes, ["secret\n"]);
assert.equal(ended, true);
});

View File

@@ -9,14 +9,16 @@ const CAPABILITY_SCRIPT_POSIX = [
"'",
'printf "%s\\n" "__NC_OS__=$(uname -s)"; ',
'command -v tmux >/dev/null 2>&1 && printf "%s\\n" __NC_TMUX__=1; ',
'docker info >/dev/null 2>&1 && printf "%s\\n" __NC_DOCKER__=1',
'command -v docker >/dev/null 2>&1 && printf "%s\\n" __NC_DOCKER__=1',
"'",
].join("");
const PROCESS_LIST_SCRIPT_POSIX = [
"exec sh -c ",
"'",
"ps -eo pid= -o ppid= -o user= -o stat= -o pcpu= -o pmem= -o rss= -o vsz= -o etime= -o args= 2>/dev/null | head -n 200",
// Safety cap: head -n 2000 prevents maxBuffer/timeout on process-dense hosts.
// This is NOT a functional limit — monitored processes still show accurate metrics.
"ps -eo pid= -o ppid= -o user= -o stat= -o pcpu= -o pmem= -o rss= -o vsz= -o etime= -o args= 2>/dev/null | head -n 2000",
"'",
].join("");
@@ -109,10 +111,10 @@ function createSystemManagerBridge(deps) {
ensureMoshStatsConnection,
});
const { execOnSession, execOnLocalMachine, isLocalSession } = execApi;
const { execOnSession, execOnLocalMachine, isLocalSession, getSession } = execApi;
const tmuxOps = createTmuxOpsApi({ execOnSession });
const dockerOps = createDockerOpsApi({ execOnSession });
const dockerOps = createDockerOpsApi({ execOnSession, getSession });
async function probeCapabilities(event, payload) {
const sessionId = payload?.sessionId;
@@ -134,7 +136,7 @@ function createSystemManagerBridge(deps) {
8000,
);
if (!result.success) {
const fallback = await execOnLocalMachine("uname -s; command -v tmux; docker info >/dev/null 2>&1 && echo docker_ok", 8000);
const fallback = await execOnLocalMachine("uname -s; command -v tmux; command -v docker >/dev/null 2>&1 && echo docker_ok", 8000);
if (!fallback.success) return { success: false, error: fallback.error || "Probe failed" };
const text = fallback.stdout || "";
return {
@@ -164,8 +166,10 @@ function createSystemManagerBridge(deps) {
if (!sessionId) return { success: false, error: "Missing sessionId" };
if (isLocalSession(sessionId) && process.platform === "win32") {
// Safety cap: -First 2000 prevents maxBuffer/timeout on process-dense hosts.
// This is NOT a functional limit — monitored processes still show accurate metrics.
const result = await execOnLocalMachine(
"Get-CimInstance Win32_Process | Sort-Object KernelModeTime -Descending | Select-Object -First 200 ProcessId,ParentProcessId,Name,WorkingSetSize | ConvertTo-Json -Compress",
"Get-CimInstance Win32_Process | Sort-Object KernelModeTime -Descending | Select-Object -First 2000 ProcessId,ParentProcessId,Name,WorkingSetSize | ConvertTo-Json -Compress",
10000,
);
if (!result.success) return { success: false, error: result.error };

View File

@@ -46,3 +46,24 @@ test("listProcesses uses a ps format that works on CentOS 7 procps", async () =>
assert.equal(result.processes[0].pid, 1);
assert.equal(result.processes[0].command, "/usr/lib/systemd/systemd --switched-root --system --deserialize 21");
});
test("probeCapabilities reports Docker when docker is installed even if plain docker access is denied", async () => {
const conn = {
exec(command, callback) {
assert.match(command, /command -v docker/);
assert.doesNotMatch(command, /docker info/);
assert.doesNotMatch(command, /docker\.sock/);
callback(null, createFakeExecStream("__NC_OS__=Linux\n__NC_DOCKER__=1\n"));
},
};
const sessions = new Map([["s1", { conn, type: "ssh" }]]);
const bridge = createSystemManagerBridge({
getSessions: () => sessions,
process,
});
const result = await bridge.probeCapabilities(null, { sessionId: "s1" });
assert.equal(result.success, true);
assert.equal(result.capabilities.hasDocker, true);
});

View File

@@ -654,7 +654,7 @@ main();
args.push(session.sshUserHost, command);
return new Promise((resolve) => {
execFile(sshCmd, args, {
const child = execFile(sshCmd, args, {
env: { ...process.env, ...session.sshEnv },
timeout: timeoutMs,
encoding: "utf8",
@@ -672,6 +672,9 @@ main();
resolve({ success: true, stdout: stdout || "", stderr: stderr || "", code: 0 });
}
});
if (typeof execOpts.stdin === "string") {
child.stdin?.end(execOpts.stdin);
}
});
}
@@ -791,6 +794,9 @@ main();
knownHosts: options.knownHosts,
hasJumpHost: Array.isArray(options.jumpHosts) && options.jumpHosts.length > 0,
},
systemManagerSudoPassword: typeof options.sudoAutofillPassword === "string" && options.sudoAutofillPassword.length > 0
? options.sudoAutofillPassword
: undefined,
flushPendingData: null,
lastIdlePrompt: "",
lastIdlePromptAt: 0,

View File

@@ -572,6 +572,9 @@ function createMoshSessionApi(ctx) {
// does not depend on this.
knownHosts: options.knownHosts,
};
session.systemManagerSudoPassword = typeof options.sudoAutofillPassword === "string" && options.sudoAutofillPassword.length > 0
? options.sudoAutofillPassword
: undefined;
if (process.platform !== "win32") {
const decoder = new StringDecoder("utf8");

2
global.d.ts vendored
View File

@@ -119,6 +119,8 @@ declare global {
algorithmOverrides?: import("./domain/models").HostAlgorithmOverrides;
// Use sudo for SFTP server
sudo?: boolean;
// Saved host password used by background system tools when they need sudo.
sudoAutofillPassword?: string;
// Session log configuration for real-time streaming
sessionLog?: { enabled: boolean; directory: string; format: string; timestampsEnabled?: boolean };
// SSH connection diagnostics. Does not capture terminal output.

View File

@@ -181,6 +181,26 @@
transition: width 220ms cubic-bezier(0.4, 0, 0.2, 1);
}
.terminal-topbar {
container-type: inline-size;
}
.terminal-title-cluster {
min-width: 8rem;
}
@container (max-width: 760px) {
.terminal-server-stats {
display: none;
}
}
@container (max-width: 420px) {
.terminal-title-cluster {
min-width: 0;
}
}
.host-tree-notes-scroll {
scrollbar-width: thin;
scrollbar-color: hsl(var(--muted-foreground) / 0.28) transparent;

View File

@@ -0,0 +1,17 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
XTERM_UNLIMITED_SCROLLBACK_CAP,
resolveXTermScrollback,
} from './xtermPerformance';
test('resolveXTermScrollback maps the unlimited sentinel to a 50000 row cap', () => {
assert.equal(XTERM_UNLIMITED_SCROLLBACK_CAP, 50000);
assert.equal(resolveXTermScrollback(0), 50000);
});
test('resolveXTermScrollback preserves explicit positive scrollback values', () => {
assert.equal(resolveXTermScrollback(10000), 10000);
assert.equal(resolveXTermScrollback(50000), 50000);
});

View File

@@ -8,6 +8,14 @@
* - Memory pressure handling
*/
export const XTERM_UNLIMITED_SCROLLBACK_CAP = 50000;
export function resolveXTermScrollback(scrollback: number): number {
// xterm.js treats 0 as "no scrollback". Keep the app's 0 sentinel useful
// without asking xterm to resize/reflow nearly one million buffer rows.
return scrollback === 0 ? XTERM_UNLIMITED_SCROLLBACK_CAP : scrollback;
}
export const XTERM_PERFORMANCE_CONFIG = {
// Memory and Scrollback Settings
scrollback: {

View File

@@ -35,7 +35,7 @@
"tool:cli": "node electron/cli/netcatty-tool-cli.cjs",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test": "node --test --import tsx electron/bridges/*.test.cjs electron/bridges/*/*.test.cjs electron/bridges/aiBridge/sdk/*.test.cjs scripts/*.test.cjs application/*.test.ts application/app/*.test.ts application/state/*.test.ts application/state/*/*.test.ts components/*.test.ts components/*.test.tsx components/editor/*.test.ts components/editor/*.test.tsx components/terminalLayer/*.test.ts components/settings/*.test.tsx components/settings/tabs/ai/*.test.ts components/ai/*.test.ts components/ai-elements/*.test.tsx components/sftp/*.test.ts components/terminal/*.test.ts components/terminal/runtime/*.test.ts domain/*.test.ts infrastructure/ai/*.test.ts infrastructure/config/*.test.ts infrastructure/services/*/*.test.ts lib/*.test.ts"
"test": "node --test --import tsx electron/bridges/*.test.cjs electron/bridges/*/*.test.cjs electron/bridges/aiBridge/sdk/*.test.cjs scripts/*.test.cjs application/*.test.ts application/app/*.test.ts application/i18n/locales/*.test.ts application/state/*.test.ts application/state/*/*.test.ts components/*.test.ts components/*.test.tsx components/editor/*.test.ts components/editor/*.test.tsx components/terminalLayer/*.test.ts components/settings/*.test.tsx components/settings/tabs/ai/*.test.ts components/ai/*.test.ts components/ai-elements/*.test.tsx components/sftp/*.test.ts components/terminal/*.test.ts components/terminal/runtime/*.test.ts domain/*.test.ts infrastructure/ai/*.test.ts infrastructure/config/*.test.ts infrastructure/services/*/*.test.ts lib/*.test.ts"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.58",

View File

@@ -0,0 +1 @@
<svg role="img" viewBox="80 45 93 105" xmlns="http://www.w3.org/2000/svg"><title>openEuler</title><path d="m170.54,69.83l-42.27-24.4c-1.13-.65-2.53-.65-3.66.02l-42.28,24.4c-1.12.65-1.82,1.85-1.83,3.15v48.82c0,1.31.69,2.52,1.83,3.18l42.28,24.41c1.13.65,2.53.65,3.66,0l42.27-24.41c1.14-.65,1.83-1.86,1.83-3.17v-48.83c0-1.31-.7-2.52-1.83-3.17Zm-33.72,49.99c-3.3,4.2-9.89,5.42-14.3,2.66-4.19-2.59-4.52-7.61-1.19-11.18,3.39-3.46,8.57-4.45,13-2.49.65.28,1.26.67,1.8,1.13,2.92,2.54,3.23,6.96.69,9.88Zm7.89-39.11c-2.14,2.21-6.79,3.15-10.14,2.09-1.92-.6-2.94-1.87-3-2.87-.04-.8-1.24-1.53-2.57-1.93-1.45-.34-2.94-.39-4.41-.15-.59.09-1.18.22-1.75.4-1.33.39-2.58,1.02-3.68,1.86-1.02.95-1.62,2.25-1.67,3.64.05.98.95,1.82,2.33,2.34,1.52.51,3.14.62,4.71.34,2.01-.58,4.14-.55,6.13.07,3.36,1.19,4.22,4.22,1.67,6.82-3,2.77-7.31,3.64-11.15,2.23-1.54-.61-2.58-2.07-2.65-3.73-.08-1.09-1.05-1.96-2.38-2.51-1.5-.49-3.09-.62-4.65-.38-.23.02-1.27.23-1.91.4-1.57.35-3.02,1.12-4.19,2.22-.85.87-1.53,1.88-2,3-.2.5-.33,1.02-.4,1.55.02,1.31.8,2.48,2,3,1.55.73,3.28.95,4.96.65,2.14-.66,4.45-.53,6.51.35,3.39,1.65,3.79,5.59.6,8.84-3.38,3.4-9.02,4.5-12.38,2.4-1.68-1.02-2.53-2.98-2.12-4.9,0-.25-.03-.5-.07-.74-.05-.32-.15-.62-.3-.91-.38-.72-.97-1.32-1.68-1.71-1.5-.76-3.2-1-4.85-.68-1.95.67-4.08.53-5.93-.38-2.48-1.55-1.73-4.83,1.45-7.31,1.62-1.25,3.52-2.09,5.53-2.45,1.86-.37,3.61-1.17,5.1-2.34,1.19-.79,2-2.05,2.23-3.46.01-.39-.01-.78-.08-1.17-.14-1.02.16-2.06.83-2.84.82-.8,1.87-1.33,3-1.5.88-.12,1.76-.17,2.64-.16.63-.03,1.25-.12,1.86-.25,1.1-.31,2.12-.85,3-1.58.83-.55,1.43-1.39,1.69-2.36.04-.33.06-.67.06-1-.32-1.75,2.13-3.68,5.47-4.3,1.49-.32,3.04-.26,4.51.15h.12c.98.15,1.83.75,2.3,1.63v.06c.05.13.09.26.1.4.5.96,1.43,1.63,2.5,1.81,1.47.36,3,.41,4.49.13,1.89-.51,3.87-.55,5.77-.11,3.3.85,4.54,3.13,2.4,5.34Zm18.23,5.26c-2.02,2.56-7.23,3.6-11.37,2.28-3.94-1.26-5.14-4.28-3.08-6.43,2.06-2.15,6.67-3.15,10.51-2.15,4,1.04,5.96,3.74,3.94,6.3Z"/></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -29,6 +29,7 @@ declare global {
moshServerPath?: string;
moshClientPath?: string;
agentForwarding?: boolean;
sudoAutofillPassword?: string;
// Algorithm settings, forwarded so the host-info stats companion SSH
// connection (issue #1198) negotiates the same KEX / cipher / host-key
// set the interactive session would.
@@ -63,6 +64,7 @@ declare global {
knownHosts?: import("../../domain/models").KnownHost[];
jumpHosts?: NetcattyJumpHost[];
agentForwarding?: boolean;
sudoAutofillPassword?: string;
cols?: number;
rows?: number;
charset?: string;