Compare commits

...

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

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

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

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

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

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

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

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

Validated locally with lint, full tests, and build. Multi-agent review completed with no remaining issues.
2026-06-12 14:00:16 +08:00
陈大猫
17c8f11194 Fix macOS package ad-hoc signing (#1433)
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
2026-06-12 12:36:46 +08:00
陈大猫
4d1a7ea55a Prevent app reload shortcut from closing sessions (#1432) 2026-06-12 12:10:59 +08:00
陈大猫
babe06a944 Fix local debug launch
Fix local debug launch
2026-06-12 11:54:26 +08:00
陈大猫
9e31d53bdd Slim release package
Slim release package
2026-06-12 11:48:59 +08:00
lengyuqu
ea24841939 Fix Windows AI model selection
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 the Windows issue where AI models could not be selected.
2026-06-12 01:42:38 +08:00
陈大猫
bf9f557e42 Fix popup timestamps and sidebar return animation
Hide timestamp controls in popup shell windows and keep the terminal host sidebar width while root pages are shown so returning to terminal tabs does not replay the open animation.
2026-06-12 01:35:50 +08:00
陈大猫
106e748a9b Fix terminal tab theme sync (#1421) 2026-06-12 01:21:28 +08:00
陈大猫
94fff62f9b [codex] Virtualize process manager list
Render only visible process rows while preserving sorting, search, status pills, and row actions.
2026-06-12 00:57:05 +08:00
陈大猫
324253f23a [codex] Optimize terminal side panel performance
Optimize terminal side panel performance, keep process status labels visible, and improve heavy panel loading behavior.
2026-06-12 00:47:17 +08:00
陈大猫
e9a2e44a91 Improve terminal timestamp gutter (#1417) 2026-06-12 00:45:08 +08:00
陈大猫
7b4f046001 [codex] Optimize AI settings agent performance (#1416)
* Optimize AI settings agent performance

* Address AI settings review feedback

* Retry interrupted agent discovery

* Harden agent CLI probe timeout cleanup

* Avoid duplicate agent discovery retries

* Ensure timed-out CLI probes are killed
2026-06-11 22:38:08 +08:00
陈大猫
1a3560a19f Polish tmux session modal (#1412)
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
2026-06-11 18:07:11 +08:00
陈大猫
3b525300e0 Tighten host tree topbar UX (#1411) 2026-06-11 17:49:35 +08:00
陈大猫
08ff49d3f5 Avoid broadcasting remote image paste paths (#1410) 2026-06-11 17:10:13 +08:00
陈大猫
f5c4271a07 Polish AI settings integrations UI (#1409) 2026-06-11 17:07:11 +08:00
陈大猫
74d41b43b6 [codex] Support remote image clipboard paste (#1408)
* Support remote image clipboard paste

* Address remote image paste review findings
2026-06-11 17:01:36 +08:00
陈大猫
3408bba303 [codex] Add Cursor SDK agent support (#1399)
* feat(ai): add Cursor SDK agent support

* fix(ai): harden Cursor SDK support

* fix(ai): address Cursor SDK review findings

* fix(ai): refresh Cursor environment handling

* fix(ai): refresh Cursor discovery scans

* fix(ai): enable Cursor recheck without path

* Use official Cursor agent icon

* Clarify Cursor SDK setup requirements

* Split Cursor SDK setup status

* Simplify Cursor settings copy

* Improve Cursor API key error

* Add safe Cursor auth diagnostics

* Disable Cursor local sandbox by default

* Show Cursor MCP tool names in tool cards

* Add spacing inside tool call groups
2026-06-11 16:43:34 +08:00
陈大猫
5e00e998a8 Add vault drag reorder ordering (#1407) 2026-06-11 16:05:17 +08:00
陈大猫
3847f0cda0 Merge pull request #1406 from binaricat/codex/per-host-line-timestamps
Make terminal timestamps per-host
2026-06-11 15:26:09 +08:00
bincxz
1ebcd017bd Make terminal timestamps per-host 2026-06-11 15:25:34 +08:00
陈大猫
9013a7e312 fix terminal popup release behavior (#1403) 2026-06-11 14:48:52 +08:00
陈大猫
afefbd953f Add serial YMODEM send support (#1400) 2026-06-11 11:11:31 +08:00
陈大猫
535b141b23 Merge pull request #1322 from lengyuqu/codebuddy
[Codebuddy] sdk
2026-06-11 10:21:35 +08:00
bincxz
b21e44b65f fix(ai): address CodeBuddy PR review findings 2026-06-11 10:18:49 +08:00
陈大猫
b42be379e3 Merge pull request #1393 from binaricat/fix/host-tree-search-clear
fix(terminal): add clear button to host tree search field
2026-06-11 04:34:18 +08:00
bincxz
b2f0a3bea3 fix(terminal): add clear button to host tree search field
Show an X control beside the host sidebar search input so users can reset the filter without manually deleting text.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 04:34:07 +08:00
陈大猫
df3745d185 Merge pull request #1392 from binaricat/feat/terminal-system-sidebar-button
feat(terminal): add statusbar button to open system sidebar
2026-06-11 04:31:27 +08:00
bincxz
f85bb3f9b2 feat(terminal): add statusbar button to open system sidebar
Expose a quick Activity icon on SSH Linux sessions so users can jump directly to the system manager side panel from the terminal header.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 04:31:01 +08:00
陈大猫
566f3e3c32 Merge pull request #1391 from binaricat/fix/history-scope-tab-style
fix(terminal): align history scope tab styles with system manager panel
2026-06-11 04:26:17 +08:00
bincxz
58eb91fb23 fix(terminal): align history scope tab styles with system manager panel
Use the shared bg-muted selected state so history host/global tabs match the system manager tab styling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 04:24:48 +08:00
陈大猫
36267717ac feat(terminal): add system manager side panel for processes, tmux, and Docker
Introduce workspace-aware System side panel with remote process/tmux/Docker management, terminal popup for interactive attach, capability warmup, review-hardened IPC, performance optimizations, toast action errors, and SSH channel recovery on reconnect.
2026-06-11 04:19:21 +08:00
陈大猫
5e323f1f8f feat(terminal): add global command history tab in side panel (#1387)
Record commands as users type across sessions with dedup and AI-noise
filtering, and browse them alongside per-host remote history. Refine the
scope switcher UI and route fullscreen layout recovery through the terminal
backend hook. Closes #1253.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 03:14:30 +08:00
陈大猫
c0efc9d5c1 feat(ai): add quick messages with slash command picker (#691)
Closes #691
2026-06-11 03:11:39 +08:00
陈大猫
61188ab8e2 fix(ai): reset panel view when deleting active chat session (#1386)
Fixes #1382

Deleting the active AI chat session left panelViewByScope pointing at a removed session, causing the side panel UI to blank. Sync panel view cleanup with session deletion, stop in-flight streams on delete, and return to draft with the deleted session's agent preserved.
2026-06-10 23:22:19 +08:00
陈大猫
ae209d37c1 feat(terminal): remote command history side panel (#1385)
* feat(terminal): add remote command history side panel

Read remote shell history over SSH/ET/Mosh exec channels, browse it in a virtualized side panel with search, paste, and save-as-snippet actions. Closes #1381.

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

* fix(history): expand command detail inline below selected row

Move the detail strip from a fixed slot above the list into the row
immediately below the clicked entry so expansion reads top-to-bottom.

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

* fix(history): filter Netcatty AI PTY commands from remote history

Drop shell history lines containing the __NCMCP_ marker so AI exec noise
does not clutter the command history panel.

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

* fix(history): tighten detail strip and add run action

Size the expanded row to its content, add a run-in-terminal button, and
use clearer snippet icon/tooltip for save-as-snippet.

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

* fix(history): address review findings before merge

Key cache by host+session, retry Mosh pending reads, and clamp virtual
list scroll position when filtered items shrink.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 23:19:31 +08:00
陈大猫
a5b0efba75 fix(hotkeys): toggle quick switcher with the same shortcut (#1384)
Allow the quick switch shortcut to close the panel when pressed again,
including while the search input is focused, so users are not limited to
clicking outside to dismiss it.

Closes #1355

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 23:00:13 +08:00
陈大猫
5adb64e40e fix(terminal): recover terminal fit after app background and fullscreen (#1383)
Restore resize when macOS App Nap or GPU eviction leaves xterm stale after
switching away, by refitting on visibility/focus/fullscreen and fixing the
ResizeObserver race with async xterm boot.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 22:43:16 +08:00
陈大猫
41fea1028d Merge pull request #1380 from binaricat/fix/issue-1363-nerd-font-glyphs-on-startup
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(terminal): refresh Nerd Font glyphs on cold start (fixes #1363)
2026-06-10 19:59:13 +08:00
bincxz
5a90a4331b fix(terminal): refresh Nerd Font glyphs after bundled fonts load
Cold-start local terminals on Linux could cache Powerline icon tofu when the
shell prompt arrived before Symbols Nerd Font Mono finished loading. Preload
the icon fallback at the active cell size and clear the xterm atlas so
already-drawn prompt glyphs re-rasterize (fixes #1363).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 19:55:46 +08:00
秋秋
881f3b1a34 feat(et): support server stats for EternalTerminal sessions (#1377)
* feat(et): support server stats for EternalTerminal sessions

- Generalize the Mosh stats companion into reusable connection helpers
- Open a companion SSH connection so the host-info bar works for ET sessions
- Fall back to execOnEtSession for jumped ET sessions without a direct connection
- Forward host-key and algorithm options to the ET backend for companion parity
- Close the ET stats companion on session close, cleanup, and PTY exit

* fix(et): harden stats exec host-key trust and cleanup

Enforce StrictHostKeyChecking=yes for background ET stats/distro probes
instead of accept-new, merge vault known_hosts for parity with ssh2
companions, and wrap companion connection teardown in try/catch.

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

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 19:46:24 +08:00
陈大猫
8be5865b76 fix(terminal): isolate workspace pane font zoom from global settings (#1379)
Store per-session font size in workspace splits so Ctrl+wheel zoom no longer
changes sibling panes or reverts on blur when terminalSettings re-sync runs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 19:38:39 +08:00
陈大猫
685d1cb41a Merge pull request #1378 from binaricat/fix/issue-1375-shell-only-tab-numbers
feat(shortcuts): add option to skip pinned tabs for number keys
2026-06-10 19:15:48 +08:00
bincxz
14fe1e3ecb feat(shortcuts): add option to skip pinned tabs for number keys
Fixes #1375 by letting Cmd/Ctrl+[1...9] target only work tabs when enabled, while keeping the existing Terminus-style default.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 19:15:15 +08:00
陈大猫
636f4d7037 fix(terminal): reflow workspace when compose bar or side panel layout changes (#1376)
Keep the compose bar inside the terminal workspace so SFTP side panels stay full height, refit xterm when the bar toggles, and remeasure split-pane geometry when side panels open or close.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 19:00:46 +08:00
lengyuqu
c92ad2f601 fix: resolve merge artifacts - duplicate div tag and undefined deps 2026-06-10 16:01:35 +08:00
陈大猫
8a876fd67d Merge pull request #1372 from binaricat/fix/settings-remove-lazy-tabs
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(settings): eager-load AI and sync tabs
2026-06-10 15:45:12 +08:00
bincxz
d39cd60863 fix(settings): eager-load AI and sync tabs
Remove React.lazy/Suspense for settings AI and sync tabs to avoid loading flashes when switching tabs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 15:44:34 +08:00
lengyuqu
602ca92476 merge: sync fork with upstream/main (v1.1.31+) 2026-06-10 15:42:24 +08:00
陈大猫
f413035295 fix(terminal): refocus input when switching work tabs on macOS (#1371)
Restore xterm keyboard focus after top-level tab changes so macOS users
can type immediately without an extra click (discussion #1339).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 15:27:14 +08:00
陈大猫
bfd3fb4dad feat(terminal): enhance compose bar with quick snippets and resizable input (#1370)
Add a persistent quick-snippet strip, draggable height, and terminal-matched UI to the compose bar, addressing quick-command and resize requests from community discussions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 15:18:37 +08:00
陈大猫
733e19a6f6 perf(settings): reduce Mac settings window input lag (#1347) (#1368)
* perf(settings): reduce Mac settings window input lag (#1347)

Debounce custom CSS commits, memoize heavy tabs, and replace Radix ScrollArea
with native scrolling so typing and navigation stay responsive on macOS.

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

* fix(settings): flush debounced textarea on unmount

Avoid losing custom CSS edits when the settings window closes before the
debounce timer fires.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 14:45:33 +08:00
陈大猫
85b552e1a6 fix(terminal): fix black block glyphs on Linux local terminal (#1364) (#1369)
* fix(terminal): resolve bold font weight without document.fonts.check false positives

Chromium reports unavailable bold weights as available, so xterm tried to rasterize weight 700 while the bundled JetBrains Mono fallback only ships 400/500/600. Bold glyphs then rendered as black blocks on Linux local terminals (fixes #1364).

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

* chore: drop unused primaryFontFamily from terminal effects context

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 14:39:36 +08:00
陈大猫
068730c53c fix(ui): improve host tree inline group rename interactions (#1367)
* fix(ui): improve host tree inline group rename interactions

Cancel rename when clicking another tree row and prevent parent drag from blocking text selection in the rename input.

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

* fix(ui): block group toggle keyboard while inline renaming

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

* fix(ui): cancel host inline rename when clicking other tree rows

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 14:38:33 +08:00
陈奇
c9d84c7ce3 Merge commit 'refs/restore/pr-1365'
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
2026-06-10 04:22:46 +00:00
陈奇
d558aea7de Merge commit 'refs/restore/pr-1361' 2026-06-10 04:22:44 +00:00
陈奇
e211eec693 Merge commit 'refs/restore/pr-1360' 2026-06-10 04:22:41 +00:00
陈奇
6b1277d3e1 fix(packaging): refresh hicolor icon cache in FPM after-install to fix Arch pacman icon (#1358)
Root cause: FPM-generated .pacman packages copy icons directly to
/usr/share/icons/hicolor/*/apps/netcatty.png, bypassing Arch's alpm
hooks that normally run gtk-update-icon-cache. Without a refreshed
cache, KDE Plasma cannot resolve Icon=netcatty and falls back to a
generic document icon in the app menu.

Fix:
- Copy electron-builder's default after-install template to
  scripts/linux/after-install.tpl, append gtk-update-icon-cache call
- Create scripts/linux/after-remove.tpl with the same cache refresh
- Wire into pacman.afterInstall/pacman.afterRemove
  (NOT linux.afterInstall — the schema places these under target-level
  options like PacmanOptions/DebOptions, not LinuxConfiguration)
- Add test in electron-builder-config.test.cjs

The command is idempotent on systems without gtk-update-icon-cache
(hash guard) and uses || true to never break package installation.
2026-06-10 03:24:18 +00:00
bincxz
35bf38be70 Improve host tree rename and hover details 2026-06-10 11:04:36 +08:00
bincxz
555c00406e Polish vault and log icons 2026-06-10 10:41:42 +08:00
bincxz
e67012654a Improve SFTP bookmark list accessibility 2026-06-10 10:25:19 +08:00
bincxz
ecdb1d17cd Address SFTP toolbar review feedback 2026-06-10 10:22:51 +08:00
bincxz
a5578b5e60 Refine SFTP toolbar view and bookmarks 2026-06-10 10:16:44 +08:00
陈大猫
fb4641878f Merge pull request #1354 from binaricat/fix/issue-1352-window-controls
fix(ui): restore Windows title bar window control hover (#1352)
2026-06-10 01:32:31 +08:00
bincxz
7d6f30f51f fix(ui): restore Windows title bar window control hover (#1352)
Align window controls with utility icons, extend hover to the full title bar height, restore red close-button hover, flush close to the right edge, and use neutral gray hover for top-bar utility buttons.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 01:29:49 +08:00
陈大猫
9869b645b1 Merge pull request #1353 from binaricat/fix/active-chrome-theme-split-autocomplete
fix(ui): smooth work-tab chrome transitions and split-pane autocomplete
2026-06-10 01:17:34 +08:00
bincxz
037b85bd66 fix(ui): smooth work-tab chrome transitions and split-pane autocomplete
Replace immersive instant-switch with animated active chrome theme sync so
top tabs match terminal sessions immediately on tab click, and clamp
autocomplete popups to the active pane so they stay anchored to the cursor
in split workspaces.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 01:10:33 +08:00
陈大猫
ba784b8b35 fix: resolve .cmd shim to native exe on Windows to avoid spawn EINVAL (#1350) (#1351)
Windows + Node >= 24: spawning .cmd files with shell=false causes EINVAL.
Claude Code v2.1.169 ships as native binary (no cli.js), npm global install
creates only claude.cmd. Netcatty detected claude.cmd but spawned it with
shell:false -> EINVAL.

Changes:
- resolveWindowsShimToNativeExe: new function that reads .cmd/.bat shims
  and resolves to the real .exe using "%~dp0\...\*.exe" pattern matching
- prepareCommandForSpawn: tries native exe resolution first, falls back
  to shell:true wrapping
- resolveClaudeCodeExecutableForSdk: when cli.js not found, looks for
  bin/claude.exe native binary
- 3 new tests for shim resolution and spawn spec
- Codex CLI unaffected (already handles native exe resolution)

Test: 38/38 shellUtils tests pass, npx tsc --noEmit clean
2026-06-09 23:40:21 +08:00
陈大猫
eae760db3f fix: upload terminal drops to current cwd
Fix terminal drag-and-drop uploads so they target the active terminal cwd and avoid fallback home/login-shell cwd when the active cwd cannot be confirmed.
2026-06-09 21:25:32 +08:00
陈大猫
4b5993cad6 fix host sidebar behavior for editor tabs (#1348) 2026-06-09 21:24:16 +08:00
陈大猫
6af62aa093 Add Arch pacman Linux package (#1344) 2026-06-09 21:07:56 +08:00
陈奇
61e8de4270 fix: synchronous preventDefault in paste handler + text paste fallback
- event.preventDefault() must be called synchronously before the
  async IPC call, otherwise the browser processes the default paste
  action before we can intercept it
- When clipboard has no files (or on error), fall back to text paste
  via pasteTextIntoTerminal since the default action was already
  prevented
2026-06-09 12:13:59 +00:00
陈大猫
27dce4e427 feat: local terminal paste file inserts file path (#1345) (#1346)
When pasting (Ctrl+V / right-click paste) in a local terminal,
if the clipboard contains files, insert their paths instead of
doing nothing.

- New hook useTerminalFilePaste: capture-phase paste listener
  on terminal container, reads clipboard files via Electron bridge,
  formats paths (spaces quoted, deduped), writes to session
- Updated useTerminalContextActions: right-click paste checks
  clipboard files first, falls back to text paste
- New terminalHelpers.extractRootPathsFromClipboardFiles
- Tests: 9 unit tests for path extraction logic
- Verified via headless Chromium integration test (15 tests)
- Build: npm run build , npm test  (1899 pass)
2026-06-09 20:11:12 +08:00
Xuer
8b53fb1c7b feat(snippets): 为快速添加弹窗补充仅粘贴选项 (#1342)
终端侧快速添加片段时无法设置 noAutoRun,与完整编辑面板行为不一致,
补充该选项以便在终端上下文中直接创建仅粘贴不执行的片段。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 18:56:49 +08:00
陈大猫
6c1661dc3c fix: keep connection logs fully visible (#1343) 2026-06-09 18:52:30 +08:00
陈大猫
3662b45121 fix(ai): resolve Windows Codex npm shims before SDK spawn (#1337)
* fix(ai): resolve Windows Codex npm shims before SDK spawn

Codex SDK spawns codexPathOverride without shell:true, so passing
codex.cmd triggers spawn EINVAL on Node 18+. Rewrite npm shims to the
native codex.exe (mirroring #1102 for Claude) on SDK paths only.

Fixes #1101

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

* fix(ai): drop unused ctx export and restore test file encoding

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

* fix(ai): harden Windows Codex SDK executable resolution

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 18:34:45 +08:00
陈大猫
437253179e fix(linux): Ubuntu software icon missing due to single-size linux.icon override (#1341)
* fix(linux): restore multi-size hicolor icons for Ubuntu launchers (#1340)

PR #816 set linux.icon to a single 1024px PNG, which regressed the #274
fix and left only hicolor/1024x1024 on .deb installs. Drop the override
so electron-builder uses build/icons again, regenerate those PNGs from the
tight-crop icon-win source, and add a helper script plus a config test.

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

* fix(linux): set linux.icon to icons dir for proper multi-size hicolor icons (#1340)

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 18:26:07 +08:00
Pyro
d85f4edbbb fix: host tree tab jump on first terminal open (#1331) 2026-06-09 17:27:10 +08:00
陈大猫
96c9ccaaa0 fix(vault): add duplicate host to tree view context menu (#1336)
* fix(vault): add duplicate host action to tree view context menu

Wire the existing onDuplicateHost handler into vault host tree menus so
tree view matches grid/list duplicate behavior. Fixes #1329.

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

* fix(vault): switch to hosts section when duplicating from terminal tree

Ensure the host details panel is visible after duplicate is triggered
from the terminal host tree sidebar.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 17:20:12 +08:00
lengyuqu
3203ed7a19 merge: sync fork with upstream/main (latest) 2026-06-09 13:35:13 +08:00
陈大猫
517cbb6cee fix(ai): compress Catty requests only after 413 (#1327)
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
* fix(ai): compress Catty requests only after 413

* fix(ai): retry 413 after tool progress safely

* fix(ai): mark thrown 413 retries after tool progress

* fix(ai): preserve tool results in 413 retry
2026-06-09 13:11:42 +08:00
陈大猫
3bc373dbec Expand custom CSS hooks (#1326) 2026-06-09 12:36:15 +08:00
陈大猫
273fe10296 fix(ui): keep terminal tabs clear of host tree (#1325) 2026-06-09 12:12:41 +08:00
陈大猫
2a10a28cc8 fix(ai): cap Catty agent request payload to prevent HTTP 413 (#1323) (#1324)
* fix(ai): cap Catty agent request payload to prevent HTTP 413

Long-running chats accumulated full terminal tool outputs in SDK history
while token-based compaction only triggered near the model context window,
so nginx gateways could reject oversized JSON bodies before the model saw them.

Add a byte-budget pass that compresses verbose output, tail-preserving
truncation, and a safe sliding window before each Catty agent turn.

Fixes #1323

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

* fix(ai): compaction was using unfiltered sdkMessages, fix didAdjust and emergency loop

* fix(ai): compact and retry oversized Catty requests

* fix(ai): preserve current input while guarding 413 retries

* fix(ai): avoid false 413 detection and fit oversized current input

* fix(ai): pair replayed tool results chronologically

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 11:51:03 +08:00
陈奇
f74645e1a4 chore: upgrade Electron to 42.3.3, @electron/asar to 4.2.0, electron-builder to 26.15.2 2026-06-08 22:48:02 +00:00
lengyuqu
846d8246a3 Merge branch 'binaricat:main' into codebuddy 2026-06-08 23:44:08 +08:00
lengyuqu
26a04b22d3 merge: sync fork with upstream/main 2026-06-08 21:44:19 +08:00
lengyuqu
f5f55ffc2e 完整修复功能 2026-06-08 21:35:44 +08:00
lengyuqu
0792ce1415 codebuddy sdk 接入 2026-06-08 20:10:27 +08:00
lengyuqu
eca23a2691 Merge remote-tracking branch 'origin/codebuddy' into codebuddy 2026-06-08 19:02:54 +08:00
lengyuqu
aa1781577b 修复检查无 cli 的问题 2026-06-04 20:57:23 +08:00
lengyuqu
409d293faa 修复缺陷 2026-06-04 20:43:18 +08:00
lengyuqu
39fea86f13 过滤一些工具文件 2026-06-04 18:51:00 +08:00
lengyuqu
ce5d1d0e5a 文件遗漏 2026-06-04 18:49:07 +08:00
lengyuqu
7ac29366ae 修正 codebuddy认证服务 2026-06-04 18:48:34 +08:00
lengyuqu
4860581525 Merge branch 'binaricat:main' into codebuddy 2026-06-04 16:17:35 +08:00
lengyuqu
d9156349e1 Merge branch 'binaricat:main' into codebuddy 2026-06-01 21:41:08 +08:00
lengyuqu
983b0b2f1d 修复 CodeBuddy 未检测时无法预配置 API Key 的问题
当 discovered_codebuddy 条目不存在时,updateCodebuddyEnv 现在会创建
一个 disabled 的托管条目,允许用户在安装 CLI 前预配置认证信息。
2026-06-01 21:28:44 +08:00
lengyuqu
a552c14cbd Delete .qoder directory 2026-06-01 21:06:26 +08:00
lengyuqu
3f5787ceb1 Merge branch 'binaricat:main' into codebuddy 2026-06-01 21:05:51 +08:00
lengyuqu
e4ec2363d0 md 2026-06-01 21:03:53 +08:00
lengyuqu
84b71910ee 修复 CodeBuddy CLI 集成缺陷,对齐 Claude/Codex 标准
- 修复 ALLOWED_AGENT_COMMANDS 缺少 codebuddy(非 ACP 回退路径)
- 添加 CODEBUDDY_API_KEY 预检(list-models + stream handler)
- 添加 codebuddyAuthPresence 认证状态检测
- 错误时强制清理 provider 进程(避免僵尸进程)
- 认证失败时显示友好错误提示(引导用户配置 API Key)
2026-06-01 20:55:35 +08:00
lengyuqu
371217832b 移除 CodeBuddy auto 模型预设,新增 tool call 分组折叠
- 移除 CODEBUDDY_MODEL_PRESETS 常量及 getAgentModelPresets 中的 codebuddy 分支(auto 模型实际不可用)
- 新增 ToolCallGroup 组件,支持 Codex 风格可折叠 tool call 分组
- ChatMessageList 将连续 tool 消息、底部 pending calls、内嵌未解析 calls 分组展示
- 流式输出时自动展开,完成后自动折叠,支持手动切换
2026-06-01 20:22:08 +08:00
lengyuqu
afb514b472 md 2026-06-01 18:24:41 +08:00
lengyuqu
e14dc22bba 修正 bug 2026-06-01 18:14:48 +08:00
lengyuqu
6b7c12c23c Merge branch 'binaricat:main' into codebuddy 2026-06-01 16:40:44 +08:00
lengyuqu
222b3869dd 修复 codebuddy 的 acp 路径问题 2026-05-29 15:35:19 +08:00
lengyuqu
56af2d3840 Merge branch 'binaricat:main' into codebuddy 2026-05-29 15:32:07 +08:00
lengyuqu
1695470089 修复 codebuddy cli 2026-05-28 23:50:51 +08:00
lengyuqu
d4b5f799cb 添加 codebuddy cli 2026-05-28 23:50:15 +08:00
650 changed files with 45940 additions and 5411 deletions

View File

@@ -34,7 +34,7 @@ body:
attributes:
label: How did you install Netcatty?
options:
- GitHub Release (.dmg / .exe / .AppImage / .deb)
- GitHub Release (.dmg / .exe / .AppImage / .deb / .rpm / .pacman)
- Homebrew
- Built from source (npm run dev / pack)
- Other

View File

@@ -50,6 +50,7 @@ const baseUrl = `https://github.com/${repo}/releases/download/${tag}`;
// - AppImage: x64 -> x86_64, arm64 -> arm64
// - deb: x64 -> amd64, arm64 -> arm64
// - rpm: x64 -> x86_64, arm64 -> aarch64
// - pacman: x64 -> x64, arm64 -> aarch64
const files = {
mac: {
arm64: `Netcatty-${version}-mac-arm64.dmg`,
@@ -70,6 +71,10 @@ const files = {
rpm: {
x64: `Netcatty-${version}-linux-x86_64.rpm`,
arm64: `Netcatty-${version}-linux-aarch64.rpm`
},
pacman: {
x64: `Netcatty-${version}-linux-x64.pacman`,
arm64: `Netcatty-${version}-linux-aarch64.pacman`
}
}
};
@@ -88,7 +93,9 @@ const badges = {
deb_x64: `[![DebPackage x64](https://img.shields.io/badge/DebPackage-x64-A80030?style=flat-square&logo=debian)](${baseUrl}/${files.linux.deb.x64})`,
deb_arm64: `[![DebPackage arm64](https://img.shields.io/badge/DebPackage-arm64-A80030?style=flat-square&logo=debian)](${baseUrl}/${files.linux.deb.arm64})`,
rpm_x64: `[![RpmPackage x64](https://img.shields.io/badge/RpmPackage-x64-CC0000?style=flat-square&logo=redhat)](${baseUrl}/${files.linux.rpm.x64})`,
rpm_arm64: `[![RpmPackage arm64](https://img.shields.io/badge/RpmPackage-arm64-CC0000?style=flat-square&logo=redhat)](${baseUrl}/${files.linux.rpm.arm64})`
rpm_arm64: `[![RpmPackage arm64](https://img.shields.io/badge/RpmPackage-arm64-CC0000?style=flat-square&logo=redhat)](${baseUrl}/${files.linux.rpm.arm64})`,
pacman_x64: `[![ArchPackage x64](https://img.shields.io/badge/ArchPackage-x64-1793D1?style=flat-square&logo=archlinux)](${baseUrl}/${files.linux.pacman.x64})`,
pacman_arm64: `[![ArchPackage arm64](https://img.shields.io/badge/ArchPackage-arm64-1793D1?style=flat-square&logo=archlinux)](${baseUrl}/${files.linux.pacman.arm64})`
}
};
@@ -99,7 +106,7 @@ const content = `
| :--- | :--- |
| **Windows** | ${badges.win.setup_x64} |
| **macOS** | ${badges.mac.apple_silicon} ${badges.mac.intel} |
| **Linux** | ${badges.linux.appimage_x64} ${badges.linux.deb_x64} ${badges.linux.rpm_x64} <br> ${badges.linux.appimage_arm64} ${badges.linux.deb_arm64} ${badges.linux.rpm_arm64} |
| **Linux** | ${badges.linux.appimage_x64} ${badges.linux.deb_x64} ${badges.linux.rpm_x64} ${badges.linux.pacman_x64} <br> ${badges.linux.appimage_arm64} ${badges.linux.deb_arm64} ${badges.linux.rpm_arm64} ${badges.linux.pacman_arm64} |
`;
fs.writeFileSync('release_notes.md', content);

View File

@@ -348,6 +348,7 @@ jobs:
release/*.AppImage
release/*.deb
release/*.rpm
release/*.pacman
release/*.tar.gz
release/*.yml
release/*.blockmap
@@ -410,6 +411,9 @@ jobs:
- name: Install deps
run: npm ci
- name: Install pacman packaging dependencies
run: sudo apt-get update && sudo apt-get install -y libarchive-tools
- name: Set version
shell: bash
run: |
@@ -457,6 +461,7 @@ jobs:
release/*.AppImage
release/*.deb
release/*.rpm
release/*.pacman
release/*.yml
release/*.blockmap
if-no-files-found: ignore
@@ -510,6 +515,7 @@ jobs:
run: |
apt-get update
apt-get install -y curl build-essential python3 git libfuse2 file rpm \
libarchive-tools \
libglib2.0-0 libgtk-3-0 libnss3 libxss1 libxtst6 libasound2 \
libatk-bridge2.0-0 libdrm2 libgbm1 libx11-xcb1 libxcb-dri3-0
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
@@ -568,6 +574,7 @@ jobs:
release/*.AppImage
release/*.deb
release/*.rpm
release/*.pacman
release/*.yml
release/*.blockmap
if-no-files-found: ignore
@@ -673,6 +680,7 @@ jobs:
artifacts/*.AppImage
artifacts/*.deb
artifacts/*.rpm
artifacts/*.pacman
artifacts/*.yml
artifacts/*.blockmap
generate_release_notes: true

10
.gitignore vendored
View File

@@ -8,6 +8,7 @@ pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
@@ -41,6 +42,15 @@ coverage
# Codex
/.codex/
# Qoder
.qoder
# Workbuddy
.workbuddy
# Codebuddy
.codebuddy
# AI / Superpowers generated docs (local only)
/docs/superpowers/

44
App.tsx
View File

@@ -28,6 +28,7 @@ import { resolveHostAuth } from './domain/sshAuth';
import { isEncryptedCredentialPlaceholder } from './domain/credentials';
import {
mergeTerminalHostUpdate,
type TerminalHostUpdate,
} from './domain/terminalAppearance';
import { selectConnectionLogForTerminalDataCapture } from './domain/connectionLog';
import { collectSessionIds } from './domain/workspace';
@@ -138,7 +139,7 @@ function App({ settings }: { settings: SettingsState }) {
sessionLogsDir,
sessionLogsFormat,
sessionLogsTimestampsEnabled,
reapplyCurrentTheme,
applyAppTheme,
workspaceFocusStyle,
} = settings;
@@ -193,7 +194,6 @@ function App({ settings }: { settings: SettingsState }) {
const keysRef = useRef(keys);
keysRef.current = keys;
const knownHostsRef = useRef(knownHosts);
knownHostsRef.current = knownHosts;
// Bridge the gap while useVaultState hydrates: its async init awaits
// hosts/keys/identities/proxyProfiles decryption before reading knownHosts,
// so the state is briefly [] at boot even when localStorage has entries.
@@ -204,6 +204,7 @@ function App({ settings }: { settings: SettingsState }) {
() => getEffectiveKnownHosts(knownHosts) ?? [],
[knownHosts],
);
knownHostsRef.current = effectiveKnownHosts;
const {
sessions,
@@ -215,6 +216,7 @@ function App({ settings }: { settings: SettingsState }) {
sessionRenameValue,
setSessionRenameValue,
startSessionRename,
renameSessionInline,
submitSessionRename,
resetSessionRename,
workspaceRenameTarget,
@@ -229,9 +231,12 @@ function App({ settings }: { settings: SettingsState }) {
closeSession,
closeWorkspace,
updateSessionStatus,
updateSessionFontSize,
clearSessionFontSizeOverride,
createWorkspaceWithHosts,
createWorkspaceFromSessions,
addSessionToWorkspace,
removeSessionFromWorkspace,
appendHostToWorkspace,
appendLocalTerminalToWorkspace,
createWorkspaceFromTargets,
@@ -244,6 +249,7 @@ function App({ settings }: { settings: SettingsState }) {
runSnippet,
orphanSessions,
orderedTabs,
getOrderedWorkTabs,
reorderTabs,
toggleBroadcast,
isBroadcastEnabled,
@@ -267,12 +273,11 @@ function App({ settings }: { settings: SettingsState }) {
const isMacClient = typeof navigator !== 'undefined' && /Mac|Macintosh/.test(navigator.userAgent);
// ---------------------------------------------------------------------------
// Immersive Mode — derive UI chrome colors from the active terminal's theme
// Active tab lookup maps
// ---------------------------------------------------------------------------
const customThemes = useCustomThemes();
const editorTabs = useEditorTabs();
// Resolve the effective TerminalTheme for the currently focused terminal tab
const hostById = useMemo(
() => new Map(hosts.map((host) => [host.id, host])),
[hosts],
@@ -291,8 +296,8 @@ function App({ settings }: { settings: SettingsState }) {
() => new Map([...customThemes, ...TERMINAL_THEMES].map((theme) => [theme.id, theme])),
[customThemes],
);
// activeTabId-derived chrome (immersive theme, window title, sftp guard) is
// owned by <AppActiveTabChrome/> so switching tabs does not re-render App.
// activeTabId-derived chrome (window title, sftp guard) is owned by
// <AppActiveTabChrome/> so switching tabs does not re-render App.
useEffect(() => {
const bridge = netcattyBridge.get();
@@ -697,12 +702,25 @@ function App({ settings }: { settings: SettingsState }) {
const closeTabsInFlightRef = useRef(false);
const editorTabTopIds = useMemo(
() => editorTabs.map((tab) => toEditorTabId(tab.id)),
[editorTabs],
);
// 顶层标签顺序需要包含编辑器标签,供顶部标签和编辑器邻居计算使用。
const orderedTabsWithEditors = useMemo(
() => [...orderedTabs, ...editorTabs.map((tab) => toEditorTabId(tab.id))],
[orderedTabs, editorTabs],
() => getOrderedWorkTabs(editorTabTopIds),
[editorTabTopIds, getOrderedWorkTabs],
);
const reorderWorkTabs = useCallback((
draggedId: string,
targetId: string,
position: 'before' | 'after' = 'before',
) => {
reorderTabs(draggedId, targetId, position, editorTabTopIds);
}, [editorTabTopIds, reorderTabs]);
// Close many tabs at once with a single batched busy-shell confirmation.
// Used by the "Close all / Close others / Close to the right" context-menu
// actions on tabs (#748).
@@ -712,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, lastMoveFocusTimeRef, moveFocusInWorkspace, orderedTabs, resolveCloseIntent, resolveSnippetsShortcutIntent, sessions, setActiveTabId, setAddToWorkspaceDialog, setIsQuickSwitcherOpen, setNavigateToSection, settings, splitSessionWithCurrentShell, systemInfoRef, toEditorTabId, toggleBroadcast, toggleScriptsSidePanelRef, toggleSidePanelRef, workspaces }), action, e); }, [orderedTabs, editorTabs, sessions, workspaces, 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"]'));
@@ -859,7 +877,7 @@ function App({ settings }: { settings: SettingsState }) {
}
}, [updateSessionStatus, updateHostLastConnected]);
const handleUpdateHostFromTerminal = useCallback((host: Host) => {
const handleUpdateHostFromTerminal = useCallback((host: TerminalHostUpdate) => {
updateHosts(hosts.map((h) => (
h.id === host.id ? mergeTerminalHostUpdate(h, host) : h
)));
@@ -959,20 +977,20 @@ function App({ settings }: { settings: SettingsState }) {
<AppActiveTabChrome
showSftpTab={settings.showSftpTab}
setActiveTabId={setActiveTabId}
applyAppTheme={applyAppTheme}
hostById={hostById}
sessionById={sessionById}
workspaceById={workspaceById}
themeById={themeById}
workspaceById={workspaceById}
currentTerminalTheme={currentTerminalTheme}
followAppTerminalTheme={followAppTerminalTheme}
accentMode={accentMode}
customAccent={customAccent}
reapplyCurrentTheme={reapplyCurrentTheme}
editorTabs={editorTabs}
logViews={logViews}
t={t}
/>
<AppView ctx={{ accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace, clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, 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, reorderTabs, 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, 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,10 +1,19 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { 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';
class FakeInputHTMLElement {
tagName = 'INPUT';
isContentEditable = false;
closest(): FakeInputHTMLElement | null {
return null;
}
}
class FakeHTMLElement {
tagName = 'TEXTAREA';
isContentEditable = false;
@@ -68,3 +77,119 @@ test('global hotkey handler lets terminal font size shortcuts reach xterm', () =
assert.equal(prevented, false);
assert.equal(stopped, false);
});
test('global hotkey handler routes quick switch through focused search inputs', () => {
const target = new FakeInputHTMLElement();
const handledActions: string[] = [];
const event = {
key: 'j',
code: 'KeyJ',
ctrlKey: true,
metaKey: false,
altKey: false,
shiftKey: false,
target,
composedPath: () => [target],
preventDefault: () => {},
stopPropagation: () => {},
} as unknown as KeyboardEvent;
handleGlobalHotkeyKeyDownImpl(
() => ({
HOTKEY_DEBUG: false,
closeTabKeyStr: 'Ctrl + W',
executeHotkeyAction: (action: string) => {
handledActions.push(action);
},
hotkeyScheme: 'pc',
keyBindings: DEFAULT_KEY_BINDINGS,
matchesKeyBinding,
}),
event,
);
assert.deepEqual(handledActions, ['quickSwitch']);
});
test('quick switch hotkey toggles the quick switcher open state', () => {
let isQuickSwitcherOpen = false;
const setIsQuickSwitcherOpen = (next: boolean) => {
isQuickSwitcherOpen = next;
};
const noop = () => {};
const baseCtx = {
IS_DEV: false,
MOVE_FOCUS_DEBOUNCE_MS: 0,
activeTabStore: { getActiveTabId: () => 'vault' },
addConnectionLogRef: { current: noop },
closeSession: noop,
closeTabInFlightRef: { current: false },
closeWorkspace: noop,
collectSessionIds: () => [],
confirmIfBusyLocalTerminal: async () => true,
createLocalTerminalWithCurrentShell: noop,
editorTabs: [],
fromEditorTabId: () => null,
handleOpenSettingsRef: { current: noop },
handleRequestCloseEditorTabRef: { current: noop },
isEditorTabId: () => false,
isQuickSwitcherOpen,
lastMoveFocusTimeRef: { current: 0 },
moveFocusInWorkspace: noop,
orderedTabs: [],
resolveCloseIntent: () => ({ kind: 'noop' }),
resolveSnippetsShortcutIntent: () => ({ kind: 'noop' }),
sessions: [],
setActiveTabId: noop,
setAddToWorkspaceDialog: noop,
setIsQuickSwitcherOpen,
setNavigateToSection: noop,
settings: { showSftpTab: true, shellOnlyTabNumberShortcuts: false },
splitSessionWithCurrentShell: noop,
systemInfoRef: { current: { username: 'user', hostname: 'host' } },
toEditorTabId: (id: string) => `editor:${id}`,
toggleBroadcast: noop,
toggleScriptsSidePanelRef: { current: noop },
toggleSidePanelRef: { current: noop },
workspaces: [],
};
const event = {
key: 'j',
code: 'KeyJ',
ctrlKey: true,
metaKey: false,
altKey: false,
shiftKey: false,
} as KeyboardEvent;
executeHotkeyActionImpl(() => baseCtx, 'quickSwitch', event);
assert.equal(isQuickSwitcherOpen, true);
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

@@ -5,14 +5,10 @@ import {
isEditorTabId,
useActiveTabId,
} from '../state/activeTabStore';
import { setImmersiveActive } from '../state/immersiveStore';
import { useImmersiveMode } from '../state/useImmersiveMode';
import { updateActiveChromeThemeDeps } from '../state/activeChromeThemeSync';
import { useActiveChromeTheme } from '../state/useActiveChromeTheme';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import {
applyCustomAccentToTerminalTheme,
resolveHostTerminalThemeId,
} from '../../domain/terminalAppearance';
import { collectSessionIds } from '../../domain/workspace';
import { resolveActiveChromeTheme } from './activeChromeTheme';
import type {
Host,
TerminalSession,
@@ -25,15 +21,15 @@ import type { EditorTab } from '../state/editorTabStore';
interface AppActiveTabChromeProps {
showSftpTab: boolean;
setActiveTabId: (id: string) => void;
applyAppTheme: () => void;
hostById: Map<string, Host>;
sessionById: Map<string, TerminalSession>;
workspaceById: Map<string, Workspace>;
themeById: Map<string, TerminalTheme>;
workspaceById: Map<string, Workspace>;
currentTerminalTheme: TerminalTheme;
followAppTerminalTheme: boolean;
accentMode: 'theme' | 'custom';
customAccent: string;
reapplyCurrentTheme: () => void;
editorTabs: readonly EditorTab[];
logViews: readonly LogView[];
t: (key: string) => string;
@@ -41,27 +37,24 @@ interface AppActiveTabChromeProps {
/**
* Owns the `activeTabId` subscription and the purely side-effectful "chrome"
* work derived from it: immersive-mode theming, window title, and the
* SFTP-tab guard. Extracted out of <App> so that switching top tabs only
* work derived from it: window title and the SFTP-tab guard.
* Extracted out of <App> so that switching top tabs only
* re-renders this null-rendering component (and the self-subscribing leaves)
* instead of forcing the entire App tree (which holds all vault/session/
* settings state and rebuilds the giant AppView ctx) to re-render.
*
* Renders nothing; publishes "immersive active" to immersiveStore so AppView
* and TopTabs can read it without re-rendering App.
*/
export function AppActiveTabChrome({
showSftpTab,
setActiveTabId,
applyAppTheme,
hostById,
sessionById,
workspaceById,
themeById,
workspaceById,
currentTerminalTheme,
followAppTerminalTheme,
accentMode,
customAccent,
reapplyCurrentTheme,
editorTabs,
logViews,
t,
@@ -74,55 +67,43 @@ export function AppActiveTabChrome({
}
}, [showSftpTab, activeTabId, setActiveTabId]);
const activeTerminalTheme = useMemo<TerminalTheme | null>(() => {
if (activeTabId === 'vault' || activeTabId === 'sftp') return null;
const chromeThemeDeps = useMemo(() => ({
accentMode,
applyAppTheme,
currentTerminalTheme,
customAccent,
editorTabs,
followAppTerminalTheme,
hostById,
logViews,
sessionById,
themeById,
workspaceById,
}), [
accentMode,
applyAppTheme,
currentTerminalTheme,
customAccent,
editorTabs,
followAppTerminalTheme,
hostById,
logViews,
sessionById,
themeById,
workspaceById,
]);
const resolveTheme = (s: TerminalSession): TerminalTheme => {
let baseTheme: TerminalTheme;
if (followAppTerminalTheme) {
baseTheme = currentTerminalTheme;
} else {
const host = hostById.get(s.hostId) ?? null;
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
baseTheme = themeById.get(themeId) || currentTerminalTheme;
}
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
};
updateActiveChromeThemeDeps(chromeThemeDeps);
const workspace = workspaceById.get(activeTabId);
if (workspace) {
if (workspace.viewMode === 'focus') {
const wsSessionIds = collectSessionIds(workspace.root);
const focused = (workspace.focusedSessionId
? sessionById.get(workspace.focusedSessionId)
: null)
?? wsSessionIds.map((id) => sessionById.get(id)).find(Boolean);
return focused ? resolveTheme(focused) : null;
}
const sessionIds = collectSessionIds(workspace.root);
const wsSessions = sessionIds
.map((id) => sessionById.get(id))
.filter(Boolean) as TerminalSession[];
if (wsSessions.length === 0) return null;
const firstTheme = resolveTheme(wsSessions[0]);
const allSame = wsSessions.every((s) => resolveTheme(s).id === firstTheme.id);
return allSame ? firstTheme : null;
}
const session = sessionById.get(activeTabId);
if (!session) return null;
return resolveTheme(session);
}, [accentMode, activeTabId, currentTerminalTheme, customAccent, followAppTerminalTheme, hostById, sessionById, themeById, workspaceById]);
useImmersiveMode({
const activeChromeTheme = useMemo(() => resolveActiveChromeTheme({
...chromeThemeDeps,
activeTabId,
activeTerminalTheme,
restoreOriginalTheme: reapplyCurrentTheme,
});
}), [chromeThemeDeps, activeTabId]);
useEffect(() => {
setImmersiveActive(activeTerminalTheme !== null);
}, [activeTerminalTheme]);
useActiveChromeTheme({
activeTheme: activeChromeTheme,
applyAppTheme,
});
const editorTabFileNameCounts = useMemo(() => {
const counts = new Map<string, number>();

View File

@@ -2,11 +2,25 @@
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();
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();
{
@@ -65,6 +79,7 @@ export function handleTrayPanelConnectImpl(getCtx: AppContextGetter, hostId: str
hostname: host.hostname,
username,
protocol: 'serial',
...getLogHostVisualSnapshot(effectiveHost),
startTime: Date.now(),
localUsername: username,
localHostname: localHost,
@@ -83,6 +98,7 @@ export function handleTrayPanelConnectImpl(getCtx: AppContextGetter, hostId: str
hostname: host.hostname,
username: resolvedAuth.username || 'root',
protocol: protocol as 'ssh' | 'telnet' | 'local' | 'mosh' | 'et',
...getLogHostVisualSnapshot(effectiveHost),
startTime: Date.now(),
localUsername: username,
localHostname: localHost,
@@ -123,7 +139,11 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
target instanceof HTMLElement &&
!!target.closest?.(".xterm, .xterm-helper-textarea, .xterm-screen, .xterm-viewport");
if ((isFormElement || isMonacoElement) && !isXtermInput && e.key !== 'Escape') {
const quickSwitchBinding = keyBindings.find((binding) => binding.action === 'quickSwitch');
const quickSwitchKeyStr = quickSwitchBinding ? (isMac ? quickSwitchBinding.mac : quickSwitchBinding.pc) : null;
const isQuickSwitchHotkey = quickSwitchKeyStr ? matchesKeyBinding(e, quickSwitchKeyStr, isMac) : false;
if ((isFormElement || isMonacoElement) && !isXtermInput && e.key !== 'Escape' && !isQuickSwitchHotkey) {
return;
}
@@ -427,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, 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
@@ -436,13 +456,19 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
const allTabs = settings.showSftpTab
? ['vault', 'sftp', ...orderedTabs, ...editorTabs.map((t) => toEditorTabId(t.id))]
: ['vault', ...orderedTabs, ...editorTabs.map((t) => toEditorTabId(t.id))];
const numberShortcutTabs = buildNumberShortcutTabTargets({
showSftpTab: settings.showSftpTab ?? true,
shellOnlyTabNumberShortcuts: settings.shellOnlyTabNumberShortcuts ?? false,
orderedTabs,
editorTabIds: editorTabs.map((t) => toEditorTabId(t.id)),
});
switch (action) {
case 'switchToTab': {
// Get the number key pressed (1-9)
const num = parseInt(e.key, 10);
if (num >= 1 && num <= 9) {
if (num <= allTabs.length) {
setActiveTabId(allTabs[num - 1]);
if (num <= numberShortcutTabs.length) {
setActiveTabId(numberShortcutTabs[num - 1]);
}
}
break;
@@ -520,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
@@ -545,6 +605,8 @@ export function executeHotkeyActionImpl(getCtx: AppContextGetter, action: string
}
break;
case 'quickSwitch':
setIsQuickSwitcherOpen(!isQuickSwitcherOpen);
break;
case 'commandPalette':
setIsQuickSwitcherOpen(true);
break;
@@ -623,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();
@@ -708,6 +779,7 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host) {
hostname: host.hostname,
username: username,
protocol: 'serial',
...getLogHostVisualSnapshot(effectiveHost),
startTime: Date.now(),
localUsername: username,
localHostname: localHost,
@@ -726,6 +798,7 @@ export function handleConnectToHostImpl(getCtx: AppContextGetter, host: Host) {
hostname: host.hostname,
username: resolvedAuth.username || 'root',
protocol: protocol as 'ssh' | 'telnet' | 'local' | 'mosh' | 'et',
...getLogHostVisualSnapshot(effectiveHost),
startTime: Date.now(),
localUsername: username,
localHostname: localHost,

View File

@@ -0,0 +1,44 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const storage = new Map<string, string>();
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
},
});
const {
getAppHostTreeLayerStyle,
} = await import('./AppHostTreeLayer');
const hostTreeLayerSource = readFileSync(new URL('./AppHostTreeLayer.tsx', import.meta.url), 'utf8');
test('shared host tree layer is visible above work tabs', () => {
assert.deepEqual(getAppHostTreeLayerStyle(true), {
visibility: 'visible',
pointerEvents: 'auto',
zIndex: 30,
});
});
test('shared host tree layer is hidden behind root pages', () => {
assert.deepEqual(getAppHostTreeLayerStyle(false), {
visibility: 'hidden',
pointerEvents: 'none',
zIndex: 0,
});
});
test('shared host tree does not force open when entering a work tab surface', () => {
assert.doesNotMatch(hostTreeLayerSource, /setIsOpen\(true\)/);
assert.doesNotMatch(hostTreeLayerSource, /shouldAutoOpenHostTreeOnSurfaceChange/);
});
test('host tree layer hides immediately when leaving work tab surfaces', () => {
assert.match(hostTreeLayerSource, /getAppHostTreeLayerStyle\(surfaceVisible\)/);
assert.doesNotMatch(hostTreeLayerSource, /layerVisible/);
});

View File

@@ -0,0 +1,118 @@
import React, { useMemo } from 'react';
import { useActiveTabId } from '../state/activeTabStore';
import type { EditorTab } from '../state/editorTabStore';
import type { LogView } from '../state/logViewState';
import { TerminalHostTreeSidebar } from '../../components/terminalLayer/TerminalHostTreeSidebar';
import type { GroupConfig, Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
import {
isHostTreeWorkTabSurface,
resolveWorkTabActiveHostId,
resolveWorkTabHostTreeTheme,
} from './workTabSurface';
interface AppHostTreeLayerProps {
enabled: boolean;
hosts: Host[];
customGroups: string[];
groupConfigs: GroupConfig[];
sessions: TerminalSession[];
workspaces: Workspace[];
editorTabs: readonly EditorTab[];
logViews: readonly LogView[];
orderedTabs: readonly string[];
accentMode: 'theme' | 'custom';
currentTerminalTheme: TerminalTheme;
customAccent: string;
followAppTerminalTheme: boolean;
hostById: ReadonlyMap<string, Host>;
themeById: ReadonlyMap<string, TerminalTheme>;
onConnect: (host: Host) => void;
onCreateLocalTerminal?: () => void;
}
export function getAppHostTreeLayerStyle(surfaceVisible: boolean): React.CSSProperties {
return {
visibility: surfaceVisible ? 'visible' : 'hidden',
pointerEvents: surfaceVisible ? 'auto' : 'none',
zIndex: surfaceVisible ? 30 : 0,
};
}
export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
enabled,
hosts,
customGroups,
groupConfigs,
sessions,
workspaces,
editorTabs,
logViews,
orderedTabs,
accentMode,
currentTerminalTheme,
customAccent,
followAppTerminalTheme,
hostById,
themeById,
onConnect,
onCreateLocalTerminal,
}) => {
const activeTabId = useActiveTabId();
const sessionIds = useMemo(() => new Set(sessions.map((session) => session.id)), [sessions]);
const workspaceIds = useMemo(() => new Set(workspaces.map((workspace) => workspace.id)), [workspaces]);
const logViewIds = useMemo(() => new Set(logViews.map((logView) => logView.id)), [logViews]);
const surfaceVisible = isHostTreeWorkTabSurface({
enabled,
activeTabId,
logViewIds,
orderedTabs,
sessionIds,
workspaceIds,
});
const activeHostId = useMemo(() => resolveWorkTabActiveHostId({
activeTabId,
editorTabs,
sessions,
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"
data-section="app-host-tree-layer"
style={getAppHostTreeLayerStyle(surfaceVisible)}
>
<TerminalHostTreeSidebar
enabled={enabled}
surfaceVisible={surfaceVisible}
hosts={hosts}
customGroups={customGroups}
groupConfigs={groupConfigs}
resolvedPreviewTheme={hostTreeTheme}
activeHostId={activeHostId}
onConnect={onConnect}
onCreateLocalTerminal={onCreateLocalTerminal}
/>
</div>
);
};

View File

@@ -0,0 +1,45 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const storage = new Map<string, string>();
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
},
});
const { getLogViewWrapperStyle, shouldRenderTerminalLayerMount } = await import('./AppMounts.tsx');
const activeTabChromeSource = readFileSync(new URL('./AppActiveTabChrome.tsx', import.meta.url), 'utf8');
test('visible log view leaves room for the terminal host sidebar', () => {
assert.deepEqual(getLogViewWrapperStyle(true, 220), {
left: 220,
});
});
test('hidden log view remains hidden while preserving host sidebar offset', () => {
assert.deepEqual(getLogViewWrapperStyle(false, 220), {
visibility: 'hidden',
pointerEvents: 'none',
position: 'absolute',
zIndex: -1,
left: 220,
});
});
test('terminal layer renders only after terminal content is visible or mounted', () => {
assert.equal(shouldRenderTerminalLayerMount(true, false), true);
assert.equal(shouldRenderTerminalLayerMount(false, true), true);
assert.equal(shouldRenderTerminalLayerMount(false, false), false);
});
test('active tab chrome keeps removed theme side effects unmounted', () => {
const removedThemeHook = ['use', 'Im', 'mersive', 'Mode'].join('');
const removedThemeStoreSetter = ['set', 'Im', 'mersive', 'Active'].join('');
assert.equal(activeTabChromeSource.includes(removedThemeHook), false);
assert.equal(activeTabChromeSource.includes(removedThemeStoreSetter), false);
});

View File

@@ -1,5 +1,7 @@
import React, { Suspense, lazy, useEffect, useState } from 'react';
import { useActiveTabId, useIsSftpActive, useIsTerminalLayerVisible, useIsVaultActive } from '../state/activeTabStore';
import React, { Suspense, lazy, useEffect, useMemo, useState } from 'react';
import { useActiveTabId, useIsSftpActive, useIsVaultActive } from '../state/activeTabStore';
import { useTerminalHostTreeLayoutWidth } from '../state/terminalHostTreeStore';
import { isTerminalContentTabSurface } from './workTabSurface';
import { cn } from '../../lib/utils';
import { ConnectionLog, TerminalTheme } from '../../types';
import type { LogView as LogViewType } from '../state/logViewState';
@@ -29,14 +31,24 @@ interface LogViewWrapperProps {
onUpdateLog: (logId: string, updates: Partial<ConnectionLog>) => void;
}
export function getLogViewWrapperStyle(
isVisible: boolean,
hostTreeLayoutWidth: number,
): React.CSSProperties {
const baseStyle = {
left: hostTreeLayoutWidth,
};
return isVisible
? baseStyle
: { visibility: 'hidden', pointerEvents: 'none', position: 'absolute', zIndex: -1, ...baseStyle };
}
export const LogViewWrapper: React.FC<LogViewWrapperProps> = ({ logView, defaultTerminalTheme, defaultFontSize, onClose, onUpdateLog }) => {
const activeTabId = useActiveTabId();
const isVisible = activeTabId === logView.id;
const hostTreeLayoutWidth = useTerminalHostTreeLayoutWidth();
// Use same pattern as VaultViewContainer for visibility
const containerStyle: React.CSSProperties = isVisible
? {}
: { visibility: 'hidden', pointerEvents: 'none', position: 'absolute', zIndex: -1 };
const containerStyle = getLogViewWrapperStyle(isVisible, hostTreeLayoutWidth);
return (
<div className={cn("absolute inset-0", isVisible ? "z-20" : "")} style={containerStyle}>
@@ -67,6 +79,13 @@ const LazyTerminalLayer = lazy(() =>
type SftpViewProps = React.ComponentProps<typeof SftpViewComponent>;
type TerminalLayerProps = React.ComponentProps<typeof TerminalLayerComponent>;
export function shouldRenderTerminalLayerMount(
isVisible: boolean,
shouldMount: boolean,
): boolean {
return isVisible || shouldMount;
}
export const SftpViewMount: React.FC<SftpViewProps> = (props) => {
const isActive = useIsSftpActive();
const [shouldMount, setShouldMount] = useState(isActive);
@@ -85,7 +104,14 @@ export const SftpViewMount: React.FC<SftpViewProps> = (props) => {
};
export const TerminalLayerMount: React.FC<TerminalLayerProps> = (props) => {
const isVisible = useIsTerminalLayerVisible(props.draggingSessionId);
const activeTabId = useActiveTabId();
const sessionIds = useMemo(() => new Set(props.sessions.map((session) => session.id)), [props.sessions]);
const workspaceIds = useMemo(() => new Set(props.workspaces.map((workspace) => workspace.id)), [props.workspaces]);
const isVisible = isTerminalContentTabSurface({
activeTabId,
sessionIds,
workspaceIds,
}) || !!props.draggingSessionId;
const [shouldMount, setShouldMount] = useState(isVisible);
useEffect(() => {
@@ -107,7 +133,7 @@ export const TerminalLayerMount: React.FC<TerminalLayerProps> = (props) => {
return () => window.clearTimeout(id);
}, [shouldMount]);
const shouldRender = shouldMount || isVisible;
const shouldRender = shouldRenderTerminalLayerMount(isVisible, shouldMount);
if (!shouldRender) return null;

View File

@@ -2,7 +2,6 @@
import React, { Suspense, lazy } from 'react';
import { AlertTriangle, Download, Trash2 } from 'lucide-react';
import { activeTabStore, toEditorTabId } from '../state/activeTabStore';
import { useImmersiveActive } from '../state/immersiveStore';
import { editorTabStore } from '../state/editorTabStore';
import { releaseEditorTabSaveCoordinator, saveEditorTab } from '../state/editorTabSave';
import { TopTabs } from '../../components/TopTabs';
@@ -19,7 +18,7 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
import { Input } from '../../components/ui/input';
import { Label } from '../../components/ui/label';
import { toast } from '../../components/ui/toast';
import { cn } from '../../lib/utils';
import { AppHostTreeLayer } from './AppHostTreeLayer';
const LazyProtocolSelectDialog = lazy(() => import('../../components/ProtocolSelectDialog'));
const LazyQuickSwitcher = lazy(() =>
@@ -43,25 +42,19 @@ 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, reorderTabs, 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,
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,
VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper,
} = ctx;
// Immersive flag from store (not ctx) so toggling it doesn't re-render <App>.
// Note: we intentionally do NOT subscribe to the active tab id here — editor
// tab visibility self-subscribes inside TextEditorTabView — so plain tab
// switches don't re-render AppView/App at all.
const isImmersive = useImmersiveActive();
return (
<SnippetExecutionProvider>
<UnsavedChangesProvider>
@@ -113,10 +106,9 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
handleRequestCloseEditorTabRef.current = handleRequestCloseEditorTab;
return (
<div className={cn("flex flex-col h-screen text-foreground font-sans netcatty-shell", isImmersive && "immersive-transition")} onContextMenu={handleRootContextMenu}>
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" onContextMenu={handleRootContextMenu}>
<TopTabs
theme={resolvedTheme}
followAppTerminalTheme={followAppTerminalTheme}
hosts={hosts}
sessions={sessions}
orphanSessions={orphanSessions}
@@ -139,17 +131,38 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
windowOpacity={settings.windowOpacity}
setWindowOpacity={settings.setWindowOpacity}
onSyncNow={handleSyncNowManual}
isImmersiveActive={isImmersive}
onStartSessionDrag={setDraggingSessionId}
onEndSessionDrag={handleEndSessionDrag}
onReorderTabs={reorderTabs}
onReorderTabs={reorderWorkTabs}
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
showSftpTab={settings.showSftpTab}
showHostTreeSidebar={settings.showHostTreeSidebar}
editorTabs={editorTabs}
onRequestCloseEditorTab={handleRequestCloseEditorTab}
hostById={hostById}
/>
<div className="flex-1 relative min-h-0">
<AppHostTreeLayer
enabled={settings.showHostTreeSidebar}
hosts={hosts}
customGroups={customGroups}
groupConfigs={groupConfigs}
sessions={sessions}
workspaces={workspaces}
editorTabs={editorTabs}
logViews={logViews}
orderedTabs={orderedTabsWithEditors}
accentMode={accentMode}
currentTerminalTheme={currentTerminalTheme}
customAccent={customAccent}
followAppTerminalTheme={followAppTerminalTheme}
hostById={hostById}
themeById={themeById}
onConnect={handleConnectToHost}
onCreateLocalTerminal={handleCreateLocalTerminal}
/>
<VaultViewContainer>
<VaultView
hosts={hosts}
@@ -207,9 +220,11 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
hosts={hosts}
keys={keys}
identities={identities}
knownHosts={effectiveKnownHosts}
proxyProfiles={proxyProfiles}
groupConfigs={groupConfigs}
updateHosts={updateHosts}
onAddKnownHost={handleAddKnownHost}
sftpDefaultViewMode={sftpDefaultViewMode}
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
sftpAutoSync={sftpAutoSync}
@@ -243,11 +258,14 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
terminalFontFamilyId={terminalFontFamilyId}
fontSize={terminalFontSize}
hotkeyScheme={hotkeyScheme}
disableTerminalFontZoom={settings.disableTerminalFontZoom}
keyBindings={keyBindings}
onHotkeyAction={handleHotkeyAction}
onUpdateTerminalThemeId={setTerminalThemeId}
onUpdateTerminalFontFamilyId={setTerminalFontFamilyId}
onUpdateTerminalFontSize={setTerminalFontSize}
onUpdateSessionFontSize={updateSessionFontSize}
onClearSessionFontSizeOverride={clearSessionFontSizeOverride}
onUpdateTerminalFontWeight={(w) => updateTerminalSetting('fontWeight', w)}
onCloseSession={closeSession}
onUpdateSessionStatus={handleSessionStatusChange}
@@ -257,6 +275,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
onCommandExecuted={(command, hostId, hostLabel, sessionId) => {
addShellHistoryEntry({ command, hostId, hostLabel, sessionId });
}}
shellHistory={shellHistory}
onTerminalDataCapture={handleTerminalDataCapture}
onCreateWorkspaceFromSessions={createWorkspaceFromSessions}
onAddSessionToWorkspace={addSessionToWorkspace}
@@ -268,12 +287,17 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
onSetWorkspaceFocusedSession={setWorkspaceFocusedSession}
onReorderWorkspaceSessions={reorderWorkspaceSessions}
onReorderTabs={reorderWorkTabs}
onCopySession={copySessionWithCurrentShell}
onCopySessionToNewWindow={copySessionToNewWindowWithCurrentShell}
onSplitSession={splitSessionWithCurrentShell}
onConnectToHost={handleConnectToHost}
onCreateLocalTerminal={handleCreateLocalTerminal}
isBroadcastEnabled={isBroadcastEnabled}
onToggleBroadcast={toggleBroadcast}
updateHosts={updateHosts}
updateSnippets={updateSnippets}
updateSnippetPackages={updateSnippetPackages}
sftpDefaultViewMode={sftpDefaultViewMode}
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
sftpAutoSync={sftpAutoSync}
@@ -289,8 +313,12 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
sessionLogsFormat={sessionLogsFormat}
sessionLogsTimestampsEnabled={sessionLogsTimestampsEnabled}
sshDebugLogsEnabled={sshDebugLogsEnabled}
showHostTreeSidebar={settings.showHostTreeSidebar}
toggleScriptsSidePanelRef={toggleScriptsSidePanelRef}
toggleSidePanelRef={toggleSidePanelRef}
onStartSessionRename={startSessionRename}
onSubmitSessionRename={submitSessionRename}
onRemoveSessionFromWorkspace={removeSessionFromWorkspace}
/>
{/* Log Views - readonly terminal replays */}

View File

@@ -0,0 +1,126 @@
import assert from "node:assert/strict";
import test from "node:test";
import { toEditorTabId } from "../state/activeTabStore.ts";
import type { EditorTab } from "../state/editorTabStore.ts";
import type { LogView } from "../state/logViewState.ts";
import { isActiveChromeThemeResolvable, resolveActiveChromeTheme } from "./activeChromeTheme.ts";
import type { Host, TerminalSession, TerminalTheme, Workspace } from "../../types";
const theme = (id: string, type: "dark" | "light" = "dark"): TerminalTheme => ({
id,
name: id,
type,
colors: {
background: type === "dark" ? "#111111" : "#eeeeee",
foreground: type === "dark" ? "#eeeeee" : "#111111",
cursor: "#22aaff",
},
});
const currentTheme = theme("current");
const hostTheme = theme("host-theme");
const logTheme = theme("log-theme", "light");
const baseInput = {
accentMode: "theme" as const,
currentTerminalTheme: currentTheme,
customAccent: "221.2 83.2% 53.3%",
editorTabs: [],
followAppTerminalTheme: false,
hostById: new Map<string, Host>(),
logViews: [],
sessionById: new Map<string, TerminalSession>(),
themeById: new Map([
[currentTheme.id, currentTheme],
[hostTheme.id, hostTheme],
[logTheme.id, logTheme],
]),
workspaceById: new Map<string, Workspace>(),
};
test("editor tabs use the owning host terminal theme when follow-app terminal theme is off", () => {
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],
hostById: new Map([
["host-1", { id: "host-1", theme: hostTheme.id } as unknown as 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,
activeTabId: "log-1",
logViews: [{
id: "log-1",
connectionLogId: "1",
log: { id: "1", themeId: logTheme.id },
} as unknown as LogView],
});
assert.equal(resolved?.id, logTheme.id);
});
test("root pages use the normal application theme", () => {
const resolved = resolveActiveChromeTheme({
...baseInput,
activeTabId: "vault",
});
assert.equal(resolved, null);
});
test("chrome theme sync waits until a newly opened session is present in deps", () => {
assert.equal(
isActiveChromeThemeResolvable({
activeTabId: "session-new",
editorTabs: [],
logViews: [],
sessionById: new Map(),
workspaceById: new Map(),
}),
false,
);
assert.equal(
isActiveChromeThemeResolvable({
activeTabId: "session-new",
editorTabs: [],
logViews: [],
sessionById: new Map([["session-new", { id: "session-new" } as TerminalSession]]),
workspaceById: new Map(),
}),
true,
);
});

View File

@@ -0,0 +1,103 @@
import { fromEditorTabId, isEditorTabId } from "../state/activeTabStore";
export type ResolveActiveChromeThemeInput = {
accentMode: "theme" | "custom";
activeTabId: string;
currentTerminalTheme: TerminalTheme;
customAccent: string;
editorTabs: readonly EditorTab[];
followAppTerminalTheme: boolean;
hostById: Map<string, Host>;
logViews: readonly LogView[];
sessionById: Map<string, TerminalSession>;
themeById: Map<string, TerminalTheme>;
workspaceById: Map<string, Workspace>;
};
export function isActiveChromeThemeResolvable({
activeTabId,
editorTabs,
logViews,
sessionById,
workspaceById,
}: Pick<
ResolveActiveChromeThemeInput,
"activeTabId" | "editorTabs" | "logViews" | "sessionById" | "workspaceById"
>): boolean {
if (activeTabId === "vault" || activeTabId === "sftp") return true;
if (isEditorTabId(activeTabId)) {
return editorTabs.some((tab) => tab.id === fromEditorTabId(activeTabId));
}
if (logViews.some((item) => item.id === activeTabId)) return true;
if (workspaceById.has(activeTabId)) return true;
if (sessionById.has(activeTabId)) return true;
return false;
}
import { applyCustomAccentToTerminalTheme, resolveHostTerminalThemeId } from "../../domain/terminalAppearance";
import { collectSessionIds } from "../../domain/workspace";
import type { EditorTab } from "../state/editorTabStore";
import type { LogView } from "../state/logViewState";
import type { Host, TerminalSession, TerminalTheme, Workspace } from "../../types";
export function resolveActiveChromeTheme({
accentMode,
activeTabId,
currentTerminalTheme,
customAccent,
editorTabs,
followAppTerminalTheme,
hostById,
logViews,
sessionById,
themeById,
workspaceById,
}: ResolveActiveChromeThemeInput): TerminalTheme | null {
if (activeTabId === "vault" || activeTabId === "sftp") return null;
const resolveHostTheme = (hostId: string): TerminalTheme => {
if (followAppTerminalTheme) return currentTerminalTheme;
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;
return resolveHostTheme(editorTab.hostId);
}
const logView = logViews.find((item) => item.id === activeTabId);
if (logView) {
const explicitThemeId = logView.log.themeId;
return explicitThemeId ? themeById.get(explicitThemeId) ?? currentTerminalTheme : currentTerminalTheme;
}
const workspace = workspaceById.get(activeTabId);
if (workspace) {
if (workspace.viewMode === "focus") {
const workspaceSessionIds = collectSessionIds(workspace.root);
const focusedSession = (workspace.focusedSessionId
? sessionById.get(workspace.focusedSessionId)
: null)
?? workspaceSessionIds.map((id) => sessionById.get(id)).find(Boolean);
return focusedSession ? resolveSessionTheme(focusedSession) : null;
}
const workspaceSessions = collectSessionIds(workspace.root)
.map((id) => sessionById.get(id))
.filter(Boolean) as TerminalSession[];
if (workspaceSessions.length === 0) return null;
const firstTheme = resolveSessionTheme(workspaceSessions[0]);
const allSame = workspaceSessions.every((session) => resolveSessionTheme(session).id === firstTheme.id);
return allSame ? firstTheme : null;
}
const session = sessionById.get(activeTabId);
return session ? resolveSessionTheme(session) : null;
}

View File

@@ -0,0 +1,40 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildNumberShortcutTabTargets } from './tabShortcutTargets.ts';
test('number shortcut tabs include vault and sftp by default', () => {
assert.deepEqual(
buildNumberShortcutTabTargets({
showSftpTab: true,
shellOnlyTabNumberShortcuts: false,
orderedTabs: ['session-1', 'workspace-1'],
editorTabIds: ['editor:file-1'],
}),
['vault', 'sftp', 'session-1', 'workspace-1', 'editor:file-1'],
);
});
test('number shortcut tabs skip vault and sftp when shell-only mode is enabled', () => {
assert.deepEqual(
buildNumberShortcutTabTargets({
showSftpTab: true,
shellOnlyTabNumberShortcuts: true,
orderedTabs: ['session-1', 'workspace-1'],
editorTabIds: ['editor:file-1'],
}),
['session-1', 'workspace-1', 'editor:file-1'],
);
});
test('hidden sftp tab is omitted from default number shortcut targets', () => {
assert.deepEqual(
buildNumberShortcutTabTargets({
showSftpTab: false,
shellOnlyTabNumberShortcuts: false,
orderedTabs: ['session-1'],
editorTabIds: [],
}),
['vault', 'session-1'],
);
});

View File

@@ -0,0 +1,14 @@
/** Tab ids targeted by Cmd/Ctrl+[1...9] number shortcuts. */
export function buildNumberShortcutTabTargets(params: {
showSftpTab: boolean;
shellOnlyTabNumberShortcuts: boolean;
orderedTabs: readonly string[];
editorTabIds: readonly string[];
}): string[] {
const workTabs = [...params.orderedTabs, ...params.editorTabIds];
if (params.shellOnlyTabNumberShortcuts) {
return workTabs;
}
const pinnedTabs = params.showSftpTab ? ['vault', 'sftp'] : ['vault'];
return [...pinnedTabs, ...workTabs];
}

View File

@@ -0,0 +1,18 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
test("active chrome theme applies top tab vars and clears them before vault restore transition", () => {
const chromeThemeSource = readFileSync(new URL("../state/useActiveChromeTheme.ts", import.meta.url), "utf8");
const syncSource = readFileSync(new URL("../state/activeChromeThemeSync.ts", import.meta.url), "utf8");
const effectsSource = readFileSync(new URL("../../components/terminalLayer/useTerminalLayerEffects.ts", import.meta.url), "utf8");
assert.match(chromeThemeSource, /applyTopTabsChromeThemeVars\(theme\)/);
const restoreBlock = chromeThemeSource.match(
/clearTopTabsChromeThemeVars\(\);\s*runThemeTransition\(\(\) => \{\s*removeActiveChromeTheme\(\);/,
)?.[0] ?? "";
assert.notEqual(restoreBlock, "", "top tab vars must clear before the vault restore transition starts");
assert.match(syncSource, /activeTabId === 'vault' \|\| activeTabId === 'sftp'\)[\s\S]*clearTopTabsChromeThemeVars\(\)/);
assert.match(effectsSource, /if \(!isTerminalLayerVisible\) \{[\s\S]*clearTopTabsPreviewVars\(\)/);
});

View File

@@ -0,0 +1,109 @@
import type { TerminalTheme } from '../../types';
function hexToHslToken(hex: string): string {
const normalized = hex.startsWith('#') ? hex : `#${hex}`;
const r = parseInt(normalized.slice(1, 3), 16) / 255;
const g = parseInt(normalized.slice(3, 5), 16) / 255;
const b = parseInt(normalized.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0;
let s = 0;
const l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r:
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
break;
case g:
h = ((b - r) / d + 2) / 6;
break;
default:
h = ((r - g) / d + 4) / 6;
break;
}
}
return `${Math.round(h * 3600) / 10} ${Math.round(s * 1000) / 10}% ${Math.round(l * 1000) / 10}%`;
}
function adjustLightnessToken(hsl: string, delta: number): string {
const parts = hsl.split(/\s+/);
const newL = Math.max(0, Math.min(100, parseFloat(parts[2]) + delta));
return `${parts[0]} ${parts[1]} ${Math.round(newL * 10) / 10}%`;
}
function adjustSaturationToken(hsl: string, factor: number): string {
const parts = hsl.split(/\s+/);
const newS = Math.max(0, Math.min(100, parseFloat(parts[1]) * factor));
return `${parts[0]} ${Math.round(newS * 10) / 10}% ${parts[2]}`;
}
const setStylePropertyIfChanged = (element: HTMLElement, property: string, value: string) => {
if (element.style.getPropertyValue(property) === value) return;
element.style.setProperty(property, value);
};
const removeStylePropertyIfSet = (element: HTMLElement, property: string) => {
if (!element.style.getPropertyValue(property)) return;
element.style.removeProperty(property);
};
const TOP_TABS_THEME_PROPERTIES = [
'--top-tabs-bg',
'--top-tabs-fg',
'--top-tabs-muted',
'--top-tabs-active-bg',
'--top-tabs-accent',
'--background',
'--foreground',
'--accent',
'--primary',
'--secondary',
'--border',
'--muted-foreground',
] as const;
export function clearTopTabsChromeThemeVars(): void {
if (typeof document === 'undefined') return;
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
if (!tabsRoot) return;
for (const property of TOP_TABS_THEME_PROPERTIES) {
removeStylePropertyIfSet(tabsRoot, property);
}
}
export function applyTopTabsChromeThemeVars(theme: TerminalTheme): void {
if (typeof document === 'undefined') return;
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
if (!tabsRoot) return;
const bg = hexToHslToken(theme.colors.background);
const fg = hexToHslToken(theme.colors.foreground);
const accent = hexToHslToken(theme.colors.cursor);
const isDark = theme.type === 'dark';
const secondary = adjustLightnessToken(bg, isDark ? 6 : -5);
const border = adjustLightnessToken(bg, isDark ? 12 : -10);
const mutedFg = adjustSaturationToken(adjustLightnessToken(fg, isDark ? -20 : 20), 0.5);
setStylePropertyIfChanged(tabsRoot, '--background', bg);
setStylePropertyIfChanged(tabsRoot, '--foreground', fg);
setStylePropertyIfChanged(tabsRoot, '--accent', accent);
setStylePropertyIfChanged(tabsRoot, '--primary', accent);
setStylePropertyIfChanged(tabsRoot, '--secondary', secondary);
setStylePropertyIfChanged(tabsRoot, '--border', border);
setStylePropertyIfChanged(tabsRoot, '--muted-foreground', mutedFg);
setStylePropertyIfChanged(tabsRoot, '--top-tabs-bg', 'hsl(var(--secondary))');
setStylePropertyIfChanged(tabsRoot, '--top-tabs-fg', 'hsl(var(--foreground))');
setStylePropertyIfChanged(tabsRoot, '--top-tabs-muted', 'hsl(var(--muted-foreground))');
setStylePropertyIfChanged(tabsRoot, '--top-tabs-active-bg', 'hsl(var(--background))');
setStylePropertyIfChanged(tabsRoot, '--top-tabs-accent', 'hsl(var(--accent))');
}
export function hasActiveChromeThemeDataset(): boolean {
if (typeof document === 'undefined') return false;
return Boolean(document.documentElement.dataset.activeChromeTheme);
}

View File

@@ -0,0 +1,205 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildOrderedWorkTabIds,
isHostTreeWorkTabSurface,
isRootPageTabId,
isTerminalContentTabSurface,
reorderWorkTabIds,
resolveWorkTabActiveHostId,
resolveWorkTabHostTreeTheme,
} from './workTabSurface';
import type { EditorTab } from '../state/editorTabStore';
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(
buildOrderedWorkTabIds(['log-1', 'session-1'], ['session-1', 'workspace-1', 'log-1', 'editor:file-1']),
['log-1', 'session-1', 'workspace-1', 'editor:file-1'],
);
});
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);
assert.equal(isRootPageTabId('session-1'), false);
});
test('shared host tree is visible for editor, log, session, and workspace tabs', () => {
const sessionIds = new Set(['session-1']);
const workspaceIds = new Set(['workspace-1']);
const logViewIds = new Set(['log-1']);
const orderedTabs = ['session-1', 'workspace-1', 'editor:file-1', 'log-1'];
for (const activeTabId of orderedTabs) {
assert.equal(isHostTreeWorkTabSurface({
enabled: true,
activeTabId,
logViewIds,
orderedTabs,
sessionIds,
workspaceIds,
}), true);
}
});
test('shared host tree recognizes active log view before tab ordering catches up', () => {
assert.equal(isHostTreeWorkTabSurface({
enabled: true,
activeTabId: 'log-1',
logViewIds: new Set(['log-1']),
orderedTabs: [],
sessionIds: new Set(),
workspaceIds: new Set(),
}), true);
});
test('terminal content surface is limited to sessions and workspaces', () => {
const sessionIds = new Set(['session-1']);
const workspaceIds = new Set(['workspace-1']);
assert.equal(isTerminalContentTabSurface({ activeTabId: 'session-1', sessionIds, workspaceIds }), true);
assert.equal(isTerminalContentTabSurface({ activeTabId: 'workspace-1', sessionIds, workspaceIds }), true);
assert.equal(isTerminalContentTabSurface({ activeTabId: 'editor:file-1', sessionIds, workspaceIds }), false);
assert.equal(isTerminalContentTabSurface({ activeTabId: 'log-1', sessionIds, workspaceIds }), false);
});
test('shared host tree resolves active host ids across work tab types', () => {
const sessions = [
{ id: 'session-1', hostId: 'host-1' },
{ id: 'session-2', hostId: 'host-2' },
] as TerminalSession[];
const workspaces = [
{ id: 'workspace-1', focusedSessionId: 'session-2' },
] as Workspace[];
const editorTabs = [
{ id: 'file-1', hostId: 'host-3' },
] as EditorTab[];
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'session-1', sessions, workspaces, editorTabs }), 'host-1');
assert.equal(resolveWorkTabActiveHostId({ activeTabId: 'workspace-1', sessions, workspaces, editorTabs }), 'host-2');
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

@@ -0,0 +1,153 @@
import {
fromEditorTabId,
isEditorTabId,
} from '../state/activeTabStore';
import { applyCustomAccentToTerminalTheme, resolveHostTerminalThemeId } from '../../domain/terminalAppearance';
import type { EditorTab } from '../state/editorTabStore';
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';
}
export function buildOrderedWorkTabIds(
tabOrder: readonly string[],
allTabIds: readonly string[],
): string[] {
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 = 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,
logViewIds = new Set(),
orderedTabs,
sessionIds,
workspaceIds,
}: {
enabled: boolean;
activeTabId: string;
logViewIds?: ReadonlySet<string>;
orderedTabs: readonly string[];
sessionIds: ReadonlySet<string>;
workspaceIds: ReadonlySet<string>;
}): boolean {
if (!enabled) return false;
if (isRootPageTabId(activeTabId)) return false;
return orderedTabs.includes(activeTabId)
|| isEditorTabId(activeTabId)
|| logViewIds.has(activeTabId)
|| sessionIds.has(activeTabId)
|| workspaceIds.has(activeTabId);
}
export function isTerminalContentTabSurface({
activeTabId,
sessionIds,
workspaceIds,
}: {
activeTabId: string;
sessionIds: ReadonlySet<string>;
workspaceIds: ReadonlySet<string>;
}): boolean {
return sessionIds.has(activeTabId) || workspaceIds.has(activeTabId);
}
export function resolveWorkTabActiveHostId({
activeTabId,
editorTabs,
sessions,
workspaces,
}: {
activeTabId: string;
editorTabs: readonly EditorTab[];
sessions: readonly TerminalSession[];
workspaces: readonly Workspace[];
}): string | null {
if (isEditorTabId(activeTabId)) {
const editorId = fromEditorTabId(activeTabId);
return editorTabs.find((tab) => tab.id === editorId)?.hostId ?? null;
}
const activeSession = sessions.find((session) => session.id === activeTabId);
if (activeSession) return activeSession.hostId ?? null;
const activeWorkspace = workspaces.find((workspace) => workspace.id === activeTabId);
if (!activeWorkspace) return null;
const focusedSessionId = activeWorkspace.focusedSessionId;
if (focusedSessionId) {
return sessions.find((session) => session.id === focusedSessionId)?.hostId ?? null;
}
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

@@ -14,11 +14,19 @@ const I18nContext = createContext<I18nContextValue | null>(null);
const interpolate = (template: string, values?: InterpolationValues): string => {
if (!values) return template;
return template.replace(/\{(\w+)\}/g, (_match, key: string) => {
const replaceDoubleBraceToken = (match: string, key: string) => {
const v = values[key];
if (v === null || v === undefined) return match;
return String(v);
};
const replaceSingleBraceToken = (_match: string, key: string) => {
const v = values[key];
if (v === null || v === undefined) return '';
return String(v);
});
};
return template
.replace(/\{\{(\w+)\}\}/g, replaceDoubleBraceToken)
.replace(/\{(\w+)\}/g, replaceSingleBraceToken);
};
const resolveMessage = (resolvedLocale: string, key: string): string | undefined => {

View File

@@ -3,6 +3,7 @@ import { enCoreMessages } from './en/core';
import { enVaultMessages } from './en/vault';
import { enTerminalMessages } from './en/terminal';
import { enAiMessages } from './en/ai';
import { enSystemManagerMessages } from './en/systemManager';
export type { Messages } from './types';
@@ -11,6 +12,7 @@ const en: Messages = {
...enVaultMessages,
...enTerminalMessages,
...enAiMessages,
...enSystemManagerMessages,
};
export default en;

View File

@@ -3,9 +3,11 @@ import type { Messages } from '../types';
export const enAiMessages: Messages = {
// AI Settings
'ai.agentSettings': 'Agent Settings',
'ai.chat.preparing': 'Preparing…',
'ai.title': 'AI',
'ai.description': 'Configure AI providers, agents, and safety settings',
'ai.providers': 'Providers',
'ai.agents': 'Agents',
'ai.providers.empty': 'No providers configured. Add a provider to get started.',
'ai.providers.add': 'Add Provider',
'ai.providers.active': 'Active',
@@ -106,6 +108,54 @@ export const enAiMessages: Messages = {
'ai.copilot.customPathPlaceholder': 'e.g. /usr/local/bin/copilot',
'ai.copilot.check': 'Check',
// AI Cursor SDK
'ai.cursor.title': 'Cursor',
'ai.cursor.description': 'Uses the Cursor SDK.',
'ai.cursor.detecting': 'Detecting...',
'ai.cursor.detected': 'Available',
'ai.cursor.notFound': 'Unavailable',
'ai.cursor.path': 'Runtime:',
'ai.cursor.notFoundHint': 'Enter an API key to enable Cursor.',
'ai.cursor.notInstalledHint': 'Cursor SDK was not detected.',
'ai.cursor.installStatus': 'Cursor SDK',
'ai.cursor.installed': 'Detected',
'ai.cursor.notInstalled': 'Not detected',
'ai.cursor.apiKeyStatus': 'API Key',
'ai.cursor.apiKeyConfigured': 'Configured',
'ai.cursor.apiKeyMissing': 'Missing',
'ai.cursor.apiKeyFromEnv': 'From environment',
'ai.cursor.apiKey': 'API Key',
'ai.cursor.apiKeyPlaceholder': 'Enter Cursor API key',
'ai.cursor.apiKeyPlaceholder.env': 'Using CURSOR_API_KEY; enter a key to override',
'ai.cursor.apiKeyEnvHint': 'Cursor can use CURSOR_API_KEY from your shell. Save a key here only if you want Netcatty to override it.',
'ai.cursor.apiKeyOverrideHint': 'Netcatty will use the saved key here before CURSOR_API_KEY.',
'ai.cursor.saveApiKey': 'Save',
'ai.cursor.saved': 'Saved',
'ai.cursor.showApiKey': 'Show API key',
'ai.cursor.hideApiKey': 'Hide API key',
'ai.cursor.customPathPlaceholder': 'e.g. /usr/local/bin/cursor',
'ai.cursor.check': 'Check',
// AI CodeBuddy Code
'ai.codebuddy.title': 'CodeBuddy Code',
'ai.codebuddy.description': 'Uses CodeBuddy Code via the official Agent SDK (`@tencent-ai/agent-sdk`). Once detected, it can be selected as an external coding agent.',
'ai.codebuddy.detecting': 'Detecting...',
'ai.codebuddy.detected': 'Detected',
'ai.codebuddy.notFound': 'Not found',
'ai.codebuddy.path': 'Path:',
'ai.codebuddy.notFoundHint': 'Could not find codebuddy in PATH. Install it or specify the executable path below.',
'ai.codebuddy.customPathPlaceholder': 'e.g. /usr/local/bin/codebuddy',
'ai.codebuddy.check': 'Check',
'ai.codebuddy.configSection': 'Authentication & config (optional)',
'ai.codebuddy.internetEnv': 'Internet Environment',
'ai.codebuddy.internetEnv.default': 'Default (overseas)',
'ai.codebuddy.internetEnv.internal': 'Internal',
'ai.codebuddy.internetEnv.ioa': 'IOA',
'ai.codebuddy.internetEnv.hint': 'Sets CODEBUDDY_INTERNET_ENVIRONMENT — choose Internal or IOA for restricted network environments.',
'ai.codebuddy.envVars': 'Environment variables',
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
'ai.codebuddy.envVars.hint': 'One KEY=VALUE per line, passed to the CodeBuddy agent. Set CODEBUDDY_API_KEY or CODEBUDDY_AUTH_TOKEN here for authentication. Stored locally in plaintext.',
// AI Default Agent
'ai.defaultAgent': 'Default Agent',
'ai.defaultAgent.description': 'Agent to use when starting a new AI session',
@@ -127,6 +177,29 @@ export const enAiMessages: Messages = {
'ai.userSkills.status.ready': 'Ready',
'ai.userSkills.status.warning': 'Warning',
// AI Quick Messages
'ai.quickMessages.title': 'Quick Messages',
'ai.quickMessages.description': 'Create reusable prompts you can insert from the AI chat with / or the quick-message button. Unlike user skills, quick messages fill the composer with text.',
'ai.quickMessages.add': 'Add Quick Message',
'ai.quickMessages.createTitle': 'New Quick Message',
'ai.quickMessages.editTitle': 'Edit Quick Message',
'ai.quickMessages.name': 'Name',
'ai.quickMessages.name.placeholder': 'e.g. Check disk space',
'ai.quickMessages.slug': 'Command',
'ai.quickMessages.slug.placeholder': 'disk-check',
'ai.quickMessages.descriptionField': 'Description (optional)',
'ai.quickMessages.descriptionField.placeholder': 'Short hint about what this prompt does',
'ai.quickMessages.content': 'Message content',
'ai.quickMessages.content.placeholder': 'Full prompt text to insert when selected...',
'ai.quickMessages.empty': 'No quick messages yet. Add a few prompts you use often.',
'ai.quickMessages.confirmDelete': 'Delete quick message "{name}"?',
'ai.quickMessages.error.nameRequired': 'Name is required.',
'ai.quickMessages.error.invalidSlug': 'Command may only contain lowercase letters, numbers, and hyphens.',
'ai.quickMessages.error.contentRequired': 'Message content is required.',
'ai.quickMessages.error.slugTaken': 'This command is already used by another quick message.',
'ai.quickMessages.error.slugConflictsWithSkill': 'This command conflicts with user skill "/{slug}". Choose another.',
'ai.quickMessages.error.maxItems': 'You can save at most {max} quick messages.',
// AI Chat
'ai.chat.noProvider': 'No AI provider is configured. Go to **Settings → AI → Providers** to add and enable a provider.',
'ai.chat.toolDenied': 'Action was rejected by the user.',
@@ -175,6 +248,7 @@ export const enAiMessages: Messages = {
'ai.chat.newChat': 'New Chat',
'ai.chat.allSessions': 'All Sessions',
'ai.chat.loadEarlierMessages': 'Load earlier messages ({n} more)',
'ai.chat.usedTools': 'Tools used: {n}',
'ai.chat.loadMoreSessions': 'Load more sessions ({n} more)',
'ai.chat.noSessions': 'No previous sessions',
'ai.chat.retryHint': 'You can retry by sending your message again.',
@@ -185,6 +259,18 @@ export const enAiMessages: Messages = {
'ai.chat.menuImage': 'Image',
'ai.chat.menuMentionHost': 'Mention Host',
'ai.chat.menuUserSkills': 'User Skills',
'ai.chat.menuSlashCommands': 'Slash Commands',
'ai.chat.slashCommands': 'Slash commands',
'ai.chat.slashQuickMessages': 'Quick messages',
'ai.chat.slashUserSkills': 'User skills',
'ai.chat.quickMessages': 'Slash commands',
'ai.chat.slashNoResults': 'No matching commands',
'ai.chat.slashEmptyHint': 'Add prompts in Settings → AI → Quick Messages.',
// AI Chat Shortcuts
'ai.chatShortcuts.title': 'Chat Shortcuts',
'ai.chatShortcuts.selectionAction': 'Show Add to Conversation when selecting terminal text',
'ai.chatShortcuts.selectionAction.description': 'Show a small AI button next to selected terminal text.',
// AI Error
'ai.codex.bridgeError': 'Codex main-process handlers are not loaded yet. Fully restart Netcatty, or restart the Electron dev process, then try again.',
@@ -228,6 +314,7 @@ export const enAiMessages: Messages = {
'terminal.layer.switchToSplitView': 'Switch to Split View',
'terminal.layer.sftp': 'SFTP',
'terminal.layer.scripts': 'Scripts',
'terminal.layer.history': 'History',
'terminal.layer.theme': 'Theme',
'terminal.layer.aiChat': 'AI Chat',
'terminal.layer.movePanelLeft': 'Move panel to left',
@@ -243,6 +330,13 @@ export const enAiMessages: Messages = {
'terminal.layer.hostTree.collapse': 'Collapse host list',
'terminal.layer.hostTree.expand': 'Expand host list',
'terminal.layer.hostTree.empty': 'No hosts found',
'terminal.layer.hostTree.details.host': 'Host',
'terminal.layer.hostTree.details.user': 'User',
'terminal.layer.hostTree.details.port': 'Port',
'terminal.layer.hostTree.details.protocol': 'Protocol',
'terminal.layer.hostTree.details.group': 'Group',
'terminal.layer.hostTree.details.tags': 'Tags',
'terminal.layer.hostTree.details.lastConnected': 'Last connected',
'topTabs.openQuickSwitcher': 'Open quick switcher',
'topTabs.moreTabs': 'More tabs',
'topTabs.aiAssistant': 'AI Assistant',

View File

@@ -42,6 +42,7 @@ export const enCoreMessages: Messages = {
'common.more': 'More',
'common.selectAHost': 'Select a host',
'common.selectAHostPlaceholder': 'Select a host...',
'sort.manual': 'Manual order',
'sort.az': 'A-z',
'sort.za': 'Z-a',
'sort.newest': 'Newest to oldest',
@@ -225,6 +226,8 @@ export const enCoreMessages: Messages = {
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'When enabled, the root host list only shows hosts without a group. Open a group from the sidebar to see grouped hosts.',
'settings.vault.showSftpTab': 'Show SFTP tab',
'settings.vault.showSftpTabDesc': 'Display the standalone SFTP view in the top tab bar. When hidden, use the in-session SFTP side panel instead.',
'settings.vault.showHostTreeSidebar': 'Show host list sidebar',
'settings.vault.showHostTreeSidebarDesc': 'Display the host list sidebar and its top-bar toggle on terminal and editor tabs.',
// Update notifications
'update.available.title': 'Update Available',
@@ -264,9 +267,9 @@ export const enCoreMessages: Messages = {
'settings.appearance.themeColor.dark': 'Dark palette',
'settings.appearance.customCss': 'Custom CSS',
'settings.appearance.customCss.desc':
'Add custom CSS to personalize the app appearance. Changes apply immediately. Major UI regions expose a [data-section="..."] attribute you can target — e.g. snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar (focus-mode terminal list), terminal-side-panel (SFTP/Scripts/Theme/AI panel), terminal-sftp-panel, terminal-split-pane, terminal-split-resizer, top-tabs.',
'Add custom CSS to personalize the app appearance. Changes apply immediately. Major UI regions expose a [data-section="..."] attribute you can target — e.g. snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar (focus-mode terminal list), terminal-host-tree-sidebar, terminal-host-tree-sidebar-content, terminal-host-tree-sidebar-row, terminal-side-panel (SFTP/Scripts/Theme/AI panel, available while open), terminal-side-panel-tabs, terminal-side-panel-content, terminal-sftp-panel, terminal-sftp-host-header, terminal-sftp-pane, terminal-sftp-toolbar, terminal-sftp-path, terminal-sftp-filter-bar, terminal-sftp-list, terminal-sftp-list-header, terminal-sftp-list-row, terminal-sftp-tree, terminal-sftp-tree-row, terminal-sftp-transfer-queue, terminal-sftp-transfer-row, terminal-split-pane, terminal-split-resizer, top-tabs, top-tabs-host-tree-toggle, top-tabs-quick-switcher-toggle.',
'settings.appearance.customCss.placeholder':
'/* Examples — use !important to beat Tailwind utility specificity */\n\n/* Border around the SFTP / side panel (not the focus-mode terminal list) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Thicker split dividers */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Highlight the focused split pane */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Or use Settings → Terminal → Workspace Focus Indicator → Border on focused pane */',
'/* Examples — use !important to beat Tailwind utility specificity */\n\n/* Hide the host-list toggle in the top tab bar */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Hide the plus button that opens the quick switcher */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Border around the SFTP / side panel (does not linger after closing) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Change the whole side panel background, not only the top tabs */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Style selected SFTP file rows */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Thicker split dividers */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Highlight the focused split pane */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Or use Settings → Terminal → Workspace Focus Indicator → Border on focused pane */',
'settings.appearance.language': 'Language',
'settings.appearance.language.desc': 'Choose the UI language',
'settings.appearance.uiFont': 'Interface Font',
@@ -309,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',
@@ -338,6 +350,11 @@ export const enCoreMessages: Messages = {
'settings.terminal.behavior.middleClickPaste': 'Middle-click paste',
'settings.terminal.behavior.middleClickPaste.desc':
'Paste clipboard content on middle-click',
'settings.terminal.behavior.middleClick': 'Middle-click behavior',
'settings.terminal.behavior.middleClick.desc': 'Action when middle-clicking in terminal',
'settings.terminal.behavior.middleClick.menu': 'Show menu',
'settings.terminal.behavior.middleClick.paste': 'Paste',
'settings.terminal.behavior.middleClick.disabled': 'Do nothing',
'settings.terminal.behavior.bracketedPaste': 'Bracketed paste mode',
'settings.terminal.behavior.bracketedPaste.desc':
'Wrap pasted text with escape sequences so the shell can distinguish paste from typed input. Disable if you see ^[[200~ artifacts.',
@@ -429,6 +446,15 @@ export const enCoreMessages: Messages = {
'settings.terminal.connection.x11Display.desc': 'Optional local display address for X11 forwarding. Leave empty to use the system default.',
'settings.terminal.connection.x11Display.placeholder': 'Auto (:0 or DISPLAY)',
'settings.terminal.section.serverStats': 'Server Stats (Linux)',
'settings.terminal.section.systemManager': 'System Manager',
'settings.terminal.systemManager.processRefreshInterval': 'Process list refresh',
'settings.terminal.systemManager.processRefreshInterval.desc': 'How often to refresh the process list in the system manager side panel.',
'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux session refresh',
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'How often to refresh the tmux session list.',
'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker container list refresh',
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'How often to refresh the Docker container list.',
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker stats refresh',
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'How often to refresh Docker container CPU/memory/network stats.',
'settings.terminal.serverStats.show': 'Show Server Stats',
'settings.terminal.serverStats.show.desc': 'Display CPU, memory, and disk usage in the terminal statusbar (Linux servers only).',
'settings.terminal.serverStats.refreshInterval': 'Refresh Interval',
@@ -440,8 +466,6 @@ export const enCoreMessages: Messages = {
'settings.terminal.rendering.renderer': 'Renderer',
'settings.terminal.rendering.renderer.desc': 'Choose the terminal rendering technology. Auto will use DOM on low-memory devices. Changes take effect on new terminal sessions.',
'settings.terminal.rendering.auto': 'Auto',
'settings.terminal.rendering.lineTimestamps': 'Prefix output with timestamps',
'settings.terminal.rendering.lineTimestamps.desc': 'Insert local time before terminal output lines. The timestamp becomes part of the visible terminal content.',
// Settings > Terminal > Workspace Focus Indicator
'settings.terminal.section.workspaceFocus': 'Workspace Focus Indicator',
@@ -466,6 +490,10 @@ export const enCoreMessages: Messages = {
'settings.shortcuts.scheme.disabled': 'Disabled',
'settings.shortcuts.scheme.mac': 'Mac (Cmd)',
'settings.shortcuts.scheme.pc': 'PC (Ctrl)',
'settings.shortcuts.disableTerminalFontZoom.label': 'Disable terminal zoom',
'settings.shortcuts.disableTerminalFontZoom.desc': 'Turn off terminal font zoom shortcuts, including Cmd/Ctrl + mouse wheel.',
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': 'Number keys skip pinned tabs',
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': 'When enabled, Cmd/Ctrl+[1...9] switches only work tabs (terminals, workspaces, editors), not the pinned Vault or SFTP tabs.',
'settings.shortcuts.section.custom': 'Custom Shortcuts',
'settings.shortcuts.resetAll': 'Reset All',
'settings.shortcuts.recording': 'Press keys...',
@@ -667,6 +695,7 @@ export const enCoreMessages: Messages = {
'vault.hosts.connectSelected': 'Connect ({count})',
'vault.hosts.connectMultiple.success': 'Connecting {count} hosts',
'vault.hosts.moveToGroup.success': 'Moved {host} to {group}',
'vault.hosts.errors.nameRequired': 'Host name is required.',
'vault.hosts.empty.title': 'Set up your hosts',
'vault.hosts.empty.desc': 'Save hosts to quickly connect to your servers, VMs, and containers.',

View File

@@ -0,0 +1,181 @@
import type { Messages } from '../types';
export const enSystemManagerMessages: Messages = {
'terminal.layer.system': 'System',
'systemManager.noSession': 'No active terminal session.',
'systemManager.notConnected': 'Connect to a host to manage processes and services.',
'systemManager.empty': 'No data available.',
'systemManager.tabs.processes': 'Processes',
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
'systemManager.popup.loading': 'Opening terminal…',
'systemManager.popup.startupFailed': 'The startup command did not complete successfully. Check that the target is still available and try again.',
'systemManager.errors.loadProcesses': 'Failed to load processes',
'systemManager.errors.loadTmux': 'Failed to load tmux sessions',
'systemManager.errors.loadTmuxWindows': 'Failed to load tmux windows',
'systemManager.errors.loadTmuxPanes': 'Failed to load tmux panes',
'systemManager.errors.loadTmuxClients': 'Failed to load tmux clients',
'systemManager.errors.actionFailed': 'Action failed',
'systemManager.errors.loadDocker': 'Failed to load containers',
'systemManager.errors.loadDockerStats': 'Failed to load container stats',
'systemManager.errors.loadDockerImages': 'Failed to load images',
'systemManager.errors.sshChannelUnavailable': 'The server refused to open a new execution channel. Try again later, or reconnect this host.',
'systemManager.processes.search': 'Search processes…',
'systemManager.processes.command': 'Command',
'systemManager.processes.user': 'User',
'systemManager.processes.term': 'Terminate',
'systemManager.processes.kill': 'Kill',
'systemManager.processes.stop': 'Stop (SIGSTOP)',
'systemManager.processes.cont': 'Continue (SIGCONT)',
'systemManager.processes.hup': 'Hang up (SIGHUP)',
'systemManager.processes.renice': 'Renice',
'systemManager.processes.renicePrompt': 'Nice value (-20 to 19)',
'systemManager.processes.reniceInvalid': 'Nice value must be between -20 and 19',
'systemManager.processes.confirmKill': 'Send SIGKILL to process {{pid}}?',
'systemManager.processes.confirmSignal': 'Send SIG{{signal}} to process {{pid}}?',
'systemManager.processes.filter.all': 'All',
'systemManager.processes.filter.running': 'Running',
'systemManager.processes.ppid': 'Parent PID',
'systemManager.processes.rss': 'RSS',
'systemManager.processes.vsz': 'Virtual size',
'systemManager.processes.elapsed': 'Elapsed',
'systemManager.processes.stat': 'State',
'systemManager.processes.meta': '{{count}} process(es)',
'systemManager.processes.loading': 'Loading processes…',
'systemManager.processes.loadingMore': 'Loading more processes…',
'systemManager.processes.state.running': 'Running',
'systemManager.processes.state.sleeping': 'Sleeping',
'systemManager.processes.state.stopped': 'Stopped',
'systemManager.processes.state.zombie': 'Zombie',
'systemManager.processes.sort.cpu': 'CPU',
'systemManager.processes.sort.mem': 'MEM',
'systemManager.processes.sort.pid': 'PID',
'systemManager.processes.sort.command': 'Command',
'systemManager.processes.sort.user': 'User',
'systemManager.common.dismiss': 'Dismiss',
'systemManager.common.checkingAvailability': 'Checking availability…',
'systemManager.common.loading': 'Loading…',
'systemManager.common.loadingDetails': 'Loading details…',
'systemManager.common.loadingStats': 'Loading stats…',
'systemManager.tmux.new': 'New',
'systemManager.tmux.search': 'Search sessions…',
'systemManager.tmux.newSessionTitle': 'New tmux session',
'systemManager.tmux.newSessionDesc': 'Name the session and optionally run a script on start.',
'systemManager.tmux.newSessionTabCustom': 'Custom command',
'systemManager.tmux.newSessionTabSnippet': 'From snippet',
'systemManager.tmux.pickSnippet': 'From snippets',
'systemManager.tmux.pickSnippetEmpty': 'No snippets yet — add some in the Scripts panel or Vault.',
'systemManager.tmux.selectedSnippet': 'Using snippet: {{label}}',
'systemManager.tmux.newSessionName': 'Session name',
'systemManager.tmux.newSessionCommand': 'Start command',
'systemManager.tmux.newSessionCommandPlaceholder': 'e.g. htop or npm run dev (optional)',
'systemManager.tmux.newSessionCommandHint': 'Leave empty for a default shell session.',
'systemManager.tmux.creating': 'Creating…',
'systemManager.tmux.newSessionPlaceholder': 'my-session',
'systemManager.tmux.newSessionRequired': 'Enter a session name first',
'systemManager.tmux.empty': 'No tmux sessions',
'systemManager.tmux.attach': 'Attach',
'systemManager.tmux.attached': 'Attached',
'systemManager.tmux.detached': 'Detached',
'systemManager.tmux.windows': '{{count}} window(s)',
'systemManager.tmux.created': 'Created',
'systemManager.tmux.activity': 'Activity',
'systemManager.tmux.rename': 'Rename',
'systemManager.tmux.detach': 'Detach all',
'systemManager.tmux.killSession': 'Kill session',
'systemManager.tmux.killServer': 'Kill server',
'systemManager.tmux.loadingDetails': 'Loading details…',
'systemManager.tmux.clients': 'Attached clients',
'systemManager.tmux.windowList': 'Windows',
'systemManager.tmux.newWindow': 'New window',
'systemManager.tmux.newWindowPlaceholder': 'Window name (optional)',
'systemManager.tmux.noWindows': 'No windows',
'systemManager.tmux.unavailable': 'tmux is not available on this host',
'systemManager.docker.unavailable': 'Docker is not available on this host',
'systemManager.tmux.windowsMismatch': 'Session reports {{count}} window(s) but list-windows returned none',
'systemManager.tmux.lastCommand': 'last command: {{command}}',
'systemManager.tmux.noPanes': 'No panes',
'systemManager.tmux.panes': '{{count}} pane(s)',
'systemManager.tmux.active': 'active',
'systemManager.tmux.unnamedWindow': 'Unnamed window',
'systemManager.tmux.unnamedPane': 'Unnamed pane',
'systemManager.tmux.attachWindow': 'Attach to window',
'systemManager.tmux.selectWindow': 'Select window',
'systemManager.tmux.killWindow': 'Kill window',
'systemManager.tmux.killPane': 'Kill pane',
'systemManager.tmux.splitHorizontal': 'Split horizontal',
'systemManager.tmux.splitVertical': 'Split vertical',
'systemManager.tmux.sendKeys': 'Send keys',
'systemManager.tmux.sendKeysTo': 'Send keys to window {{window}} pane {{pane}}',
'systemManager.tmux.sendKeysPlaceholder': 'Command or text…',
'systemManager.tmux.renameSessionPrompt': 'Rename session',
'systemManager.tmux.renameWindowPrompt': 'Rename window',
'systemManager.tmux.windowName': 'Window name',
'systemManager.tmux.confirmKillSession': 'Kill tmux session "{{name}}"?',
'systemManager.tmux.confirmDetachSession': 'Detach all clients from "{{name}}"?',
'systemManager.tmux.confirmKillWindow': 'Kill window "{{name}}"?',
'systemManager.tmux.confirmKillPane': 'Kill pane #{{index}}?',
'systemManager.tmux.confirmKillServer': 'Kill tmux server? All sessions will be terminated.',
'systemManager.tmux.meta': '{{count}} session(s)',
'systemManager.docker.title': 'Containers',
'systemManager.docker.subTabs.containers': 'Containers',
'systemManager.docker.subTabs.images': 'Images',
'systemManager.docker.empty': 'No containers found',
'systemManager.docker.imagesEmpty': 'No images found',
'systemManager.docker.search': 'Search containers…',
'systemManager.docker.searchImages': 'Search images…',
'systemManager.docker.filter.all': 'All',
'systemManager.docker.filter.running': 'Running',
'systemManager.docker.filter.stopped': 'Stopped',
'systemManager.docker.filter.paused': 'Paused',
'systemManager.docker.shell': 'Shell',
'systemManager.docker.logs': 'Logs',
'systemManager.docker.details': 'Details',
'systemManager.docker.inspect': 'Inspect',
'systemManager.docker.imageInspect': 'Image inspect',
'systemManager.docker.confirmRemove': 'Remove this container?',
'systemManager.docker.confirmKill': 'Force kill this container?',
'systemManager.docker.confirmRemoveImage': 'Remove image "{{name}}"?',
'systemManager.docker.confirmPrune': 'Remove dangling images?',
'systemManager.docker.confirmPruneAll': 'Remove all unused images?',
'systemManager.docker.pause': 'Pause',
'systemManager.docker.unpause': 'Unpause',
'systemManager.docker.restart': 'Restart',
'systemManager.docker.kill': 'Kill',
'systemManager.docker.renamePrompt': 'Container name',
'systemManager.docker.prune': 'Prune',
'systemManager.docker.pruneAll': 'Prune all',
'systemManager.docker.tag': 'Tag',
'systemManager.docker.tagRepoPrompt': 'Repository name',
'systemManager.docker.tagNamePrompt': 'Tag name',
'systemManager.docker.meta': '{{count}} container(s)',
'systemManager.docker.imagesMeta': '{{count}} image(s)',
'systemManager.docker.start': 'Start',
'systemManager.docker.stop': 'Stop',
'systemManager.inspect.status': 'Status',
'systemManager.inspect.image': 'Image',
'systemManager.inspect.created': 'Created',
'systemManager.inspect.started': 'Started',
'systemManager.inspect.restartPolicy': 'Restart policy',
'systemManager.inspect.command': 'Command',
'systemManager.inspect.ports': 'Ports',
'systemManager.inspect.networks': 'Networks',
'systemManager.inspect.mounts': 'Mounts',
'systemManager.inspect.env': 'Environment',
'systemManager.inspect.labels': 'Labels',
'systemManager.inspect.tags': 'Tags',
'systemManager.inspect.digests': 'Digests',
'systemManager.inspect.size': 'Size',
'systemManager.inspect.platform': 'Platform',
'systemManager.inspect.workdir': 'Working dir',
'systemManager.inspect.exposedPorts': 'Exposed ports',
'systemManager.inspect.showRaw': 'JSON',
'systemManager.inspect.hideRaw': 'Hide JSON',
};

View File

@@ -5,14 +5,34 @@ export const enTerminalMessages: Messages = {
// Terminal toolbar / search / context menu / auth
'terminal.toolbar.openSftp': 'Open SFTP',
'terminal.toolbar.availableAfterConnect': 'Available after connect',
'terminal.toolbar.sendYmodem': 'Send with YMODEM',
'terminal.toolbar.receiveYmodem': 'Receive with YMODEM',
'terminal.toolbar.sftp': 'SFTP',
'terminal.toolbar.more': 'More actions',
'terminal.toolbar.scripts': 'Scripts',
'terminal.toolbar.history': 'Command history',
'history.scope.label': 'History scope',
'history.tab.host': 'Host',
'history.tab.global': 'Global',
'history.searchPlaceholder': 'Search history...',
'history.loading': 'Loading remote history...',
'history.meta.count': '{count} commands',
'history.empty.noSession': 'Open a remote session to view its command history.',
'history.empty.unsupportedProtocol': 'Command history is only available for SSH/Mosh/ET sessions.',
'history.empty.noHistory': 'No command history found on this host.',
'history.empty.noGlobalHistory': 'No global command history yet. Commands you run will appear here.',
'history.action.refresh': 'Refresh',
'history.action.retry': 'Retry',
'history.action.paste': 'Paste to terminal',
'history.action.run': 'Run in terminal',
'history.action.saveAsSnippet': 'Save as snippet',
'terminal.toolbar.library': 'Library',
'terminal.toolbar.noSnippets': 'No snippets available',
'terminal.toolbar.terminalSettings': 'Terminal settings',
'terminal.toolbar.searchTerminal': 'Search terminal',
'terminal.toolbar.search': 'Search',
'terminal.toolbar.timestampsEnable': 'Show timestamps',
'terminal.toolbar.timestampsDisable': 'Hide timestamps',
'terminal.toolbar.broadcast': 'Broadcast',
'terminal.toolbar.broadcastEnable': 'Enable Broadcast Mode',
'terminal.toolbar.broadcastDisable': 'Disable Broadcast Mode',
@@ -21,8 +41,17 @@ export const enTerminalMessages: Messages = {
'terminal.composeBar.send': 'Send',
'terminal.composeBar.close': 'Close compose bar',
'terminal.composeBar.broadcasting': 'Broadcasting to all sessions',
'terminal.composeBar.resize': 'Resize compose bar height',
'terminal.composeBar.manageSnippets': 'Manage quick snippets',
'terminal.composeBar.searchSnippets': 'Search snippets...',
'terminal.composeBar.noPinnedSnippets': 'Pin snippets with + for quick access',
'terminal.composeBar.noMatchingSnippets': 'No matching snippets',
'terminal.composeBar.pinnedCount': '{count} pinned',
'terminal.composeBar.unpinSnippet': 'Remove {label} from quick bar',
'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',
@@ -61,7 +90,9 @@ export const enTerminalMessages: Messages = {
'terminal.dragDrop.localTitle': 'Drop to Insert Paths',
'terminal.dragDrop.localMessage': 'File paths will be inserted into the terminal',
'terminal.dragDrop.remoteTitle': 'Drop to Upload Files',
'terminal.dragDrop.remoteMessage': 'Files will be uploaded via SFTP',
'terminal.dragDrop.remoteZmodemMessage': 'Files will be uploaded via ZMODEM (PTY)',
'terminal.dragDrop.remoteSftpMessage': 'Files will be uploaded via SFTP',
'terminal.dragDrop.noFiles': 'No files to upload',
'terminal.dragDrop.notConnected': 'Cannot drop files - terminal is not connected',
'terminal.dragDrop.errorTitle': 'Drop Error',
'terminal.dragDrop.errorMessage': 'Failed to process dropped files',
@@ -75,10 +106,27 @@ export const enTerminalMessages: Messages = {
'terminal.menu.pasteSelection': 'Paste Selection',
'terminal.menu.selectAll': 'Select All',
'terminal.menu.reconnect': 'Reconnect',
'terminal.menu.sendYmodem': 'Send with YMODEM',
'terminal.menu.receiveYmodem': 'Receive with YMODEM',
'terminal.menu.splitHorizontal': 'Split Horizontal',
'terminal.menu.splitVertical': 'Split Vertical',
'terminal.menu.clearBuffer': 'Clear Buffer',
'terminal.menu.closeTerminal': 'Close terminal',
'terminal.menu.rename': 'Rename',
'terminal.menu.detach': 'Detach from workspace',
'terminal.menu.detachSession': 'Detach {name}',
'terminal.ymodem.selectFile': 'Select file to send',
'terminal.ymodem.allFiles': 'All files',
'terminal.ymodem.started': 'YMODEM sending {fileName}',
'terminal.ymodem.complete': 'YMODEM sent {fileName}',
'terminal.ymodem.failed': 'YMODEM send failed',
'terminal.ymodem.selectReceiveDirectory': 'Select folder to save received files',
'terminal.ymodem.receiveStarted': 'YMODEM receiving...',
'terminal.ymodem.receiveComplete': 'YMODEM received {fileName}',
'terminal.ymodem.receiveCompleteMultiple': 'YMODEM received {count} files',
'terminal.ymodem.receiveEmpty': 'No YMODEM files received',
'terminal.ymodem.receiveFailed': 'YMODEM receive failed',
'terminal.ymodem.unavailable': 'YMODEM is unavailable',
'terminal.selection.addToAI': 'Add to Conversation',
'terminal.selection.addToAIDesc': 'Attach selected terminal output to the AI draft',
'terminal.auth.password': 'Password',

View File

@@ -123,6 +123,7 @@ export const enVaultMessages: Messages = {
'sftp.filter.placeholder': 'Filter by filename...',
'sftp.bookmark.add': 'Bookmark this path',
'sftp.bookmark.remove': 'Remove bookmark',
'sftp.bookmark.list': 'Bookmarked paths',
'sftp.bookmark.addGlobal': '+Global',
'sftp.bookmark.addGlobalTooltip': 'Save as global bookmark (shared across all hosts)',
'sftp.bookmark.empty': 'No bookmarks yet',
@@ -150,9 +151,14 @@ 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',
'sftp.viewMode.switchToList': 'Switch to list view',
'sftp.viewMode.switchToTree': 'Switch to tree view',
'sftp.tree.loadError': 'Failed to load directory',
'sftp.tree.loading': 'Loading...',
'sftp.kind.folder': 'Folder',
@@ -255,6 +261,8 @@ export const enVaultMessages: Messages = {
'sftp.tabs.addTab': 'Add new tab',
'sftp.tabs.closeTab': 'Close tab',
'sftp.tabs.newTab': 'New Tab',
'sftp.tabs.copyDefaultPath': 'Copy tab (default path)',
'sftp.tabs.copyCurrentPath': 'Copy and go to current path',
'sftp.conflict.title': 'File Conflict',
'sftp.conflict.desc': 'A file with the same name already exists at the destination',
'sftp.conflict.alreadyExistsSuffix': 'already exists',
@@ -459,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',
@@ -480,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',
@@ -527,6 +581,8 @@ export const enVaultMessages: Messages = {
'hostDetails.deviceType.warning': 'AI agent commands will be sent directly without exit code tracking. Only enable for devices that do not run a standard shell.',
'hostDetails.section.sshAlgorithms': 'SSH Algorithms',
'hostDetails.section.terminalBehavior': 'Terminal Behavior',
'hostDetails.lineTimestamps': 'Show output timestamps',
'hostDetails.lineTimestamps.desc': 'Show local time beside visible output lines for this host without changing terminal text.',
'hostDetails.legacyAlgorithms': 'Allow Legacy Algorithms',
'hostDetails.legacyAlgorithms.desc': 'Enable deprecated SSH algorithms (diffie-hellman-group1, ssh-dss, 3des-cbc, etc.) for connecting to older network equipment.',
'hostDetails.legacyAlgorithms.warning': 'These algorithms have known security weaknesses. Only enable for legacy devices that do not support modern cryptography.',

View File

@@ -3,6 +3,7 @@ import { ruCoreMessages } from './ru/core';
import { ruVaultMessages } from './ru/vault';
import { ruTerminalMessages } from './ru/terminal';
import { ruAiMessages } from './ru/ai';
import { ruSystemManagerMessages } from './ru/systemManager';
export type { Messages } from './types';
@@ -11,6 +12,7 @@ const ru: Messages = {
...ruVaultMessages,
...ruTerminalMessages,
...ruAiMessages,
...ruSystemManagerMessages,
};
export default ru;

View File

@@ -3,9 +3,11 @@ import type { Messages } from '../types';
export const ruAiMessages: Messages = {
// AI Settings
'ai.agentSettings': 'Настройки агента',
'ai.chat.preparing': 'Подготовка…',
'ai.title': 'AI',
'ai.description': 'Настройка AI-провайдеров, агентов и параметров безопасности',
'ai.providers': 'Провайдеры',
'ai.agents': 'Агенты',
'ai.providers.empty': 'Провайдеры не настроены. Добавьте провайдера, чтобы начать.',
'ai.providers.add': 'Добавить провайдера',
'ai.providers.active': 'Активен',
@@ -106,6 +108,54 @@ export const ruAiMessages: Messages = {
'ai.copilot.customPathPlaceholder': 'например, /usr/local/bin/copilot',
'ai.copilot.check': 'Проверить',
// AI Cursor SDK
'ai.cursor.title': 'Cursor',
'ai.cursor.description': 'Использует Cursor SDK.',
'ai.cursor.detecting': 'Обнаружение...',
'ai.cursor.detected': 'Доступен',
'ai.cursor.notFound': 'Недоступен',
'ai.cursor.path': 'Среда:',
'ai.cursor.notFoundHint': 'Укажите API-ключ, чтобы включить Cursor.',
'ai.cursor.notInstalledHint': 'Cursor SDK не обнаружен.',
'ai.cursor.installStatus': 'Cursor SDK',
'ai.cursor.installed': 'Обнаружено',
'ai.cursor.notInstalled': 'Не обнаружено',
'ai.cursor.apiKeyStatus': 'API-ключ',
'ai.cursor.apiKeyConfigured': 'Настроен',
'ai.cursor.apiKeyMissing': 'Не указан',
'ai.cursor.apiKeyFromEnv': 'Из окружения',
'ai.cursor.apiKey': 'API-ключ',
'ai.cursor.apiKeyPlaceholder': 'Введите API-ключ Cursor',
'ai.cursor.apiKeyPlaceholder.env': 'Используется CURSOR_API_KEY; введите ключ для замены',
'ai.cursor.apiKeyEnvHint': 'Cursor может использовать CURSOR_API_KEY из shell. Сохраняйте ключ здесь только если хотите переопределить его в Netcatty.',
'ai.cursor.apiKeyOverrideHint': 'Netcatty сначала использует сохранённый здесь ключ, затем CURSOR_API_KEY.',
'ai.cursor.saveApiKey': 'Сохранить',
'ai.cursor.saved': 'Сохранено',
'ai.cursor.showApiKey': 'Показать API-ключ',
'ai.cursor.hideApiKey': 'Скрыть API-ключ',
'ai.cursor.customPathPlaceholder': 'например, /usr/local/bin/cursor',
'ai.cursor.check': 'Проверить',
// AI CodeBuddy Code
'ai.codebuddy.title': 'CodeBuddy Code',
'ai.codebuddy.description': 'Использует CodeBuddy Code через официальный Agent SDK (`@tencent-ai/agent-sdk`). После обнаружения может быть выбран как внешний агент для программирования.',
'ai.codebuddy.detecting': 'Обнаружение...',
'ai.codebuddy.detected': 'Обнаружен',
'ai.codebuddy.notFound': 'Не найден',
'ai.codebuddy.path': 'Путь:',
'ai.codebuddy.notFoundHint': 'Не удалось найти codebuddy в PATH. Установите его или укажите путь к исполняемому файлу ниже.',
'ai.codebuddy.customPathPlaceholder': 'например, /usr/local/bin/codebuddy',
'ai.codebuddy.check': 'Проверить',
'ai.codebuddy.configSection': 'Аутентификация и конфигурация (необязательно)',
'ai.codebuddy.internetEnv': 'Сетевая среда',
'ai.codebuddy.internetEnv.default': 'По умолчанию (зарубежная)',
'ai.codebuddy.internetEnv.internal': 'Internal',
'ai.codebuddy.internetEnv.ioa': 'IOA',
'ai.codebuddy.internetEnv.hint': 'Устанавливает CODEBUDDY_INTERNET_ENVIRONMENT — выберите Internal или IOA для ограниченных сетевых сред.',
'ai.codebuddy.envVars': 'Переменные окружения',
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
'ai.codebuddy.envVars.hint': 'По одной записи KEY=VALUE на строку, передаются агенту CodeBuddy. Укажите CODEBUDDY_API_KEY или CODEBUDDY_AUTH_TOKEN для аутентификации. Хранятся локально в открытом виде.',
// AI Default Agent
'ai.defaultAgent': 'Агент по умолчанию',
'ai.defaultAgent.description': 'Агент, который будет использоваться при запуске новой AI-сессии',
@@ -127,6 +177,29 @@ export const ruAiMessages: Messages = {
'ai.userSkills.status.ready': 'Готово',
'ai.userSkills.status.warning': 'Предупреждение',
// AI Quick Messages
'ai.quickMessages.title': 'Быстрые сообщения',
'ai.quickMessages.description': 'Создавайте часто используемые подсказки и вставляйте их в AI-чат через / или кнопку быстрых сообщений. В отличие от user skills, быстрые сообщения заполняют поле ввода текстом.',
'ai.quickMessages.add': 'Добавить быстрое сообщение',
'ai.quickMessages.createTitle': 'Новое быстрое сообщение',
'ai.quickMessages.editTitle': 'Редактировать быстрое сообщение',
'ai.quickMessages.name': 'Название',
'ai.quickMessages.name.placeholder': 'например: Проверить диск',
'ai.quickMessages.slug': 'Команда',
'ai.quickMessages.slug.placeholder': 'disk-check',
'ai.quickMessages.descriptionField': 'Описание (необязательно)',
'ai.quickMessages.descriptionField.placeholder': 'Краткая подсказка о назначении',
'ai.quickMessages.content': 'Текст сообщения',
'ai.quickMessages.content.placeholder': 'Полный текст подсказки для вставки...',
'ai.quickMessages.empty': 'Быстрых сообщений пока нет. Добавьте несколько часто используемых подсказок.',
'ai.quickMessages.confirmDelete': 'Удалить быстрое сообщение «{name}»?',
'ai.quickMessages.error.nameRequired': 'Укажите название.',
'ai.quickMessages.error.invalidSlug': 'Команда может содержать только строчные буквы, цифры и дефисы.',
'ai.quickMessages.error.contentRequired': 'Укажите текст сообщения.',
'ai.quickMessages.error.slugTaken': 'Эта команда уже используется другим быстрым сообщением.',
'ai.quickMessages.error.slugConflictsWithSkill': 'Команда конфликтует с user skill «/{slug}». Выберите другую.',
'ai.quickMessages.error.maxItems': 'Можно сохранить не более {max} быстрых сообщений.',
// AI Chat
'ai.chat.noProvider': 'AI-провайдер не настроен. Перейдите в **Настройки → AI → Провайдеры**, чтобы добавить и включить провайдера.',
'ai.chat.toolDenied': 'Действие было отклонено пользователем.',
@@ -175,6 +248,7 @@ export const ruAiMessages: Messages = {
'ai.chat.newChat': 'Новый чат',
'ai.chat.allSessions': 'Все сессии',
'ai.chat.loadEarlierMessages': 'Загрузить более ранние сообщения (ещё {n})',
'ai.chat.usedTools': 'Использовано инструментов: {n}',
'ai.chat.loadMoreSessions': 'Загрузить больше сессий (ещё {n})',
'ai.chat.noSessions': 'Предыдущих сессий нет',
'ai.chat.retryHint': 'Вы можете повторить попытку, отправив сообщение ещё раз.',
@@ -185,6 +259,18 @@ export const ruAiMessages: Messages = {
'ai.chat.menuImage': 'Изображение',
'ai.chat.menuMentionHost': 'Упомянуть хост',
'ai.chat.menuUserSkills': 'Пользовательские skills',
'ai.chat.menuSlashCommands': 'Команды /',
'ai.chat.slashCommands': 'Команды /',
'ai.chat.slashQuickMessages': 'Быстрые сообщения',
'ai.chat.slashUserSkills': 'User skills',
'ai.chat.quickMessages': 'Команды /',
'ai.chat.slashNoResults': 'Нет подходящих команд',
'ai.chat.slashEmptyHint': 'Добавьте подсказки в Настройки → AI → Быстрые сообщения.',
// AI Chat Shortcuts
'ai.chatShortcuts.title': 'Быстрые действия чата',
'ai.chatShortcuts.selectionAction': 'Показывать «Добавить в чат» при выделении в терминале',
'ai.chatShortcuts.selectionAction.description': 'Показывать небольшую кнопку AI рядом с выделенным текстом терминала.',
// AI Error
'ai.codex.bridgeError': 'Обработчики главного процесса Codex ещё не загружены. Полностью перезапустите Netcatty или dev-процесс Electron и попробуйте снова.',
@@ -228,6 +314,7 @@ export const ruAiMessages: Messages = {
'terminal.layer.switchToSplitView': 'Переключить в режим разделения',
'terminal.layer.sftp': 'SFTP',
'terminal.layer.scripts': 'Скрипты',
'terminal.layer.history': 'История',
'terminal.layer.theme': 'Тема',
'terminal.layer.aiChat': 'AI-чат',
'terminal.layer.movePanelLeft': 'Переместить панель влево',

View File

@@ -42,6 +42,7 @@ export const ruCoreMessages: Messages = {
'common.more': 'Ещё',
'common.selectAHost': 'Выберите хост',
'common.selectAHostPlaceholder': 'Выберите хост...',
'sort.manual': 'Ручной порядок',
'sort.az': 'А-Я',
'sort.za': 'Я-А',
'sort.newest': 'Сначала новые',
@@ -225,6 +226,8 @@ export const ruCoreMessages: Messages = {
'settings.vault.showOnlyUngroupedHostsInRootDesc': 'Если включено, в корневом списке хостов будут показаны только хосты без группы. Откройте группу на боковой панели, чтобы увидеть сгруппированные хосты.',
'settings.vault.showSftpTab': 'Показывать вкладку SFTP',
'settings.vault.showSftpTabDesc': 'Показывать отдельный SFTP-вид в верхней панели вкладок. Если скрыто, используйте боковую панель SFTP внутри сессии.',
'settings.vault.showHostTreeSidebar': 'Показывать боковую панель хостов',
'settings.vault.showHostTreeSidebarDesc': 'Показывать список хостов и кнопку в верхней панели для вкладок терминала и редактора.',
// Update notifications
'update.available.title': 'Доступно обновление',
@@ -264,9 +267,9 @@ export const ruCoreMessages: Messages = {
'settings.appearance.themeColor.dark': 'Палитра тёмной темы',
'settings.appearance.customCss': 'Пользовательский CSS',
'settings.appearance.customCss.desc':
'Добавьте пользовательский CSS, чтобы настроить внешний вид приложения. Изменения применяются сразу. Основные области интерфейса имеют атрибут [data-section="..."], который можно использовать для выбора элементов, например: snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar (список терминалов в режиме Focus), terminal-side-panel (панель SFTP/скриптов/темы/AI), terminal-sftp-panel, terminal-split-pane, terminal-split-resizer, top-tabs.',
'Добавьте пользовательский CSS, чтобы настроить внешний вид приложения. Изменения применяются сразу. Основные области интерфейса имеют атрибут [data-section="..."], который можно использовать для выбора элементов, например: snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar (список терминалов в режиме Focus), terminal-host-tree-sidebar, terminal-host-tree-sidebar-content, terminal-host-tree-sidebar-row, terminal-side-panel (панель SFTP/скриптов/темы/AI, доступна пока открыта), terminal-side-panel-tabs, terminal-side-panel-content, terminal-sftp-panel, terminal-sftp-host-header, terminal-sftp-pane, terminal-sftp-toolbar, terminal-sftp-path, terminal-sftp-filter-bar, terminal-sftp-list, terminal-sftp-list-header, terminal-sftp-list-row, terminal-sftp-tree, terminal-sftp-tree-row, terminal-sftp-transfer-queue, terminal-sftp-transfer-row, terminal-split-pane, terminal-split-resizer, top-tabs, top-tabs-host-tree-toggle, top-tabs-quick-switcher-toggle.',
'settings.appearance.customCss.placeholder':
'/* Примеры — используйте !important, чтобы переопределить специфичность утилит Tailwind */\n\n/* Рамка вокруг боковой панели SFTP (не список терминалов Focus) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Более заметные разделители сплита */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Подсветка активной панели сплита */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Или: Настройки → Терминал → Индикатор фокуса → Рамка вокруг активной панели */',
'/* Примеры — используйте !important, чтобы переопределить специфичность утилит Tailwind */\n\n/* Скрыть переключатель списка хостов в верхней панели вкладок */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Скрыть кнопку плюса, открывающую быстрый переключатель */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Рамка вокруг боковой панели SFTP (не остаётся после закрытия) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Изменить фон всей боковой панели, а не только верхних вкладок */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Настроить выбранные строки SFTP */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Более заметные разделители сплита */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Подсветка активной панели сплита */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Или: Настройки → Терминал → Индикатор фокуса → Рамка вокруг активной панели */',
'settings.appearance.language': 'Язык',
'settings.appearance.language.desc': 'Выберите язык интерфейса',
'settings.appearance.uiFont': 'Шрифт интерфейса',
@@ -309,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': 'Межстрочный отступ',
@@ -338,6 +350,11 @@ export const ruCoreMessages: Messages = {
'settings.terminal.behavior.middleClickPaste': 'Вставка средней кнопкой мыши',
'settings.terminal.behavior.middleClickPaste.desc':
'Вставлять содержимое буфера обмена по щелчку средней кнопкой',
'settings.terminal.behavior.middleClick': 'Поведение средней кнопки мыши',
'settings.terminal.behavior.middleClick.desc': 'Действие при щелчке средней кнопкой в терминале',
'settings.terminal.behavior.middleClick.menu': 'Показать меню',
'settings.terminal.behavior.middleClick.paste': 'Вставить',
'settings.terminal.behavior.middleClick.disabled': 'Ничего не делать',
'settings.terminal.behavior.bracketedPaste': 'Режим bracketed paste',
'settings.terminal.behavior.bracketedPaste.desc':
'Оборачивать вставляемый текст escape-последовательностями, чтобы оболочка отличала вставку от обычного ввода. Отключите, если видите артефакты вида ^[[200~.',
@@ -429,6 +446,15 @@ export const ruCoreMessages: Messages = {
'settings.terminal.connection.x11Display.desc': 'Необязательный адрес локального дисплея для перенаправления X11. Оставьте пустым, чтобы использовать системное значение по умолчанию.',
'settings.terminal.connection.x11Display.placeholder': 'Авто (:0 или DISPLAY)',
'settings.terminal.section.serverStats': 'Статистика сервера (Linux)',
'settings.terminal.section.systemManager': 'Системный менеджер',
'settings.terminal.systemManager.processRefreshInterval': 'Обновление списка процессов',
'settings.terminal.systemManager.processRefreshInterval.desc': 'Как часто обновлять список процессов в боковой панели системного менеджера.',
'settings.terminal.systemManager.tmuxRefreshInterval': 'Обновление сессий tmux',
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'Как часто обновлять список сессий tmux.',
'settings.terminal.systemManager.dockerListRefreshInterval': 'Обновление списка контейнеров Docker',
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Как часто обновлять список контейнеров Docker.',
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Обновление статистики Docker',
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Как часто обновлять CPU/память/сеть контейнеров Docker.',
'settings.terminal.serverStats.show': 'Показывать статистику сервера',
'settings.terminal.serverStats.show.desc': 'Показывать загрузку CPU, памяти и диска в строке состояния терминала (только для Linux-серверов).',
'settings.terminal.serverStats.refreshInterval': 'Интервал обновления',
@@ -440,8 +466,6 @@ export const ruCoreMessages: Messages = {
'settings.terminal.rendering.renderer': 'Рендерер',
'settings.terminal.rendering.renderer.desc': 'Выберите технологию рендеринга терминала. В режиме "Авто" на устройствах с малым объёмом памяти будет использоваться DOM. Изменения применяются к новым терминальным сессиям.',
'settings.terminal.rendering.auto': 'Авто',
'settings.terminal.rendering.lineTimestamps': 'Добавлять время к выводу',
'settings.terminal.rendering.lineTimestamps.desc': 'Вставлять локальное время перед строками вывода терминала. Метка времени становится частью видимого содержимого терминала.',
// Settings > Terminal > Workspace Focus Indicator
'settings.terminal.section.workspaceFocus': 'Индикатор фокуса рабочей области',
@@ -466,6 +490,10 @@ export const ruCoreMessages: Messages = {
'settings.shortcuts.scheme.disabled': 'Отключено',
'settings.shortcuts.scheme.mac': 'Mac (Cmd)',
'settings.shortcuts.scheme.pc': 'PC (Ctrl)',
'settings.shortcuts.disableTerminalFontZoom.label': 'Отключить масштаб терминала',
'settings.shortcuts.disableTerminalFontZoom.desc': 'Отключает быстрый масштаб текста в терминале, включая Cmd/Ctrl + колесо мыши.',
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': 'Цифры без закреплённых вкладок',
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': 'Если включено, Cmd/Ctrl+[1...9] переключает только рабочие вкладки (терминалы, рабочие области, редакторы), а не закреплённые Vault и SFTP.',
'settings.shortcuts.section.custom': 'Пользовательские сочетания',
'settings.shortcuts.resetAll': 'Сбросить все',
'settings.shortcuts.recording': 'Нажмите клавиши...',
@@ -481,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': 'Вставить в терминал',
@@ -488,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

@@ -0,0 +1,181 @@
import type { Messages } from '../types';
export const ruSystemManagerMessages: Messages = {
'terminal.layer.system': 'Система',
'systemManager.noSession': 'Нет активного терминального сеанса.',
'systemManager.notConnected': 'Подключитесь к хосту для управления процессами и сервисами.',
'systemManager.empty': 'Нет данных.',
'systemManager.tabs.processes': 'Процессы',
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
'systemManager.popup.loading': 'Открытие терминала…',
'systemManager.popup.startupFailed': 'Команда запуска не была выполнена успешно. Проверьте, что цель доступна, и повторите попытку.',
'systemManager.errors.loadProcesses': 'Не удалось загрузить процессы',
'systemManager.errors.loadTmux': 'Не удалось загрузить сессии tmux',
'systemManager.errors.loadTmuxWindows': 'Не удалось загрузить окна tmux',
'systemManager.errors.loadTmuxPanes': 'Не удалось загрузить панели tmux',
'systemManager.errors.loadTmuxClients': 'Не удалось загрузить клиентов tmux',
'systemManager.errors.actionFailed': 'Не удалось выполнить действие',
'systemManager.errors.loadDocker': 'Не удалось загрузить контейнеры',
'systemManager.errors.loadDockerStats': 'Не удалось загрузить статистику контейнеров',
'systemManager.errors.loadDockerImages': 'Не удалось загрузить образы',
'systemManager.errors.sshChannelUnavailable': 'Сервер отказался открыть новый канал выполнения. Повторите попытку позже или переподключите этот хост.',
'systemManager.processes.search': 'Поиск процессов…',
'systemManager.processes.command': 'Команда',
'systemManager.processes.user': 'Пользователь',
'systemManager.processes.term': 'Завершить',
'systemManager.processes.kill': 'Убить',
'systemManager.processes.stop': 'Остановить (SIGSTOP)',
'systemManager.processes.cont': 'Продолжить (SIGCONT)',
'systemManager.processes.hup': 'Сигнал SIGHUP',
'systemManager.processes.renice': 'Renice',
'systemManager.processes.renicePrompt': 'Значение nice (-20 до 19)',
'systemManager.processes.reniceInvalid': 'Nice должно быть от -20 до 19',
'systemManager.processes.confirmKill': 'Отправить SIGKILL процессу {{pid}}?',
'systemManager.processes.confirmSignal': 'Отправить SIG{{signal}} процессу {{pid}}?',
'systemManager.processes.filter.all': 'Все',
'systemManager.processes.filter.running': 'Активные',
'systemManager.processes.ppid': 'Родительский PID',
'systemManager.processes.rss': 'RSS',
'systemManager.processes.vsz': 'Виртуальный размер',
'systemManager.processes.elapsed': 'Время работы',
'systemManager.processes.stat': 'Состояние',
'systemManager.processes.meta': '{{count}} проц.',
'systemManager.processes.loading': 'Загрузка процессов…',
'systemManager.processes.loadingMore': 'Загрузка следующих процессов…',
'systemManager.processes.state.running': 'Активен',
'systemManager.processes.state.sleeping': 'Сон',
'systemManager.processes.state.stopped': 'Остановлен',
'systemManager.processes.state.zombie': 'Зомби',
'systemManager.processes.sort.cpu': 'CPU',
'systemManager.processes.sort.mem': 'Память',
'systemManager.processes.sort.pid': 'PID',
'systemManager.processes.sort.command': 'Команда',
'systemManager.processes.sort.user': 'Пользователь',
'systemManager.common.dismiss': 'Закрыть',
'systemManager.common.checkingAvailability': 'Проверка доступности…',
'systemManager.common.loading': 'Загрузка…',
'systemManager.common.loadingDetails': 'Загрузка деталей…',
'systemManager.common.loadingStats': 'Загрузка статистики…',
'systemManager.tmux.new': 'Создать',
'systemManager.tmux.search': 'Поиск сессий…',
'systemManager.tmux.newSessionTitle': 'Новая сессия tmux',
'systemManager.tmux.newSessionDesc': 'Задайте имя сессии и при необходимости команду запуска.',
'systemManager.tmux.newSessionTabCustom': 'Своя команда',
'systemManager.tmux.newSessionTabSnippet': 'Из сниппета',
'systemManager.tmux.pickSnippet': 'Из сниппетов',
'systemManager.tmux.pickSnippetEmpty': 'Сниппетов пока нет — добавьте их на панели скриптов или в хранилище.',
'systemManager.tmux.selectedSnippet': 'Выбран сниппет: {{label}}',
'systemManager.tmux.newSessionName': 'Имя сессии',
'systemManager.tmux.newSessionCommand': 'Команда запуска',
'systemManager.tmux.newSessionCommandPlaceholder': 'например htop или npm run dev (необяз.)',
'systemManager.tmux.newSessionCommandHint': 'Оставьте пустым для сессии с shell по умолчанию.',
'systemManager.tmux.creating': 'Создание…',
'systemManager.tmux.newSessionPlaceholder': 'my-session',
'systemManager.tmux.newSessionRequired': 'Сначала введите имя сессии',
'systemManager.tmux.empty': 'Нет сессий tmux',
'systemManager.tmux.attach': 'Подключить',
'systemManager.tmux.attached': 'Подключена',
'systemManager.tmux.detached': 'Отключена',
'systemManager.tmux.windows': '{{count}} окон',
'systemManager.tmux.created': 'Создана',
'systemManager.tmux.activity': 'Активность',
'systemManager.tmux.rename': 'Переименовать',
'systemManager.tmux.detach': 'Отключить всех',
'systemManager.tmux.killSession': 'Завершить сессию',
'systemManager.tmux.killServer': 'Остановить сервер',
'systemManager.tmux.loadingDetails': 'Загрузка деталей…',
'systemManager.tmux.clients': 'Подключённые клиенты',
'systemManager.tmux.windowList': 'Окна',
'systemManager.tmux.newWindow': 'Новое окно',
'systemManager.tmux.newWindowPlaceholder': 'Имя окна (необязательно)',
'systemManager.tmux.noWindows': 'Нет окон',
'systemManager.tmux.unavailable': 'tmux недоступен на этом хосте',
'systemManager.docker.unavailable': 'Docker недоступен на этом хосте',
'systemManager.tmux.windowsMismatch': 'В сессии указано {{count}} окон, но list-windows ничего не вернул',
'systemManager.tmux.lastCommand': 'последняя команда: {{command}}',
'systemManager.tmux.noPanes': 'Нет панелей',
'systemManager.tmux.panes': '{{count}} пан.',
'systemManager.tmux.active': 'активно',
'systemManager.tmux.unnamedWindow': 'Безымянное окно',
'systemManager.tmux.unnamedPane': 'Безымянная панель',
'systemManager.tmux.attachWindow': 'Подключить к окну',
'systemManager.tmux.selectWindow': 'Выбрать окно',
'systemManager.tmux.killWindow': 'Закрыть окно',
'systemManager.tmux.killPane': 'Закрыть панель',
'systemManager.tmux.splitHorizontal': 'Разделить горизонтально',
'systemManager.tmux.splitVertical': 'Разделить вертикально',
'systemManager.tmux.sendKeys': 'Отправить клавиши',
'systemManager.tmux.sendKeysTo': 'Отправить клавиши в окно {{window}} панель {{pane}}',
'systemManager.tmux.sendKeysPlaceholder': 'Команда или текст…',
'systemManager.tmux.renameSessionPrompt': 'Переименовать сессию',
'systemManager.tmux.renameWindowPrompt': 'Переименовать окно',
'systemManager.tmux.windowName': 'Имя окна',
'systemManager.tmux.confirmKillSession': 'Завершить сессию tmux «{{name}}»?',
'systemManager.tmux.confirmDetachSession': 'Отключить всех клиентов от «{{name}}»?',
'systemManager.tmux.confirmKillWindow': 'Закрыть окно «{{name}}»?',
'systemManager.tmux.confirmKillPane': 'Закрыть панель #{{index}}?',
'systemManager.tmux.confirmKillServer': 'Остановить сервер tmux? Все сессии будут завершены.',
'systemManager.tmux.meta': '{{count}} сессий',
'systemManager.docker.title': 'Контейнеры',
'systemManager.docker.subTabs.containers': 'Контейнеры',
'systemManager.docker.subTabs.images': 'Образы',
'systemManager.docker.empty': 'Контейнеры не найдены',
'systemManager.docker.imagesEmpty': 'Образы не найдены',
'systemManager.docker.search': 'Поиск контейнеров…',
'systemManager.docker.searchImages': 'Поиск образов…',
'systemManager.docker.filter.all': 'Все',
'systemManager.docker.filter.running': 'Запущены',
'systemManager.docker.filter.stopped': 'Остановлены',
'systemManager.docker.filter.paused': 'На паузе',
'systemManager.docker.shell': 'Shell',
'systemManager.docker.logs': 'Логи',
'systemManager.docker.details': 'Детали',
'systemManager.docker.inspect': 'Inspect',
'systemManager.docker.imageInspect': 'Inspect образа',
'systemManager.docker.confirmRemove': 'Удалить этот контейнер?',
'systemManager.docker.confirmKill': 'Принудительно завершить контейнер?',
'systemManager.docker.confirmRemoveImage': 'Удалить образ «{{name}}»?',
'systemManager.docker.confirmPrune': 'Удалить dangling-образы?',
'systemManager.docker.confirmPruneAll': 'Удалить все неиспользуемые образы?',
'systemManager.docker.pause': 'Пауза',
'systemManager.docker.unpause': 'Возобновить',
'systemManager.docker.restart': 'Перезапустить',
'systemManager.docker.kill': 'Kill',
'systemManager.docker.renamePrompt': 'Имя контейнера',
'systemManager.docker.prune': 'Prune',
'systemManager.docker.pruneAll': 'Prune all',
'systemManager.docker.tag': 'Tag',
'systemManager.docker.tagRepoPrompt': 'Имя репозитория',
'systemManager.docker.tagNamePrompt': 'Имя тега',
'systemManager.docker.meta': '{{count}} конт.',
'systemManager.docker.imagesMeta': '{{count}} образов',
'systemManager.docker.start': 'Запустить',
'systemManager.docker.stop': 'Остановить',
'systemManager.inspect.status': 'Статус',
'systemManager.inspect.image': 'Образ',
'systemManager.inspect.created': 'Создан',
'systemManager.inspect.started': 'Запущен',
'systemManager.inspect.restartPolicy': 'Перезапуск',
'systemManager.inspect.command': 'Команда',
'systemManager.inspect.ports': 'Порты',
'systemManager.inspect.networks': 'Сети',
'systemManager.inspect.mounts': 'Тома',
'systemManager.inspect.env': 'Окружение',
'systemManager.inspect.labels': 'Метки',
'systemManager.inspect.tags': 'Теги',
'systemManager.inspect.digests': 'Дайджесты',
'systemManager.inspect.size': 'Размер',
'systemManager.inspect.platform': 'Платформа',
'systemManager.inspect.workdir': 'Рабочий каталог',
'systemManager.inspect.exposedPorts': 'Открытые порты',
'systemManager.inspect.showRaw': 'JSON',
'systemManager.inspect.hideRaw': 'Скрыть JSON',
};

View File

@@ -26,14 +26,34 @@ export const ruTerminalMessages: Messages = {
// Terminal toolbar / search / context menu / auth
'terminal.toolbar.openSftp': 'Открыть SFTP',
'terminal.toolbar.availableAfterConnect': 'Доступно после подключения',
'terminal.toolbar.sendYmodem': 'Отправить через YMODEM',
'terminal.toolbar.receiveYmodem': 'Получить через YMODEM',
'terminal.toolbar.sftp': 'SFTP',
'terminal.toolbar.more': 'Другие действия',
'terminal.toolbar.scripts': 'Скрипты',
'terminal.toolbar.history': 'История команд',
'history.scope.label': 'Область истории',
'history.tab.host': 'Хост',
'history.tab.global': 'Глобальная',
'history.searchPlaceholder': 'Поиск по истории...',
'history.loading': 'Загрузка удалённой истории...',
'history.meta.count': '{count} команд',
'history.empty.noSession': 'Откройте удалённую сессию, чтобы просмотреть историю команд.',
'history.empty.unsupportedProtocol': 'История команд доступна только для сессий SSH/Mosh/ET.',
'history.empty.noHistory': 'История команд на этом хосте не найдена.',
'history.empty.noGlobalHistory': 'Глобальной истории команд пока нет. Выполненные команды появятся здесь.',
'history.action.refresh': 'Обновить',
'history.action.retry': 'Повторить',
'history.action.paste': 'Вставить в терминал',
'history.action.run': 'Выполнить в терминале',
'history.action.saveAsSnippet': 'Сохранить как сниппет',
'terminal.toolbar.library': 'Библиотека',
'terminal.toolbar.noSnippets': 'Нет доступных сниппетов',
'terminal.toolbar.terminalSettings': 'Настройки терминала',
'terminal.toolbar.searchTerminal': 'Поиск по терминалу',
'terminal.toolbar.search': 'Поиск',
'terminal.toolbar.timestampsEnable': 'Показать время',
'terminal.toolbar.timestampsDisable': 'Скрыть время',
'terminal.toolbar.broadcast': 'Трансляция',
'terminal.toolbar.broadcastEnable': 'Включить режим трансляции',
'terminal.toolbar.broadcastDisable': 'Отключить режим трансляции',
@@ -42,8 +62,17 @@ export const ruTerminalMessages: Messages = {
'terminal.composeBar.send': 'Отправить',
'terminal.composeBar.close': 'Закрыть строку ввода',
'terminal.composeBar.broadcasting': 'Трансляция во все сессии',
'terminal.composeBar.resize': 'Изменить высоту строки ввода',
'terminal.composeBar.manageSnippets': 'Управление быстрыми сниппетами',
'terminal.composeBar.searchSnippets': 'Поиск сниппетов...',
'terminal.composeBar.noPinnedSnippets': 'Закрепите сниппеты через + для быстрого доступа',
'terminal.composeBar.noMatchingSnippets': 'Сниппеты не найдены',
'terminal.composeBar.pinnedCount': 'Закреплено: {count}',
'terminal.composeBar.unpinSnippet': 'Убрать {label} из панели',
'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',
@@ -82,7 +111,9 @@ export const ruTerminalMessages: Messages = {
'terminal.dragDrop.localTitle': 'Перетащите для вставки путей',
'terminal.dragDrop.localMessage': 'Пути к файлам будут вставлены в терминал',
'terminal.dragDrop.remoteTitle': 'Перетащите для загрузки файлов',
'terminal.dragDrop.remoteMessage': 'Файлы будут загружены через SFTP',
'terminal.dragDrop.remoteZmodemMessage': 'Файлы будут загружены через ZMODEM (PTY)',
'terminal.dragDrop.remoteSftpMessage': 'Файлы будут загружены через SFTP',
'terminal.dragDrop.noFiles': 'Нет файлов для загрузки',
'terminal.dragDrop.notConnected': 'Нельзя перетащить файлы — терминал не подключён',
'terminal.dragDrop.errorTitle': 'Ошибка перетаскивания',
'terminal.dragDrop.errorMessage': 'Не удалось обработать перетащенные файлы',
@@ -96,10 +127,27 @@ export const ruTerminalMessages: Messages = {
'terminal.menu.pasteSelection': 'Вставить выделенное',
'terminal.menu.selectAll': 'Выбрать всё',
'terminal.menu.reconnect': 'Переподключиться',
'terminal.menu.sendYmodem': 'Отправить через YMODEM',
'terminal.menu.receiveYmodem': 'Получить через YMODEM',
'terminal.menu.splitHorizontal': 'Разделить по горизонтали',
'terminal.menu.splitVertical': 'Разделить по вертикали',
'terminal.menu.clearBuffer': 'Очистить буфер',
'terminal.menu.closeTerminal': 'Закрыть терминал',
'terminal.menu.rename': 'Переименовать',
'terminal.menu.detach': 'Открепить из рабочей области',
'terminal.menu.detachSession': 'Открепить {name}',
'terminal.ymodem.selectFile': 'Выберите файл для отправки',
'terminal.ymodem.allFiles': 'Все файлы',
'terminal.ymodem.started': 'YMODEM отправляет {fileName}',
'terminal.ymodem.complete': 'YMODEM отправил {fileName}',
'terminal.ymodem.failed': 'Не удалось отправить через YMODEM',
'terminal.ymodem.selectReceiveDirectory': 'Выберите папку для полученных файлов',
'terminal.ymodem.receiveStarted': 'YMODEM получает...',
'terminal.ymodem.receiveComplete': 'YMODEM получил {fileName}',
'terminal.ymodem.receiveCompleteMultiple': 'YMODEM получил файлов: {count}',
'terminal.ymodem.receiveEmpty': 'Файлы YMODEM не получены',
'terminal.ymodem.receiveFailed': 'Не удалось получить через YMODEM',
'terminal.ymodem.unavailable': 'YMODEM недоступен',
'terminal.selection.addToAI': 'Добавить в чат',
'terminal.selection.addToAIDesc': 'Прикрепить выбранный вывод терминала к черновику AI',
'terminal.auth.password': 'Пароль',

View File

@@ -158,6 +158,7 @@ export const ruVaultMessages: Messages = {
'sftp.filter.placeholder': 'Фильтр по имени файла...',
'sftp.bookmark.add': 'Добавить путь в закладки',
'sftp.bookmark.remove': 'Удалить закладку',
'sftp.bookmark.list': 'Закладки путей',
'sftp.bookmark.addGlobal': '+Глобальная',
'sftp.bookmark.addGlobalTooltip': 'Сохранить как глобальную закладку (общую для всех хостов)',
'sftp.bookmark.empty': 'Пока нет закладок',
@@ -185,9 +186,14 @@ 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': 'Дерево',
'sftp.viewMode.switchToList': 'Переключиться на список',
'sftp.viewMode.switchToTree': 'Переключиться на дерево',
'sftp.tree.loadError': 'Не удалось загрузить каталог',
'sftp.tree.loading': 'Загрузка...',
'sftp.kind.folder': 'Папка',
@@ -290,6 +296,8 @@ export const ruVaultMessages: Messages = {
'sftp.tabs.addTab': 'Добавить новую вкладку',
'sftp.tabs.closeTab': 'Закрыть вкладку',
'sftp.tabs.newTab': 'Новая вкладка',
'sftp.tabs.copyDefaultPath': 'Копировать вкладку (путь по умолчанию)',
'sftp.tabs.copyCurrentPath': 'Копировать и перейти к текущему пути',
'sftp.conflict.title': 'Конфликт файлов',
'sftp.conflict.desc': 'В месте назначения уже существует файл с таким именем',
'sftp.conflict.alreadyExistsSuffix': 'уже существует',
@@ -494,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': 'Ручное переопределение',
@@ -515,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',
@@ -559,6 +613,8 @@ export const ruVaultMessages: Messages = {
'hostDetails.deviceType.warning': 'Команды AI-агента будут отправляться напрямую без отслеживания кода выхода. Включайте только для устройств, на которых нет стандартной оболочки.',
'hostDetails.section.sshAlgorithms': 'SSH-алгоритмы',
'hostDetails.section.terminalBehavior': 'Поведение терминала',
'hostDetails.lineTimestamps': 'Показывать время вывода',
'hostDetails.lineTimestamps.desc': 'Показывать локальное время рядом с видимыми строками вывода для этого хоста, не изменяя текст терминала.',
'hostDetails.legacyAlgorithms': 'Разрешить устаревшие алгоритмы',
'hostDetails.legacyAlgorithms.desc': 'Включить устаревшие SSH-алгоритмы (diffie-hellman-group1, ssh-dss, 3des-cbc и т. д.) для подключения к старому сетевому оборудованию.',
'hostDetails.legacyAlgorithms.warning': 'У этих алгоритмов есть известные слабые места безопасности. Включайте только для устаревших устройств, которые не поддерживают современную криптографию.',

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

@@ -3,6 +3,7 @@ import { zhCNCoreMessages } from './zh-CN/core';
import { zhCNVaultMessages } from './zh-CN/vault';
import { zhCNTerminalMessages } from './zh-CN/terminal';
import { zhCNAiMessages } from './zh-CN/ai';
import { zhCnSystemManagerMessages } from './zh-CN/systemManager';
export type { Messages } from './types';
@@ -11,6 +12,7 @@ const zhCN: Messages = {
...zhCNVaultMessages,
...zhCNTerminalMessages,
...zhCNAiMessages,
...zhCnSystemManagerMessages,
};
export default zhCN;

View File

@@ -3,9 +3,11 @@ import type { Messages } from '../types';
export const zhCNAiMessages: Messages = {
// AI Settings
'ai.agentSettings': 'Agent 设置',
'ai.chat.preparing': '准备中…',
'ai.title': 'AI',
'ai.description': '配置 AI 提供商、Agent 和安全设置',
'ai.providers': '提供商',
'ai.agents': 'Agent',
'ai.providers.empty': '尚未配置提供商。添加一个提供商以开始使用。',
'ai.providers.add': '添加提供商',
'ai.providers.active': '活跃',
@@ -106,6 +108,54 @@ export const zhCNAiMessages: Messages = {
'ai.copilot.customPathPlaceholder': '例如 /usr/local/bin/copilot',
'ai.copilot.check': '检查',
// AI Cursor SDK
'ai.cursor.title': 'Cursor',
'ai.cursor.description': '使用 Cursor SDK。',
'ai.cursor.detecting': '检测中...',
'ai.cursor.detected': '可用',
'ai.cursor.notFound': '不可用',
'ai.cursor.path': '运行环境:',
'ai.cursor.notFoundHint': '填写 API Key 后即可使用。',
'ai.cursor.notInstalledHint': '未检测到 Cursor SDK。',
'ai.cursor.installStatus': 'Cursor SDK',
'ai.cursor.installed': '已检测到',
'ai.cursor.notInstalled': '未检测到',
'ai.cursor.apiKeyStatus': 'API Key',
'ai.cursor.apiKeyConfigured': '已填写',
'ai.cursor.apiKeyMissing': '未填写',
'ai.cursor.apiKeyFromEnv': '来自环境变量',
'ai.cursor.apiKey': 'API Key',
'ai.cursor.apiKeyPlaceholder': '输入 Cursor API Key',
'ai.cursor.apiKeyPlaceholder.env': '已使用 CURSOR_API_KEY填写后会覆盖',
'ai.cursor.apiKeyEnvHint': '已检测到本机 CURSOR_API_KEY。留空即可继续使用填写保存后会覆盖它。',
'ai.cursor.apiKeyOverrideHint': '当前优先使用这里保存的 Key清空保存后会回到 CURSOR_API_KEY。',
'ai.cursor.saveApiKey': '保存',
'ai.cursor.saved': '已保存',
'ai.cursor.showApiKey': '显示 API Key',
'ai.cursor.hideApiKey': '隐藏 API Key',
'ai.cursor.customPathPlaceholder': '例如 /usr/local/bin/cursor',
'ai.cursor.check': '检查',
// AI CodeBuddy Code
'ai.codebuddy.title': 'CodeBuddy Code',
'ai.codebuddy.description': '通过官方 Agent SDK`@tencent-ai/agent-sdk`)接入 CodeBuddy Code。检测到后即可作为外部编程 Agent 使用。',
'ai.codebuddy.detecting': '检测中...',
'ai.codebuddy.detected': '已检测到',
'ai.codebuddy.notFound': '未找到',
'ai.codebuddy.path': '路径:',
'ai.codebuddy.notFoundHint': '在 PATH 中未找到 codebuddy。请安装或在下方指定可执行文件路径。',
'ai.codebuddy.customPathPlaceholder': '例如 /usr/local/bin/codebuddy',
'ai.codebuddy.check': '检查',
'ai.codebuddy.configSection': '认证与配置(可选)',
'ai.codebuddy.internetEnv': '网络环境',
'ai.codebuddy.internetEnv.default': '默认(海外)',
'ai.codebuddy.internetEnv.internal': 'Internal',
'ai.codebuddy.internetEnv.ioa': 'IOA',
'ai.codebuddy.internetEnv.hint': '设置 CODEBUDDY_INTERNET_ENVIRONMENT —— 受限网络环境请选择 Internal 或 IOA。',
'ai.codebuddy.envVars': '环境变量',
'ai.codebuddy.envVars.placeholder': 'CODEBUDDY_API_KEY=...\nCODEBUDDY_AUTH_TOKEN=...\nOTHER_VAR=...',
'ai.codebuddy.envVars.hint': '每行一个 KEY=VALUE传给 CodeBuddy agent。可在此设置 CODEBUDDY_API_KEY 或 CODEBUDDY_AUTH_TOKEN 完成认证。明文存在本地。',
// AI Default Agent
'ai.defaultAgent': '默认 Agent',
'ai.defaultAgent.description': '创建新 AI 会话时使用的 Agent',
@@ -127,6 +177,29 @@ export const zhCNAiMessages: Messages = {
'ai.userSkills.status.ready': '正常',
'ai.userSkills.status.warning': '警告',
// AI Quick Messages
'ai.quickMessages.title': '快捷消息',
'ai.quickMessages.description': '创建常用提示词,在 AI 聊天框输入 / 或点击快捷按钮即可插入到输入框。与用户 Skills 不同,快捷消息会直接填入消息内容。',
'ai.quickMessages.add': '添加快捷消息',
'ai.quickMessages.createTitle': '新建快捷消息',
'ai.quickMessages.editTitle': '编辑快捷消息',
'ai.quickMessages.name': '名称',
'ai.quickMessages.name.placeholder': '例如:检查磁盘空间',
'ai.quickMessages.slug': '命令',
'ai.quickMessages.slug.placeholder': 'disk-check',
'ai.quickMessages.descriptionField': '说明(可选)',
'ai.quickMessages.descriptionField.placeholder': '简短描述这条快捷消息的用途',
'ai.quickMessages.content': '消息内容',
'ai.quickMessages.content.placeholder': '输入选择后要插入的完整提示词...',
'ai.quickMessages.empty': '还没有快捷消息。添加几条常用提示,聊天时就能一键插入。',
'ai.quickMessages.confirmDelete': '确定删除快捷消息「{name}」吗?',
'ai.quickMessages.error.nameRequired': '请填写名称。',
'ai.quickMessages.error.invalidSlug': '命令只能包含小写字母、数字和连字符。',
'ai.quickMessages.error.contentRequired': '请填写消息内容。',
'ai.quickMessages.error.slugTaken': '该命令已被其他快捷消息使用。',
'ai.quickMessages.error.slugConflictsWithSkill': '该命令与用户 Skill「/{slug}」冲突,请换一个命令。',
'ai.quickMessages.error.maxItems': '最多只能保存 {max} 条快捷消息。',
// AI Chat
'ai.chat.noProvider': '尚未配置 AI 提供商。请前往 **设置 → AI → 提供商** 添加并启用一个提供商。',
'ai.chat.toolDenied': '操作已被用户拒绝。',
@@ -175,6 +248,7 @@ export const zhCNAiMessages: Messages = {
'ai.chat.newChat': '新对话',
'ai.chat.allSessions': '所有会话',
'ai.chat.loadEarlierMessages': '加载更早的消息(还有 {n} 条)',
'ai.chat.usedTools': '已使用 {n} 个工具',
'ai.chat.loadMoreSessions': '加载更多会话(还有 {n} 条)',
'ai.chat.noSessions': '没有历史会话',
'ai.chat.retryHint': '你可以重新发送消息来重试。',
@@ -185,6 +259,18 @@ export const zhCNAiMessages: Messages = {
'ai.chat.menuImage': '图片',
'ai.chat.menuMentionHost': '提及主机',
'ai.chat.menuUserSkills': '用户 Skills',
'ai.chat.menuSlashCommands': '快捷命令',
'ai.chat.slashCommands': '快捷命令',
'ai.chat.slashQuickMessages': '快捷消息',
'ai.chat.slashUserSkills': '用户 Skills',
'ai.chat.quickMessages': '快捷命令',
'ai.chat.slashNoResults': '没有匹配的命令',
'ai.chat.slashEmptyHint': '可在 设置 → AI → 快捷消息 中添加常用提示词。',
// AI 聊天快捷入口
'ai.chatShortcuts.title': '聊天快捷入口',
'ai.chatShortcuts.selectionAction': '选中终端内容时显示“添加到对话”',
'ai.chatShortcuts.selectionAction.description': '在终端里选中文本后显示 AI 快捷按钮。',
// AI Error
'ai.codex.bridgeError': 'Codex 主进程处理器尚未加载。请完全重启 Netcatty 或重启 Electron 开发进程,然后重试。',
@@ -228,6 +314,7 @@ export const zhCNAiMessages: Messages = {
'terminal.layer.switchToSplitView': '切换到分屏视图',
'terminal.layer.sftp': '文件传输',
'terminal.layer.scripts': '脚本',
'terminal.layer.history': '命令历史',
'terminal.layer.theme': '主题',
'terminal.layer.aiChat': 'AI 助手',
'terminal.layer.movePanelLeft': '面板移至左侧',
@@ -243,6 +330,13 @@ export const zhCNAiMessages: Messages = {
'terminal.layer.hostTree.collapse': '收起主机列表',
'terminal.layer.hostTree.expand': '展开主机列表',
'terminal.layer.hostTree.empty': '没有匹配的主机',
'terminal.layer.hostTree.details.host': '主机',
'terminal.layer.hostTree.details.user': '用户',
'terminal.layer.hostTree.details.port': '端口',
'terminal.layer.hostTree.details.protocol': '协议',
'terminal.layer.hostTree.details.group': '分组',
'terminal.layer.hostTree.details.tags': '标签',
'terminal.layer.hostTree.details.lastConnected': '最近连接',
'topTabs.openQuickSwitcher': '打开快速切换',
'topTabs.moreTabs': '更多标签页',
'topTabs.aiAssistant': 'AI 助手',

View File

@@ -29,6 +29,7 @@ export const zhCNCoreMessages: Messages = {
'common.right': '右侧',
'common.more': '更多',
'common.selectAHost': '选择主机',
'sort.manual': '手动顺序',
'sort.az': 'A-z',
'sort.za': 'Z-a',
'sort.newest': '从新到旧',
@@ -209,6 +210,8 @@ export const zhCNCoreMessages: Messages = {
'settings.vault.showOnlyUngroupedHostsInRootDesc': '开启后,主机库根目录的主机列表只显示没有分组的主机,已分组主机请从左侧分组进入查看。',
'settings.vault.showSftpTab': '显示 SFTP 标签页',
'settings.vault.showSftpTabDesc': '在顶部标签栏显示独立的 SFTP 视图。关闭后可改用会话内左侧的 SFTP 侧栏。',
'settings.vault.showHostTreeSidebar': '显示主机列表侧栏',
'settings.vault.showHostTreeSidebarDesc': '在终端和编辑器标签页显示主机列表侧栏及顶部开关。',
// Update notifications
'update.available.title': '发现新版本',
@@ -248,9 +251,9 @@ export const zhCNCoreMessages: Messages = {
'settings.appearance.themeColor.dark': '深色主题',
'settings.appearance.customCss': '自定义 CSS',
'settings.appearance.customCss.desc':
'使用自定义 CSS 个性化界面,修改会立即生效。主要 UI 区块都暴露了 [data-section="..."] 属性供你定位比如snippets-panel、host-details-panel、group-details-panel、serial-host-details-panel、ai-chat-panel、vault-sidebar、vault-main、vault-hosts-header、vault-host-list、vault-view、terminal-workspace、terminal-workspace-sidebarFocus 模式终端列表、terminal-side-panelSFTP/脚本/主题/AI 侧栏、terminal-sftp-panel、terminal-split-pane、terminal-split-resizer、top-tabs。',
'使用自定义 CSS 个性化界面,修改会立即生效。主要 UI 区块都暴露了 [data-section="..."] 属性供你定位比如snippets-panel、host-details-panel、group-details-panel、serial-host-details-panel、ai-chat-panel、vault-sidebar、vault-main、vault-hosts-header、vault-host-list、vault-view、terminal-workspace、terminal-workspace-sidebarFocus 模式终端列表、terminal-host-tree-sidebar、terminal-host-tree-sidebar-content、terminal-host-tree-sidebar-row、terminal-side-panelSFTP/脚本/主题/AI 侧栏,打开时生效、terminal-side-panel-tabs、terminal-side-panel-content、terminal-sftp-panel、terminal-sftp-host-header、terminal-sftp-pane、terminal-sftp-toolbar、terminal-sftp-path、terminal-sftp-filter-bar、terminal-sftp-list、terminal-sftp-list-header、terminal-sftp-list-row、terminal-sftp-tree、terminal-sftp-tree-row、terminal-sftp-transfer-queue、terminal-sftp-transfer-row、terminal-split-pane、terminal-split-resizer、top-tabs、top-tabs-host-tree-toggle、top-tabs-quick-switcher-toggle。',
'settings.appearance.customCss.placeholder':
'/* 示例 — 由于 Tailwind 优先级较高,需要使用 !important */\n\n/* SFTP / 操作侧栏边框(不是 Focus 模式终端列表) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 加粗分屏分割线 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 高亮当前聚焦的分屏 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 设置 → 终端 → 工作区聚焦指示 → 聚焦窗格显示边框 */',
'/* 示例 — 由于 Tailwind 优先级较高,需要使用 !important */\n\n/* 隐藏顶部标签栏里的主机列表开关 */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* 隐藏打开快速切换器的加号按钮 */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* SFTP / 操作侧栏边框(关闭侧栏后不会残留) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 修改整个操作侧栏背景,而不只是顶部标签 */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* 修改选中的 SFTP 文件行 */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* 加粗分屏分割线 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 高亮当前聚焦的分屏 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 设置 → 终端 → 工作区聚焦指示 → 聚焦窗格显示边框 */',
'settings.appearance.language': '语言',
'settings.appearance.language.desc': '选择界面语言',
'settings.appearance.uiFont': '界面字体',
@@ -441,6 +444,7 @@ export const zhCNCoreMessages: Messages = {
'vault.hosts.connectSelected': '连接 ({count})',
'vault.hosts.connectMultiple.success': '正在连接 {count} 个主机',
'vault.hosts.moveToGroup.success': '已将 {host} 移动到 {group}',
'vault.hosts.errors.nameRequired': '主机名称不能为空。',
'vault.hosts.empty.title': '设置你的主机',
'vault.hosts.empty.desc': '保存主机以快速连接到你的服务器、虚拟机和容器。',
@@ -541,6 +545,7 @@ export const zhCNCoreMessages: Messages = {
'sftp.filter.placeholder': '按文件名筛选...',
'sftp.bookmark.add': '收藏此路径',
'sftp.bookmark.remove': '取消收藏',
'sftp.bookmark.list': '收藏路径',
'sftp.bookmark.addGlobal': '+全局',
'sftp.bookmark.addGlobalTooltip': '保存为全局收藏(所有主机共享)',
'sftp.bookmark.empty': '暂无收藏路径',
@@ -568,9 +573,14 @@ 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': '树形视图',
'sftp.viewMode.switchToList': '切换到列表视图',
'sftp.viewMode.switchToTree': '切换到树形视图',
'sftp.tree.loadError': '加载目录失败',
'sftp.tree.loading': '加载中...',
'sftp.kind.folder': '文件夹',

View File

@@ -0,0 +1,181 @@
import type { Messages } from '../types';
export const zhCnSystemManagerMessages: Messages = {
'terminal.layer.system': '系统',
'systemManager.noSession': '没有活动的终端会话。',
'systemManager.notConnected': '请先连接到主机以管理进程与服务。',
'systemManager.empty': '暂无数据。',
'systemManager.tabs.processes': '进程',
'systemManager.tabs.tmux': 'tmux',
'systemManager.tabs.docker': 'Docker',
'systemManager.popup.loading': '正在打开终端…',
'systemManager.popup.startupFailed': '启动命令未成功。请确认目标仍然可用后重试。',
'systemManager.errors.loadProcesses': '加载进程列表失败',
'systemManager.errors.loadTmux': '加载 tmux 会话失败',
'systemManager.errors.loadTmuxWindows': '加载 tmux 窗口失败',
'systemManager.errors.loadTmuxPanes': '加载 tmux 面板失败',
'systemManager.errors.loadTmuxClients': '加载 tmux 客户端失败',
'systemManager.errors.actionFailed': '操作失败',
'systemManager.errors.loadDocker': '加载容器列表失败',
'systemManager.errors.loadDockerStats': '加载容器性能数据失败',
'systemManager.errors.loadDockerImages': '加载镜像列表失败',
'systemManager.errors.sshChannelUnavailable': '服务器拒绝打开新的执行通道。请稍后重试,或重新连接当前主机。',
'systemManager.processes.search': '搜索进程…',
'systemManager.processes.command': '命令',
'systemManager.processes.user': '用户',
'systemManager.processes.term': '终止',
'systemManager.processes.kill': '强杀',
'systemManager.processes.stop': '暂停 (SIGSTOP)',
'systemManager.processes.cont': '继续 (SIGCONT)',
'systemManager.processes.hup': '挂断 (SIGHUP)',
'systemManager.processes.renice': '调整优先级',
'systemManager.processes.renicePrompt': 'Nice 值 (-20 到 19)',
'systemManager.processes.reniceInvalid': 'Nice 值必须在 -20 到 19 之间',
'systemManager.processes.confirmKill': '向进程 {{pid}} 发送 SIGKILL',
'systemManager.processes.confirmSignal': '向进程 {{pid}} 发送 SIG{{signal}}',
'systemManager.processes.filter.all': '全部',
'systemManager.processes.filter.running': '运行中',
'systemManager.processes.ppid': '父进程 PID',
'systemManager.processes.rss': '物理内存',
'systemManager.processes.vsz': '虚拟内存',
'systemManager.processes.elapsed': '运行时长',
'systemManager.processes.stat': '状态',
'systemManager.processes.meta': '{{count}} 个进程',
'systemManager.processes.loading': '正在加载进程…',
'systemManager.processes.loadingMore': '正在显示更多进程…',
'systemManager.processes.state.running': '运行中',
'systemManager.processes.state.sleeping': '睡眠',
'systemManager.processes.state.stopped': '已暂停',
'systemManager.processes.state.zombie': '僵尸',
'systemManager.processes.sort.cpu': 'CPU',
'systemManager.processes.sort.mem': '内存',
'systemManager.processes.sort.pid': 'PID',
'systemManager.processes.sort.command': '命令',
'systemManager.processes.sort.user': '用户',
'systemManager.common.dismiss': '关闭',
'systemManager.common.checkingAvailability': '正在检查可用状态…',
'systemManager.common.loading': '正在加载…',
'systemManager.common.loadingDetails': '正在加载详情…',
'systemManager.common.loadingStats': '正在加载性能数据…',
'systemManager.tmux.new': '新建',
'systemManager.tmux.search': '搜索会话…',
'systemManager.tmux.newSessionTitle': '新建 tmux 会话',
'systemManager.tmux.newSessionDesc': '为会话命名,并可选在启动时执行脚本。',
'systemManager.tmux.newSessionTabCustom': '自定义命令',
'systemManager.tmux.newSessionTabSnippet': '从代码片段',
'systemManager.tmux.pickSnippet': '从代码片段选择',
'systemManager.tmux.pickSnippetEmpty': '暂无代码片段,可在脚本侧栏或仓库中添加。',
'systemManager.tmux.selectedSnippet': '已选片段:{{label}}',
'systemManager.tmux.newSessionName': '会话名称',
'systemManager.tmux.newSessionCommand': '启动命令',
'systemManager.tmux.newSessionCommandPlaceholder': '例如 htop 或 npm run dev可选',
'systemManager.tmux.newSessionCommandHint': '留空则创建默认 shell 会话。',
'systemManager.tmux.creating': '创建中…',
'systemManager.tmux.newSessionPlaceholder': 'my-session',
'systemManager.tmux.newSessionRequired': '请先输入会话名称',
'systemManager.tmux.empty': '没有 tmux 会话',
'systemManager.tmux.attach': '附加',
'systemManager.tmux.attached': '已附加',
'systemManager.tmux.detached': '未附加',
'systemManager.tmux.windows': '{{count}} 个窗口',
'systemManager.tmux.created': '创建时间',
'systemManager.tmux.activity': '活动时间',
'systemManager.tmux.rename': '重命名',
'systemManager.tmux.detach': '全部分离',
'systemManager.tmux.killSession': '结束会话',
'systemManager.tmux.killServer': '结束 tmux 服务',
'systemManager.tmux.loadingDetails': '正在加载详情…',
'systemManager.tmux.clients': '已附加客户端',
'systemManager.tmux.windowList': '窗口',
'systemManager.tmux.newWindow': '新建窗口',
'systemManager.tmux.newWindowPlaceholder': '窗口名称(可选)',
'systemManager.tmux.noWindows': '没有窗口',
'systemManager.tmux.unavailable': '此主机未检测到 tmux',
'systemManager.docker.unavailable': '此主机未检测到 Docker',
'systemManager.tmux.windowsMismatch': '会话显示有 {{count}} 个窗口,但 list-windows 未返回任何窗口',
'systemManager.tmux.lastCommand': '最后执行的命令:{{command}}',
'systemManager.tmux.noPanes': '没有面板',
'systemManager.tmux.panes': '{{count}} 个面板',
'systemManager.tmux.active': '当前',
'systemManager.tmux.unnamedWindow': '未命名窗口',
'systemManager.tmux.unnamedPane': '未命名面板',
'systemManager.tmux.attachWindow': '附加到窗口',
'systemManager.tmux.selectWindow': '选中窗口',
'systemManager.tmux.killWindow': '关闭窗口',
'systemManager.tmux.killPane': '关闭面板',
'systemManager.tmux.splitHorizontal': '水平分屏',
'systemManager.tmux.splitVertical': '垂直分屏',
'systemManager.tmux.sendKeys': '发送按键',
'systemManager.tmux.sendKeysTo': '向窗口 {{window}} 面板 {{pane}} 发送按键',
'systemManager.tmux.sendKeysPlaceholder': '命令或文本…',
'systemManager.tmux.renameSessionPrompt': '重命名会话',
'systemManager.tmux.renameWindowPrompt': '重命名窗口',
'systemManager.tmux.windowName': '窗口名称',
'systemManager.tmux.confirmKillSession': '确定结束 tmux 会话「{{name}}」?',
'systemManager.tmux.confirmDetachSession': '确定将所有客户端从「{{name}}」分离?',
'systemManager.tmux.confirmKillWindow': '确定关闭窗口「{{name}}」?',
'systemManager.tmux.confirmKillPane': '确定关闭面板 #{{index}}',
'systemManager.tmux.confirmKillServer': '确定结束 tmux 服务?所有会话将被终止。',
'systemManager.tmux.meta': '{{count}} 个会话',
'systemManager.docker.title': '容器',
'systemManager.docker.subTabs.containers': '容器',
'systemManager.docker.subTabs.images': '镜像',
'systemManager.docker.empty': '未找到容器',
'systemManager.docker.imagesEmpty': '未找到镜像',
'systemManager.docker.search': '搜索容器…',
'systemManager.docker.searchImages': '搜索镜像…',
'systemManager.docker.filter.all': '全部',
'systemManager.docker.filter.running': '运行中',
'systemManager.docker.filter.stopped': '已停止',
'systemManager.docker.filter.paused': '已暂停',
'systemManager.docker.shell': 'Shell',
'systemManager.docker.logs': '日志',
'systemManager.docker.details': '详情',
'systemManager.docker.inspect': 'Inspect',
'systemManager.docker.imageInspect': '镜像 Inspect',
'systemManager.docker.confirmRemove': '确定删除此容器?',
'systemManager.docker.confirmKill': '确定强制终止此容器?',
'systemManager.docker.confirmRemoveImage': '确定删除镜像「{{name}}」?',
'systemManager.docker.confirmPrune': '确定清理悬空镜像?',
'systemManager.docker.confirmPruneAll': '确定清理所有未使用镜像?',
'systemManager.docker.pause': '暂停',
'systemManager.docker.unpause': '恢复',
'systemManager.docker.restart': '重启',
'systemManager.docker.kill': '强杀',
'systemManager.docker.renamePrompt': '容器名称',
'systemManager.docker.prune': '清理悬空',
'systemManager.docker.pruneAll': '清理全部',
'systemManager.docker.tag': '打标签',
'systemManager.docker.tagRepoPrompt': '仓库名',
'systemManager.docker.tagNamePrompt': '标签名',
'systemManager.docker.meta': '{{count}} 个容器',
'systemManager.docker.imagesMeta': '{{count}} 个镜像',
'systemManager.docker.start': '启动',
'systemManager.docker.stop': '停止',
'systemManager.inspect.status': '状态',
'systemManager.inspect.image': '镜像',
'systemManager.inspect.created': '创建时间',
'systemManager.inspect.started': '启动时间',
'systemManager.inspect.restartPolicy': '重启策略',
'systemManager.inspect.command': '启动命令',
'systemManager.inspect.ports': '端口映射',
'systemManager.inspect.networks': '网络',
'systemManager.inspect.mounts': '挂载',
'systemManager.inspect.env': '环境变量',
'systemManager.inspect.labels': '标签',
'systemManager.inspect.tags': '镜像标签',
'systemManager.inspect.digests': '摘要',
'systemManager.inspect.size': '大小',
'systemManager.inspect.platform': '平台',
'systemManager.inspect.workdir': '工作目录',
'systemManager.inspect.exposedPorts': '暴露端口',
'systemManager.inspect.showRaw': 'JSON',
'systemManager.inspect.hideRaw': '收起 JSON',
};

View File

@@ -2,9 +2,30 @@ 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',
'terminal.et.proxyUnsupported': 'EternalTerminal 目前不支持 Netcatty 的代理设置。请改用 SSH或移除该主机的代理。',
'terminal.et.multiJumpUnsupported': 'EternalTerminal 目前在 Netcatty 中最多支持一个跳板机。',
// Command history side panel
'history.scope.label': '历史范围',
'history.tab.host': '主机',
'history.tab.global': '全局',
'history.searchPlaceholder': '搜索历史命令...',
'history.loading': '正在读取远程历史...',
'history.meta.count': '{count} 条',
'history.empty.noSession': '请先打开一个远程会话以查看其命令历史。',
'history.empty.unsupportedProtocol': '仅 SSH/Mosh/ET 会话支持命令历史。',
'history.empty.noHistory': '该主机上未找到命令历史。',
'history.empty.noGlobalHistory': '暂无全局命令历史。你执行的命令会记录在这里。',
'history.action.refresh': '刷新',
'history.action.retry': '重试',
'history.action.paste': '粘贴到终端',
'history.action.run': '在终端执行',
'history.action.saveAsSnippet': '保存为代码片段',
// SFTP File Opener
'sftp.context.copyPath': '复制文件路径',
'sftp.context.openWith': '打开方式...',
@@ -169,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': '行间距',
@@ -194,6 +224,11 @@ export const zhCNTerminalMessages: Messages = {
'settings.terminal.behavior.copyOnSelect.desc': '自动复制选中的文本。在 tmux/vim 鼠标模式下macOS 按住 OptionWindows/Linux 按住 Shift 拖选即可选中文本',
'settings.terminal.behavior.middleClickPaste': '中键粘贴',
'settings.terminal.behavior.middleClickPaste.desc': '中键点击时粘贴剪贴板内容',
'settings.terminal.behavior.middleClick': '中键行为',
'settings.terminal.behavior.middleClick.desc': '在终端中点击鼠标中键时执行的操作',
'settings.terminal.behavior.middleClick.menu': '显示菜单',
'settings.terminal.behavior.middleClick.paste': '粘贴',
'settings.terminal.behavior.middleClick.disabled': '无动作',
'settings.terminal.behavior.bracketedPaste': '括号粘贴模式',
'settings.terminal.behavior.bracketedPaste.desc':
'粘贴文本时使用转义序列包裹,以便终端区分粘贴和键入。如果出现 ^[[200~ 字样请关闭此选项。',
@@ -286,14 +321,28 @@ export const zhCNTerminalMessages: Messages = {
'settings.terminal.serverStats.refreshInterval': '刷新间隔',
'settings.terminal.serverStats.refreshInterval.desc': '服务器状态刷新的频率。',
'settings.terminal.serverStats.seconds': '秒',
'settings.terminal.section.systemManager': '系统管理',
'settings.terminal.systemManager.processRefreshInterval': '进程列表刷新间隔',
'settings.terminal.systemManager.processRefreshInterval.desc': '系统管理侧栏中进程列表的刷新频率。',
'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux 会话刷新间隔',
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'tmux 会话列表的刷新频率。',
'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker 容器列表刷新间隔',
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Docker 容器列表的刷新频率。',
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker 性能数据刷新间隔',
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Docker 容器 CPU/内存/网络指标的刷新频率。',
// Settings > Terminal > Rendering
'settings.terminal.section.rendering': '渲染',
'settings.terminal.rendering.renderer': '渲染器',
'settings.terminal.rendering.renderer.desc': '选择终端渲染技术。自动模式会在低内存设备上使用 DOM 渲染。更改将在新终端会话中生效。',
'settings.terminal.rendering.auto': '自动',
'settings.terminal.rendering.lineTimestamps': '给输出加时间戳',
'settings.terminal.rendering.lineTimestamps.desc': '在终端输出行前插入本地时间,时间戳会成为终端可见内容的一部分。',
// 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': '自动补全',
@@ -311,6 +360,10 @@ export const zhCNTerminalMessages: Messages = {
'settings.shortcuts.scheme.disabled': '禁用',
'settings.shortcuts.scheme.mac': 'Mac (Cmd)',
'settings.shortcuts.scheme.pc': 'PC (Ctrl)',
'settings.shortcuts.disableTerminalFontZoom.label': '禁用终端缩放',
'settings.shortcuts.disableTerminalFontZoom.desc': '关闭终端文字缩放快捷操作,包括 Cmd/Ctrl 加滚轮。',
'settings.shortcuts.shellOnlyTabNumberShortcuts.label': '数字键跳过固定标签',
'settings.shortcuts.shellOnlyTabNumberShortcuts.desc': '开启后Cmd/Ctrl+[1...9] 仅在终端、工作区、编辑器等可关闭标签页之间切换,不包括固定的 Vault 和 SFTP 标签页。',
'settings.shortcuts.section.custom': '自定义快捷键',
'settings.shortcuts.resetAll': '全部重置',
'settings.shortcuts.recording': '请按键...',
@@ -325,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': '快速切换',
@@ -352,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': '思科',
@@ -113,6 +159,8 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.deviceType.warning': 'AI 代理命令将直接发送,无法获取退出码。仅建议在设备不运行标准 Shell 时启用。',
'hostDetails.section.sshAlgorithms': 'SSH 算法',
'hostDetails.section.terminalBehavior': '终端行为',
'hostDetails.lineTimestamps': '显示输出时间',
'hostDetails.lineTimestamps.desc': '在终端输出行旁边显示本地时间,不改变终端文本内容。',
'hostDetails.legacyAlgorithms': '允许旧版算法',
'hostDetails.legacyAlgorithms.desc': '启用已弃用的 SSH 算法diffie-hellman-group1、ssh-dss、3des-cbc 等)以连接老旧网络设备。',
'hostDetails.legacyAlgorithms.warning': '这些算法存在已知安全漏洞,仅建议在老旧设备不支持现代加密时启用。',
@@ -213,9 +261,12 @@ export const zhCNVaultMessages: Messages = {
// Terminal toolbar / search / context menu / auth
'terminal.toolbar.openSftp': '打开 SFTP',
'terminal.toolbar.availableAfterConnect': '连接后可用',
'terminal.toolbar.sendYmodem': 'YMODEM 发送',
'terminal.toolbar.receiveYmodem': 'YMODEM 接收',
'terminal.toolbar.sftp': 'SFTP',
'terminal.toolbar.more': '更多操作',
'terminal.toolbar.scripts': '脚本',
'terminal.toolbar.history': '命令历史',
'terminal.toolbar.library': '库',
'terminal.toolbar.noSnippets': '暂无代码片段',
'terminal.toolbar.terminalSettings': '终端设置',
@@ -229,8 +280,17 @@ export const zhCNVaultMessages: Messages = {
'terminal.composeBar.send': '发送',
'terminal.composeBar.close': '关闭撰写栏',
'terminal.composeBar.broadcasting': '正在广播到所有会话',
'terminal.composeBar.resize': '拖拽调整撰写栏高度',
'terminal.composeBar.manageSnippets': '管理快捷代码片段',
'terminal.composeBar.searchSnippets': '搜索代码片段...',
'terminal.composeBar.noPinnedSnippets': '点击 + 固定常用代码片段',
'terminal.composeBar.noMatchingSnippets': '没有匹配的代码片段',
'terminal.composeBar.pinnedCount': '已固定 {count} 个',
'terminal.composeBar.unpinSnippet': '从快捷栏移除 {label}',
'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',
@@ -269,7 +329,9 @@ export const zhCNVaultMessages: Messages = {
'terminal.dragDrop.localTitle': '拖放以插入路径',
'terminal.dragDrop.localMessage': '文件路径将被插入到终端',
'terminal.dragDrop.remoteTitle': '拖放以上传文件',
'terminal.dragDrop.remoteMessage': '文件将通过 SFTP 上传',
'terminal.dragDrop.remoteZmodemMessage': '文件将通过 ZMODEMPTY上传',
'terminal.dragDrop.remoteSftpMessage': '文件将通过 SFTP 上传',
'terminal.dragDrop.noFiles': '没有可上传的文件',
'terminal.dragDrop.notConnected': '无法拖放文件 - 终端未连接',
'terminal.dragDrop.errorTitle': '拖放错误',
'terminal.dragDrop.errorMessage': '处理拖放文件失败',
@@ -283,10 +345,27 @@ export const zhCNVaultMessages: Messages = {
'terminal.menu.pasteSelection': '粘贴选中文本',
'terminal.menu.selectAll': '全选',
'terminal.menu.reconnect': '重新连接',
'terminal.menu.sendYmodem': 'YMODEM 发送',
'terminal.menu.receiveYmodem': 'YMODEM 接收',
'terminal.menu.splitHorizontal': '水平分屏',
'terminal.menu.splitVertical': '垂直分屏',
'terminal.menu.clearBuffer': '清空缓冲区',
'terminal.menu.closeTerminal': '关闭终端',
'terminal.menu.rename': '重命名',
'terminal.menu.detach': '从工作区移出',
'terminal.menu.detachSession': '移出 {name}',
'terminal.ymodem.selectFile': '选择要发送的文件',
'terminal.ymodem.allFiles': '所有文件',
'terminal.ymodem.started': '正在通过 YMODEM 发送 {fileName}',
'terminal.ymodem.complete': 'YMODEM 已发送 {fileName}',
'terminal.ymodem.failed': 'YMODEM 发送失败',
'terminal.ymodem.selectReceiveDirectory': '选择接收文件保存位置',
'terminal.ymodem.receiveStarted': '正在通过 YMODEM 接收...',
'terminal.ymodem.receiveComplete': 'YMODEM 已接收 {fileName}',
'terminal.ymodem.receiveCompleteMultiple': 'YMODEM 已接收 {count} 个文件',
'terminal.ymodem.receiveEmpty': '没有接收到 YMODEM 文件',
'terminal.ymodem.receiveFailed': 'YMODEM 接收失败',
'terminal.ymodem.unavailable': 'YMODEM 当前不可用',
'terminal.selection.addToAI': '添加到对话',
'terminal.selection.addToAIDesc': '将选中的终端输出作为附件加入 AI 草稿',
'terminal.auth.password': '密码',
@@ -657,6 +736,8 @@ export const zhCNVaultMessages: Messages = {
'sftp.tabs.addTab': '新建标签页',
'sftp.tabs.closeTab': '关闭标签页',
'sftp.tabs.newTab': '新标签页',
'sftp.tabs.copyDefaultPath': '复制标签页(默认路径)',
'sftp.tabs.copyCurrentPath': '复制并跳转到当前路径',
'sftp.conflict.title': '文件冲突',
'sftp.conflict.desc': '目标位置已存在同名文件',
'sftp.conflict.alreadyExistsSuffix': '已存在',

View File

@@ -0,0 +1,20 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
test("active tab changes notify chrome theme before react subscribers", () => {
const storeSource = readFileSync(new URL("./activeTabStore.ts", import.meta.url), "utf8");
const syncSource = readFileSync(new URL("./activeChromeThemeSync.ts", import.meta.url), "utf8");
const setActiveTabIdBody = storeSource.match(/setActiveTabId = \(id: string\) => \{[\s\S]*?\n {2}\};/)?.[0] ?? "";
assert.match(setActiveTabIdBody, /this\.syncListeners\.forEach\(\(listener\) => listener\(id\)\)/);
assert.match(setActiveTabIdBody, /this\.scheduleNotify\(\)/);
assert.ok(
setActiveTabIdBody.indexOf("syncListeners.forEach") < setActiveTabIdBody.indexOf("scheduleNotify"),
"sync chrome theme listeners must run before deferred react notify",
);
assert.match(syncSource, /activeTabStore\.subscribeSync\(notifyActiveChromeThemeForTab\)/);
assert.match(syncSource, /isActiveChromeThemeResolvable/);
assert.match(syncSource, /clearTopTabsChromeThemeVars/);
});

View File

@@ -0,0 +1,39 @@
import { isActiveChromeThemeResolvable, resolveActiveChromeTheme } from '../app/activeChromeTheme';
import { clearTopTabsChromeThemeVars } from '../app/topTabsChromeTheme';
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
import { activeTabStore } from './activeTabStore';
import type { EditorTab } from './editorTabStore';
import type { LogView } from './logViewState';
import { syncActiveChromeTheme } from './useActiveChromeTheme';
export type ActiveChromeThemeDeps = {
accentMode: 'theme' | 'custom';
applyAppTheme: () => void;
currentTerminalTheme: TerminalTheme;
customAccent: string;
editorTabs: readonly EditorTab[];
followAppTerminalTheme: boolean;
hostById: Map<string, Host>;
logViews: readonly LogView[];
sessionById: Map<string, TerminalSession>;
themeById: Map<string, TerminalTheme>;
workspaceById: Map<string, Workspace>;
};
let depsRef: ActiveChromeThemeDeps | null = null;
export function updateActiveChromeThemeDeps(deps: ActiveChromeThemeDeps): void {
depsRef = deps;
}
export function notifyActiveChromeThemeForTab(activeTabId: string): void {
if (!depsRef || typeof document === 'undefined') return;
if (activeTabId === 'vault' || activeTabId === 'sftp') {
clearTopTabsChromeThemeVars();
}
if (!isActiveChromeThemeResolvable({ ...depsRef, activeTabId })) return;
const activeTheme = resolveActiveChromeTheme({ ...depsRef, activeTabId });
syncActiveChromeTheme(activeTheme, depsRef.applyAppTheme);
}
activeTabStore.subscribeSync(notifyActiveChromeThemeForTab);

View File

@@ -0,0 +1,14 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { fromEditorTabId, isEditorTabId, toEditorTabId } from './activeTabStore';
test('editor tab helpers round trip ids', () => {
assert.equal(toEditorTabId('file-1'), 'editor:file-1');
assert.equal(fromEditorTabId('editor:file-1'), 'file-1');
});
test('editor tab helper detects editor top-tab ids', () => {
assert.equal(isEditorTabId('editor:file-1'), true);
assert.equal(isEditorTabId('session-1'), false);
});

View File

@@ -4,6 +4,7 @@ import { terminalLayoutSuppressStore } from './terminalLayoutSuppressStore';
// Simple store for active tab that allows fine-grained subscriptions
type Listener = () => void;
type SyncListener = (activeTabId: string) => void;
// ----- Editor tab id helpers -----
export const EDITOR_PREFIX = 'editor:';
@@ -20,6 +21,7 @@ export const fromEditorTabId = (tabId: string): string => tabId.slice(EDITOR_PRE
class ActiveTabStore {
private activeTabId: string = 'vault';
private listeners = new Set<Listener>();
private syncListeners = new Set<SyncListener>();
private notifyRafId: number | null = null;
getActiveTabId = () => this.activeTabId;
@@ -39,6 +41,7 @@ class ActiveTabStore {
if (this.activeTabId !== id) {
terminalLayoutSuppressStore.begin();
this.activeTabId = id;
this.syncListeners.forEach((listener) => listener(id));
// Coalesce rapid tab switches into one notification per frame and avoid
// "setState during render" if called from a render phase.
this.scheduleNotify();
@@ -57,6 +60,11 @@ class ActiveTabStore {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
subscribeSync = (listener: SyncListener) => {
this.syncListeners.add(listener);
return () => this.syncListeners.delete(listener);
};
}
export const activeTabStore = new ActiveTabStore();
@@ -109,15 +117,3 @@ export const useIsEditorTabActive = (tabId: string): boolean => {
const getSnapshot = useCallback(() => activeTabStore.getActiveTabId() === editorTopId, [editorTopId]);
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot, getSnapshot);
};
// Check if terminal layer should be visible
// Editor tabs are NOT terminal tabs, so exclude them from the visibility condition.
export const useIsTerminalLayerVisible = (draggingSessionId: string | null) => {
const getSnapshot = useCallback(() => {
const activeTabId = activeTabStore.getActiveTabId();
const isTerminalTab = activeTabId !== 'vault' && activeTabId !== 'sftp' && !isEditorTabId(activeTabId);
return isTerminalTab || !!draggingSessionId;
}, [draggingSessionId]);
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot, getSnapshot);
};

View File

@@ -10,6 +10,7 @@ import {
ensureDraftForScopeState,
getDraftMutationVersionState,
getDraftUploadGenerationState,
pruneStaleSessionPanelViews,
pruneTerminalScopeState,
pruneTerminalTransientState,
resolvePanelView,
@@ -89,6 +90,39 @@ test("setSessionView records target session id", () => {
});
});
test("pruneStaleSessionPanelViews resets session views that no longer exist", () => {
const panelViewByScope = {
"terminal:1": { mode: "session", sessionId: "deleted-session" },
"workspace:2": { mode: "session", sessionId: "session-keep" },
"terminal:3": { mode: "draft" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = pruneStaleSessionPanelViews(
panelViewByScope,
new Set(["session-keep"]),
);
assert.deepEqual(next, {
"terminal:1": { mode: "draft" },
"workspace:2": { mode: "session", sessionId: "session-keep" },
"terminal:3": { mode: "draft" },
});
});
test("pruneStaleSessionPanelViews returns the original ref when nothing is stale", () => {
const panelViewByScope = {
"terminal:1": { mode: "session", sessionId: "session-keep" },
"terminal:2": { mode: "draft" },
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
const next = pruneStaleSessionPanelViews(
panelViewByScope,
new Set(["session-keep"]),
);
assert.equal(next, panelViewByScope);
});
test("clearScopeDraftState removes both the draft and current panel view", () => {
const draftsByScope = {
"terminal:1": createEmptyDraft("agent-alpha"),

View File

@@ -115,6 +115,25 @@ export function setSessionView(
};
}
export function pruneStaleSessionPanelViews(
panelViewByScope: PanelViewByScope,
validSessionIds: Set<string>,
): PanelViewByScope {
let next = panelViewByScope;
for (const [scopeKey, panelView] of Object.entries(panelViewByScope)) {
if (panelView?.mode !== 'session' || validSessionIds.has(panelView.sessionId)) {
continue;
}
const updated = setDraftView(next, scopeKey);
if (updated !== next) {
next = updated;
}
}
return next;
}
export function updateDraftForScope(
draftsByScope: DraftsByScope,
scopeKey: string,

View File

@@ -196,6 +196,21 @@ export function setLatestAIActiveSessionMapSnapshot(activeSessionIdMap: Record<s
latestAIActiveSessionMapSnapshot = activeSessionIdMap;
}
export function prewarmAIStateStorageSnapshots() {
try {
if (latestAISessionsSnapshot === null) {
latestAISessionsSnapshot =
localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? [];
}
if (latestAIActiveSessionMapSnapshot === null) {
latestAIActiveSessionMapSnapshot =
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {};
}
} catch (error) {
console.warn('[AIState] Failed to prewarm AI state storage snapshots:', error);
}
}
export function setLatestAIDraftsByScopeSnapshot(draftsByScope: DraftsByScope) {
latestAIDraftsByScopeSnapshot = draftsByScope;
}

View File

@@ -0,0 +1,41 @@
import { useSyncExternalStore } from 'react';
export type HostTreeInlineHostEdit = {
hostId: string;
initialName: string;
};
type Listener = () => void;
class HostTreeInlineHostEditStore {
private edit: HostTreeInlineHostEdit | null = null;
private listeners = new Set<Listener>();
getEdit = () => this.edit;
startEdit = (edit: HostTreeInlineHostEdit) => {
this.edit = edit;
this.listeners.forEach((listener) => listener());
};
clear = () => {
if (!this.edit) return;
this.edit = null;
this.listeners.forEach((listener) => listener());
};
subscribe = (listener: Listener) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
}
export const hostTreeInlineHostEditStore = new HostTreeInlineHostEditStore();
export const useHostTreeInlineHostEdit = () => {
return useSyncExternalStore(
hostTreeInlineHostEditStore.subscribe,
hostTreeInlineHostEditStore.getEdit,
hostTreeInlineHostEditStore.getEdit,
);
};

View File

@@ -1,32 +0,0 @@
import { useSyncExternalStore } from 'react';
/**
* Tiny external store for "immersive mode active" (whether the active terminal
* tab's theme is driving the app chrome). Kept out of the App component's render
* so that toggling immersive — and tab switches in general — do not force a
* full App re-render. The owner (AppActiveTabChrome) calls setImmersiveActive;
* AppView/TopTabs read it via useImmersiveActive without re-rendering App.
*/
type Listener = () => void;
let immersiveActive = false;
const listeners = new Set<Listener>();
export function setImmersiveActive(active: boolean): void {
if (immersiveActive === active) return;
immersiveActive = active;
listeners.forEach((listener) => listener());
}
function subscribe(listener: Listener): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
function getSnapshot(): boolean {
return immersiveActive;
}
export function useImmersiveActive(): boolean {
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}

View File

@@ -1,7 +1,10 @@
import test from "node:test";
import assert from "node:assert/strict";
import { resolveTerminalSessionExitIntent } from "./resolveTerminalSessionExitIntent.ts";
import {
resolveTerminalSessionExitIntent,
shouldCloseTerminalPopupOnExit,
} from "./resolveTerminalSessionExitIntent.ts";
test("normal backend exited events close the session tab", () => {
assert.deepEqual(
@@ -30,3 +33,10 @@ test("backend closed events keep the tab and mark it disconnected", () => {
{ kind: "markDisconnected" },
);
});
test("terminal popup only auto-closes after clean command exit", () => {
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "exited", exitCode: 0 }), true);
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "exited", exitCode: 1 }), false);
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "error", error: "connection reset" }), false);
assert.equal(shouldCloseTerminalPopupOnExit({ reason: "closed", exitCode: 0 }), false);
});

View File

@@ -20,3 +20,7 @@ export function resolveTerminalSessionExitIntent(
// so the user can inspect output and reconnect.
return { kind: "markDisconnected" };
}
export function shouldCloseTerminalPopupOnExit(evt: TerminalSessionExitEvent): boolean {
return evt.reason === "exited" && evt.exitCode === 0;
}

View File

@@ -0,0 +1,76 @@
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, 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 {
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, ttlMs: number) {
const entry: StoreEntry = {
capabilities: {
...capabilities,
probedAt: Date.now(),
},
expiresAt: Date.now() + ttlMs,
};
capabilitiesBySessionId.set(sessionId, entry);
notifySession(sessionId);
},
delete(sessionId: string) {
if (!capabilitiesBySessionId.delete(sessionId)) return;
notifySession(sessionId);
listenersBySessionId.delete(sessionId);
},
/** Drop cached capabilities for sessions that no longer exist. */
prune(liveSessionIds: ReadonlySet<string>) {
for (const sessionId of capabilitiesBySessionId.keys()) {
if (!liveSessionIds.has(sessionId)) {
capabilitiesBySessionId.delete(sessionId);
listenersBySessionId.delete(sessionId);
}
}
},
subscribe(sessionId: string, listener: Listener): () => void {
let set = listenersBySessionId.get(sessionId);
if (!set) {
set = new Set();
listenersBySessionId.set(sessionId, set);
}
set.add(listener);
return () => {
set?.delete(listener);
if (set && set.size === 0) {
listenersBySessionId.delete(sessionId);
}
};
},
};

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ import {
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
STORAGE_KEY_HOTKEY_RECORDING,
STORAGE_KEY_HOTKEY_SCHEME,
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
STORAGE_KEY_SESSION_LOGS_DIR,
STORAGE_KEY_SESSION_LOGS_ENABLED,
STORAGE_KEY_SESSION_LOGS_FORMAT,
@@ -34,6 +35,7 @@ import {
STORAGE_KEY_UI_THEME_DARK,
STORAGE_KEY_UI_THEME_LIGHT,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
STORAGE_KEY_WINDOW_OPACITY,
} from '../../infrastructure/config/storageKeys';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
@@ -71,6 +73,8 @@ interface UseSettingsIpcSyncParams {
setSftpFollowTerminalCwd: Dispatch<SetStateAction<boolean>>;
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
setDisableTerminalFontZoomState: Dispatch<SetStateAction<boolean>>;
setSftpTransferConcurrencyState: Dispatch<SetStateAction<number>>;
}
@@ -102,6 +106,8 @@ export function useSettingsIpcSync({
setSftpFollowTerminalCwd,
setSftpDefaultViewMode,
setWorkspaceFocusStyleState,
setShowHostTreeSidebarState,
setDisableTerminalFontZoomState,
setSftpTransferConcurrencyState,
}: UseSettingsIpcSyncParams) {
// Listen for settings changes from other windows via IPC
@@ -222,6 +228,12 @@ export function useSettingsIpcSync({
if (key === STORAGE_KEY_WORKSPACE_FOCUS_STYLE && (value === 'dim' || value === 'border')) {
setWorkspaceFocusStyleState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR && typeof value === 'boolean') {
setShowHostTreeSidebarState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM && typeof value === 'boolean') {
setDisableTerminalFontZoomState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY && typeof value === 'number') {
setSftpTransferConcurrencyState((prev) => (prev === value ? prev : value));
}
@@ -251,6 +263,8 @@ export function useSettingsIpcSync({
setSftpAutoOpenSidebar,
setSftpFollowTerminalCwd,
setSftpDefaultViewMode,
setShowHostTreeSidebarState,
setDisableTerminalFontZoomState,
setSftpTransferConcurrencyState,
setTerminalFontFamilyId,
setTerminalFontSize,

View File

@@ -63,6 +63,9 @@ export const DEFAULT_SFTP_DEFAULT_VIEW_MODE: 'list' | 'tree' = 'list';
export const DEFAULT_SHOW_RECENT_HOSTS = true;
export const DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT = false;
export const DEFAULT_SHOW_SFTP_TAB = true;
export const DEFAULT_SHOW_HOST_TREE_SIDEBAR = true;
export const DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS = false;
export const DEFAULT_DISABLE_TERMINAL_FONT_ZOOM = false;
// Editor defaults
export const DEFAULT_EDITOR_WORD_WRAP = false;
@@ -129,11 +132,8 @@ export const applyThemeTokens = (
accentOverride: string,
) => {
const root = window.document.documentElement;
// If immersive override is active (style tag present), it owns the dark/light class — don't override
if (!document.getElementById('netcatty-immersive-override')) {
root.classList.remove('light', 'dark');
root.classList.add(resolvedTheme);
}
root.classList.remove('light', 'dark');
root.classList.add(resolvedTheme);
root.style.setProperty('--background', tokens.background);
root.style.setProperty('--foreground', tokens.foreground);
root.style.setProperty('--card', tokens.card);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,16 @@
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export async function writeSystemManagerDiagnostic(
message: string,
extra?: Record<string, unknown>,
) {
try {
await netcattyBridge.get()?.logDiagnostic?.({
source: 'system-manager',
message,
extra,
});
} catch {
// Diagnostics must never block the user action being diagnosed.
}
}

View File

@@ -43,6 +43,8 @@ function createTerminalSessionClone(
localShellArgs: session.localShellArgs,
localShellName: session.localShellName,
localShellIcon: session.localShellIcon,
fontSize: session.fontSize,
fontSizeOverride: session.fontSizeOverride,
reuseConnectionFromSessionId: canReuseTerminalConnection(session) ? session.id : undefined,
};

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

@@ -0,0 +1,5 @@
export const TERMINAL_HOST_TREE_ANIMATION_MS = 220;
export const TERMINAL_HOST_TREE_ANIMATION_EASING = 'cubic-bezier(0.4, 0, 0.2, 1)';
export const TERMINAL_HOST_TREE_ANIMATION = `${TERMINAL_HOST_TREE_ANIMATION_MS}ms ${TERMINAL_HOST_TREE_ANIMATION_EASING}`;
export const TERMINAL_HOST_TREE_LEFT_TRANSITION = `left ${TERMINAL_HOST_TREE_ANIMATION}`;
export const TERMINAL_HOST_TREE_WIDTH_TRANSITION = `width ${TERMINAL_HOST_TREE_ANIMATION}`;

View File

@@ -0,0 +1,46 @@
import assert from 'node:assert/strict';
import test from 'node:test';
const storage = new Map<string, string>();
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
},
});
const {
TERMINAL_HOST_TREE_DEFAULT_WIDTH,
clampTerminalHostTreeWidth,
terminalHostTreeStore,
} = await import('./terminalHostTreeStore.ts');
test('closing host tree state does not mutate layout width by itself', () => {
terminalHostTreeStore.setIsOpen(true);
terminalHostTreeStore.setLayoutWidth(240);
terminalHostTreeStore.setIsOpen(false);
assert.equal(terminalHostTreeStore.getLayoutWidth(), 240);
terminalHostTreeStore.setLayoutWidth(0);
});
test('opening host tree state does not jump the layout width', () => {
storage.set('netcatty_terminal_host_tree_width_v1', '300');
terminalHostTreeStore.setLayoutWidth(0);
terminalHostTreeStore.setIsOpen(false);
terminalHostTreeStore.setIsOpen(true);
assert.equal(terminalHostTreeStore.getLayoutWidth(), 0);
terminalHostTreeStore.setLayoutWidth(0);
});
test('host tree restored layout width is clamped', () => {
assert.equal(clampTerminalHostTreeWidth(80), 160);
assert.equal(clampTerminalHostTreeWidth(999), 360);
assert.equal(clampTerminalHostTreeWidth(0), 160);
assert.equal(TERMINAL_HOST_TREE_DEFAULT_WIDTH, 220);
});

View File

@@ -5,6 +5,17 @@ import { localStorageAdapter } from '../../infrastructure/persistence/localStora
type Listener = () => void;
export const TERMINAL_HOST_TREE_MIN_WIDTH = 160;
export const TERMINAL_HOST_TREE_DEFAULT_WIDTH = 220;
export const TERMINAL_HOST_TREE_MAX_WIDTH = 360;
export function clampTerminalHostTreeWidth(width: number): number {
return Math.max(
TERMINAL_HOST_TREE_MIN_WIDTH,
Math.min(TERMINAL_HOST_TREE_MAX_WIDTH, width),
);
}
function readIsOpen(): boolean {
const stored = localStorageAdapter.readString(STORAGE_KEY_TERMINAL_HOST_TREE_COLLAPSED);
// Legacy key stores "collapsed"; open is the inverse.
@@ -26,9 +37,6 @@ class TerminalHostTreeStore {
setIsOpen = (open: boolean) => {
if (this.isOpen === open) return;
this.isOpen = open;
if (!open) {
this.layoutWidth = 0;
}
localStorageAdapter.writeString(
STORAGE_KEY_TERMINAL_HOST_TREE_COLLAPSED,
open ? 'false' : 'true',
@@ -37,7 +45,7 @@ class TerminalHostTreeStore {
};
setLayoutWidth = (width: number) => {
const next = Math.max(0, width);
const next = Math.max(0, Math.round(width));
if (this.layoutWidth === next) return;
this.layoutWidth = next;
this.listeners.forEach((listener) => listener());

View File

@@ -0,0 +1,129 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
THEME_TRANSITION_ATTR,
THEME_TRANSITION_MS,
runThemeTransition,
} from "./themeTransition.ts";
function createRoot() {
const attributes = new Map<string, string>();
return {
attributes,
ownerDocument: { startViewTransition: undefined },
setAttribute: (name: string, value: string) => attributes.set(name, value),
removeAttribute: (name: string) => attributes.delete(name),
getAttribute: (name: string) => attributes.get(name) ?? null,
} as unknown as HTMLElement;
}
test("runThemeTransition applies tokens and clears fallback marker after duration", async () => {
const root = createRoot();
let applied = false;
runThemeTransition(() => {
applied = true;
}, root);
assert.equal(applied, true);
assert.equal(root.getAttribute(THEME_TRANSITION_ATTR), "true");
await new Promise((resolve) => setTimeout(resolve, THEME_TRANSITION_MS + 60));
assert.equal(root.getAttribute(THEME_TRANSITION_ATTR), null);
});
test("runThemeTransition cancels a pending fallback reset when invoked again", () => {
const root = createRoot();
let count = 0;
runThemeTransition(() => {
count += 1;
}, root);
runThemeTransition(() => {
count += 2;
}, root);
assert.equal(count, 3);
assert.equal(root.getAttribute(THEME_TRANSITION_ATTR), "true");
});
test("runThemeTransition uses view transition API when available", async () => {
const root = createRoot();
let applied = false;
let finished = false;
const doc = {
startViewTransition: (callback: () => void) => {
callback();
return {
finished: Promise.resolve().then(() => {
finished = true;
}),
skipTransition: () => {},
};
},
};
(root as { ownerDocument: typeof doc }).ownerDocument = doc;
runThemeTransition(() => {
applied = true;
}, root);
assert.equal(applied, true);
assert.equal(root.getAttribute(THEME_TRANSITION_ATTR), null);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(finished, true);
});
test("runThemeTransition handles skipped view transitions", async () => {
const root = createRoot();
let applied = false;
let rejectFinished!: (reason: unknown) => void;
const doc = {
startViewTransition: (callback: () => void) => {
callback();
return {
finished: new Promise<void>((_, reject) => {
rejectFinished = reject;
}),
skipTransition: () => {},
};
},
};
(root as { ownerDocument: typeof doc }).ownerDocument = doc;
runThemeTransition(() => {
applied = true;
}, root);
rejectFinished(new DOMException("Transition was skipped", "AbortError"));
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(applied, true);
assert.equal(root.getAttribute(THEME_TRANSITION_ATTR), null);
});
test("runThemeTransition can apply without animation for heavy tab switches", () => {
const root = createRoot();
let applied = false;
let startViewTransitionCalled = false;
const doc = {
startViewTransition: (callback: () => void) => {
startViewTransitionCalled = true;
callback();
return {
finished: Promise.resolve(),
skipTransition: () => {},
};
},
};
(root as { ownerDocument: typeof doc }).ownerDocument = doc;
runThemeTransition(() => {
applied = true;
}, { root, mode: "instant" });
assert.equal(applied, true);
assert.equal(startViewTransitionCalled, false);
assert.equal(root.getAttribute(THEME_TRANSITION_ATTR), null);
});

View File

@@ -0,0 +1,108 @@
import { TERMINAL_HOST_TREE_ANIMATION_MS } from './terminalHostTreeAnimation';
export const THEME_TRANSITION_ATTR = 'data-theme-transition';
export const THEME_TRANSITION_MS = TERMINAL_HOST_TREE_ANIMATION_MS;
export type ThemeTransitionMode = 'view' | 'css' | 'instant';
type DocumentWithViewTransition = Document & {
startViewTransition?: (callback: () => void | Promise<void>) => {
finished: Promise<void>;
skipTransition: () => void;
};
};
type ThemeTransitionOptions = {
root?: HTMLElement;
mode?: ThemeTransitionMode;
};
let cancelThemeTransitionReset: (() => void) | null = null;
function resolveOptions(rootOrOptions?: HTMLElement | ThemeTransitionOptions): Required<ThemeTransitionOptions> {
if (
rootOrOptions
&& (
Object.prototype.hasOwnProperty.call(rootOrOptions, 'root')
|| Object.prototype.hasOwnProperty.call(rootOrOptions, 'mode')
)
) {
const options = rootOrOptions as ThemeTransitionOptions;
return {
root: options.root ?? document.documentElement,
mode: options.mode ?? 'view',
};
}
return {
root: rootOrOptions as HTMLElement | undefined ?? document.documentElement,
mode: 'view',
};
}
function runCssThemeTransition(apply: () => void, root: HTMLElement, cleanup: () => void): void {
root.setAttribute(THEME_TRANSITION_ATTR, 'true');
apply();
const timer = globalThis.setTimeout(cleanup, THEME_TRANSITION_MS + 40);
cancelThemeTransitionReset = () => {
globalThis.clearTimeout(timer);
cleanup();
};
}
function skipViewTransition(transition: ReturnType<NonNullable<DocumentWithViewTransition['startViewTransition']>>): void {
try {
transition.skipTransition();
} catch {
// Already finished or skipped by the browser.
}
}
export function runThemeTransition(
apply: () => void,
rootOrOptions?: HTMLElement | ThemeTransitionOptions,
): void {
const { root, mode } = resolveOptions(rootOrOptions);
cancelThemeTransitionReset?.();
const cleanup = () => {
root.removeAttribute(THEME_TRANSITION_ATTR);
cancelThemeTransitionReset = null;
};
if (mode === 'instant') {
apply();
cleanup();
return;
}
if (mode === 'css') {
runCssThemeTransition(apply, root, cleanup);
return;
}
const doc = root.ownerDocument as DocumentWithViewTransition | null;
const startViewTransition = doc?.startViewTransition?.bind(doc);
if (startViewTransition) {
let transition: ReturnType<NonNullable<DocumentWithViewTransition['startViewTransition']>> | null = null;
try {
transition = startViewTransition(() => {
apply();
});
} catch {
runCssThemeTransition(apply, root, cleanup);
return;
}
cancelThemeTransitionReset = () => {
if (transition) {
skipViewTransition(transition);
}
cleanup();
};
void transition.finished.then(cleanup, cleanup);
return;
}
runCssThemeTransition(apply, root, cleanup);
}

View File

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

View File

@@ -0,0 +1,350 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import {
STORAGE_KEY_AI_PROVIDERS,
STORAGE_KEY_AI_ACTIVE_PROVIDER,
STORAGE_KEY_AI_ACTIVE_MODEL,
STORAGE_KEY_AI_PERMISSION_MODE,
STORAGE_KEY_AI_TOOL_INTEGRATION_MODE,
STORAGE_KEY_AI_EXTERNAL_AGENTS,
STORAGE_KEY_AI_DEFAULT_AGENT,
STORAGE_KEY_AI_COMMAND_BLOCKLIST,
STORAGE_KEY_AI_COMMAND_TIMEOUT,
STORAGE_KEY_AI_MAX_ITERATIONS,
STORAGE_KEY_AI_AGENT_MODEL_MAP,
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
STORAGE_KEY_AI_WEB_SEARCH,
STORAGE_KEY_AI_QUICK_MESSAGES,
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
} from '../../infrastructure/config/storageKeys';
import type { AIQuickMessage } from '../../infrastructure/ai/quickMessages';
import { sanitizeQuickMessages } from '../../infrastructure/ai/quickMessages';
import type {
AIPermissionMode,
AIToolIntegrationMode,
ExternalAgentConfig,
ProviderConfig,
WebSearchConfig,
} from '../../infrastructure/ai/types';
import { DEFAULT_COMMAND_BLOCKLIST } from '../../infrastructure/ai/types';
import { removeProviderReferences } from './aiProviderCleanup';
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
import { getAIBridge } from './aiStateSnapshots';
import { useStoredBoolean } from './useStoredBoolean';
function readPermissionMode(): AIPermissionMode {
const stored = localStorageAdapter.readString(STORAGE_KEY_AI_PERMISSION_MODE);
if (stored === 'observer' || stored === 'confirm' || stored === 'autonomous') return stored;
return 'confirm';
}
function readToolIntegrationMode(): AIToolIntegrationMode {
return localStorageAdapter.readString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE) === 'skills'
? 'skills'
: 'mcp';
}
export function useAISettingsState() {
const [providers, setProvidersRaw] = useState<ProviderConfig[]>(() =>
localStorageAdapter.read<ProviderConfig[]>(STORAGE_KEY_AI_PROVIDERS) ?? []
);
const [activeProviderId, setActiveProviderIdRaw] = useState<string>(() =>
localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_PROVIDER) ?? ''
);
const [activeModelId, setActiveModelIdRaw] = useState<string>(() =>
localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_MODEL) ?? ''
);
const [globalPermissionMode, setGlobalPermissionModeRaw] = useState<AIPermissionMode>(readPermissionMode);
const [toolIntegrationMode, setToolIntegrationModeRaw] = useState<AIToolIntegrationMode>(readToolIntegrationMode);
const [externalAgents, setExternalAgentsRaw] = useState<ExternalAgentConfig[]>(() =>
localStorageAdapter.read<ExternalAgentConfig[]>(STORAGE_KEY_AI_EXTERNAL_AGENTS) ?? []
);
const [defaultAgentId, setDefaultAgentIdRaw] = useState<string>(() =>
localStorageAdapter.readString(STORAGE_KEY_AI_DEFAULT_AGENT) ?? 'catty'
);
const [commandBlocklist, setCommandBlocklistRaw] = useState<string[]>(() =>
localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST) ?? [...DEFAULT_COMMAND_BLOCKLIST]
);
const [commandTimeout, setCommandTimeoutRaw] = useState<number>(() =>
localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? 60
);
const [maxIterations, setMaxIterationsRaw] = useState<number>(() =>
localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20
);
const [webSearchConfig, setWebSearchConfigRaw] = useState<WebSearchConfig | null>(() =>
localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null
);
const [quickMessages, setQuickMessagesRaw] = useState<AIQuickMessage[]>(() =>
sanitizeQuickMessages(localStorageAdapter.read<unknown>(STORAGE_KEY_AI_QUICK_MESSAGES)),
);
const [showTerminalSelectionAIAction, setShowTerminalSelectionAIAction] = useStoredBoolean(
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
true,
);
const setProviders = useCallback((value: ProviderConfig[] | ((prev: ProviderConfig[]) => ProviderConfig[])) => {
setProvidersRaw((prev) => {
const next = typeof value === 'function' ? value(prev) : value;
localStorageAdapter.write(STORAGE_KEY_AI_PROVIDERS, next);
return next;
});
}, []);
const addProvider = useCallback((provider: ProviderConfig) => {
setProviders((prev) => [...prev, provider]);
}, [setProviders]);
const updateProvider = useCallback((id: string, updates: Partial<ProviderConfig>) => {
setProviders((prev) => prev.map((provider) => (
provider.id === id ? { ...provider, ...updates } : provider
)));
}, [setProviders]);
const removeProvider = useCallback((id: string) => {
setProviders((prev) => prev.filter((provider) => provider.id !== id));
setActiveProviderIdRaw((prevId) => {
if (prevId !== id) return prevId;
localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_PROVIDER, '');
return '';
});
const agentProviderMap =
localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_PROVIDER_MAP) ?? {};
const agentModelMap =
localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP) ?? {};
const cleanup = removeProviderReferences(id, agentProviderMap, agentModelMap);
if (cleanup.providerMapChanged) {
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_PROVIDER_MAP, cleanup.agentProviderMap);
}
if (cleanup.modelMapChanged) {
localStorageAdapter.write(STORAGE_KEY_AI_AGENT_MODEL_MAP, cleanup.agentModelMap);
}
}, [setProviders]);
const setActiveProviderId = useCallback((id: string) => {
setActiveProviderIdRaw(id);
localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_PROVIDER, id);
}, []);
const setActiveModelId = useCallback((id: string) => {
setActiveModelIdRaw(id);
localStorageAdapter.writeString(STORAGE_KEY_AI_ACTIVE_MODEL, id);
}, []);
const setGlobalPermissionMode = useCallback((mode: AIPermissionMode) => {
setGlobalPermissionModeRaw(mode);
localStorageAdapter.writeString(STORAGE_KEY_AI_PERMISSION_MODE, mode);
getAIBridge()?.aiMcpSetPermissionMode?.(mode);
}, []);
const setToolIntegrationMode = useCallback((mode: AIToolIntegrationMode) => {
setToolIntegrationModeRaw(mode);
localStorageAdapter.writeString(STORAGE_KEY_AI_TOOL_INTEGRATION_MODE, mode);
getAIBridge()?.aiMcpSetToolIntegrationMode?.(mode);
}, []);
const setExternalAgents = useCallback((value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => {
setExternalAgentsRaw((prev) => {
const next = typeof value === 'function' ? value(prev) : value;
localStorageAdapter.write(STORAGE_KEY_AI_EXTERNAL_AGENTS, next);
return next;
});
}, []);
const setDefaultAgentId = useCallback((id: string) => {
setDefaultAgentIdRaw(id);
localStorageAdapter.writeString(STORAGE_KEY_AI_DEFAULT_AGENT, id);
}, []);
const setCommandBlocklist = useCallback((value: string[]) => {
setCommandBlocklistRaw(value);
localStorageAdapter.write(STORAGE_KEY_AI_COMMAND_BLOCKLIST, value);
getAIBridge()?.aiMcpSetCommandBlocklist?.(value);
}, []);
const setCommandTimeout = useCallback((value: number) => {
setCommandTimeoutRaw(value);
localStorageAdapter.writeNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT, value);
getAIBridge()?.aiMcpSetCommandTimeout?.(value);
}, []);
const setMaxIterations = useCallback((value: number) => {
setMaxIterationsRaw(value);
localStorageAdapter.writeNumber(STORAGE_KEY_AI_MAX_ITERATIONS, value);
getAIBridge()?.aiMcpSetMaxIterations?.(value);
}, []);
const setWebSearchConfig = useCallback((config: WebSearchConfig | null) => {
setWebSearchConfigRaw(config);
if (config) {
localStorageAdapter.write(STORAGE_KEY_AI_WEB_SEARCH, config);
} else {
localStorageAdapter.remove(STORAGE_KEY_AI_WEB_SEARCH);
}
}, []);
const setQuickMessages = useCallback((value: AIQuickMessage[] | ((prev: AIQuickMessage[]) => AIQuickMessage[])) => {
setQuickMessagesRaw((prev) => {
const nextRaw = typeof value === 'function' ? value(prev) : value;
const next = sanitizeQuickMessages(nextRaw);
localStorageAdapter.write(STORAGE_KEY_AI_QUICK_MESSAGES, next);
emitAIStateChanged(STORAGE_KEY_AI_QUICK_MESSAGES);
return next;
});
}, []);
useEffect(() => {
const syncFromStorageKey = (key: string | null) => {
try {
switch (key) {
case STORAGE_KEY_AI_PROVIDERS: {
const parsed = localStorageAdapter.read<ProviderConfig[]>(STORAGE_KEY_AI_PROVIDERS);
if (parsed != null && !Array.isArray(parsed)) break;
setProvidersRaw(parsed ?? []);
break;
}
case STORAGE_KEY_AI_ACTIVE_PROVIDER:
setActiveProviderIdRaw(localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_PROVIDER) ?? '');
break;
case STORAGE_KEY_AI_ACTIVE_MODEL:
setActiveModelIdRaw(localStorageAdapter.readString(STORAGE_KEY_AI_ACTIVE_MODEL) ?? '');
break;
case STORAGE_KEY_AI_PERMISSION_MODE:
setGlobalPermissionModeRaw(readPermissionMode());
getAIBridge()?.aiMcpSetPermissionMode?.(readPermissionMode());
break;
case STORAGE_KEY_AI_TOOL_INTEGRATION_MODE:
setToolIntegrationModeRaw(readToolIntegrationMode());
getAIBridge()?.aiMcpSetToolIntegrationMode?.(readToolIntegrationMode());
break;
case STORAGE_KEY_AI_EXTERNAL_AGENTS: {
const agents = localStorageAdapter.read<ExternalAgentConfig[]>(STORAGE_KEY_AI_EXTERNAL_AGENTS);
if (agents != null && !Array.isArray(agents)) break;
setExternalAgentsRaw(agents ?? []);
break;
}
case STORAGE_KEY_AI_DEFAULT_AGENT:
setDefaultAgentIdRaw(localStorageAdapter.readString(STORAGE_KEY_AI_DEFAULT_AGENT) ?? 'catty');
break;
case STORAGE_KEY_AI_COMMAND_BLOCKLIST: {
const list = localStorageAdapter.read<string[]>(STORAGE_KEY_AI_COMMAND_BLOCKLIST);
if (list != null && !Array.isArray(list)) break;
const blocklist = list ?? [...DEFAULT_COMMAND_BLOCKLIST];
setCommandBlocklistRaw(blocklist);
getAIBridge()?.aiMcpSetCommandBlocklist?.(blocklist);
break;
}
case STORAGE_KEY_AI_COMMAND_TIMEOUT: {
const timeout = localStorageAdapter.readNumber(STORAGE_KEY_AI_COMMAND_TIMEOUT) ?? 60;
if (!Number.isFinite(timeout)) break;
setCommandTimeoutRaw(timeout);
getAIBridge()?.aiMcpSetCommandTimeout?.(timeout);
break;
}
case STORAGE_KEY_AI_MAX_ITERATIONS: {
const iters = localStorageAdapter.readNumber(STORAGE_KEY_AI_MAX_ITERATIONS) ?? 20;
if (!Number.isFinite(iters)) break;
setMaxIterationsRaw(iters);
getAIBridge()?.aiMcpSetMaxIterations?.(iters);
break;
}
case STORAGE_KEY_AI_WEB_SEARCH:
setWebSearchConfigRaw(localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null);
break;
case STORAGE_KEY_AI_QUICK_MESSAGES:
setQuickMessagesRaw(sanitizeQuickMessages(localStorageAdapter.read<unknown>(STORAGE_KEY_AI_QUICK_MESSAGES)));
break;
}
} catch (err) {
console.warn('[useAISettingsState] Failed to process AI settings storage change', key, err);
}
};
const handleStorage = (event: StorageEvent) => syncFromStorageKey(event.key);
const handleLocalStateChanged = (event: Event) => {
syncFromStorageKey((event as CustomEvent<{ key?: string }>).detail?.key ?? null);
};
window.addEventListener('storage', handleStorage);
window.addEventListener(AI_STATE_CHANGED_EVENT, handleLocalStateChanged);
return () => {
window.removeEventListener('storage', handleStorage);
window.removeEventListener(AI_STATE_CHANGED_EVENT, handleLocalStateChanged);
};
}, []);
useEffect(() => {
const bridge = getAIBridge();
bridge?.aiMcpSetCommandBlocklist?.(commandBlocklist);
bridge?.aiMcpSetCommandTimeout?.(commandTimeout);
bridge?.aiMcpSetMaxIterations?.(maxIterations);
bridge?.aiMcpSetPermissionMode?.(globalPermissionMode);
bridge?.aiMcpSetToolIntegrationMode?.(toolIntegrationMode);
}, [commandBlocklist, commandTimeout, globalPermissionMode, maxIterations, toolIntegrationMode]);
const activeProvider = providers.find((provider) => provider.id === activeProviderId) ?? null;
return useMemo(() => ({
providers,
setProviders,
addProvider,
updateProvider,
removeProvider,
activeProviderId,
setActiveProviderId,
activeModelId,
setActiveModelId,
activeProvider,
globalPermissionMode,
setGlobalPermissionMode,
toolIntegrationMode,
setToolIntegrationMode,
externalAgents,
setExternalAgents,
defaultAgentId,
setDefaultAgentId,
commandBlocklist,
setCommandBlocklist,
commandTimeout,
setCommandTimeout,
maxIterations,
setMaxIterations,
webSearchConfig,
setWebSearchConfig,
quickMessages,
setQuickMessages,
showTerminalSelectionAIAction,
setShowTerminalSelectionAIAction,
}), [
providers,
setProviders,
addProvider,
updateProvider,
removeProvider,
activeProviderId,
setActiveProviderId,
activeModelId,
setActiveModelId,
activeProvider,
globalPermissionMode,
setGlobalPermissionMode,
toolIntegrationMode,
setToolIntegrationMode,
externalAgents,
setExternalAgents,
defaultAgentId,
setDefaultAgentId,
commandBlocklist,
setCommandBlocklist,
commandTimeout,
setCommandTimeout,
maxIterations,
setMaxIterations,
webSearchConfig,
setWebSearchConfig,
quickMessages,
setQuickMessages,
showTerminalSelectionAIAction,
setShowTerminalSelectionAIAction,
]);
}

View File

@@ -17,7 +17,10 @@ import {
STORAGE_KEY_AI_AGENT_MODEL_MAP,
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
STORAGE_KEY_AI_WEB_SEARCH,
STORAGE_KEY_AI_QUICK_MESSAGES,
} from '../../infrastructure/config/storageKeys';
import type { AIQuickMessage } from '../../infrastructure/ai/quickMessages';
import { sanitizeQuickMessages } from '../../infrastructure/ai/quickMessages';
import type {
AIDraft,
AISession,
@@ -35,6 +38,8 @@ import {
activateDraftView,
clearScopeDraftState,
ensureDraftForScopeState,
pruneStaleSessionPanelViews,
setDraftView,
setSessionView,
updateDraftForScope,
} from './aiDraftState';
@@ -110,7 +115,9 @@ export function useAIState() {
// ── Sessions ──
const [sessions, setSessionsRaw] = useState<AISession[]>(() =>
localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? []
latestAISessionsSnapshot
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
?? []
);
// Ref that always holds the latest sessions for use inside debounced callbacks
const sessionsRef = useRef(sessions);
@@ -119,7 +126,9 @@ export function useAIState() {
}, [sessions]);
// Per-scope active session: keyed by `${scopeType}:${scopeTargetId}`
const [activeSessionIdMap, setActiveSessionIdMapRaw] = useState<Record<string, string | null>>(() =>
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {}
latestAIActiveSessionMapSnapshot
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
?? {}
);
// Per-scope draft/view state is intentionally memory-only so a relaunch
// does not restore stale composer input or panel intent against new history.
@@ -158,6 +167,11 @@ export function useAIState() {
localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null
);
// ── Quick Messages (slash prompts) ──
const [quickMessages, setQuickMessagesRaw] = useState<AIQuickMessage[]>(() =>
sanitizeQuickMessages(localStorageAdapter.read<unknown>(STORAGE_KEY_AI_QUICK_MESSAGES)),
);
useEffect(() => {
setLatestAISessionsSnapshot(sessions);
}, [sessions]);
@@ -175,7 +189,7 @@ export function useAIState() {
}, [panelViewByScope]);
useEffect(() => {
const validSessionIds = new Set(sessions.map((session) => session.id));
const validSessionIds = new Set<string>(sessions.map((session) => session.id));
let changed = false;
const nextActiveSessionIdMap: Record<string, string | null> = {};
@@ -187,12 +201,22 @@ export function useAIState() {
}
}
if (!changed) return;
if (changed) {
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
setActiveSessionIdMapRaw(nextActiveSessionIdMap);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
}
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
setActiveSessionIdMapRaw(nextActiveSessionIdMap);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
setPanelViewByScopeRaw((prev) => {
const next = pruneStaleSessionPanelViews(prev, validSessionIds);
if (next === prev) {
return prev;
}
setLatestAIPanelViewByScopeSnapshot(next);
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
return next;
});
}, [sessions, activeSessionIdMap]);
const setActiveSessionId = useCallback((scopeKey: string, id: string | null) => {
@@ -263,6 +287,16 @@ export function useAIState() {
}
}, []);
const setQuickMessages = useCallback((value: AIQuickMessage[] | ((prev: AIQuickMessage[]) => AIQuickMessage[])) => {
setQuickMessagesRaw((prev) => {
const nextRaw = typeof value === 'function' ? value(prev) : value;
const next = sanitizeQuickMessages(nextRaw);
localStorageAdapter.write(STORAGE_KEY_AI_QUICK_MESSAGES, next);
emitAIStateChanged(STORAGE_KEY_AI_QUICK_MESSAGES);
return next;
});
}, []);
// ── Persist helpers ──
const setProviders = useCallback((value: ProviderConfig[] | ((prev: ProviderConfig[]) => ProviderConfig[])) => {
setProvidersRaw(prev => {
@@ -454,6 +488,11 @@ export function useAIState() {
case STORAGE_KEY_AI_WEB_SEARCH:
setWebSearchConfigRaw(localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null);
break;
case STORAGE_KEY_AI_QUICK_MESSAGES: {
const messages = localStorageAdapter.read<unknown>(STORAGE_KEY_AI_QUICK_MESSAGES);
setQuickMessagesRaw(sanitizeQuickMessages(messages));
break;
}
}
} catch (err) {
console.warn('[useAIState] Cross-window sync: failed to process storage event for key', e.key, err);
@@ -593,6 +632,19 @@ export function useAIState() {
}
return prev;
});
setPanelViewByScopeRaw((prev) => {
const currentPanelView = prev[scopeKey];
if (currentPanelView?.mode !== 'session' || currentPanelView.sessionId !== sessionId) {
return prev;
}
const next = setDraftView(prev, scopeKey);
if (next === prev) {
return prev;
}
setLatestAIPanelViewByScopeSnapshot(next);
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
return next;
});
}
}, [persistSessions]);
@@ -974,6 +1026,8 @@ export function useAIState() {
setAgentProvider,
webSearchConfig,
setWebSearchConfig,
quickMessages,
setQuickMessages,
sessions,
activeSessionIdMap,
draftsByScope,
@@ -1029,6 +1083,8 @@ export function useAIState() {
setAgentProvider,
webSearchConfig,
setWebSearchConfig,
quickMessages,
setQuickMessages,
sessions,
activeSessionIdMap,
draftsByScope,

View File

@@ -0,0 +1,95 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
scheduleChromeLayoutAnimation,
syncActiveChromeTheme,
themeFingerprint,
} from "./useActiveChromeTheme.ts";
import { TERMINAL_THEMES } from "../../infrastructure/config/terminalThemes.ts";
function createInlineStyle() {
const values = new Map<string, string>();
return {
getPropertyValue: (name: string) => values.get(name) ?? "",
setProperty: (name: string, value: string) => values.set(name, value),
removeProperty: (name: string) => values.delete(name),
};
}
function createRafRoot() {
const callbacks = new Map<number, FrameRequestCallback>();
let nextId = 1;
const view = {
requestAnimationFrame: (callback: FrameRequestCallback) => {
const id = nextId++;
callbacks.set(id, callback);
return id;
},
cancelAnimationFrame: (id: number) => {
callbacks.delete(id);
},
};
const root = {
ownerDocument: { defaultView: view },
} as unknown as HTMLElement;
const flushFrame = () => {
const [id, callback] = callbacks.entries().next().value ?? [];
if (!id || !callback) return false;
callbacks.delete(id);
callback(0);
return true;
};
return { root, flushFrame };
}
test("chrome layout animations wait until theme settle frames complete", () => {
const { root, flushFrame } = createRafRoot();
let ran = false;
const cancel = scheduleChromeLayoutAnimation(() => {
ran = true;
}, root);
while (!ran && flushFrame()) {
// Drain scheduled animation frames.
}
assert.equal(ran, true);
cancel();
});
test("syncActiveChromeTheme refreshes top tabs when the active theme fingerprint is unchanged", () => {
const globalWithDocument = globalThis as typeof globalThis & { document?: Document };
const originalDocument = globalWithDocument.document;
const theme = TERMINAL_THEMES[0];
assert.ok(theme);
const topTabsRoot = {
style: createInlineStyle(),
};
const documentElement = {
dataset: { activeChromeTheme: themeFingerprint(theme) },
};
const fakeDocument = {
documentElement,
querySelector: (selector: string) => selector === "[data-top-tabs-root]" ? topTabsRoot : null,
};
globalWithDocument.document = fakeDocument as unknown as Document;
try {
syncActiveChromeTheme(theme, () => {
throw new Error("app theme should not be restored for an unchanged active chrome theme");
});
assert.notEqual(topTabsRoot.style.getPropertyValue("--top-tabs-bg"), "");
assert.notEqual(topTabsRoot.style.getPropertyValue("--top-tabs-active-bg"), "");
assert.notEqual(topTabsRoot.style.getPropertyValue("--top-tabs-accent"), "");
} finally {
if (originalDocument) {
globalWithDocument.document = originalDocument;
} else {
delete globalWithDocument.document;
}
}
});

View File

@@ -0,0 +1,272 @@
import { useLayoutEffect, useRef } from "react";
import type { TerminalTheme } from "../../domain/models";
import {
applyTopTabsChromeThemeVars,
clearTopTabsChromeThemeVars,
} from "../app/topTabsChromeTheme";
import { runThemeTransition } from "./themeTransition";
import { TERMINAL_THEMES } from "../../infrastructure/config/terminalThemes";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
function hexToHsl(hex: string): string {
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0;
let s = 0;
const l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r:
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
break;
case g:
h = ((b - r) / d + 2) / 6;
break;
default:
h = ((r - g) / d + 4) / 6;
break;
}
}
return `${Math.round(h * 3600) / 10} ${Math.round(s * 1000) / 10}% ${Math.round(l * 1000) / 10}%`;
}
function adjustLightness(hsl: string, delta: number): string {
const parts = hsl.split(/\s+/);
const nextLightness = Math.max(0, Math.min(100, parseFloat(parts[2]) + delta));
return `${parts[0]} ${parts[1]} ${Math.round(nextLightness * 10) / 10}%`;
}
function adjustSaturation(hsl: string, factor: number): string {
const parts = hsl.split(/\s+/);
const nextSaturation = Math.max(0, Math.min(100, parseFloat(parts[1]) * factor));
return `${parts[0]} ${Math.round(nextSaturation * 10) / 10}% ${parts[2]}`;
}
const CSS_VARS = [
"background",
"foreground",
"card",
"card-foreground",
"popover",
"popover-foreground",
"primary",
"primary-foreground",
"secondary",
"secondary-foreground",
"muted",
"muted-foreground",
"accent",
"accent-foreground",
"destructive",
"destructive-foreground",
"border",
"input",
"ring",
] as const;
function buildChromeCss(theme: TerminalTheme): string {
const bg = hexToHsl(theme.colors.background);
const fg = hexToHsl(theme.colors.foreground);
const cursor = hexToHsl(theme.colors.cursor);
const isDark = theme.type === "dark";
const card = adjustLightness(bg, isDark ? 4 : -3);
const secondary = adjustLightness(bg, isDark ? 6 : -5);
const muted = adjustLightness(bg, isDark ? 10 : -8);
const mutedFg = adjustSaturation(adjustLightness(fg, isDark ? -20 : 20), 0.5);
const border = adjustLightness(bg, isDark ? 12 : -10);
const cursorLightness = parseFloat(cursor.split(" ")[2] ?? "50");
const primaryFg = cursorLightness > 55 ? "0 0% 0%" : "0 0% 100%";
const values = [
bg, fg, card, fg,
card, fg,
cursor, primaryFg,
secondary, fg,
muted, mutedFg,
cursor, primaryFg,
"0 70% 50%", "0 0% 100%",
border, border, cursor,
];
const rules = CSS_VARS.map((name, index) => `--${name}: ${values[index]} !important`).join("; ");
return [
`:root { ${rules}; }`,
`:root[data-active-chrome-theme] [data-agent-badge] { border-color: hsl(var(--primary) / 0.2) !important; background-color: hsl(var(--primary) / 0.1) !important; }`,
].join("\n");
}
const cssCache = new Map<string, string>();
export function themeFingerprint(theme: TerminalTheme): string {
return `${theme.id}\0${theme.type}\0${theme.colors.background}\0${theme.colors.foreground}\0${theme.colors.cursor}`;
}
function getAppliedChromeFingerprint(): string | null {
if (typeof document === "undefined") return null;
return document.documentElement.dataset.activeChromeTheme ?? null;
}
for (const theme of TERMINAL_THEMES) {
cssCache.set(themeFingerprint(theme), buildChromeCss(theme));
}
function getChromeCss(theme: TerminalTheme): string {
const fingerprint = themeFingerprint(theme);
let css = cssCache.get(fingerprint);
if (!css) {
css = buildChromeCss(theme);
cssCache.set(fingerprint, css);
}
return css;
}
const STYLE_ID = "netcatty-active-chrome-theme";
/** Double-rAF window used to let layout settle after a paint. */
export const INSTANT_THEME_SWITCH_SETTLE_FRAMES = 2;
function getAnimationView(root: HTMLElement) {
return root.ownerDocument?.defaultView ?? globalThis.window;
}
/** Run after instant theme switch finishes suppressing CSS transitions. */
export function scheduleAfterInstantThemeSwitch(
callback: () => void,
root: HTMLElement = document.documentElement,
): () => void {
const view = getAnimationView(root);
const requestFrame = view?.requestAnimationFrame?.bind(view)
?? ((cb: FrameRequestCallback) => globalThis.setTimeout(() => cb(0), 0) as unknown as number);
const cancelFrame = view?.cancelAnimationFrame?.bind(view)
?? ((id: number) => { globalThis.clearTimeout(id); });
const frameIds: number[] = [];
const scheduleFrames = (remaining: number) => {
const frameId = requestFrame(() => {
const index = frameIds.indexOf(frameId);
if (index >= 0) frameIds.splice(index, 1);
if (remaining <= 1) {
callback();
return;
}
scheduleFrames(remaining - 1);
});
frameIds.push(frameId);
};
scheduleFrames(INSTANT_THEME_SWITCH_SETTLE_FRAMES);
return () => {
for (const frameId of frameIds) cancelFrame(frameId);
};
}
/**
* Run one frame after instant theme switch settles so layout transitions can
* start from the pre-animation state without `transition: none` on :root.
*/
export function scheduleChromeLayoutAnimation(
callback: () => void,
root: HTMLElement = document.documentElement,
): () => void {
let layoutFrameId = 0;
const cancelSettle = scheduleAfterInstantThemeSwitch(() => {
const view = getAnimationView(root);
const requestFrame = view?.requestAnimationFrame?.bind(view)
?? ((cb: FrameRequestCallback) => globalThis.setTimeout(() => cb(0), 0) as unknown as number);
layoutFrameId = requestFrame(() => callback());
}, root);
return () => {
cancelSettle();
const view = getAnimationView(root);
const cancelFrame = view?.cancelAnimationFrame?.bind(view)
?? ((id: number) => { globalThis.clearTimeout(id); });
if (layoutFrameId) cancelFrame(layoutFrameId);
};
}
function removeActiveChromeTheme() {
document.getElementById(STYLE_ID)?.remove();
delete document.documentElement.dataset.activeChromeTheme;
}
function applyActiveChromeTheme(theme: TerminalTheme) {
runThemeTransition(() => {
const root = document.documentElement;
const targetClass = theme.type === "dark" ? "dark" : "light";
root.classList.remove("light", "dark");
root.classList.add(targetClass);
let style = document.getElementById(STYLE_ID) as HTMLStyleElement | null;
if (!style) {
style = document.createElement("style");
style.id = STYLE_ID;
document.head.appendChild(style);
}
style.textContent = getChromeCss(theme);
root.dataset.activeChromeTheme = themeFingerprint(theme);
refreshActiveChromeThemeSurfaces(theme);
}, { mode: "instant" });
}
function refreshActiveChromeThemeSurfaces(theme: TerminalTheme) {
const targetClass = theme.type === "dark" ? "dark" : "light";
if (typeof window !== "undefined") {
netcattyBridge.get()?.setTheme?.(targetClass);
netcattyBridge.get()?.setBackgroundColor?.(theme.colors.background);
}
applyTopTabsChromeThemeVars(theme);
}
export function syncActiveChromeTheme(
activeTheme: TerminalTheme | null,
applyAppTheme: () => void,
): void {
const nextFingerprint = activeTheme ? themeFingerprint(activeTheme) : null;
const appliedFingerprint = getAppliedChromeFingerprint();
if (nextFingerprint === appliedFingerprint) {
if (activeTheme) {
refreshActiveChromeThemeSurfaces(activeTheme);
} else {
clearTopTabsChromeThemeVars();
}
return;
}
if (activeTheme) {
applyActiveChromeTheme(activeTheme);
return;
}
clearTopTabsChromeThemeVars();
runThemeTransition(() => {
removeActiveChromeTheme();
applyAppTheme();
}, { mode: "instant" });
}
export function useActiveChromeTheme({
activeTheme,
applyAppTheme,
}: {
activeTheme: TerminalTheme | null;
applyAppTheme: () => void;
}) {
const applyAppThemeRef = useRef(applyAppTheme);
applyAppThemeRef.current = applyAppTheme;
useLayoutEffect(() => {
syncActiveChromeTheme(activeTheme, applyAppTheme);
}, [activeTheme, applyAppTheme]);
useLayoutEffect(() => {
return () => {
removeActiveChromeTheme();
clearTopTabsChromeThemeVars();
applyAppThemeRef.current();
};
}, []);
}

View File

@@ -1,15 +1,24 @@
import { useCallback, useEffect, useState } from 'react';
import { startTransition, useCallback, useEffect, useRef, useState } from 'react';
import type { DiscoveredAgent, ExternalAgentConfig } from '../../infrastructure/ai/types';
import { getExternalAgentSdkBackend } from '../../infrastructure/ai/managedAgents';
interface NetcattyBridge {
aiDiscoverAgents(): Promise<DiscoveredAgent[]>;
aiDiscoverAgents(options?: { refreshShellEnv?: boolean; apiKeyPresent?: boolean }): Promise<DiscoveredAgent[]>;
}
function getBridge(): NetcattyBridge | undefined {
return (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
}
const AGENT_DISCOVERY_CACHE_TTL_MS = 60_000;
let agentDiscoveryCache: {
agents: DiscoveredAgent[];
apiKeyPresent: boolean;
updatedAt: number;
} | null = null;
const agentDiscoveryPromises = new Map<string, Promise<DiscoveredAgent[]>>();
let agentDiscoveryWriteGeneration = 0;
export function useAgentDiscovery(
externalAgents: ExternalAgentConfig[],
setExternalAgents?: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void,
@@ -18,21 +27,86 @@ export function useAgentDiscovery(
const enabled = options?.enabled ?? true;
const [discoveredAgents, setDiscoveredAgents] = useState<DiscoveredAgent[]>([]);
const [isDiscovering, setIsDiscovering] = useState(false);
const discoverSeqRef = useRef(0);
const mountedRef = useRef(true);
const enabledRef = useRef(enabled);
const discover = useCallback(async () => {
enabledRef.current = enabled;
useEffect(() => () => {
mountedRef.current = false;
discoverSeqRef.current += 1;
}, []);
const cursorApiKeyPresent = externalAgents.some(
(agent) => agent.id === "discovered_cursor" && Boolean(agent.apiKey),
);
const discover = useCallback(async (discoverOptions?: { refreshShellEnv?: boolean }) => {
if (!enabledRef.current) return;
const bridge = getBridge();
if (!bridge) return;
const forceRefresh = discoverOptions?.refreshShellEnv === true;
const cacheFresh =
agentDiscoveryCache
&& agentDiscoveryCache.apiKeyPresent === cursorApiKeyPresent
&& Date.now() - agentDiscoveryCache.updatedAt < AGENT_DISCOVERY_CACHE_TTL_MS;
if (!forceRefresh && cacheFresh) {
startTransition(() => setDiscoveredAgents(agentDiscoveryCache?.agents ?? []));
return;
}
setIsDiscovering(true);
const discoverSeq = ++discoverSeqRef.current;
const writeGeneration = ++agentDiscoveryWriteGeneration;
const promiseKey = JSON.stringify({
apiKeyPresent: cursorApiKeyPresent,
refreshShellEnv: forceRefresh,
});
try {
const agents = await bridge.aiDiscoverAgents();
setDiscoveredAgents(agents);
let discoveryPromise = agentDiscoveryPromises.get(promiseKey) ?? null;
if (!discoveryPromise) {
const sharedPromise = bridge.aiDiscoverAgents({
...discoverOptions,
apiKeyPresent: cursorApiKeyPresent,
}).finally(() => {
if (agentDiscoveryPromises.get(promiseKey) === sharedPromise) {
agentDiscoveryPromises.delete(promiseKey);
}
});
agentDiscoveryPromises.set(promiseKey, sharedPromise);
discoveryPromise = sharedPromise;
}
const agents = await discoveryPromise;
if (
!mountedRef.current
|| !enabledRef.current
|| discoverSeq !== discoverSeqRef.current
|| writeGeneration !== agentDiscoveryWriteGeneration
) return;
agentDiscoveryCache = {
agents,
apiKeyPresent: cursorApiKeyPresent,
updatedAt: Date.now(),
};
startTransition(() => setDiscoveredAgents(agents));
} catch (err) {
console.error('Agent discovery failed:', err);
} finally {
if (mountedRef.current && discoverSeq === discoverSeqRef.current) {
setIsDiscovering(false);
}
}
}, [cursorApiKeyPresent]);
useEffect(() => {
discoverSeqRef.current += 1;
if (!enabled) {
setIsDiscovering(false);
}
}, []);
}, [cursorApiKeyPresent, enabled]);
useEffect(() => {
if (!enabled) return;
@@ -61,6 +135,7 @@ export function useAgentDiscovery(
// the canonical args from discovery change (e.g. after an app update).
useEffect(() => {
if (!setExternalAgents || discoveredAgents.length === 0) return;
if (!enabled) return;
setExternalAgents((prev) => {
let changed = false;
@@ -95,7 +170,7 @@ export function useAgentDiscovery(
});
return changed ? next : prev;
});
}, [discoveredAgents, setExternalAgents]);
}, [discoveredAgents, enabled, setExternalAgents]);
// Filter out agents that are already configured as external agents
const unconfiguredAgents = discoveredAgents.filter(
@@ -128,7 +203,7 @@ export function useAgentDiscovery(
discoveredAgents,
unconfiguredAgents,
isDiscovering,
rediscover: discover,
rediscover: () => discover({ refreshShellEnv: true }),
enableAgent,
};
}

View File

@@ -0,0 +1,34 @@
import { useCallback } from 'react';
import { STORAGE_KEY_COMPOSE_BAR_HEIGHT } from '../../infrastructure/config/storageKeys';
import { useStoredNumber } from './useStoredNumber';
export const COMPOSE_BAR_HEIGHT_DEFAULT = 120;
export const COMPOSE_BAR_HEIGHT_MIN = 72;
export const COMPOSE_BAR_HEIGHT_MAX = 360;
const HEIGHT_CLAMP = { min: COMPOSE_BAR_HEIGHT_MIN, max: COMPOSE_BAR_HEIGHT_MAX };
function clampHeight(height: number): number {
return Math.max(HEIGHT_CLAMP.min, Math.min(HEIGHT_CLAMP.max, height));
}
/** Persisted compose bar height; call `persist` on mouseup after a drag. */
export function useComposeBarHeight() {
const [height, setHeight, persist] = useStoredNumber(
STORAGE_KEY_COMPOSE_BAR_HEIGHT,
COMPOSE_BAR_HEIGHT_DEFAULT,
HEIGHT_CLAMP,
);
const setClampedHeight = useCallback(
(next: number | ((prev: number) => number)) => {
setHeight((prev) => {
const raw = typeof next === 'function' ? next(prev) : next;
return clampHeight(raw);
});
},
[setHeight],
);
return [height, setClampedHeight, persist] as const;
}

View File

@@ -0,0 +1,106 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { STORAGE_KEY_COMPOSE_BAR_PINNED_SNIPPETS } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
interface PinnedState {
pinnedIds: string[];
/** True when the user has never saved pins (localStorage key absent). */
neverSaved: boolean;
}
function readPinnedState(): PinnedState {
const stored = localStorageAdapter.read<string[]>(STORAGE_KEY_COMPOSE_BAR_PINNED_SNIPPETS);
if (stored === null) {
return { pinnedIds: [], neverSaved: true };
}
return {
pinnedIds: Array.isArray(stored) ? stored.filter((id) => typeof id === 'string') : [],
neverSaved: false,
};
}
function parseSnippetIdKey(snippetIdKey?: string): Set<string> | null {
if (!snippetIdKey) return null;
const ids = snippetIdKey.split('\0').filter(Boolean);
if (ids.length === 0) return null;
return new Set(ids);
}
/**
* Persisted snippet IDs shown on the terminal compose bar quick strip.
* Pass a stable `snippetIdKey` (`ids.join('\\0')`) to prune pins for deleted snippets.
* On first run, `defaultSeedIds` are written once when pins were never saved.
*/
export function useComposeBarPinnedSnippets(
snippetIdKey?: string,
defaultSeedIds?: readonly string[],
) {
const [{ pinnedIds, neverSaved }, setPinnedState] = useState(readPinnedState);
const skipNextPersistRef = useRef(true);
const needsSeedRef = useRef(neverSaved);
const setPinnedIds = useCallback((updater: string[] | ((prev: string[]) => string[])) => {
setPinnedState((prev) => {
const nextIds = typeof updater === 'function' ? updater(prev.pinnedIds) : updater;
return { pinnedIds: nextIds, neverSaved: false };
});
}, []);
useEffect(() => {
if (skipNextPersistRef.current) {
skipNextPersistRef.current = false;
return;
}
localStorageAdapter.write(STORAGE_KEY_COMPOSE_BAR_PINNED_SNIPPETS, pinnedIds);
}, [pinnedIds]);
useEffect(() => {
if (!needsSeedRef.current) return;
const seed = defaultSeedIds?.filter(Boolean).slice(0, 4) ?? [];
if (seed.length === 0) return;
const applySeed = () => {
if (!needsSeedRef.current) return;
needsSeedRef.current = false;
setPinnedState({ pinnedIds: [...seed], neverSaved: false });
};
const isBuiltinSeed = seed.every((id) => id.startsWith('__compose_builtin_'));
if (!isBuiltinSeed) {
applySeed();
return;
}
// Brief delay so vault snippets can load before falling back to built-ins.
const timer = setTimeout(applySeed, 300);
return () => clearTimeout(timer);
}, [defaultSeedIds]);
useEffect(() => {
const valid = parseSnippetIdKey(snippetIdKey);
if (!valid) return;
setPinnedIds((prev) => {
const next = prev.filter((id) => valid.has(id) || id.startsWith('__compose_builtin_'));
return next.length === prev.length ? prev : next;
});
}, [snippetIdKey, setPinnedIds]);
const pin = useCallback((id: string) => {
setPinnedIds((prev) => (prev.includes(id) ? prev : [...prev, id]));
}, [setPinnedIds]);
const unpin = useCallback((id: string) => {
setPinnedIds((prev) => prev.filter((x) => x !== id));
}, [setPinnedIds]);
const toggle = useCallback((id: string) => {
setPinnedIds((prev) => (
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
));
}, [setPinnedIds]);
const isPinned = useCallback((id: string) => pinnedIds.includes(id), [pinnedIds]);
return { pinnedIds, pin, unpin, toggle, isPinned };
}

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

@@ -1,214 +0,0 @@
/**
* Immersive Mode — makes the entire UI chrome adapt colors to match the active terminal's theme.
*
* Performance strategy:
* - All built-in themes' CSS strings are pre-computed at module load (zero cost at switch time)
* - Custom/unknown themes are computed lazily and cached
* - A single `<style>` tag with `!important` overrides inline CSS variables atomically
* - `useLayoutEffect` ensures the update happens before browser paint (no flash)
*/
import { useEffect, useLayoutEffect, useRef } from 'react';
import { TerminalTheme } from '../../domain/models';
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
// ---------------------------------------------------------------------------
// Hex → HSL conversion (returns "H S% L%" without the hsl() wrapper)
// ---------------------------------------------------------------------------
function hexToHsl(hex: string): string {
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0;
let s = 0;
const l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
case g: h = ((b - r) / d + 2) / 6; break;
case b: h = ((r - g) / d + 4) / 6; break;
}
}
return `${Math.round(h * 3600) / 10} ${Math.round(s * 1000) / 10}% ${Math.round(l * 1000) / 10}%`;
}
function adjustLightness(hsl: string, delta: number): string {
const parts = hsl.split(/\s+/);
const newL = Math.max(0, Math.min(100, parseFloat(parts[2]) + delta));
return `${parts[0]} ${parts[1]} ${Math.round(newL * 10) / 10}%`;
}
function adjustSaturation(hsl: string, factor: number): string {
const parts = hsl.split(/\s+/);
const newS = Math.max(0, Math.min(100, parseFloat(parts[1]) * factor));
return `${parts[0]} ${Math.round(newS * 10) / 10}% ${parts[2]}`;
}
// ---------------------------------------------------------------------------
// Build the CSS rule string from a TerminalTheme
// ---------------------------------------------------------------------------
const CSS_VARS = [
'background', 'foreground', 'card', 'card-foreground',
'popover', 'popover-foreground', 'primary', 'primary-foreground',
'secondary', 'secondary-foreground', 'muted', 'muted-foreground',
'accent', 'accent-foreground', 'destructive', 'destructive-foreground',
'border', 'input', 'ring',
] as const;
function buildImmersiveCss(theme: TerminalTheme): string {
const bg = hexToHsl(theme.colors.background);
const fg = hexToHsl(theme.colors.foreground);
const cursor = hexToHsl(theme.colors.cursor);
const isDark = theme.type === 'dark';
const card = adjustLightness(bg, isDark ? 4 : -3);
const secondary = adjustLightness(bg, isDark ? 6 : -5);
const muted = adjustLightness(bg, isDark ? 10 : -8);
const mutedFg = adjustSaturation(adjustLightness(fg, isDark ? -20 : 20), 0.5);
const border = adjustLightness(bg, isDark ? 12 : -10);
const cursorL = parseFloat(cursor.split(' ')[2] ?? '50');
const primaryFg = cursorL > 55 ? '0 0% 0%' : '0 0% 100%';
const values = [
bg, fg, card, fg, // background, foreground, card, card-foreground
card, fg, // popover, popover-foreground
cursor, primaryFg, // primary, primary-foreground
secondary, fg, // secondary, secondary-foreground
muted, mutedFg, // muted, muted-foreground
cursor, primaryFg, // accent, accent-foreground
'0 70% 50%', '0 0% 100%', // destructive, destructive-foreground
border, border, cursor, // border, input, ring
];
const rules = CSS_VARS.map((name, i) => `--${name}: ${values[i]} !important`).join('; ');
return `:root { ${rules}; }`;
}
// ---------------------------------------------------------------------------
// Pre-compute CSS for all built-in themes at module load — O(1) lookup at switch time
// ---------------------------------------------------------------------------
const cssCache = new Map<string, string>();
// Fingerprint: id + type + 3 key colors (detects in-place edits including dark↔light)
function themeFingerprint(t: TerminalTheme): string {
return `${t.id}\0${t.type}\0${t.colors.background}\0${t.colors.foreground}\0${t.colors.cursor}`;
}
// Pre-compute built-in themes
for (const theme of TERMINAL_THEMES) {
cssCache.set(themeFingerprint(theme), buildImmersiveCss(theme));
}
/** Get (or lazily compute & cache) the immersive CSS for a theme. */
function getImmersiveCss(theme: TerminalTheme): string {
const fp = themeFingerprint(theme);
let css = cssCache.get(fp);
if (!css) {
css = buildImmersiveCss(theme);
cssCache.set(fp, css);
}
return css;
}
// ---------------------------------------------------------------------------
// Style tag management
// ---------------------------------------------------------------------------
const STYLE_ID = 'netcatty-immersive-override';
function applyImmersiveStyle(css: string, isDark: boolean, bg: string) {
const root = document.documentElement;
const targetClass = isDark ? 'dark' : 'light';
if (!root.classList.contains(targetClass)) {
root.classList.remove('light', 'dark');
root.classList.add(targetClass);
}
let style = document.getElementById(STYLE_ID) as HTMLStyleElement | null;
if (!style) {
style = document.createElement('style');
style.id = STYLE_ID;
document.head.appendChild(style);
}
style.textContent = css;
// Sync native Electron window chrome
netcattyBridge.get()?.setTheme?.(isDark ? 'dark' : 'light');
netcattyBridge.get()?.setBackgroundColor?.(bg);
}
function removeImmersiveStyle() {
document.getElementById(STYLE_ID)?.remove();
delete document.documentElement.dataset.immersiveTheme;
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
export function useImmersiveMode({
activeTabId,
activeTerminalTheme,
restoreOriginalTheme,
}: {
activeTabId: string;
activeTerminalTheme: TerminalTheme | null;
restoreOriginalTheme: () => void;
}) {
const overrideActiveRef = useRef(false);
const appliedFpRef = useRef<string | null>(null);
const restoreRef = useRef(restoreOriginalTheme);
restoreRef.current = restoreOriginalTheme;
const isTerminalTab = activeTabId !== 'vault' && activeTabId !== 'sftp' && !activeTabId.startsWith('log-');
// APPLY: useLayoutEffect — runs before paint, O(1) Map lookup, single DOM write
useLayoutEffect(() => {
if (isTerminalTab && activeTerminalTheme) {
const fp = themeFingerprint(activeTerminalTheme);
if (appliedFpRef.current === fp) return;
overrideActiveRef.current = true;
appliedFpRef.current = fp;
applyImmersiveStyle(getImmersiveCss(activeTerminalTheme), activeTerminalTheme.type === 'dark', activeTerminalTheme.colors.background);
document.documentElement.dataset.immersiveTheme = fp;
}
}, [isTerminalTab, activeTerminalTheme]);
// RESTORE: useEffect — runs after paint, with fade overlay
useEffect(() => {
if (isTerminalTab && activeTerminalTheme) return;
if (!overrideActiveRef.current) return;
overrideActiveRef.current = false;
appliedFpRef.current = null;
const bg = getComputedStyle(document.documentElement).getPropertyValue('--background').trim();
const overlay = document.createElement('div');
overlay.className = 'immersive-fade-overlay';
overlay.style.backgroundColor = `hsl(${bg})`;
document.body.appendChild(overlay);
removeImmersiveStyle();
restoreOriginalTheme();
requestAnimationFrame(() => {
overlay.classList.add('fade-out');
overlay.addEventListener('transitionend', () => overlay.remove(), { once: true });
});
const fallback = setTimeout(() => { if (overlay.parentNode) overlay.remove(); }, 400);
return () => { clearTimeout(fallback); if (overlay.parentNode) overlay.remove(); };
}, [isTerminalTab, activeTerminalTheme, restoreOriginalTheme]);
// Cleanup on unmount
useEffect(() => {
return () => {
removeImmersiveStyle();
appliedFpRef.current = null;
if (overrideActiveRef.current) {
overrideActiveRef.current = false;
restoreRef.current();
}
};
}, []);
}

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Host, Identity, PortForwardingRule, SSHKey } from "../../domain/models";
import { getNextVaultOrder, normalizeVaultOrder, reorderVaultItems, sortByVaultOrder, type VaultOrderPosition } from "../../domain/vaultOrder";
import {
STORAGE_KEY_PF_PREFER_FORM_MODE,
STORAGE_KEY_PF_VIEW_MODE,
@@ -30,7 +31,7 @@ let heartbeatIntervalId: ReturnType<typeof setInterval> | undefined;
export type { ViewMode };
export type SortMode = "az" | "za" | "newest" | "oldest";
export type SortMode = "manual" | "az" | "za" | "newest" | "oldest";
export interface UsePortForwardingStateResult {
rules: PortForwardingRule[];
@@ -52,6 +53,7 @@ export interface UsePortForwardingStateResult {
updateRule: (id: string, updates: Partial<PortForwardingRule>) => void;
deleteRule: (id: string) => void;
duplicateRule: (id: string) => void;
reorderRule: (sourceId: string, targetId: string, position: VaultOrderPosition) => void;
importRules: (rules: PortForwardingRule[]) => void;
setRuleStatus: (
@@ -90,9 +92,9 @@ const notifyListeners = () => {
};
const setGlobalRules = (newRules: PortForwardingRule[]) => {
globalRules = newRules;
globalRules = normalizeVaultOrder(newRules);
notifyListeners();
localStorageAdapter.write(STORAGE_KEY_PORT_FORWARDING, newRules);
localStorageAdapter.write(STORAGE_KEY_PORT_FORWARDING, globalRules);
};
const normalizeRulesWithConnections = (rules: PortForwardingRule[]): PortForwardingRule[] => {
@@ -136,7 +138,7 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
STORAGE_KEY_PF_VIEW_MODE,
"grid",
);
const [sortMode, setSortMode] = useState<SortMode>("newest");
const [sortMode, setSortMode] = useState<SortMode>("manual");
const [search, setSearch] = useState("");
const [preferFormMode, setPreferFormModeState] = useState<boolean>(() => {
return localStorageAdapter.readBoolean(STORAGE_KEY_PF_PREFER_FORM_MODE) ?? false;
@@ -249,6 +251,7 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
id: crypto.randomUUID(),
createdAt: Date.now(),
status: "inactive",
order: getNextVaultOrder(globalRules),
};
const updated = [...globalRules, newRule];
setGlobalRules(updated);
@@ -294,6 +297,7 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
status: "inactive",
error: undefined,
lastUsedAt: undefined,
order: getNextVaultOrder(globalRules),
};
const updated = [...globalRules, copy];
setGlobalRules(updated);
@@ -302,6 +306,14 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
[],
);
const reorderRule = useCallback(
(sourceId: string, targetId: string, position: VaultOrderPosition) => {
setGlobalRules(reorderVaultItems(globalRules, sourceId, targetId, position));
setSortMode("manual");
},
[],
);
const importRules = useCallback((newRules: PortForwardingRule[]) => {
// When clearing all rules (e.g. "Clear local data"), stop ALL tunnels
// and broadcast per-rule reconnect cancellation. stopAllPortForwards
@@ -444,6 +456,9 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
case "oldest":
result.sort((a, b) => a.createdAt - b.createdAt);
break;
case "manual":
result = sortByVaultOrder(result);
break;
}
return result;
@@ -469,6 +484,7 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
updateRule,
deleteRule,
duplicateRule,
reorderRule,
importRules,
setRuleStatus,

View File

@@ -0,0 +1,174 @@
import { useCallback, useRef, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import {
mergeRemoteHistory,
parseBashHistory,
parseFishHistory,
parseZshHistory,
} from '../../domain/remoteHistory';
import type { RemoteHistoryEntry } from '../../domain/models';
export interface RemoteHistoryHostState {
entries: RemoteHistoryEntry[];
loading: boolean;
error: string | null;
fetchedAt: number | null;
}
const EMPTY_STATE: RemoteHistoryHostState = {
entries: [],
loading: false,
error: null,
fetchedAt: null,
};
const PENDING_RETRY_MS = 1500;
const PENDING_MAX_RETRIES = 12;
export interface UseRemoteHistoryState {
getState: (
hostId: string | null | undefined,
sessionId?: string | null,
) => RemoteHistoryHostState;
fetch: (sessionId: string, hostId: string) => Promise<void>;
clear: (hostId: string, sessionId?: string | null) => void;
}
function cacheKey(hostId: string, sessionId: string): string {
return `${hostId}\0${sessionId}`;
}
/**
* Owns per-session remote shell history state. Fetches the remote host's shell
* history via the SSH bridge — which detects the login shell and returns only
* the matching file(s) — parses and de-dupes them, and keeps an in-memory
* cache keyed by (hostId, sessionId). The cache is intentionally not persisted
* — history files can contain sensitive content.
*/
export function useRemoteHistoryState(): UseRemoteHistoryState {
const [byKey, setByKey] = useState<Record<string, RemoteHistoryHostState>>({});
const requestIdByKey = useRef<Record<string, number>>({});
const getState = useCallback(
(
hostId: string | null | undefined,
sessionId?: string | null,
): RemoteHistoryHostState => {
if (!hostId || !sessionId) return EMPTY_STATE;
return byKey[cacheKey(hostId, sessionId)] ?? EMPTY_STATE;
},
[byKey],
);
const fetch = useCallback(async (sessionId: string, hostId: string) => {
if (!sessionId || !hostId) return;
const key = cacheKey(hostId, sessionId);
const bridge = netcattyBridge.get();
if (!bridge?.readRemoteHistory) {
setByKey((prev) => ({
...prev,
[key]: {
entries: prev[key]?.entries ?? [],
loading: false,
error: 'Remote history is not available in this build.',
fetchedAt: prev[key]?.fetchedAt ?? null,
},
}));
return;
}
const reqId = (requestIdByKey.current[key] ?? 0) + 1;
requestIdByKey.current[key] = reqId;
setByKey((prev) => ({
...prev,
[key]: {
entries: prev[key]?.entries ?? [],
loading: true,
error: null,
fetchedAt: prev[key]?.fetchedAt ?? null,
},
}));
const isStale = () => requestIdByKey.current[key] !== reqId;
try {
for (let attempt = 0; attempt <= PENDING_MAX_RETRIES; attempt += 1) {
const result = await bridge.readRemoteHistory(sessionId, 1000);
if (isStale()) return;
if (!result?.success) {
if (result?.pending && attempt < PENDING_MAX_RETRIES) {
await new Promise((resolve) => {
window.setTimeout(resolve, PENDING_RETRY_MS);
});
if (isStale()) return;
continue;
}
setByKey((prev) => ({
...prev,
[key]: {
entries: prev[key]?.entries ?? [],
loading: false,
error: result?.pending
? 'Remote history is not ready yet. Try again shortly.'
: (result?.error || 'Failed to read remote history'),
fetchedAt: prev[key]?.fetchedAt ?? null,
},
}));
return;
}
const lists: RemoteHistoryEntry[][] = [];
if (result.shell === 'bash') {
lists.push(parseBashHistory(result.bash ?? ''));
} else if (result.shell === 'zsh') {
lists.push(parseZshHistory(result.zsh ?? ''));
} else if (result.shell === 'fish') {
lists.push(parseFishHistory(result.fish ?? ''));
} else {
lists.push(parseBashHistory(result.bash ?? ''));
lists.push(parseZshHistory(result.zsh ?? ''));
lists.push(parseFishHistory(result.fish ?? ''));
}
const merged = mergeRemoteHistory(lists);
setByKey((prev) => ({
...prev,
[key]: {
entries: merged,
loading: false,
error: null,
fetchedAt: Date.now(),
},
}));
return;
}
} catch (err) {
if (isStale()) return;
setByKey((prev) => ({
...prev,
[key]: {
entries: prev[key]?.entries ?? [],
loading: false,
error: err instanceof Error ? err.message : String(err),
fetchedAt: prev[key]?.fetchedAt ?? null,
},
}));
}
}, []);
const clear = useCallback((hostId: string, sessionId?: string | null) => {
const key = sessionId ? cacheKey(hostId, sessionId) : hostId;
requestIdByKey.current[key] = (requestIdByKey.current[key] ?? 0) + 1;
setByKey((prev) => {
if (!(key in prev)) return prev;
const next = { ...prev };
delete next[key];
return next;
});
}, []);
return { getState, fetch, clear };
}

View File

@@ -16,7 +16,14 @@ SplitDirection,
SplitHint,
updateWorkspaceSplitSizes,
} from '../../domain/workspace';
import { clearSessionFontSizeOverride as clearSessionFontSizeOverrideFields } from '../../domain/terminalAppearance';
import { buildOrderedWorkTabIds, reorderWorkTabIds } from '../app/workTabSurface';
import { activeTabStore } from './activeTabStore';
import {
closeSessionWorkspaceLayoutState,
detachSessionFromWorkspaceState,
replaceDissolvedWorkspaceTabOrder,
} from './sessionWorkspaceDetach';
import {
createCopiedTerminalSessionClone,
createSplitTerminalSessionClone,
@@ -71,6 +78,18 @@ export const useSessionState = () => {
setSessions(prev => prev.map(s => s.id === sessionId ? { ...s, status } : s));
}, []);
const updateSessionFontSize = useCallback((sessionId: string, fontSize: number) => {
setSessions(prev => prev.map(s => (
s.id === sessionId ? { ...s, fontSize, fontSizeOverride: true } : s
)));
}, []);
const clearSessionFontSizeOverride = useCallback((sessionId: string) => {
setSessions(prev => prev.map(s => (
s.id === sessionId ? clearSessionFontSizeOverrideFields(s) : s
)));
}, []);
const closeWorkspace = useCallback((workspaceId: string) => {
setWorkspaces(prevWorkspaces => {
const remainingWorkspaces = prevWorkspaces.filter(w => w.id !== workspaceId);
@@ -108,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];
@@ -148,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) {
@@ -191,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;
});
@@ -857,63 +882,83 @@ export const useSessionState = () => {
return broadcastWorkspaceIds.has(workspaceId);
}, [broadcastWorkspaceIds]);
// Get ordered tabs: combines orphan sessions, workspaces, and log views in the custom order
const orderedTabs = useMemo(() => {
const allTabIds = [
...orphanSessions.map(s => s.id),
...workspaces.map(w => w.id),
...logViews.map(lv => lv.id),
];
const allTabIdSet = new Set(allTabIds);
// Filter tabOrder to only include existing tabs, then add any new tabs at the end
const orderedIds = tabOrder.filter(id => allTabIdSet.has(id));
const orderedIdSet = new Set(orderedIds);
const newIds = allTabIds.filter(id => !orderedIdSet.has(id));
return [...orderedIds, ...newIds];
}, [orphanSessions, workspaces, logViews, tabOrder]);
const baseWorkTabIds = useMemo(() => [
...orphanSessions.map(s => s.id),
...workspaces.map(w => w.id),
...logViews.map(lv => lv.id),
], [orphanSessions, workspaces, logViews]);
const reorderTabs = useCallback((draggedId: string, targetId: string, position: 'before' | 'after' = 'before') => {
const getOrderedWorkTabs = useCallback((additionalTabIds: readonly string[] = []) => {
const allTabIds = [...baseWorkTabIds, ...additionalTabIds];
return buildOrderedWorkTabIds(tabOrder, allTabIds);
}, [baseWorkTabIds, tabOrder]);
// Get ordered tabs: combines orphan sessions, workspaces, and log views in the custom order
const orderedTabs = useMemo(
() => getOrderedWorkTabs(),
[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,
position: 'before' | 'after' = 'before',
additionalTabIds: readonly string[] = [],
) => {
if (draggedId === targetId) return;
setTabOrder(prevTabOrder => {
// Get all current tab IDs (orphan sessions + workspaces + log views)
const allTabIds = [
...orphanSessions.map(s => s.id),
...workspaces.map(w => w.id),
...logViews.map(lv => lv.id),
];
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;
});
}, [orphanSessions, workspaces, logViews]);
setTabOrder(prevTabOrder => reorderWorkTabIds(
prevTabOrder,
[...baseWorkTabIds, ...additionalTabIds],
draggedId,
targetId,
position,
));
}, [baseWorkTabIds]);
return {
sessions,
@@ -926,6 +971,7 @@ export const useSessionState = () => {
sessionRenameValue,
setSessionRenameValue,
startSessionRename,
renameSessionInline,
submitSessionRename,
resetSessionRename,
workspaceRenameTarget,
@@ -940,10 +986,13 @@ export const useSessionState = () => {
closeSession,
closeWorkspace,
updateSessionStatus,
updateSessionFontSize,
clearSessionFontSizeOverride,
createWorkspaceWithHosts,
createWorkspaceFromTargets,
createWorkspaceFromSessions,
addSessionToWorkspace,
removeSessionFromWorkspace,
appendHostToWorkspace,
appendLocalTerminalToWorkspace,
updateSplitSizes,
@@ -958,6 +1007,7 @@ export const useSessionState = () => {
toggleBroadcast,
isBroadcastEnabled,
orderedTabs,
getOrderedWorkTabs,
reorderTabs,
// Log views
logViews,

View File

@@ -1,4 +1,6 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
import { runThemeTransition } from './themeTransition';
import { SyncConfig, TerminalSettings, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat, normalizeTerminalSettings } from '../../domain/models';
import {
STORAGE_KEY_COLOR,
@@ -43,6 +45,9 @@ import {
STORAGE_KEY_SHOW_RECENT_HOSTS,
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
} from '../../infrastructure/config/storageKeys';
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
import {
@@ -83,6 +88,9 @@ import {
DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
DEFAULT_SHOW_RECENT_HOSTS,
DEFAULT_SHOW_SFTP_TAB,
DEFAULT_SHOW_HOST_TREE_SIDEBAR,
DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
DEFAULT_DISABLE_TERMINAL_FONT_ZOOM,
DEFAULT_SSH_DEBUG_LOGS_ENABLED,
DEFAULT_TERMINAL_THEME,
DEFAULT_THEME,
@@ -104,6 +112,7 @@ import { useSettingsStorageSync } from './settingsStorageSync';
import { useSettingsIpcSync } from './settingsIpcSync';
import { resolveCurrentTerminalTheme } from './settingsTerminalTheme';
import { useSystemSettingsEffects } from './systemSettingsEffects';
import { applyCustomCssToDocument } from '../../lib/customCss';
export const useSettingsState = () => {
const initialCustomKeyBindingsRecord =
@@ -229,6 +238,18 @@ export const useSettingsState = () => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
return stored ?? DEFAULT_SHOW_SFTP_TAB;
});
const [showHostTreeSidebar, setShowHostTreeSidebarState] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
return stored ?? DEFAULT_SHOW_HOST_TREE_SIDEBAR;
});
const [shellOnlyTabNumberShortcuts, setShellOnlyTabNumberShortcutsState] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
return stored ?? DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS;
});
const [disableTerminalFontZoom, setDisableTerminalFontZoomState] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM);
return stored ?? DEFAULT_DISABLE_TERMINAL_FONT_ZOOM;
});
const [sftpTransferConcurrency, setSftpTransferConcurrencyState] = useState<number>(() => {
const stored = localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY);
return stored != null && stored >= 1 && stored <= 16 ? stored : 4;
@@ -328,7 +349,14 @@ export const useSettingsState = () => {
const mergeIncomingTerminalSettings = useCallback((incoming: Partial<TerminalSettings>) => {
setTerminalSettingsState((prev) => {
const next = normalizeTerminalSettings({ ...prev, ...incoming });
const merged: Partial<TerminalSettings> = { ...prev, ...incoming };
if (
!Object.prototype.hasOwnProperty.call(incoming, 'middleClickBehavior') &&
Object.prototype.hasOwnProperty.call(incoming, 'middleClickPaste')
) {
delete merged.middleClickBehavior;
}
const next = normalizeTerminalSettings(merged);
if (areTerminalSettingsEqual(prev, next)) {
return prev;
}
@@ -441,7 +469,9 @@ export const useSettingsState = () => {
const effective = nextTheme === 'system' ? getSystemPreference() : nextTheme;
const tokens = getUiThemeById(effective, effective === 'dark' ? nextDarkId : nextLightId).tokens;
applyThemeTokens(nextTheme, effective, tokens, nextAccentMode, nextAccent);
runThemeTransition(() => {
applyThemeTokens(nextTheme, effective, tokens, nextAccentMode, nextAccent);
});
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
const syncCustomCssFromStorage = useCallback(() => {
@@ -523,6 +553,12 @@ export const useSettingsState = () => {
setShowOnlyUngroupedHostsInRootState(storedShowOnlyUngroupedHostsInRoot ?? DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT);
const storedShowSftpTab = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
setShowSftpTabState(storedShowSftpTab ?? DEFAULT_SHOW_SFTP_TAB);
const storedShowHostTreeSidebar = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
setShowHostTreeSidebarState(storedShowHostTreeSidebar ?? DEFAULT_SHOW_HOST_TREE_SIDEBAR);
const storedShellOnlyTabNumberShortcuts = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
setShellOnlyTabNumberShortcutsState(storedShellOnlyTabNumberShortcuts ?? DEFAULT_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
const storedDisableTerminalFontZoom = localStorageAdapter.readBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM);
setDisableTerminalFontZoomState(storedDisableTerminalFontZoom ?? DEFAULT_DISABLE_TERMINAL_FONT_ZOOM);
// Workspace focus style
const storedFocusStyle = readStoredString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
@@ -534,7 +570,12 @@ export const useSettingsState = () => {
useLayoutEffect(() => {
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
const apply = () => applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
if (persistMountedRef.current) {
runThemeTransition(apply);
} else {
apply();
}
localStorageAdapter.writeString(STORAGE_KEY_THEME, theme);
localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_LIGHT, lightUiThemeId);
localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId);
@@ -608,6 +649,8 @@ export const useSettingsState = () => {
setSftpFollowTerminalCwd,
setSftpDefaultViewMode,
setWorkspaceFocusStyleState,
setShowHostTreeSidebarState,
setDisableTerminalFontZoomState,
setSftpTransferConcurrencyState,
});
@@ -634,7 +677,7 @@ export const useSettingsState = () => {
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
@@ -643,7 +686,7 @@ export const useSettingsState = () => {
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setShellOnlyTabNumberShortcutsState, setDisableTerminalFontZoomState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled,
setGlobalHotkeyEnabled, setWindowOpacity, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
@@ -750,16 +793,30 @@ export const useSettingsState = () => {
notifySettingsChanged(STORAGE_KEY_SHOW_SFTP_TAB, enabled);
}, [notifySettingsChanged]);
const setShowHostTreeSidebar = useCallback((enabled: boolean) => {
setShowHostTreeSidebarState(enabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, enabled);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, enabled);
}, [notifySettingsChanged]);
const setShellOnlyTabNumberShortcuts = useCallback((enabled: boolean) => {
setShellOnlyTabNumberShortcutsState(enabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS, enabled);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS, enabled);
}, [notifySettingsChanged]);
const setDisableTerminalFontZoom = useCallback((enabled: boolean) => {
setDisableTerminalFontZoomState(enabled);
localStorageAdapter.writeBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM, enabled);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM, enabled);
}, [notifySettingsChanged]);
// Apply and persist custom CSS
useEffect(() => {
// Always apply CSS to document (needed on mount)
let styleEl = document.getElementById('netcatty-custom-css') as HTMLStyleElement | null;
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = 'netcatty-custom-css';
document.head.appendChild(styleEl);
}
styleEl.textContent = customCSS;
applyCustomCssToDocument(customCSS);
localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_CSS, customCSS);
// Skip IPC on initial mount
if (!persistMountedRef.current) return;
@@ -923,8 +980,7 @@ export const useSettingsState = () => {
setTerminalSettings(prev => ({ ...prev, [key]: value }));
}, [setTerminalSettings]);
/** Re-apply the current UI theme tokens (used to restore after immersive mode override). */
const reapplyCurrentTheme = useCallback(() => {
const applyAppTheme = useCallback(() => {
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
@@ -994,6 +1050,12 @@ export const useSettingsState = () => {
setShowOnlyUngroupedHostsInRoot,
showSftpTab,
setShowSftpTab,
showHostTreeSidebar,
setShowHostTreeSidebar,
shellOnlyTabNumberShortcuts,
setShellOnlyTabNumberShortcuts,
disableTerminalFontZoom,
setDisableTerminalFontZoom,
sftpTransferConcurrency,
setSftpTransferConcurrency,
// Editor Settings
@@ -1027,7 +1089,7 @@ export const useSettingsState = () => {
windowOpacity,
setWindowOpacity,
rehydrateAllFromStorage,
reapplyCurrentTheme,
applyAppTheme,
workspaceFocusStyle,
setWorkspaceFocusStyle,
// Opaque version that changes when any synced setting changes, used by useAutoSync.
@@ -1038,7 +1100,7 @@ export const useSettingsState = () => {
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
customKeyBindings, editorWordWrap,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, shellOnlyTabNumberShortcuts, disableTerminalFontZoom,
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
]),
};

View File

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

View File

@@ -0,0 +1,199 @@
import { useCallback, useMemo } from 'react';
import type { DockerContainerAction, DockerImageManageAction, TmuxManageAction } from '../../domain/systemManager/types';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function useSystemManagerBackend() {
const probeSystemCapabilities = useCallback(async (sessionId: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.probeSystemCapabilities) {
return { success: false as const, error: 'probeSystemCapabilities unavailable' };
}
return bridge.probeSystemCapabilities(sessionId);
}, []);
const listSystemProcesses = useCallback(async (sessionId: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.listSystemProcesses) {
return { success: false as const, error: 'listSystemProcesses unavailable' };
}
return bridge.listSystemProcesses(sessionId);
}, []);
const signalSystemProcess = useCallback(async (options: {
sessionId: string;
pid: number;
signal?: string;
nice?: number;
}) => {
const bridge = netcattyBridge.get();
if (!bridge?.signalSystemProcess) {
return { success: false as const, error: 'signalSystemProcess unavailable' };
}
return bridge.signalSystemProcess(options);
}, []);
const listTmuxSessions = useCallback(async (sessionId: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.listTmuxSessions) {
return { success: false as const, error: 'listTmuxSessions unavailable' };
}
return bridge.listTmuxSessions(sessionId);
}, []);
const createTmuxSession = useCallback(async (options: {
sessionId: string;
name: string;
command?: string;
}) => {
const bridge = netcattyBridge.get();
if (!bridge?.createTmuxSession) {
return { success: false as const, error: 'createTmuxSession unavailable' };
}
return bridge.createTmuxSession(options);
}, []);
const listTmuxWindows = useCallback(async (options: { sessionId: string; sessionName: string }) => {
const bridge = netcattyBridge.get();
if (!bridge?.listTmuxWindows) {
return { success: false as const, error: 'listTmuxWindows unavailable' };
}
return bridge.listTmuxWindows(options);
}, []);
const listTmuxPanes = useCallback(async (options: {
sessionId: string;
sessionName: string;
windowIndex: number;
}) => {
const bridge = netcattyBridge.get();
if (!bridge?.listTmuxPanes) {
return { success: false as const, error: 'listTmuxPanes unavailable' };
}
return bridge.listTmuxPanes(options);
}, []);
const listTmuxClients = useCallback(async (options: { sessionId: string; sessionName?: string }) => {
const bridge = netcattyBridge.get();
if (!bridge?.listTmuxClients) {
return { success: false as const, error: 'listTmuxClients unavailable' };
}
return bridge.listTmuxClients(options);
}, []);
const tmuxAction = useCallback(async (options: { sessionId: string } & TmuxManageAction) => {
const bridge = netcattyBridge.get();
if (!bridge?.tmuxAction) {
return { success: false as const, error: 'tmuxAction unavailable' };
}
return bridge.tmuxAction(options);
}, []);
const listDockerContainers = useCallback(async (sessionId: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.listDockerContainers) {
return { success: false as const, error: 'listDockerContainers unavailable' };
}
return bridge.listDockerContainers(sessionId);
}, []);
const listDockerImages = useCallback(async (sessionId: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.listDockerImages) {
return { success: false as const, error: 'listDockerImages unavailable' };
}
return bridge.listDockerImages(sessionId);
}, []);
const getDockerStats = useCallback(async (options: { sessionId: string; ids?: string[] }) => {
const bridge = netcattyBridge.get();
if (!bridge?.getDockerStats) {
return { success: false as const, error: 'getDockerStats unavailable' };
}
return bridge.getDockerStats(options);
}, []);
const dockerInspect = useCallback(async (options: { sessionId: string; containerId: string }) => {
const bridge = netcattyBridge.get();
if (!bridge?.dockerInspect) {
return { success: false as const, error: 'dockerInspect unavailable' };
}
return bridge.dockerInspect(options);
}, []);
const dockerImageInspect = useCallback(async (options: { sessionId: string; imageId: string }) => {
const bridge = netcattyBridge.get();
if (!bridge?.dockerImageInspect) {
return { success: false as const, error: 'dockerImageInspect unavailable' };
}
return bridge.dockerImageInspect(options);
}, []);
const dockerAction = useCallback(async (options: {
sessionId: string;
containerId: string;
action: DockerContainerAction;
newName?: string;
}) => {
const bridge = netcattyBridge.get();
if (!bridge?.dockerAction) {
return { success: false as const, error: 'dockerAction unavailable' };
}
return bridge.dockerAction(options);
}, []);
const dockerImageAction = useCallback(async (options: { sessionId: string } & DockerImageManageAction) => {
const bridge = netcattyBridge.get();
if (!bridge?.dockerImageAction) {
return { success: false as const, error: 'dockerImageAction unavailable' };
}
return bridge.dockerImageAction(options);
}, []);
const openTerminalPopup = useCallback(async (
payload: Parameters<NonNullable<NetcattyBridge['openTerminalPopup']>>[0],
) => {
const bridge = netcattyBridge.get();
if (!bridge?.openTerminalPopup) {
return { success: false as const, error: 'openTerminalPopup unavailable' };
}
return bridge.openTerminalPopup(payload);
}, []);
return useMemo(() => ({
probeSystemCapabilities,
listSystemProcesses,
signalSystemProcess,
listTmuxSessions,
createTmuxSession,
listTmuxWindows,
listTmuxPanes,
listTmuxClients,
tmuxAction,
listDockerContainers,
listDockerImages,
getDockerStats,
dockerInspect,
dockerImageInspect,
dockerAction,
dockerImageAction,
openTerminalPopup,
}), [
probeSystemCapabilities,
listSystemProcesses,
signalSystemProcess,
listTmuxSessions,
createTmuxSession,
listTmuxWindows,
listTmuxPanes,
listTmuxClients,
tmuxAction,
listDockerContainers,
listDockerImages,
getDockerStats,
dockerInspect,
dockerImageInspect,
dockerAction,
dockerImageAction,
openTerminalPopup,
]);
}

View File

@@ -132,6 +132,11 @@ export const useTerminalBackend = () => {
return bridge?.onConnectionReuseFallback?.(cb);
}, []);
const onWindowFullScreenChanged = useCallback((cb: (isFullscreen: boolean) => void) => {
const bridge = netcattyBridge.get();
return bridge?.onWindowFullScreenChanged?.(cb);
}, []);
const onHostKeyVerification = useCallback((cb: Parameters<NonNullable<NetcattyBridge["onHostKeyVerification"]>>[0]) => {
const bridge = netcattyBridge.get();
return bridge?.onHostKeyVerification?.(cb);
@@ -170,10 +175,88 @@ export const useTerminalBackend = () => {
return bridge.listSerialPorts();
}, []);
const getSessionPwd = useCallback(async (sessionId: string) => {
const serialYmodemAvailable = useCallback(() => {
const bridge = netcattyBridge.get();
return !!bridge?.sendSerialYmodem;
}, []);
const serialYmodemReceiveAvailable = useCallback(() => {
const bridge = netcattyBridge.get();
return !!bridge?.receiveSerialYmodem;
}, []);
const selectFileAvailable = useCallback(() => {
const bridge = netcattyBridge.get();
return !!bridge?.selectFile;
}, []);
const selectDirectoryAvailable = useCallback(() => {
const bridge = netcattyBridge.get();
return !!bridge?.selectDirectory;
}, []);
const sendSerialYmodem = useCallback(async (sessionId: string, filePath: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.sendSerialYmodem) return { success: false, error: 'sendSerialYmodem unavailable' };
return bridge.sendSerialYmodem(sessionId, filePath);
}, []);
const receiveSerialYmodem = useCallback(async (sessionId: string, destinationDir: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.receiveSerialYmodem) return { success: false, error: 'receiveSerialYmodem unavailable' };
return bridge.receiveSerialYmodem(sessionId, destinationDir);
}, []);
const selectFile = useCallback(async (
title?: string,
defaultPath?: string,
filters?: Array<{ name: string; extensions: string[] }>,
) => {
const bridge = netcattyBridge.get();
if (!bridge?.selectFile) return null;
return bridge.selectFile(title, defaultPath, filters);
}, []);
const selectDirectory = useCallback(async (title?: string, defaultPath?: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.selectDirectory) return null;
return bridge.selectDirectory(title, defaultPath);
}, []);
const startZmodemDragDropUpload = useCallback(async (
sessionId: string,
files: Array<{
path?: string;
name: string;
remoteName: string;
data?: ArrayBuffer;
}>,
uploadCommand?: string,
) => {
const bridge = netcattyBridge.get();
if (!bridge?.startZmodemDragDropUpload) {
return { success: false, error: "startZmodemDragDropUpload unavailable" };
}
return bridge.startZmodemDragDropUpload(sessionId, files, uploadCommand);
}, []);
const cancelZmodem = useCallback((sessionId: string, options?: { interrupt?: boolean }) => {
const bridge = netcattyBridge.get();
bridge?.cancelZmodem?.(sessionId, options);
}, []);
const onZmodemEvent = useCallback((
sessionId: string,
cb: Parameters<NonNullable<NetcattyBridge["onZmodemEvent"]>>[1],
) => {
const bridge = netcattyBridge.get();
return bridge?.onZmodemEvent?.(sessionId, cb) ?? (() => {});
}, []);
const getSessionPwd = useCallback(async (sessionId: string, options?: { allowHomeFallback?: boolean }) => {
const bridge = netcattyBridge.get();
if (!bridge?.getSessionPwd) return { success: false, error: 'getSessionPwd unavailable' };
return bridge.getSessionPwd(sessionId);
return bridge.getSessionPwd(sessionId, options);
}, []);
const getSessionRemoteInfo = useCallback(async (sessionId: string) => {
@@ -224,6 +307,17 @@ export const useTerminalBackend = () => {
startLocalSession,
startSerialSession,
listSerialPorts,
serialYmodemAvailable,
serialYmodemReceiveAvailable,
selectFileAvailable,
selectDirectoryAvailable,
sendSerialYmodem,
receiveSerialYmodem,
selectFile,
selectDirectory,
startZmodemDragDropUpload,
cancelZmodem,
onZmodemEvent,
execCommand,
getSessionPwd,
getSessionRemoteInfo,
@@ -240,6 +334,7 @@ export const useTerminalBackend = () => {
onTelnetAutoLoginCancelled,
onChainProgress,
onConnectionReuseFallback,
onWindowFullScreenChanged,
onHostKeyVerification,
respondHostKeyVerification,
openExternal,
@@ -260,6 +355,17 @@ export const useTerminalBackend = () => {
startLocalSession,
startSerialSession,
listSerialPorts,
serialYmodemAvailable,
serialYmodemReceiveAvailable,
selectFileAvailable,
selectDirectoryAvailable,
sendSerialYmodem,
receiveSerialYmodem,
selectFile,
selectDirectory,
startZmodemDragDropUpload,
cancelZmodem,
onZmodemEvent,
execCommand,
getSessionPwd,
getSessionRemoteInfo,
@@ -276,6 +382,7 @@ export const useTerminalBackend = () => {
onTelnetAutoLoginCancelled,
onChainProgress,
onConnectionReuseFallback,
onWindowFullScreenChanged,
onHostKeyVerification,
respondHostKeyVerification,
openExternal,

View File

@@ -0,0 +1,21 @@
import { useCallback } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import type { TerminalPopupPayload } from '../../domain/systemManager/types';
export function useTerminalPopupWindow() {
const close = useCallback(async () => {
await netcattyBridge.get()?.windowClose?.();
}, []);
const setWindowTitle = useCallback(async (title: string) => {
await netcattyBridge.get()?.setWindowTitle?.(title);
}, []);
const onPopupConfig = useCallback((cb: (payload: TerminalPopupPayload) => void) => {
const bridge = netcattyBridge.get();
if (!bridge?.onTerminalPopupConfig) return () => {};
return bridge.onTerminalPopupConfig(cb);
}, []);
return { close, setWindowTitle, onPopupConfig };
}

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { normalizeDistroId, sanitizeHost } from "../../domain/host";
import { migrateHostsFromLegacyLineTimestamps, normalizeDistroId, sanitizeHost } from "../../domain/host";
import { sanitizeGroupConfig } from "../../domain/groupConfig";
import { normalizeKnownHosts } from "../../domain/knownHosts";
import {
@@ -33,8 +33,12 @@ import {
STORAGE_KEY_SHELL_HISTORY,
STORAGE_KEY_SNIPPET_PACKAGES,
STORAGE_KEY_SNIPPETS,
STORAGE_KEY_TERM_SETTINGS,
} from "../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
import { mergeGlobalHistoryOnAppend, sanitizeGlobalHistoryEntries } from "../../domain/globalHistory";
import { getNextVaultOrder, normalizeVaultOrder } from "../../domain/vaultOrder";
import { loadSanitizedShellHistory } from "./shellHistoryPersistence";
import {
decryptGroupConfigs,
decryptHosts,
@@ -89,6 +93,7 @@ const migrateKey = (key: Partial<SSHKey>): SSHKey => {
((key.certificate ? "certificate" : "key") as KeyCategory),
created: key.created || Date.now(),
filePath: key.filePath,
order: key.order,
};
};
@@ -132,6 +137,11 @@ const pruneConnectionLogsForStorage = (logs: ConnectionLog[]): ConnectionLog[] =
return changed ? next : logs;
};
const readLegacyLineTimestampsEnabled = (): boolean => {
const stored = localStorageAdapter.read<Record<string, unknown>>(STORAGE_KEY_TERM_SETTINGS);
return stored?.showLineTimestamps === true;
};
export const useVaultState = () => {
const [isInitialized, setIsInitialized] = useState(false);
const [hosts, setHosts] = useState<Host[]>([]);
@@ -167,7 +177,7 @@ export const useVaultState = () => {
const groupConfigsReadSeq = useRef(0);
const updateHosts = useCallback((data: Host[]) => {
const cleaned = data.map(sanitizeHost);
const cleaned = normalizeVaultOrder(data.map(sanitizeHost));
setHosts(cleaned);
const ver = ++hostsWriteVersion.current;
return encryptHosts(cleaned).then((enc) => {
@@ -177,9 +187,10 @@ export const useVaultState = () => {
}, []);
const updateKeys = useCallback((data: SSHKey[]) => {
setKeys(data);
const cleaned = normalizeVaultOrder(data);
setKeys(cleaned);
const ver = ++keysWriteVersion.current;
return encryptKeys(data).then((enc) => {
return encryptKeys(cleaned).then((enc) => {
if (ver === keysWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
});
@@ -210,8 +221,9 @@ export const useVaultState = () => {
category: (draft.category || 'key') as KeyCategory,
created: Date.now(),
filePath: draft.filePath,
order: getNextVaultOrder(keys),
};
const updated = [...keys, newKey];
const updated = normalizeVaultOrder([...keys, newKey]);
setKeys(updated);
const ver = ++keysWriteVersion.current;
void encryptKeys(updated).then((enc) => {
@@ -222,26 +234,29 @@ export const useVaultState = () => {
}, [keys]);
const updateIdentities = useCallback((data: Identity[]) => {
setIdentities(data);
const cleaned = normalizeVaultOrder(data);
setIdentities(cleaned);
const ver = ++identitiesWriteVersion.current;
return encryptIdentities(data).then((enc) => {
return encryptIdentities(cleaned).then((enc) => {
if (ver === identitiesWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, enc);
});
}, []);
const updateProxyProfiles = useCallback((data: ProxyProfile[]) => {
setProxyProfiles(data);
const cleaned = normalizeVaultOrder(data);
setProxyProfiles(cleaned);
const ver = ++proxyProfilesWriteVersion.current;
return encryptProxyProfiles(data).then((enc) => {
return encryptProxyProfiles(cleaned).then((enc) => {
if (ver === proxyProfilesWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_PROXY_PROFILES, enc);
});
}, []);
const updateSnippets = useCallback((data: Snippet[]) => {
setSnippets(data);
localStorageAdapter.write(STORAGE_KEY_SNIPPETS, data);
const cleaned = normalizeVaultOrder(data);
setSnippets(cleaned);
localStorageAdapter.write(STORAGE_KEY_SNIPPETS, cleaned);
}, []);
const updateSnippetPackages = useCallback((data: string[]) => {
@@ -252,11 +267,39 @@ export const useVaultState = () => {
const updateCustomGroups = useCallback((data: string[]) => {
setCustomGroups(data);
localStorageAdapter.write(STORAGE_KEY_GROUPS, data);
}, []);
const groupOrderByPath = new Map<string, number>(
data.map((path, index) => [path, (index + 1) * 1000]),
);
const existingConfigByPath = new Map<string, GroupConfig>(
groupConfigs.map((config) => [config.path, config]),
);
const orderedConfigs = data.map((path) => {
const existing = existingConfigByPath.get(path);
const base: GroupConfig = existing ? { ...existing } : { path };
return sanitizeGroupConfig({
...base,
path,
order: groupOrderByPath.get(path),
});
});
const retainedConfigs = groupConfigs.filter((config) => !groupOrderByPath.has(config.path));
const cleanedGroupConfigs = normalizeVaultOrder([
...orderedConfigs,
...retainedConfigs.map(sanitizeGroupConfig),
]);
setGroupConfigs(cleanedGroupConfigs);
const ver = ++groupConfigsWriteVersion.current;
void encryptGroupConfigs(cleanedGroupConfigs).then((enc) => {
if (ver === groupConfigsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_GROUP_CONFIGS, enc);
});
}, [groupConfigs]);
const updateKnownHosts = useCallback((data: KnownHost[]) => {
setKnownHosts(data);
localStorageAdapter.write(STORAGE_KEY_KNOWN_HOSTS, data);
const cleaned = normalizeVaultOrder(data);
setKnownHosts(cleaned);
localStorageAdapter.write(STORAGE_KEY_KNOWN_HOSTS, cleaned);
}, []);
const updateManagedSources = useCallback((data: ManagedSource[]) => {
@@ -270,7 +313,7 @@ export const useVaultState = () => {
// pingfang-sc / comic-sans-ms override from an older client would
// sit in memory and re-persist with `fontFamilyOverride: true` until
// the next reload. Mirrors updateHosts → sanitizeHost.
const cleaned = data.map(sanitizeGroupConfig);
const cleaned = normalizeVaultOrder(data.map(sanitizeGroupConfig));
setGroupConfigs(cleaned);
const ver = ++groupConfigsWriteVersion.current;
return encryptGroupConfigs(cleaned).then((enc) => {
@@ -306,14 +349,9 @@ export const useVaultState = () => {
const addShellHistoryEntry = useCallback(
(entry: Omit<ShellHistoryEntry, "id" | "timestamp">) => {
const newEntry: ShellHistoryEntry = {
...entry,
id: crypto.randomUUID(),
timestamp: Date.now(),
};
setShellHistory((prev) => {
// Keep only the last 1000 entries
const updated = [newEntry, ...prev].slice(0, 1000);
const updated = mergeGlobalHistoryOnAppend(prev, entry);
if (updated === prev) return prev;
localStorageAdapter.write(STORAGE_KEY_SHELL_HISTORY, updated);
return updated;
});
@@ -400,6 +438,7 @@ export const useVaultState = () => {
group: "",
tags: [],
protocol: "ssh",
order: getNextVaultOrder(hosts),
};
// Update the known host to mark it as converted using functional update
@@ -413,7 +452,7 @@ export const useVaultState = () => {
// Add to hosts using functional update
setHosts((prevHosts) => {
const updated = [...prevHosts, sanitizeHost(newHost)];
const updated = normalizeVaultOrder([...prevHosts, sanitizeHost(newHost)]);
const ver = ++hostsWriteVersion.current;
encryptHosts(updated).then((enc) => {
if (ver === hostsWriteVersion.current)
@@ -423,7 +462,7 @@ export const useVaultState = () => {
});
return newHost;
}, []);
}, [hosts]);
useEffect(() => {
const init = async () => {
@@ -437,7 +476,12 @@ export const useVaultState = () => {
const ver = ++hostsWriteVersion.current;
const decrypted = await decryptHosts(savedHosts);
if (ver === hostsWriteVersion.current) {
const sanitized = decrypted.map(sanitizeHost);
const sanitized = normalizeVaultOrder(
migrateHostsFromLegacyLineTimestamps(
decrypted.map(sanitizeHost),
readLegacyLineTimestampsEnabled(),
),
);
setHosts(sanitized);
encryptHosts(sanitized).then((enc) => {
if (ver === hostsWriteVersion.current)
@@ -474,8 +518,9 @@ export const useVaultState = () => {
const keyVer = ++keysWriteVersion.current;
const decryptedKeys = await decryptKeys(migratedKeys);
if (keyVer === keysWriteVersion.current) {
setKeys(decryptedKeys);
encryptKeys(decryptedKeys).then((enc) => {
const orderedKeys = normalizeVaultOrder(decryptedKeys);
setKeys(orderedKeys);
encryptKeys(orderedKeys).then((enc) => {
if (keyVer === keysWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
});
@@ -493,8 +538,9 @@ export const useVaultState = () => {
const idVer = ++identitiesWriteVersion.current;
const decryptedIds = await decryptIdentities(savedIdentities);
if (idVer === identitiesWriteVersion.current) {
setIdentities(decryptedIds);
encryptIdentities(decryptedIds).then((enc) => {
const orderedIdentities = normalizeVaultOrder(decryptedIds);
setIdentities(orderedIdentities);
encryptIdentities(orderedIdentities).then((enc) => {
if (idVer === identitiesWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, enc);
});
@@ -507,8 +553,9 @@ export const useVaultState = () => {
const proxyVer = ++proxyProfilesWriteVersion.current;
const decryptedProfiles = await decryptProxyProfiles(savedProxyProfiles);
if (proxyVer === proxyProfilesWriteVersion.current) {
setProxyProfiles(decryptedProfiles);
encryptProxyProfiles(decryptedProfiles).then((enc) => {
const orderedProfiles = normalizeVaultOrder(decryptedProfiles);
setProxyProfiles(orderedProfiles);
encryptProxyProfiles(orderedProfiles).then((enc) => {
if (proxyVer === proxyProfilesWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_PROXY_PROFILES, enc);
});
@@ -523,7 +570,11 @@ export const useVaultState = () => {
STORAGE_KEY_SNIPPET_PACKAGES,
);
if (savedSnippets) setSnippets(savedSnippets);
if (savedSnippets) {
const orderedSnippets = normalizeVaultOrder(savedSnippets);
setSnippets(orderedSnippets);
localStorageAdapter.write(STORAGE_KEY_SNIPPETS, orderedSnippets);
}
else updateSnippets(INITIAL_SNIPPETS);
if (savedGroups) setCustomGroups(savedGroups);
@@ -540,17 +591,18 @@ export const useVaultState = () => {
);
if (savedKnownHosts) {
const normalized = normalizeKnownHosts(savedKnownHosts);
setKnownHosts(normalized);
if (normalized !== savedKnownHosts) {
localStorageAdapter.write(STORAGE_KEY_KNOWN_HOSTS, normalized);
const orderedKnownHosts = normalizeVaultOrder(normalized);
setKnownHosts(orderedKnownHosts);
if (normalized !== savedKnownHosts || orderedKnownHosts !== normalized) {
localStorageAdapter.write(STORAGE_KEY_KNOWN_HOSTS, orderedKnownHosts);
}
}
// Load shell history
const savedShellHistory = localStorageAdapter.read<ShellHistoryEntry[]>(
STORAGE_KEY_SHELL_HISTORY,
);
if (savedShellHistory) setShellHistory(savedShellHistory);
const savedShellHistory = loadSanitizedShellHistory();
if (savedShellHistory) {
setShellHistory(savedShellHistory);
}
// Load connection logs
const savedConnectionLogs = localStorageAdapter.read<ConnectionLog[]>(
@@ -570,7 +622,7 @@ export const useVaultState = () => {
const gcVer = ++groupConfigsWriteVersion.current;
const decryptedGC = await decryptGroupConfigs(savedGroupConfigs);
if (gcVer === groupConfigsWriteVersion.current) {
const sanitizedGC = decryptedGC.map(sanitizeGroupConfig);
const sanitizedGC = normalizeVaultOrder(decryptedGC.map(sanitizeGroupConfig));
setGroupConfigs(sanitizedGC);
encryptGroupConfigs(sanitizedGC).then((enc) => {
if (gcVer === groupConfigsWriteVersion.current)
@@ -605,7 +657,7 @@ export const useVaultState = () => {
// Discard if a newer storage event arrived OR a local write occurred
// during the decrypt (writeVersion would have advanced).
if (seq === hostsReadSeq.current && writeAtStart === hostsWriteVersion.current)
setHosts(dec.map(sanitizeHost));
setHosts(normalizeVaultOrder(dec.map(sanitizeHost)));
});
return;
}
@@ -624,7 +676,7 @@ export const useVaultState = () => {
const writeAtStart = keysWriteVersion.current;
decryptKeys(migratedKeys).then((dec) => {
if (seq === keysReadSeq.current && writeAtStart === keysWriteVersion.current)
setKeys(dec);
setKeys(normalizeVaultOrder(dec));
});
return;
}
@@ -636,7 +688,7 @@ export const useVaultState = () => {
const writeAtStart = identitiesWriteVersion.current;
decryptIdentities(next).then((dec) => {
if (seq === identitiesReadSeq.current && writeAtStart === identitiesWriteVersion.current)
setIdentities(dec);
setIdentities(normalizeVaultOrder(dec));
});
return;
}
@@ -648,14 +700,14 @@ export const useVaultState = () => {
const writeAtStart = proxyProfilesWriteVersion.current;
decryptProxyProfiles(next).then((dec) => {
if (seq === proxyProfilesReadSeq.current && writeAtStart === proxyProfilesWriteVersion.current)
setProxyProfiles(dec);
setProxyProfiles(normalizeVaultOrder(dec));
});
return;
}
if (key === STORAGE_KEY_SNIPPETS) {
const next = safeParse<Snippet[]>(event.newValue) ?? [];
setSnippets(next);
setSnippets(normalizeVaultOrder(next));
return;
}
@@ -673,12 +725,14 @@ export const useVaultState = () => {
if (key === STORAGE_KEY_KNOWN_HOSTS) {
const next = safeParse<KnownHost[]>(event.newValue) ?? [];
setKnownHosts(normalizeKnownHosts(next));
setKnownHosts(normalizeVaultOrder(normalizeKnownHosts(next)));
return;
}
if (key === STORAGE_KEY_SHELL_HISTORY) {
const next = safeParse<ShellHistoryEntry[]>(event.newValue) ?? [];
const next = sanitizeGlobalHistoryEntries(
safeParse<ShellHistoryEntry[]>(event.newValue) ?? [],
);
setShellHistory(next);
return;
}
@@ -702,7 +756,7 @@ export const useVaultState = () => {
const writeAtStart = groupConfigsWriteVersion.current;
decryptGroupConfigs(next).then((dec) => {
if (seq === groupConfigsReadSeq.current && writeAtStart === groupConfigsWriteVersion.current)
setGroupConfigs(dec.map(sanitizeGroupConfig));
setGroupConfigs(normalizeVaultOrder(dec.map(sanitizeGroupConfig)));
});
return;
}

View File

@@ -1,6 +1,16 @@
import { useCallback } from "react";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
export function subscribeWindowFullscreenChanged(
cb: (isFullscreen: boolean) => void,
): () => void {
try {
return netcattyBridge.get()?.onWindowFullScreenChanged?.(cb) ?? (() => {});
} catch {
return () => {};
}
}
export const useWindowControls = () => {
const notifyRendererReady = useCallback(() => {
try {
@@ -45,10 +55,7 @@ export const useWindowControls = () => {
return bridge?.windowIsFullscreen?.() ?? false;
}, []);
const onFullscreenChanged = useCallback((cb: (isFullscreen: boolean) => void) => {
const bridge = netcattyBridge.get();
return bridge?.onWindowFullScreenChanged?.(cb) ?? (() => {});
}, []);
const onFullscreenChanged = useCallback(subscribeWindowFullscreenChanged, []);
const onWindowCommandCloseRequested = useCallback((cb: () => void) => {
const bridge = netcattyBridge.get();

View File

@@ -1,17 +1,24 @@
import { useSyncExternalStore } from 'react';
import type { Host } from '../../types';
import type { VaultOrderPosition } from '../../domain/vaultOrder';
export interface VaultHostTreeActions {
onDeleteHost: (host: Host) => void;
onDuplicateHost: (host: Host) => void;
onCopyCredentials: (host: Host) => void;
onRenameHost: (host: Host) => void;
onNewGroup: (parentPath?: string) => void;
onRenameGroup: (groupPath: string) => void;
onDeleteGroup: (groupPath: string) => void;
commitInlineGroupRename: (name: string) => void;
cancelInlineGroupEdit: () => void;
commitInlineHostRename: (name: string) => void;
cancelInlineHostEdit: () => void;
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
moveGroup: (sourcePath: string, targetParent: string | null) => void;
reorderHost: (sourceHostId: string, targetHostId: string, position: VaultOrderPosition) => void;
reorderGroup: (sourcePath: string, targetPath: string, position: VaultOrderPosition) => boolean;
managedGroupPaths?: Set<string>;
onUnmanageGroup?: (groupPath: string) => void;
}

View File

@@ -44,6 +44,7 @@ const {
hasCloudSyncEntityData,
hasMeaningfulCloudSyncData,
shouldPromptCloudVaultRecovery,
SYNCABLE_SETTING_STORAGE_KEYS,
} = await import("./syncPayload.ts");
const storageKeys = await import("../infrastructure/config/storageKeys.ts");
@@ -124,6 +125,7 @@ test("buildSyncPayload includes AI configuration settings", () => {
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP, JSON.stringify({ codex: "gpt-test" }));
localStorage.setItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP, JSON.stringify({ catty: "openai-main" }));
localStorage.setItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH, JSON.stringify(webSearch));
localStorage.setItem(storageKeys.STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION, "false");
const payload = buildSyncPayload(vault([]));
@@ -140,9 +142,26 @@ test("buildSyncPayload includes AI configuration settings", () => {
agentModelMap: { codex: "gpt-test" },
agentProviderMap: { catty: "openai-main" },
webSearchConfig: webSearch,
showTerminalSelectionAction: false,
});
});
test("terminal selection AI preference is syncable for auto-sync detection", () => {
assert.ok(
(SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(
storageKeys.STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
),
);
});
test("buildSyncPayload includes host tree sidebar visibility setting", () => {
localStorage.setItem(storageKeys.STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, "false");
const payload = buildSyncPayload(vault([]));
assert.equal(payload.settings?.showHostTreeSidebar, false);
});
test("buildSyncPayload excludes externalAgents (device-local OS-bound config)", () => {
localStorage.setItem(storageKeys.STORAGE_KEY_AI_EXTERNAL_AGENTS, JSON.stringify([
{ id: "codex", name: "Codex", command: "/opt/homebrew/bin/codex", enabled: true },
@@ -207,6 +226,7 @@ test("applySyncPayload restores AI configuration settings", async () => {
agentModelMap: { claude: "claude-test" },
agentProviderMap: { catty: "anthropic-main" },
webSearchConfig: webSearch,
showTerminalSelectionAction: false,
},
},
syncedAt: 1,
@@ -226,6 +246,25 @@ test("applySyncPayload restores AI configuration settings", async () => {
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_AGENT_MODEL_MAP)!), { claude: "claude-test" });
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_AGENT_PROVIDER_MAP)!), { catty: "anthropic-main" });
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH)!), webSearch);
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION), "false");
});
test("applySyncPayload restores host tree sidebar visibility setting", async () => {
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
settings: {
showHostTreeSidebar: false,
},
syncedAt: 1,
} as SyncPayload;
await applySyncPayload(payload, { importVaultData: () => {} });
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR), "false");
});
test("applySyncPayload dispatches a same-window AI-state-changed event so the open chat panel rehydrates", async () => {
@@ -503,6 +542,7 @@ test("buildSyncPayload includes syncable terminal options from settings", () =>
localStorage.setItem(storageKeys.STORAGE_KEY_TERM_SETTINGS, JSON.stringify({
terminalEmulationType: "vt100",
altAsMeta: true,
middleClickBehavior: "context-menu",
showServerStats: false,
serverStatsRefreshInterval: 12,
rendererType: "dom",
@@ -515,6 +555,7 @@ test("buildSyncPayload includes syncable terminal options from settings", () =>
assert.deepEqual(payload.settings?.terminalSettings, {
terminalEmulationType: "vt100",
altAsMeta: true,
middleClickBehavior: "context-menu",
showServerStats: false,
serverStatsRefreshInterval: 12,
rendererType: "dom",
@@ -670,6 +711,49 @@ test("applySyncPayload preserves host proxy references when group configs are ab
assert.equal("groupConfigs" in imported, false);
});
test("applySyncPayload migrates legacy global line timestamps onto hosts", async () => {
let imported: Record<string, unknown> | null = null;
const payload: SyncPayload = {
hosts: [
{
id: "host-1",
label: "Inherited",
hostname: "example.com",
username: "root",
tags: [],
os: "linux",
},
{
id: "host-2",
label: "Explicit",
hostname: "example.net",
username: "root",
tags: [],
os: "linux",
showLineTimestamps: false,
},
],
keys: [],
identities: [],
proxyProfiles: [],
snippets: [],
customGroups: [],
syncedAt: 1,
settings: { terminalSettings: { showLineTimestamps: true } },
};
await applySyncPayload(payload, {
importVaultData: (json) => {
imported = JSON.parse(json);
},
});
assert.ok(imported);
const hosts = imported.hosts as SyncPayload["hosts"];
assert.equal(hosts[0]?.showLineTimestamps, true);
assert.equal(hosts[1]?.showLineTimestamps, false);
});
test("applySyncPayload waits for async vault imports", async () => {
let finished = false;
const payload: SyncPayload = {
@@ -736,6 +820,42 @@ test("applySyncPayload writes incoming fallbackFont into local TERM_SETTINGS", a
assert.equal(parsed.fallbackFont, "Sarasa Mono SC");
});
test("applySyncPayload lets legacy middle-click paste update the new middle-click behavior", async () => {
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({
scrollback: 2000,
middleClickBehavior: "paste",
middleClickPaste: true,
}),
);
const payload: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
syncedAt: 1,
settings: {
terminalSettings: {
middleClickPaste: false,
},
},
} as SyncPayload;
await applySyncPayload(payload, {
importVaultData: () => {},
});
const raw = localStorage.getItem(storageKeys.STORAGE_KEY_TERM_SETTINGS);
assert.ok(raw, "TERM_SETTINGS should be written");
const parsed = JSON.parse(raw!);
assert.equal(parsed.scrollback, 2000);
assert.equal(parsed.middleClickBehavior, "disabled");
assert.equal(parsed.middleClickPaste, false);
});
test("applySyncPayload from legacy client (no fallbackFont) preserves local value", async () => {
localStorage.setItem(
storageKeys.STORAGE_KEY_TERM_SETTINGS,

View File

@@ -24,6 +24,7 @@ import {
hasSyncPayloadEntityData,
type SyncPayload,
} from '../domain/sync';
import { migrateHostsFromLegacyLineTimestamps } from '../domain/host';
import {
nextCustomKeyBindingsSyncVersion,
parseCustomKeyBindingsStorageRecord,
@@ -31,6 +32,7 @@ import {
} from '../domain/customKeyBindings';
import { isEncryptedCredentialPlaceholder } from '../domain/credentials';
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
import { sanitizeQuickMessages } from '../infrastructure/ai/quickMessages';
import { emitAIStateChanged } from './state/aiStateEvents';
import { rehydrateGlobalSftpBookmarks } from './state/sftp/globalSftpBookmarks';
import {
@@ -63,6 +65,9 @@ import {
STORAGE_KEY_SHOW_RECENT_HOSTS,
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_AI_PROVIDERS,
STORAGE_KEY_AI_ACTIVE_PROVIDER,
@@ -77,6 +82,8 @@ import {
STORAGE_KEY_AI_AGENT_MODEL_MAP,
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
STORAGE_KEY_AI_WEB_SEARCH,
STORAGE_KEY_AI_QUICK_MESSAGES,
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
STORAGE_KEY_PORT_FORWARDING,
} from '../infrastructure/config/storageKeys';
@@ -188,11 +195,14 @@ const SYNCABLE_TERMINAL_KEYS = [
'linePadding', 'cursorShape', 'cursorBlink', 'minimumContrastRatio',
'altAsMeta', 'optionArrowWordJump', 'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
'smoothScrolling',
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
'rightClickBehavior', 'middleClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
'keepaliveInterval', 'keepaliveCountMax', 'disableBracketedPaste', 'clearWipesScrollback',
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'showServerStats', 'showLineTimestamps',
'serverStatsRefreshInterval', 'rendererType',
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'showServerStats',
'serverStatsRefreshInterval',
'systemManagerProcessRefreshInterval', 'systemManagerTmuxRefreshInterval',
'systemManagerDockerListRefreshInterval', 'systemManagerDockerStatsRefreshInterval',
'rendererType',
'autocompleteEnabled', 'autocompleteGhostText', 'autocompletePopupMenu',
'autocompleteDebounceMs', 'autocompleteMinChars', 'autocompleteMaxSuggestions',
] as const;
@@ -227,6 +237,7 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
STORAGE_KEY_SHOW_RECENT_HOSTS,
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_AI_PROVIDERS,
STORAGE_KEY_AI_ACTIVE_PROVIDER,
@@ -241,6 +252,8 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
STORAGE_KEY_AI_AGENT_MODEL_MAP,
STORAGE_KEY_AI_AGENT_PROVIDER_MAP,
STORAGE_KEY_AI_WEB_SEARCH,
STORAGE_KEY_AI_QUICK_MESSAGES,
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
] as const;
const isRecord = (value: unknown): value is Record<string, unknown> =>
@@ -404,6 +417,12 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
if (showOnlyUngroupedHostsInRoot != null) settings.showOnlyUngroupedHostsInRoot = showOnlyUngroupedHostsInRoot;
const showSftpTab = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_SFTP_TAB);
if (showSftpTab != null) settings.showSftpTab = showSftpTab;
const shellOnlyTabNumberShortcuts = localStorageAdapter.readBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS);
if (shellOnlyTabNumberShortcuts != null) settings.shellOnlyTabNumberShortcuts = shellOnlyTabNumberShortcuts;
const disableTerminalFontZoom = localStorageAdapter.readBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM);
if (disableTerminalFontZoom != null) settings.disableTerminalFontZoom = disableTerminalFontZoom;
const showHostTreeSidebar = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR);
if (showHostTreeSidebar != null) settings.showHostTreeSidebar = showHostTreeSidebar;
const workspaceFocusStyle = localStorageAdapter.readString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
if (workspaceFocusStyle === 'dim' || workspaceFocusStyle === 'border') {
settings.workspaceFocusStyle = workspaceFocusStyle;
@@ -441,6 +460,12 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
if (agentProviderMap) ai.agentProviderMap = agentProviderMap;
const webSearchConfig = readRecordSetting(STORAGE_KEY_AI_WEB_SEARCH);
if (webSearchConfig) ai.webSearchConfig = stripDeviceBoundApiKey(webSearchConfig);
const quickMessages = readArraySetting(STORAGE_KEY_AI_QUICK_MESSAGES);
if (quickMessages) ai.quickMessages = sanitizeQuickMessages(quickMessages);
const showTerminalSelectionAction = localStorageAdapter.readBoolean(STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION);
if (showTerminalSelectionAction != null) {
ai.showTerminalSelectionAction = showTerminalSelectionAction;
}
if (Object.keys(ai).length > 0) settings.ai = ai;
return Object.keys(settings).length > 0 ? settings : undefined;
@@ -479,11 +504,27 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
try { existing = JSON.parse(raw); } catch { /* ignore */ }
}
const merged = { ...existing };
const hasIncomingMiddleClickBehavior = 'middleClickBehavior' in settings.terminalSettings;
const hasIncomingMiddleClickPaste = 'middleClickPaste' in settings.terminalSettings;
for (const key of SYNCABLE_TERMINAL_KEYS) {
if (key in settings.terminalSettings) {
merged[key] = settings.terminalSettings[key];
}
}
if (hasIncomingMiddleClickBehavior) {
const behavior = settings.terminalSettings.middleClickBehavior;
if (
behavior === 'context-menu' ||
behavior === 'paste' ||
behavior === 'disabled'
) {
merged.middleClickPaste = behavior === 'paste';
}
} else if (hasIncomingMiddleClickPaste) {
merged.middleClickBehavior = settings.terminalSettings.middleClickPaste === false
? 'disabled'
: 'paste';
}
localStorageAdapter.writeString(STORAGE_KEY_TERM_SETTINGS, JSON.stringify(merged));
}
@@ -524,7 +565,6 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
// SFTP Bookmarks (global only)
if (settings.sftpGlobalBookmarks != null) localStorageAdapter.write(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS, settings.sftpGlobalBookmarks);
// Immersive mode (legacy — always enabled, ignore incoming value)
if (settings.showRecentHosts != null) localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS, settings.showRecentHosts);
if (settings.showOnlyUngroupedHostsInRoot != null) {
localStorageAdapter.writeBoolean(
@@ -535,6 +575,15 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
if (settings.showSftpTab != null) {
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_SFTP_TAB, settings.showSftpTab);
}
if (settings.shellOnlyTabNumberShortcuts != null) {
localStorageAdapter.writeBoolean(STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS, settings.shellOnlyTabNumberShortcuts);
}
if (settings.disableTerminalFontZoom != null) {
localStorageAdapter.writeBoolean(STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM, settings.disableTerminalFontZoom);
}
if (settings.showHostTreeSidebar != null) {
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, settings.showHostTreeSidebar);
}
if (settings.workspaceFocusStyle != null) {
localStorageAdapter.writeString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE, settings.workspaceFocusStyle);
}
@@ -570,6 +619,15 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
);
}
}
if (ai.quickMessages != null) {
localStorageAdapter.write(STORAGE_KEY_AI_QUICK_MESSAGES, sanitizeQuickMessages(ai.quickMessages));
}
if (ai.showTerminalSelectionAction != null) {
localStorageAdapter.writeBoolean(
STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION,
ai.showTerminalSelectionAction,
);
}
// After all AI writes, reconcile per-agent bindings against the final
// provider list. Sync payloads can land with a new `providers` set but
// no `agentProviderMap`, or with a stale `agentProviderMap` that
@@ -610,6 +668,10 @@ function notifyAIStateAfterSync(ai: NonNullable<SyncPayload['settings']>['ai']):
touched.push(STORAGE_KEY_AI_AGENT_MODEL_MAP);
}
if (ai.webSearchConfig !== undefined) touched.push(STORAGE_KEY_AI_WEB_SEARCH);
if (ai.quickMessages != null) touched.push(STORAGE_KEY_AI_QUICK_MESSAGES);
if (ai.showTerminalSelectionAction != null) {
touched.push(STORAGE_KEY_AI_SHOW_TERMINAL_SELECTION_ACTION);
}
for (const key of touched) {
emitAIStateChanged(key);
}
@@ -702,10 +764,11 @@ function applyPayload(
importers: SyncPayloadImporters,
options: { includeLocalOnlyData: boolean },
): Promise<void> {
const legacyLineTimestampsEnabled = payload.settings?.terminalSettings?.showLineTimestamps === true;
// Build the vault import object. Cloud sync intentionally ignores
// local-only trust records even if legacy cloud snapshots still carry them.
const vaultImport: Record<string, unknown> = {
hosts: payload.hosts,
hosts: migrateHostsFromLegacyLineTimestamps(payload.hosts, legacyLineTimestampsEnabled),
keys: payload.keys,
identities: payload.identities,
proxyProfiles: payload.proxyProfiles,

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