Compare commits

...

59 Commits

Author SHA1 Message Date
陈大猫
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
陈大猫
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
陈大猫
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
bincxz
15ec02dcae feat(tabs): smooth-scroll edge tabs toward center
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-09 04:37:59 +08:00
bincxz
e75c654a1a fix(tabs): keep host tree gutter outside tab scroll 2026-06-09 04:34:28 +08:00
bincxz
29b1eca1fd fix(settings): improve responsive settings controls 2026-06-09 04:05:28 +08:00
陈大猫
e2d036e710 Merge pull request #1318 from binaricat/codex/fix-tray-menu-1314
fix(linux): restore tray icon context menu for Linux via setContextMenu (#1314)
2026-06-09 03:53:18 +08:00
bincxz
094f0abe4a fix(sftp): follow terminal cwd after submitted commands 2026-06-09 03:51:33 +08:00
bincxz
8ab2003dae fix(sftp): restore terminal cwd following 2026-06-09 03:43:49 +08:00
bincxz
b1b0c5648c fix(terminal): stabilize tab switch follow-up 2026-06-09 03:40:18 +08:00
陈大猫
36e5779d94 perf(terminal): reduce terminal tab-switch and layout jank (#1321)
* perf(terminal): smooth layout drags and faster tab switching

Defer xterm refit during split, sidebar, and host-tree drags while keeping pane containers in sync with live layout measurements. Refactor TerminalLayer into focused sections with TabBridge/memo optimizations and add the terminal host tree sidebar.

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

* fix(terminal): keep side panels alive and guard session attach races

Prevent terminal boot unmount from leaking backend sessions, keep SFTP/scripts/theme/AI state when switching side tabs, and defer heavy SFTP UI mount so first entry stays responsive.

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

* perf(terminal): reduce tab switch jank

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-09 03:35:03 +08:00
陈大猫
53aef452cc fix(sftp): default unchecked opener preference for extensionless files (#1320)
Avoid accidentally persisting built-in editor as the default for all
extensionless files when double-clicking binaries without an extension.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 21:42:18 +08:00
陈大猫
3ef5a64b94 feat(settings): unify settings page card and section layout (#1316) (#1319)
Introduce shared SettingCard and SettingsSection primitives so AI, SFTP,
system, and terminal tabs use the same white-card + row control pattern.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 21:24:17 +08:00
陈奇
c28db932a4 fix(linux): restore tray icon context menu for Linux via setContextMenu (#1314) 2026-06-08 12:48:23 +00:00
陈大猫
f2c2501fa5 fix(sftp): Ctrl+V 粘贴文件夹不再变成空文件 (#1266) (#1312)
* fix(sftp): paste folders as directories instead of empty files (#1266)

Prefer Electron readClipboardFiles and extractDropEntries over clipboardData.files,
upload pasted directories via uploadExternalFolderPath, and add macOS NSFilenames
clipboard support.

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

* fix(sftp): harden clipboard paste after review findings

Start extractDropEntries synchronously during paste, restore path-backed
file snapshot fallback, handle per-folder upload failures, and add
public.file-url clipboard test.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 19:42:45 +08:00
陈大猫
b1f930a995 feat(sftp): 侧栏 SFTP 增加追随终端目录模式 (#1266) (#1310)
* feat(sftp): add follow terminal directory mode for sidebar (#1266)

Add a toolbar toggle that keeps the side-panel SFTP browser synced with the linked SSH terminal cwd, inspired by MobaXterm's follow-folder behavior.

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

* fix(sftp): probe pwd after commands when follow mode lacks OSC 7

Add a deferred getSessionPwd fallback after terminal commands when follow-terminal-cwd is enabled and the shell did not report OSC 7, and fix settings sync hook dependencies.

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

* fix(sftp): repair follow toggle UI and backend cwd sync fallback

Fix SftpPaneView memo skipping follow prop updates, probe fresh pwd when OSC 7 is missing, and broaden linked terminal session resolution for sidebar follow mode.

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

* fix(sftp): harden follow sync after review findings

Reuse shouldFollowTerminalCwdNavigate in production path, re-read connection
after async cwd probe, skip redundant navigate on toggle, and only probe pwd
when the SFTP side panel is open.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 19:28:30 +08:00
bincxz
de60b616cd Add structured GitHub issue templates and align in-app bug reports.
Require bug/feature reports via issue forms with automated format checks, while accepting legacy Bug: titles from older app builds.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 17:42:50 +08:00
陈大猫
6e6a0240a7 Merge pull request #1295 from binaricat/codex/fix-issue-1293
Some checks failed
build-packages / bump homebrew tap (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 / 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
fix(terminal): support Kylin sudo and telnet prompts without trailing colon (#1293)
2026-06-08 16:49:01 +08:00
bincxz
2e2360a9fc test(terminal): cover Kylin screenshot prompts and fix sudo fast path
Add issue #1293 screenshot-exact cases for sudo/telnet autofill and
include English password in the handleOutput fast-path bypass.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 16:29:25 +08:00
陈奇
8011f4e2e8 fix(terminal): support Kylin sudo and telnet prompts without trailing colon (#1293)
Kylin Professional's sudo prompt doesn't include the [sudo] tag and
doesn't end with a colon. The existing regex patterns required either
[sudo] or a trailing colon, causing the autofill hint to never fire.

Changes:
- Make trailing colon optional in SUDO_PROMPT_PATTERN and
  EXPLICIT_SUDO_PROMPT_PATTERN (terminalSudoAutofill.ts)
- Update fast path to not skip output containing Chinese password
  keywords (密码/口令) that lack a colon
- Make trailing colon/angle-bracket optional in telnet username and
  password prompt patterns (telnetAutoLogin.cjs)
- Also relax LAST_LOGIN_PATTERN for consistency
- Add Kylin-style test cases for both sudo and telnet auto-login
2026-06-08 16:04:20 +08:00
陈大猫
970037682c feat: add adjustable window opacity for overlay use (#1304) (#1309)
Expose whole-window transparency via setOpacity with settings and a top-bar quick control, persisting across restarts and syncing across windows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 15:57:25 +08:00
陈大猫
42b58efc5c fix: align top bar right-side icon buttons (#1298) (#1303)
* fix: align top bar right-side icon buttons (#1298)

Unify AI/sync/theme/settings buttons to h-7 in one row aligned with tabs.
SyncStatusButton was h-8 and settings lived in a separate container, causing
misalignment. Preserve Windows spacing before window controls (mr-2).

Fixes #1298

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

* fix: align window controls with utility icons in top bar

Merge window controls into the same row as utility buttons, match h-7 height, add left margin separation, and restore rectangular gray hover for all three buttons.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 14:07:07 +08:00
陈大猫
b20163d762 fix: add custom CSS hooks for terminal side panel and split view (#1302)
Expose data-section selectors for SFTP/side panel, split panes, and resizers
so custom CSS can target the correct regions. Clarify docs that
terminal-workspace-sidebar is focus-mode only.

Fixes #1301

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-08 13:11:51 +08:00
陈大猫
e0a56cbb14 fix(ai): infer MIME type from file extension for YAML and other code files (#1289)
* fix(ai): infer MIME type from file extension for YAML and other code files

When uploading YAML files (and other code/text files) via Electron,
file.type is often empty, causing the system to default to
'application/octet-stream'. AI providers reject this media type
with 'functionality not supported'.

Fix by inferring the correct MIME type from the file extension
when file.type is empty. Includes mappings for YAML, JSON, TOML,
shell scripts, and 50+ common code/text file extensions.

Fixes #1287

* fix(ai): use text/plain for all code/text files to ensure provider compatibility

Change all non-standard MIME types (text/x-*, application/x-*) to
text/plain for maximum provider compatibility. Anthropic and other
providers reject non-standard MIME types like application/x-yaml
with 'UnsupportedFunctionalityError'.

Changes:
- All code files (js, ts, py, rb, rs, go, java, c, cpp, sh, etc.) → text/plain
- Web component/stylesheet files (vue, svelte, scss, sass, less) → text/plain
- yaml/yml → text/plain (was application/x-yaml)
- dockerfile → text/plain (was text/x-dockerfile)
- Standard types (html, css, json, xml, csv, md, txt, pdf) preserved
2026-06-07 19:05:33 +08:00
陈大猫
8dae851ea3 fix(telnet, sudo): support Chinese-localized prompts with full-width colons (#1286) (#1288)
* fix(telnet, sudo): support Chinese-localized prompts with full-width colons (#1286)

Two bugs in prompt detection for Chinese-locale users:

1. telnetAutoLogin: USERNAME_PROMPT_PATTERN, PASSWORD_PROMPT_PATTERN,
   and LAST_LOGIN_PATTERN only matched half-width colon ':' or '>'.
   Chinese-locale telnet prompts use full-width colon ':' (U+FF1A),
   e.g. '登录:', '密码:'. Changed [:'>] to [::'|] in all three
   patterns to accept both colon variants.

2. terminalSudoAutofill: EXPLICIT_SUDO_PROMPT_PATTERN required
   '[sudo]' (closing bracket immediately after 'sudo'), but Chinese
   sudo prompts use '[sudo: authenticate] 密码:' format where sudo
   is followed by colon. Changed \[sudo\] to \[sudo[^\]]*\] to
   match any '[sudo...]' variant, making the explicit (no-arm-needed)
   hint detection work for Chinese locale.

Fixes #1286

* fix: restore OSC stripping pattern broken in previous commit

The regex negated character class [^\x07]* was truncated to just \x07,
breaking OSC sequence stripping (e.g. window title changes embedded in
terminal output). Restore the original negated class so stripTerminalControl-
Sequences continues to remove OSC title sequences before prompt detection.

This was caught by Codex review of PR #1288.
2026-06-07 19:05:27 +08:00
285 changed files with 18820 additions and 6056 deletions

118
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,118 @@
name: Bug Report
description: Report a reproducible problem in Netcatty
title: "[Bug] "
labels: ["bug", "triage"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report a bug. Incomplete reports may be closed automatically.
Please search [existing issues](https://github.com/binaricat/Netcatty/issues) first.
- type: dropdown
id: platform
attributes:
label: Operating system
options:
- macOS
- Windows
- Linux
validations:
required: true
- type: input
id: version
attributes:
label: Netcatty version
description: Find it in Settings > Application, or on the [latest release](https://github.com/binaricat/Netcatty/releases/latest) page.
placeholder: "e.g. 1.2.3"
validations:
required: true
- type: dropdown
id: install_source
attributes:
label: How did you install Netcatty?
options:
- GitHub Release (.dmg / .exe / .AppImage / .deb / .rpm / .pacman)
- Homebrew
- Built from source (npm run dev / pack)
- Other
validations:
required: true
- type: dropdown
id: area
attributes:
label: Affected area
multiple: true
options:
- SSH connection / terminal
- SFTP / file browser
- Host vault / keychain
- Port forwarding
- Snippets
- AI assistant
- Settings / sync
- UI / layout
- Crash / app won't start
- Other
validations:
required: true
- type: dropdown
id: reproducibility
attributes:
label: Can you reproduce it?
options:
- Always (100%)
- Often (>50%)
- Sometimes
- Once / not sure
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: Numbered steps so we can follow exactly.
placeholder: |
1. Open Netcatty and connect to host X
2. Click SFTP tab
3. ...
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behavior
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs / screenshots
description: |
Optional but helpful. Crash logs: Settings > System > Crash Logs > Open folder.
For SSH errors, include redacted connection details (no passwords / private keys).
placeholder: Paste relevant log lines or attach screenshots.
- type: checkboxes
id: checklist
attributes:
label: Before submitting
options:
- label: I searched existing issues and did not find a duplicate
required: true
- label: I removed passwords, private keys, and other secrets from this report
required: true

8
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: Questions & general help
url: https://github.com/binaricat/Netcatty/discussions
about: Not sure if it is a bug? Ask in Discussions first.
- name: Latest release
url: https://github.com/binaricat/Netcatty/releases/latest
about: Check your Netcatty version before reporting.

View File

@@ -0,0 +1,72 @@
name: Feature Request
description: Suggest an improvement or new capability
title: "[Feature] "
labels: ["enhancement", "triage"]
body:
- type: markdown
attributes:
value: |
Describe the problem you are trying to solve and the change you want.
Vague requests like "make it better" may be closed.
- type: textarea
id: problem
attributes:
label: Problem / pain point
description: What is hard, missing, or frustrating today?
placeholder: When I manage 50+ hosts, I cannot ...
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed solution
description: What would you like Netcatty to do?
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Other tools, workarounds, or designs you thought about.
validations:
required: true
- type: dropdown
id: area
attributes:
label: Related area
multiple: true
options:
- SSH / terminal
- SFTP
- Host vault / keychain
- Port forwarding
- Snippets
- AI assistant
- Settings / sync
- UI / UX
- Other
validations:
required: true
- type: dropdown
id: priority
attributes:
label: How important is this to you?
options:
- Nice to have
- Would improve my daily workflow
- Blocking / critical for my use case
validations:
required: true
- type: checkboxes
id: checklist
attributes:
label: Before submitting
options:
- label: I searched existing issues and discussions for similar requests
required: true

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

139
.github/workflows/issue-format.yml vendored Normal file
View File

@@ -0,0 +1,139 @@
name: issue-format
on:
issues:
types: [opened, edited]
permissions:
issues: write
jobs:
validate:
runs-on: ubuntu-latest
# Skip issues opened by bots (e.g. dependabot) and maintainers fixing format
if: >-
github.event.issue.user.type != 'Bot' &&
!contains(github.event.issue.labels.*.name, 'format-exempt')
steps:
- name: Validate title and body
uses: actions/github-script@v7
with:
script: |
const issue = context.payload.issue;
const title = issue.title.trim();
const body = (issue.body || '').trim();
const errors = [];
const modernTitle = /^\[(Bug|Feature)\] .{8,}/.test(title);
const legacyAppTitle = /^Bug:\s*.{5,}/i.test(title);
if (!modernTitle && !legacyAppTitle) {
errors.push(
'Title must start with `[Bug]` or `[Feature]` followed by a short summary (at least 8 characters after the prefix). Legacy app links using `Bug: ...` are also accepted. Example: `[Bug] SFTP upload fails on Windows`'
);
}
if (body.length < 120) {
errors.push(
'Body is too short. Please use the Bug Report or Feature Request template and fill in all required fields.'
);
}
const templateMarkers = [
'Steps to reproduce',
'Expected behavior',
'Actual behavior',
'Describe the problem',
'Problem / pain point',
'Proposed solution',
'Operating system',
];
const hasTemplateStructure = templateMarkers.some((marker) =>
body.includes(marker)
);
if (!hasTemplateStructure) {
errors.push(
'Body does not look like it came from an issue template. Choose **Bug Report** or **Feature Request** when opening an issue.'
);
}
const labels = new Set(
(issue.labels || []).map((label) =>
typeof label === 'string' ? label : label.name
)
);
if (errors.length === 0) {
if (
issue.state === 'closed' &&
labels.has('invalid-format')
) {
labels.delete('invalid-format');
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
state: 'open',
labels: [...labels],
});
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: '<!-- issue-format-bot --> Format looks good now. Reopening this issue.',
});
}
core.info('Issue format OK');
return;
}
const issueNumber = issue.number;
const marker = '<!-- issue-format-bot -->';
const bodyText = [
marker,
'## Issue format check failed',
'',
'This issue was closed automatically because it does not follow the required format.',
'',
...errors.map((e) => `- ${e}`),
'',
'### How to resubmit',
'',
'1. Go to [New Issue](https://github.com/binaricat/Netcatty/issues/new/choose)',
'2. Pick **Bug Report** or **Feature Request**',
'3. Fill in every required field',
'4. Keep the `[Bug]` or `[Feature]` prefix in the title and add a clear summary after it (older app versions may use `Bug: ...`)',
'',
'For questions and open-ended discussion, use [GitHub Discussions](https://github.com/binaricat/Netcatty/discussions) instead.',
'',
'If you believe this was a mistake, reply here after fixing the title/body and a maintainer can reopen.',
].join('\n');
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
per_page: 100,
});
const alreadyNotified = comments.some((c) =>
(c.body || '').includes(marker)
);
if (!alreadyNotified) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
body: bodyText,
});
}
labels.add('invalid-format');
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
state: 'closed',
state_reason: 'not_planned',
labels: [...labels],
});

154
App.tsx
View File

@@ -1,7 +1,6 @@
import React, { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react';
import { activeTabStore, useActiveTabId, toEditorTabId, fromEditorTabId, isEditorTabId } from './application/state/activeTabStore';
import { activeTabStore, toEditorTabId, fromEditorTabId, isEditorTabId } from './application/state/activeTabStore';
import { useAutoSync } from './application/state/useAutoSync';
import { useImmersiveMode } from './application/state/useImmersiveMode';
import { useManagedSourceSync } from './application/state/useManagedSourceSync';
import { usePortForwardingState } from './application/state/usePortForwardingState';
import { useSessionState } from './application/state/useSessionState';
@@ -28,9 +27,7 @@ import { materializeHostProxyProfile } from './domain/proxyProfiles';
import { resolveHostAuth } from './domain/sshAuth';
import { isEncryptedCredentialPlaceholder } from './domain/credentials';
import {
applyCustomAccentToTerminalTheme,
mergeTerminalHostUpdate,
resolveHostTerminalThemeId,
} from './domain/terminalAppearance';
import { selectConnectionLogForTerminalDataCapture } from './domain/connectionLog';
import { collectSessionIds } from './domain/workspace';
@@ -60,9 +57,10 @@ import { KeyboardInteractiveRequest } from './components/KeyboardInteractiveModa
import { PassphraseRequest } from './components/PassphraseModal';
import { classifyLocalShellType } from './lib/localShell';
import { useDiscoveredShells, resolveShellSetting } from './lib/useDiscoveredShells';
import { Host, HostProtocol, KnownHost, SerialConfig, Snippet, SSHKey, TerminalSession, TerminalTheme } from './types';
import { Host, HostProtocol, KnownHost, SerialConfig, Snippet, SSHKey, TerminalSession } from './types';
import { resolveSnippetCommand } from './components/SnippetExecutionProvider';
import { AppView } from './application/app/AppView';
import { AppActiveTabChrome } from './application/app/AppActiveTabChrome';
import { useAppStartupEffects } from './application/app/useAppStartupEffects';
import { LogViewWrapper, SftpViewMount, TerminalLayerMount, VaultViewContainer } from './application/app/AppMounts';
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleToggleThemeImpl, handleRootContextMenuImpl } from './application/app/AppHandlers';
@@ -131,6 +129,8 @@ function App({ settings }: { settings: SettingsState }) {
sftpShowHiddenFiles,
sftpUseCompressedUpload,
sftpAutoOpenSidebar,
sftpFollowTerminalCwd,
setSftpFollowTerminalCwd,
sftpDefaultViewMode,
editorWordWrap,
setEditorWordWrap,
@@ -138,7 +138,7 @@ function App({ settings }: { settings: SettingsState }) {
sessionLogsDir,
sessionLogsFormat,
sessionLogsTimestampsEnabled,
reapplyCurrentTheme,
applyAppTheme,
workspaceFocusStyle,
} = settings;
@@ -244,6 +244,7 @@ function App({ settings }: { settings: SettingsState }) {
runSnippet,
orphanSessions,
orderedTabs,
getOrderedWorkTabs,
reorderTabs,
toggleBroadcast,
isBroadcastEnabled,
@@ -267,19 +268,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 activeTabId = useActiveTabId();
const customThemes = useCustomThemes();
const editorTabs = useEditorTabs();
useEffect(() => {
if (!settings.showSftpTab && activeTabId === 'sftp') {
setActiveTabId('vault');
}
}, [settings.showSftpTab, activeTabId, setActiveTabId]);
// Resolve the effective TerminalTheme for the currently focused terminal tab
const hostById = useMemo(
() => new Map(hosts.map((host) => [host.id, host])),
[hosts],
@@ -298,92 +291,8 @@ function App({ settings }: { settings: SettingsState }) {
() => new Map([...customThemes, ...TERMINAL_THEMES].map((theme) => [theme.id, theme])),
[customThemes],
);
const activeTerminalTheme = useMemo<TerminalTheme | null>(() => {
if (activeTabId === 'vault' || activeTabId === 'sftp') return null;
const resolveTheme = (s: TerminalSession): TerminalTheme => {
let baseTheme: TerminalTheme;
// When "Follow Application Theme" is on, the UI-matched terminal
// theme overrides everything — including per-host theme overrides.
// This ensures all terminals match the app chrome regardless of
// individual host settings.
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);
};
// Workspace
const workspace = workspaceById.get(activeTabId);
if (workspace) {
// Focus mode: use the focused (or first remaining) session's theme
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;
}
// Split mode: require all sessions to share the same theme
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;
}
// Single session tab
const session = sessionById.get(activeTabId);
if (!session) return null;
return resolveTheme(session);
}, [accentMode, activeTabId, currentTerminalTheme, customAccent, followAppTerminalTheme, hostById, sessionById, themeById, workspaceById]);
useImmersiveMode({
activeTabId,
activeTerminalTheme,
restoreOriginalTheme: reapplyCurrentTheme,
});
const editorTabFileNameCounts = useMemo(() => {
const counts = new Map<string, number>();
for (const tab of editorTabs) counts.set(tab.fileName, (counts.get(tab.fileName) ?? 0) + 1);
return counts;
}, [editorTabs]);
const activeWindowTitle = useMemo(() => {
if (activeTabId === 'vault') return 'Vaults';
if (activeTabId === 'sftp') return 'SFTP';
if (isEditorTabId(activeTabId)) {
const editorTab = editorTabs.find((tab) => tab.id === fromEditorTabId(activeTabId));
if (!editorTab) return 'Editor';
const suffix = (editorTabFileNameCounts.get(editorTab.fileName) ?? 0) > 1
? ` · ${editorTab.remotePath.split('/').slice(-2, -1)[0] || '/'}`
: '';
return `${editorTab.fileName}${suffix}`;
}
const workspace = workspaceById.get(activeTabId);
if (workspace) return workspace.title;
const session = sessionById.get(activeTabId);
if (session) return session.hostLabel;
const logView = logViews.find((item) => item.id === activeTabId);
if (logView) {
const isLocal = logView.log.protocol === 'local' || logView.log.hostname === 'localhost';
return `${t('tabs.logPrefix')} ${isLocal ? t('tabs.logLocal') : logView.log.hostname}`;
}
return 'Netcatty';
}, [activeTabId, editorTabFileNameCounts, editorTabs, logViews, sessionById, t, workspaceById]);
useEffect(() => {
void netcattyBridge.get()?.setWindowTitle?.(activeWindowTitle);
}, [activeWindowTitle]);
// activeTabId-derived chrome (window title, sftp guard) is owned by
// <AppActiveTabChrome/> so switching tabs does not re-render App.
useEffect(() => {
const bridge = netcattyBridge.get();
@@ -788,12 +697,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).
@@ -815,7 +737,7 @@ function App({ settings }: { settings: SettingsState }) {
}
const intent = resolveWindowCommandCloseIntent({
activeTabId,
activeTabId: activeTabStore.getActiveTabId(),
editorTabIds: editorTabs.map((tab) => toEditorTabId(tab.id)),
sessionIds: sessions.map((session) => session.id),
workspaceIds: workspaces.map((workspace) => workspace.id),
@@ -833,7 +755,7 @@ function App({ settings }: { settings: SettingsState }) {
}
await netcattyBridge.get()?.windowClose?.();
}, [activeTabId, closeLogView, editorTabs, executeHotkeyAction, logViews, sessions, workspaces]);
}, [closeLogView, editorTabs, executeHotkeyAction, logViews, sessions, workspaces]);
useEffect(() => {
const unsubscribe = netcattyBridge.get()?.onWindowCommandCloseRequested?.(() => {
@@ -1045,7 +967,27 @@ function App({ settings }: { settings: SettingsState }) {
const handleRootContextMenu = useCallback((e: React.MouseEvent<HTMLDivElement>) => { return handleRootContextMenuImpl(() => ({ e }), e); }, []);
return <AppView ctx={{ accentMode, activeTabId, activeTerminalTheme, 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, 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 }} />;
return (
<>
<AppActiveTabChrome
showSftpTab={settings.showSftpTab}
setActiveTabId={setActiveTabId}
applyAppTheme={applyAppTheme}
hostById={hostById}
sessionById={sessionById}
themeById={themeById}
workspaceById={workspaceById}
currentTerminalTheme={currentTerminalTheme}
followAppTerminalTheme={followAppTerminalTheme}
accentMode={accentMode}
customAccent={customAccent}
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, reorderWorkTabs, reorderWorkspaceSessions, resetSessionRename, resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet: handleRunSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sessionRenameValue, sessions, setActiveTabId, setAddToWorkspaceDialog, setDraggingSessionId, setEditorWordWrap, setIsCreateWorkspaceOpen, setIsQuickSwitcherOpen, setNavigateToSection, setProtocolSelectHost, setQuickSearch, setSessionRenameValue, setTerminalFontFamilyId, setTerminalFontSize, setTerminalThemeId, setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior, sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, sshDebugLogsEnabled: settings.sshDebugLogsEnabled, startSessionRename, startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId, toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog, updateCustomGroups, updateGroupConfigs, updateHostDistro, updateHosts, updateIdentities, updateKeys, updateKnownHosts, updateManagedSources, updateProxyProfiles, updateSnippetPackages, updateSnippets, updateSplitSizes, updateTerminalSetting, workspaceRenameTarget, workspaceRenameValue, workspaces, VaultViewContainer, SftpViewMount, TerminalLayerMount, LogViewWrapper }} />
</>
);
}
function AppWithProviders() {

View File

@@ -0,0 +1,142 @@
import { useEffect, useMemo } from 'react';
import {
fromEditorTabId,
isEditorTabId,
useActiveTabId,
} from '../state/activeTabStore';
import { updateActiveChromeThemeDeps } from '../state/activeChromeThemeSync';
import { useActiveChromeTheme } from '../state/useActiveChromeTheme';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { resolveActiveChromeTheme } from './activeChromeTheme';
import type {
Host,
TerminalSession,
TerminalTheme,
Workspace,
} from '../../types';
import type { LogView } from '../state/logViewState';
import type { EditorTab } from '../state/editorTabStore';
interface AppActiveTabChromeProps {
showSftpTab: boolean;
setActiveTabId: (id: string) => void;
applyAppTheme: () => void;
hostById: Map<string, Host>;
sessionById: Map<string, TerminalSession>;
themeById: Map<string, TerminalTheme>;
workspaceById: Map<string, Workspace>;
currentTerminalTheme: TerminalTheme;
followAppTerminalTheme: boolean;
accentMode: 'theme' | 'custom';
customAccent: string;
editorTabs: readonly EditorTab[];
logViews: readonly LogView[];
t: (key: string) => string;
}
/**
* Owns the `activeTabId` subscription and the purely side-effectful "chrome"
* 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.
*/
export function AppActiveTabChrome({
showSftpTab,
setActiveTabId,
applyAppTheme,
hostById,
sessionById,
themeById,
workspaceById,
currentTerminalTheme,
followAppTerminalTheme,
accentMode,
customAccent,
editorTabs,
logViews,
t,
}: AppActiveTabChromeProps) {
const activeTabId = useActiveTabId();
useEffect(() => {
if (!showSftpTab && activeTabId === 'sftp') {
setActiveTabId('vault');
}
}, [showSftpTab, activeTabId, setActiveTabId]);
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,
]);
updateActiveChromeThemeDeps(chromeThemeDeps);
const activeChromeTheme = useMemo(() => resolveActiveChromeTheme({
...chromeThemeDeps,
activeTabId,
}), [chromeThemeDeps, activeTabId]);
useActiveChromeTheme({
activeTheme: activeChromeTheme,
applyAppTheme,
});
const editorTabFileNameCounts = useMemo(() => {
const counts = new Map<string, number>();
for (const tab of editorTabs) counts.set(tab.fileName, (counts.get(tab.fileName) ?? 0) + 1);
return counts;
}, [editorTabs]);
const activeWindowTitle = useMemo(() => {
if (activeTabId === 'vault') return 'Vaults';
if (activeTabId === 'sftp') return 'SFTP';
if (isEditorTabId(activeTabId)) {
const editorTab = editorTabs.find((tab) => tab.id === fromEditorTabId(activeTabId));
if (!editorTab) return 'Editor';
const suffix = (editorTabFileNameCounts.get(editorTab.fileName) ?? 0) > 1
? ` · ${editorTab.remotePath.split('/').slice(-2, -1)[0] || '/'}`
: '';
return `${editorTab.fileName}${suffix}`;
}
const workspace = workspaceById.get(activeTabId);
if (workspace) return workspace.title;
const session = sessionById.get(activeTabId);
if (session) return session.hostLabel;
const logView = logViews.find((item) => item.id === activeTabId);
if (logView) {
const isLocal = logView.log.protocol === 'local' || logView.log.hostname === 'localhost';
return `${t('tabs.logPrefix')} ${isLocal ? t('tabs.logLocal') : logView.log.hostname}`;
}
return 'Netcatty';
}, [activeTabId, editorTabFileNameCounts, editorTabs, logViews, sessionById, t, workspaceById]);
useEffect(() => {
void netcattyBridge.get()?.setWindowTitle?.(activeWindowTitle);
}, [activeWindowTitle]);
return null;
}

View File

@@ -2,11 +2,17 @@
import type React from 'react';
import type { Host, HostProtocol } from '../../types';
import type { PassphraseRequest } from '../../components/PassphraseModal';
import { getEffectiveHostDistro } from '../../domain/host';
import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
type AppContextGetter = () => Record<string, any>;
const TERMINAL_PASSTHROUGH_ACTIONS = getTerminalPassthroughActions();
const getLogHostVisualSnapshot = (host: Host) => ({
hostOs: host.os,
hostDistro: getEffectiveHostDistro(host) || undefined,
});
export function handleTrayJumpToSessionImpl(getCtx: AppContextGetter, sessionId: string) {
const { sessions, setActiveTabId, setWorkspaceFocusedSession } = getCtx();
{
@@ -65,6 +71,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 +90,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,
@@ -708,6 +716,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 +735,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,64 @@
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,
shouldAutoOpenHostTreeOnSurfaceChange,
} = 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 auto-opens when entering a work tab surface', () => {
assert.equal(shouldAutoOpenHostTreeOnSurfaceChange({
enabled: true,
previousSurfaceVisible: false,
surfaceVisible: true,
}), true);
});
test('shared host tree does not force reopen while already on work tab surfaces', () => {
assert.equal(shouldAutoOpenHostTreeOnSurfaceChange({
enabled: true,
previousSurfaceVisible: true,
surfaceVisible: true,
}), false);
});
test('shared host tree does not auto-open when disabled', () => {
assert.equal(shouldAutoOpenHostTreeOnSurfaceChange({
enabled: false,
previousSurfaceVisible: false,
surfaceVisible: true,
}), false);
});
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,124 @@
import React, { useEffect, useMemo, useRef } from 'react';
import { useActiveTabId } from '../state/activeTabStore';
import type { EditorTab } from '../state/editorTabStore';
import type { LogView } from '../state/logViewState';
import { scheduleAfterInstantThemeSwitch } from '../state/useActiveChromeTheme';
import { terminalHostTreeStore } from '../state/terminalHostTreeStore';
import { TerminalHostTreeSidebar } from '../../components/terminalLayer/TerminalHostTreeSidebar';
import type { Host, TerminalSession, TerminalTheme, Workspace } from '../../types';
import {
isHostTreeWorkTabSurface,
resolveWorkTabActiveHostId,
} from './workTabSurface';
interface AppHostTreeLayerProps {
enabled: boolean;
hosts: Host[];
customGroups: string[];
sessions: TerminalSession[];
workspaces: Workspace[];
editorTabs: readonly EditorTab[];
logViews: readonly LogView[];
orderedTabs: readonly string[];
resolvedPreviewTheme: 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 function shouldAutoOpenHostTreeOnSurfaceChange({
enabled,
previousSurfaceVisible,
surfaceVisible,
}: {
enabled: boolean;
previousSurfaceVisible: boolean;
surfaceVisible: boolean;
}): boolean {
return enabled && surfaceVisible && !previousSurfaceVisible;
}
export const AppHostTreeLayer: React.FC<AppHostTreeLayerProps> = ({
enabled,
hosts,
customGroups,
sessions,
workspaces,
editorTabs,
logViews,
orderedTabs,
resolvedPreviewTheme,
onConnect,
onCreateLocalTerminal,
}) => {
const activeTabId = useActiveTabId();
const previousSurfaceVisibleRef = useRef(false);
const cancelAutoOpenRef = useRef<(() => void) | null>(null);
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,
});
useEffect(() => {
cancelAutoOpenRef.current?.();
cancelAutoOpenRef.current = null;
const previousSurfaceVisible = previousSurfaceVisibleRef.current;
previousSurfaceVisibleRef.current = surfaceVisible;
if (shouldAutoOpenHostTreeOnSurfaceChange({
enabled,
previousSurfaceVisible,
surfaceVisible,
})) {
cancelAutoOpenRef.current = scheduleAfterInstantThemeSwitch(() => {
cancelAutoOpenRef.current = null;
terminalHostTreeStore.setIsOpen(true);
});
}
return () => {
cancelAutoOpenRef.current?.();
cancelAutoOpenRef.current = null;
};
}, [enabled, surfaceVisible]);
const activeHostId = useMemo(() => resolveWorkTabActiveHostId({
activeTabId,
editorTabs,
sessions,
workspaces,
}), [activeTabId, editorTabs, sessions, workspaces]);
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}
resolvedPreviewTheme={resolvedPreviewTheme}
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

@@ -18,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(() =>
@@ -32,7 +32,7 @@ type AppViewContext = Record<string, any>;
export function AppView({ ctx }: { ctx: AppViewContext }) {
const {
accentMode, activeTabId, activeTerminalTheme, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
accentMode, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, closeWorkspace, copySessionToNewWindowWithCurrentShell, copySessionWithCurrentShell,
connectionLogs, convertKnownHostToHost, createWorkspaceFromSessions, createWorkspaceFromTargets, createWorkspaceWithHosts, customAccent,
customGroups, currentTerminalTheme, deleteConnectionLog, draggingSessionId, effectiveKnownHosts, editorTabs, editorWordWrap, emptyVaultConflict,
@@ -42,11 +42,11 @@ 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, 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,
setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
setWorkspaceFocusedSession, setWorkspaceRenameValue, settings, sftpAutoOpenSidebar, sftpFollowTerminalCwd, setSftpFollowTerminalCwd, sftpAutoSync, sftpDefaultViewMode, sftpDoubleClickBehavior,
sftpShowHiddenFiles, sftpUseCompressedUpload, shellHistory, snippetPackages, snippets, splitSessionWithCurrentShell, startSessionRename,
startWorkspaceRename, submitSessionRename, submitWorkspaceRename, t, terminalFontFamilyId, terminalFontSize, terminalSettings, terminalThemeId,
toggleBroadcast, toggleConnectionLogSaved, toggleScriptsSidePanelRef, toggleSidePanelRef, toggleWorkspaceViewMode, unmanageSource, updateConnectionLog,
@@ -106,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", activeTerminalTheme && "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}
@@ -129,18 +128,34 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
onOpenQuickSwitcher={handleOpenQuickSwitcher}
onToggleTheme={handleToggleTheme}
onOpenSettings={handleOpenSettings}
windowOpacity={settings.windowOpacity}
setWindowOpacity={settings.setWindowOpacity}
onSyncNow={handleSyncNowManual}
isImmersiveActive={activeTerminalTheme !== null}
onStartSessionDrag={setDraggingSessionId}
onEndSessionDrag={handleEndSessionDrag}
onReorderTabs={reorderTabs}
onReorderTabs={reorderWorkTabs}
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}
sessions={sessions}
workspaces={workspaces}
editorTabs={editorTabs}
logViews={logViews}
orderedTabs={orderedTabsWithEditors}
resolvedPreviewTheme={currentTerminalTheme}
onConnect={handleConnectToHost}
onCreateLocalTerminal={handleCreateLocalTerminal}
/>
<VaultViewContainer>
<VaultView
hosts={hosts}
@@ -215,6 +230,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
<TerminalLayerMount
hosts={hosts}
customGroups={customGroups}
groupConfigs={groupConfigs}
proxyProfiles={proxyProfiles}
keys={keys}
@@ -259,6 +275,8 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
onSetWorkspaceFocusedSession={setWorkspaceFocusedSession}
onReorderWorkspaceSessions={reorderWorkspaceSessions}
onSplitSession={splitSessionWithCurrentShell}
onConnectToHost={handleConnectToHost}
onCreateLocalTerminal={handleCreateLocalTerminal}
isBroadcastEnabled={isBroadcastEnabled}
onToggleBroadcast={toggleBroadcast}
updateHosts={updateHosts}
@@ -268,6 +286,8 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
sftpShowHiddenFiles={sftpShowHiddenFiles}
sftpUseCompressedUpload={sftpUseCompressedUpload}
sftpAutoOpenSidebar={sftpAutoOpenSidebar}
sftpFollowTerminalCwd={sftpFollowTerminalCwd}
setSftpFollowTerminalCwd={setSftpFollowTerminalCwd}
editorWordWrap={editorWordWrap}
setEditorWordWrap={setEditorWordWrap}
sessionLogsEnabled={sessionLogsEnabled}
@@ -275,6 +295,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
sessionLogsFormat={sessionLogsFormat}
sessionLogsTimestampsEnabled={sessionLogsTimestampsEnabled}
sshDebugLogsEnabled={sshDebugLogsEnabled}
showHostTreeSidebar={settings.showHostTreeSidebar}
toggleScriptsSidePanelRef={toggleScriptsSidePanelRef}
toggleSidePanelRef={toggleSidePanelRef}
/>
@@ -300,7 +321,6 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
<TextEditorTabView
key={tab.id}
tabId={tab.id}
isVisible={activeTabId === toEditorTabId(tab.id)}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
hostById={hostById}

View File

@@ -0,0 +1,106 @@
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 theme from their owning host", () => {
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("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,104 @@
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 resolveSessionTheme = (session: TerminalSession): TerminalTheme => {
if (followAppTerminalTheme) return currentTerminalTheme;
const host = hostById.get(session.hostId) ?? null;
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
const baseTheme = themeById.get(themeId) ?? currentTerminalTheme;
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
};
if (isEditorTabId(activeTabId)) {
const editorTabId = fromEditorTabId(activeTabId);
const editorTab = editorTabs.find((tab) => tab.id === editorTabId);
if (!editorTab) return null;
const host = hostById.get(editorTab.hostId) ?? null;
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
const baseTheme = themeById.get(themeId) ?? currentTerminalTheme;
return applyCustomAccentToTerminalTheme(baseTheme, accentMode, customAccent);
}
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,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,82 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildOrderedWorkTabIds,
isHostTreeWorkTabSurface,
isRootPageTabId,
isTerminalContentTabSurface,
resolveWorkTabActiveHostId,
} from './workTabSurface';
import type { EditorTab } from '../state/editorTabStore';
import type { TerminalSession, Workspace } from '../../types';
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('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);
});

View File

@@ -0,0 +1,87 @@
import {
fromEditorTabId,
isEditorTabId,
} from '../state/activeTabStore';
import type { EditorTab } from '../state/editorTabStore';
import type { TerminalSession, Workspace } from '../../types';
export function isRootPageTabId(activeTabId: string): boolean {
return activeTabId === 'vault' || activeTabId === 'sftp';
}
export function buildOrderedWorkTabIds(
tabOrder: readonly string[],
allTabIds: readonly string[],
): string[] {
const allTabIdSet = new Set(allTabIds);
const orderedIds = tabOrder.filter((id) => allTabIdSet.has(id));
const orderedIdSet = new Set(orderedIds);
const newIds = allTabIds.filter((id) => !orderedIdSet.has(id));
return [...orderedIds, ...newIds];
}
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;
}

View File

@@ -174,6 +174,8 @@ export const enAiMessages: Messages = {
'ai.chat.daysAgo': '{n}d ago',
'ai.chat.newChat': 'New Chat',
'ai.chat.allSessions': 'All Sessions',
'ai.chat.loadEarlierMessages': 'Load earlier messages ({n} more)',
'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.',
'ai.chat.approvalTimeout': 'Tool approval timed out after 5 minutes. You can retry by sending your message again.',
@@ -231,9 +233,27 @@ export const enAiMessages: Messages = {
'terminal.layer.movePanelLeft': 'Move panel to left',
'terminal.layer.movePanelRight': 'Move panel to right',
'terminal.layer.closePanel': 'Close panel',
'terminal.layer.hostTree.search': 'Search hosts...',
'terminal.layer.hostTree.searchButton': 'Search',
'terminal.layer.hostTree.tagsButton': 'Filter by tags',
'terminal.layer.hostTree.newGroup': 'New group',
'terminal.layer.hostTree.localShell': 'Local shell',
'terminal.layer.hostTree.tagsEmpty': 'No tags available',
'terminal.layer.hostTree.clearTags': 'Clear selection',
'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',
'topTabs.windowOpacity': 'Window opacity',
'topTabs.toggleTheme': 'Toggle theme',
'topTabs.openSettings': 'Open Settings',
'ai.chat.sessionHistory': 'Session history',

View File

@@ -225,6 +225,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,14 +266,15 @@ 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, 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.',
'settings.appearance.customCss.placeholder':
'/* Examples — use !important to beat Tailwind utility specificity */\n\n/* Make snippet sidebar text larger */\n[data-section="snippets-panel"] {\n font-size: 14px !important;\n}\n\n/* Custom terminal background */\n.terminal { background: #1a1a2e !important; }\n\n/* Tweak global border radius */\n:root { --radius: 0.25rem; }',
'/* Examples — use !important to beat Tailwind utility specificity */\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',
'settings.appearance.uiFont.desc': 'Choose the font for the application interface',
'settings.appearance.windowOpacity': 'Window Opacity',
'settings.appearance.windowOpacity.desc': 'Adjust the transparency of the entire application window. Lower values also fade terminal text. Some Linux desktop environments may not support this.',
// Settings > Terminal
'settings.terminal.section.theme': 'Terminal Theme',
'settings.terminal.themeModal.title': 'Select Theme',
@@ -593,6 +596,7 @@ export const enCoreMessages: Messages = {
'vault.groups.hostsCount': '{count} Hosts',
'vault.groups.newSubgroup': 'New Subgroup',
'vault.groups.rename': 'Rename Group',
'vault.groups.unnamed': 'Unnamed Group',
'vault.groups.delete': 'Delete Group',
'vault.groups.createSubfolder': 'Create Subfolder',
'vault.groups.createRoot': 'Create Root Group',
@@ -665,6 +669,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

@@ -21,6 +21,14 @@ 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.encoding': 'Terminal Encoding',

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',
@@ -153,6 +154,8 @@ export const enVaultMessages: Messages = {
'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',
@@ -197,6 +200,9 @@ export const enVaultMessages: Messages = {
'sftp.transfers.dragToResize': 'Drag to resize',
'sftp.goUp': 'Go up',
'sftp.goToTerminalCwd': 'Go to terminal directory',
'sftp.followTerminalCwd': 'Follow terminal directory',
'sftp.followTerminalCwd.enable': 'Enable follow terminal directory',
'sftp.followTerminalCwd.disable': 'Disable follow terminal directory',
'sftp.encoding.label': 'Filename Encoding',
'sftp.encoding.auto': 'Auto',
'sftp.encoding.utf8': 'UTF-8',
@@ -348,6 +354,10 @@ export const enVaultMessages: Messages = {
'settings.sftp.autoOpenSidebar.desc': 'Automatically open the SFTP file browser sidebar when connecting to a host',
'settings.sftp.autoOpenSidebar.enable': 'Enable auto-open sidebar',
'settings.sftp.autoOpenSidebar.enableDesc': 'The SFTP sidebar will open automatically when a terminal session connects to a remote host',
'settings.sftp.followTerminalCwd': 'Follow terminal directory',
'settings.sftp.followTerminalCwd.desc': 'Automatically sync the sidebar SFTP browser with the terminal working directory (toggle in toolbar)',
'settings.sftp.followTerminalCwd.enable': 'Enable follow terminal directory by default',
'settings.sftp.followTerminalCwd.enableDesc': 'When the SFTP sidebar is open, follow mode stays on by default and updates after terminal cd commands',
'settings.sftp.defaultViewMode': 'Default View Mode',
'settings.sftp.defaultViewMode.desc': 'Choose the default view mode when opening a new SFTP tab. Per-host preferences override this setting.',

View File

@@ -174,6 +174,8 @@ export const ruAiMessages: Messages = {
'ai.chat.daysAgo': '{n}д назад',
'ai.chat.newChat': 'Новый чат',
'ai.chat.allSessions': 'Все сессии',
'ai.chat.loadEarlierMessages': 'Загрузить более ранние сообщения (ещё {n})',
'ai.chat.loadMoreSessions': 'Загрузить больше сессий (ещё {n})',
'ai.chat.noSessions': 'Предыдущих сессий нет',
'ai.chat.retryHint': 'Вы можете повторить попытку, отправив сообщение ещё раз.',
'ai.chat.approvalTimeout': 'Время ожидания одобрения инструмента истекло через 5 минут. Вы можете повторить попытку, отправив сообщение ещё раз.',
@@ -231,9 +233,20 @@ export const ruAiMessages: Messages = {
'terminal.layer.movePanelLeft': 'Переместить панель влево',
'terminal.layer.movePanelRight': 'Переместить панель вправо',
'terminal.layer.closePanel': 'Закрыть панель',
'terminal.layer.hostTree.search': 'Поиск хостов...',
'terminal.layer.hostTree.searchButton': 'Поиск',
'terminal.layer.hostTree.tagsButton': 'Фильтр по тегам',
'terminal.layer.hostTree.newGroup': 'Новая группа',
'terminal.layer.hostTree.localShell': 'Локальная оболочка',
'terminal.layer.hostTree.tagsEmpty': 'Нет доступных тегов',
'terminal.layer.hostTree.clearTags': 'Сбросить выбор',
'terminal.layer.hostTree.collapse': 'Свернуть список хостов',
'terminal.layer.hostTree.expand': 'Развернуть список хостов',
'terminal.layer.hostTree.empty': 'Хосты не найдены',
'topTabs.openQuickSwitcher': 'Открыть быстрый переключатель',
'topTabs.moreTabs': 'Больше вкладок',
'topTabs.aiAssistant': 'AI-помощник',
'topTabs.windowOpacity': 'Прозрачность окна',
'topTabs.toggleTheme': 'Переключить тему',
'topTabs.openSettings': 'Открыть настройки',
'ai.chat.sessionHistory': 'История сессий',

View File

@@ -225,6 +225,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,14 +266,15 @@ 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, 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.',
'settings.appearance.customCss.placeholder':
'/* Примеры — используйте !important, чтобы переопределить специфичность утилит Tailwind */\n\n/* Сделать текст в боковой панели сниппетов крупнее */\n[data-section="snippets-panel"] {\n font-size: 14px !important;\n}\n\n/* Пользовательский фон терминала */\n.terminal { background: #1a1a2e !important; }\n\n/* Настройка глобального радиуса скругления */\n:root { --radius: 0.25rem; }',
'/* Примеры — используйте !important, чтобы переопределить специфичность утилит Tailwind */\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': 'Шрифт интерфейса',
'settings.appearance.uiFont.desc': 'Выберите шрифт для интерфейса приложения',
'settings.appearance.windowOpacity': 'Прозрачность окна',
'settings.appearance.windowOpacity.desc': 'Настройте прозрачность всего окна приложения. При низких значениях текст терминала тоже бледнеет. В некоторых средах Linux это может не поддерживаться.',
// Settings > Terminal
'settings.terminal.section.theme': 'Тема терминала',
'settings.terminal.themeModal.title': 'Выберите тему',

View File

@@ -42,6 +42,14 @@ 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.encoding': 'Кодировка терминала',

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': 'Пока нет закладок',
@@ -188,6 +189,8 @@ export const ruVaultMessages: Messages = {
'sftp.viewMode.label': 'Режим просмотра',
'sftp.viewMode.list': 'Список',
'sftp.viewMode.tree': 'Дерево',
'sftp.viewMode.switchToList': 'Переключиться на список',
'sftp.viewMode.switchToTree': 'Переключиться на дерево',
'sftp.tree.loadError': 'Не удалось загрузить каталог',
'sftp.tree.loading': 'Загрузка...',
'sftp.kind.folder': 'Папка',
@@ -232,6 +235,9 @@ export const ruVaultMessages: Messages = {
'sftp.transfers.dragToResize': 'Перетащите для изменения размера',
'sftp.goUp': 'Наверх',
'sftp.goToTerminalCwd': 'Перейти в каталог терминала',
'sftp.followTerminalCwd': 'Следовать за каталогом терминала',
'sftp.followTerminalCwd.enable': 'Включить следование за каталогом терминала',
'sftp.followTerminalCwd.disable': 'Отключить следование за каталогом терминала',
'sftp.encoding.label': 'Кодировка имён файлов',
'sftp.encoding.auto': 'Авто',
'sftp.encoding.utf8': 'UTF-8',
@@ -383,6 +389,10 @@ export const ruVaultMessages: Messages = {
'settings.sftp.autoOpenSidebar.desc': 'Автоматически открывать боковую панель файлового браузера SFTP при подключении к хосту',
'settings.sftp.autoOpenSidebar.enable': 'Включить автооткрытие боковой панели',
'settings.sftp.autoOpenSidebar.enableDesc': 'Боковая панель SFTP будет автоматически открываться при подключении терминальной сессии к удалённому хосту',
'settings.sftp.followTerminalCwd': 'Следовать за каталогом терминала',
'settings.sftp.followTerminalCwd.desc': 'Автоматически синхронизировать боковую панель SFTP с рабочим каталогом терминала (переключатель на панели инструментов)',
'settings.sftp.followTerminalCwd.enable': 'Включать следование по умолчанию',
'settings.sftp.followTerminalCwd.enableDesc': 'При открытой боковой панели SFTP режим следования включён по умолчанию и обновляется после команд cd в терминале',
'settings.sftp.defaultViewMode': 'Режим просмотра по умолчанию',
'settings.sftp.defaultViewMode.desc': 'Выберите режим просмотра по умолчанию при открытии новой вкладки SFTP. Настройки конкретного хоста имеют приоритет.',

View File

@@ -174,6 +174,8 @@ export const zhCNAiMessages: Messages = {
'ai.chat.daysAgo': '{n}天前',
'ai.chat.newChat': '新对话',
'ai.chat.allSessions': '所有会话',
'ai.chat.loadEarlierMessages': '加载更早的消息(还有 {n} 条)',
'ai.chat.loadMoreSessions': '加载更多会话(还有 {n} 条)',
'ai.chat.noSessions': '没有历史会话',
'ai.chat.retryHint': '你可以重新发送消息来重试。',
'ai.chat.approvalTimeout': '工具审批已超时5 分钟)。你可以重新发送消息来重试。',
@@ -231,9 +233,27 @@ export const zhCNAiMessages: Messages = {
'terminal.layer.movePanelLeft': '面板移至左侧',
'terminal.layer.movePanelRight': '面板移至右侧',
'terminal.layer.closePanel': '关闭面板',
'terminal.layer.hostTree.search': '搜索主机...',
'terminal.layer.hostTree.searchButton': '搜索',
'terminal.layer.hostTree.tagsButton': '按标签筛选',
'terminal.layer.hostTree.newGroup': '新建分组',
'terminal.layer.hostTree.localShell': '本地 Shell',
'terminal.layer.hostTree.tagsEmpty': '暂无标签',
'terminal.layer.hostTree.clearTags': '清除筛选',
'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 助手',
'topTabs.windowOpacity': '窗口透明度',
'topTabs.toggleTheme': '切换主题',
'topTabs.openSettings': '打开设置',
'ai.chat.sessionHistory': '会话历史',

View File

@@ -209,6 +209,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,14 +250,15 @@ 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-sidebar、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。',
'settings.appearance.customCss.placeholder':
'/* 示例 — 由于 Tailwind 优先级较高,需要使用 !important */\n\n/* 放大代码片段侧边栏字号 */\n[data-section="snippets-panel"] {\n font-size: 14px !important;\n}\n\n/* 自定义终端背景色 */\n.terminal { background: #1a1a2e !important; }\n\n/* 调整全局圆角 */\n:root { --radius: 0.25rem; }',
'/* 示例 — 由于 Tailwind 优先级较高,需要使用 !important */\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': '界面字体',
'settings.appearance.uiFont.desc': '选择软件界面使用的字体',
'settings.appearance.windowOpacity': '窗口透明度',
'settings.appearance.windowOpacity.desc': '调节整个应用窗口的透明度,方便叠在其他内容上方。较低时终端文字也会变淡;部分 Linux 桌面环境可能不支持。',
// Context menus / common actions
'action.newHost': '新建主机',
'action.newSubfolder': '新建文件夹',
@@ -367,6 +370,7 @@ export const zhCNCoreMessages: Messages = {
'vault.groups.hostsCount': '{count} 台主机',
'vault.groups.newSubgroup': '新建子分组',
'vault.groups.rename': '重命名分组',
'vault.groups.unnamed': '未命名分组',
'vault.groups.delete': '删除分组',
'vault.groups.createSubfolder': '创建子分组',
'vault.groups.createRoot': '创建根分组',
@@ -439,6 +443,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': '保存主机以快速连接到你的服务器、虚拟机和容器。',
@@ -539,6 +544,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': '暂无收藏路径',
@@ -569,6 +575,8 @@ export const zhCNCoreMessages: Messages = {
'sftp.viewMode.label': '视图模式',
'sftp.viewMode.list': '列表视图',
'sftp.viewMode.tree': '树形视图',
'sftp.viewMode.switchToList': '切换到列表视图',
'sftp.viewMode.switchToTree': '切换到树形视图',
'sftp.tree.loadError': '加载目录失败',
'sftp.tree.loading': '加载中...',
'sftp.kind.folder': '文件夹',
@@ -613,6 +621,9 @@ export const zhCNCoreMessages: Messages = {
'sftp.transfers.dragToResize': '拖拽调整高度',
'sftp.goUp': '上一级',
'sftp.goToTerminalCwd': '定位到终端当前目录',
'sftp.followTerminalCwd': '追随终端目录',
'sftp.followTerminalCwd.enable': '开启追随终端目录',
'sftp.followTerminalCwd.disable': '关闭追随终端目录',
'sftp.encoding.label': '文件名编码',
'sftp.encoding.auto': '自动',
'sftp.encoding.utf8': 'UTF-8',

View File

@@ -80,6 +80,11 @@ export const zhCNTerminalMessages: Messages = {
'settings.sftp.autoOpenSidebar.enable': '启用自动打开侧栏',
'settings.sftp.autoOpenSidebar.enableDesc': '当终端会话连接到远程主机时SFTP 侧栏将自动打开',
'settings.sftp.followTerminalCwd': '追随终端目录',
'settings.sftp.followTerminalCwd.desc': '在侧栏 SFTP 中自动跟随终端当前工作目录变化(可在工具栏切换)',
'settings.sftp.followTerminalCwd.enable': '默认开启追随终端目录',
'settings.sftp.followTerminalCwd.enableDesc': '打开侧栏 SFTP 时默认启用追随模式,终端执行 cd 后文件浏览器会自动跳转',
'settings.sftp.defaultViewMode': '默认视图模式',
'settings.sftp.defaultViewMode.desc': '选择打开新 SFTP 标签页时的默认视图模式。每个主机的偏好设置会覆盖此全局设置。',
'settings.sftp.defaultViewMode.list': '列表视图',

View File

@@ -229,6 +229,14 @@ 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.encoding': '终端编码',

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

@@ -1,7 +1,10 @@
import { useCallback, useSyncExternalStore } from 'react';
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:';
@@ -18,19 +21,37 @@ export const fromEditorTabId = (tabId: string): string => tabId.slice(EDITOR_PRE
class ActiveTabStore {
private activeTabId: string = 'vault';
private listeners = new Set<Listener>();
private pendingNotify = false;
private syncListeners = new Set<SyncListener>();
private notifyRafId: number | null = null;
getActiveTabId = () => this.activeTabId;
private scheduleNotify = () => {
if (this.notifyRafId !== null) return;
const schedule = typeof requestAnimationFrame === 'function'
? requestAnimationFrame
: (cb: () => void) => window.setTimeout(cb, 0) as unknown as number;
this.notifyRafId = schedule(() => {
this.notifyRafId = null;
this.listeners.forEach((listener) => listener());
});
};
setActiveTabId = (id: string) => {
if (this.activeTabId !== id) {
terminalLayoutSuppressStore.begin();
this.activeTabId = id;
// Defer listener notification to avoid "setState during render" if called from a render phase
if (this.pendingNotify) return;
this.pendingNotify = true;
Promise.resolve().then(() => {
this.pendingNotify = false;
this.listeners.forEach(listener => listener());
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();
const schedule = typeof requestAnimationFrame === 'function'
? requestAnimationFrame
: (cb: () => void) => window.setTimeout(cb, 0) as unknown as number;
schedule(() => {
schedule(() => {
terminalLayoutSuppressStore.end();
});
});
}
};
@@ -39,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();
@@ -47,7 +73,8 @@ export const activeTabStore = new ActiveTabStore();
export const useActiveTabId = () => {
return useSyncExternalStore(
activeTabStore.subscribe,
activeTabStore.getActiveTabId
activeTabStore.getActiveTabId,
activeTabStore.getActiveTabId,
);
};
@@ -59,7 +86,7 @@ export const useSetActiveTabId = () => {
// Check if a specific tab is active - only re-renders when this specific tab's active state changes
export const useIsTabActive = (tabId: string) => {
const getSnapshot = useCallback(() => activeTabStore.getActiveTabId() === tabId, [tabId]);
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot);
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot, getSnapshot);
};
// Stable snapshot functions - defined once outside components
@@ -70,7 +97,8 @@ const getIsSftpActive = () => activeTabStore.getActiveTabId() === 'sftp';
export const useIsVaultActive = () => {
return useSyncExternalStore(
activeTabStore.subscribe,
getIsVaultActive
getIsVaultActive,
getIsVaultActive,
);
};
@@ -78,7 +106,8 @@ export const useIsVaultActive = () => {
export const useIsSftpActive = () => {
return useSyncExternalStore(
activeTabStore.subscribe,
getIsSftpActive
getIsSftpActive,
getIsSftpActive,
);
};
@@ -86,17 +115,5 @@ export const useIsSftpActive = () => {
export const useIsEditorTabActive = (tabId: string): boolean => {
const editorTopId = toEditorTabId(tabId);
const getSnapshot = useCallback(() => activeTabStore.getActiveTabId() === editorTopId, [editorTopId]);
return useSyncExternalStore(activeTabStore.subscribe, 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);
return useSyncExternalStore(activeTabStore.subscribe, getSnapshot, getSnapshot);
};

View File

@@ -238,9 +238,9 @@ export const editorTabStore = new EditorTabStore();
const getTabsSnapshot = () => editorTabStore.getTabs();
export const useEditorTabs = (): readonly EditorTab[] =>
useSyncExternalStore(editorTabStore.subscribe, getTabsSnapshot);
useSyncExternalStore(editorTabStore.subscribe, getTabsSnapshot, getTabsSnapshot);
export const useEditorTab = (id: EditorTabId): EditorTab | undefined => {
const getSnapshot = useCallback(() => editorTabStore.getTab(id), [id]);
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot);
return useSyncExternalStore(editorTabStore.subscribe, getSnapshot, getSnapshot);
};

View File

@@ -0,0 +1,36 @@
import { useSyncExternalStore } from 'react';
type Listener = () => void;
class HostTreeInlineGroupDeleteStore {
private targetPath: string | null = null;
private listeners = new Set<Listener>();
getTargetPath = () => this.targetPath;
open = (groupPath: string) => {
this.targetPath = groupPath;
this.listeners.forEach((listener) => listener());
};
close = () => {
if (!this.targetPath) return;
this.targetPath = null;
this.listeners.forEach((listener) => listener());
};
subscribe = (listener: Listener) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
}
export const hostTreeInlineGroupDeleteStore = new HostTreeInlineGroupDeleteStore();
export const useHostTreeInlineGroupDeleteTarget = () => {
return useSyncExternalStore(
hostTreeInlineGroupDeleteStore.subscribe,
hostTreeInlineGroupDeleteStore.getTargetPath,
hostTreeInlineGroupDeleteStore.getTargetPath,
);
};

View File

@@ -0,0 +1,52 @@
import { useSyncExternalStore } from 'react';
export type HostTreeInlineGroupEdit = {
groupPath: string;
initialName: string;
isNew: boolean;
shouldScrollIntoView?: boolean;
};
type Listener = () => void;
class HostTreeInlineGroupEditStore {
private edit: HostTreeInlineGroupEdit | null = null;
private listeners = new Set<Listener>();
getEdit = () => this.edit;
startEdit = (edit: HostTreeInlineGroupEdit) => {
this.edit = {
...edit,
shouldScrollIntoView: edit.isNew ? true : edit.shouldScrollIntoView,
};
this.listeners.forEach((listener) => listener());
};
markScrollHandled = () => {
if (!this.edit?.shouldScrollIntoView) return;
this.edit = { ...this.edit, shouldScrollIntoView: false };
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 hostTreeInlineGroupEditStore = new HostTreeInlineGroupEditStore();
export const useHostTreeInlineGroupEdit = () => {
return useSyncExternalStore(
hostTreeInlineGroupEditStore.subscribe,
hostTreeInlineGroupEditStore.getEdit,
hostTreeInlineGroupEditStore.getEdit,
);
};

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

@@ -74,5 +74,6 @@ export const useSessionActivityMap = () => {
return useSyncExternalStore(
sessionActivityStore.subscribe,
sessionActivityStore.getSnapshot,
sessionActivityStore.getSnapshot,
);
};

View File

@@ -18,6 +18,7 @@ import {
STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED,
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY,
STORAGE_KEY_TERM_FOLLOW_APP_THEME,
@@ -33,9 +34,15 @@ 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';
import { isValidUiFontId, migrateIncomingTerminalFontId } from './settingsStateDefaults';
import {
clampWindowOpacity,
isValidUiFontId,
migrateIncomingTerminalFontId,
} from './settingsStateDefaults';
interface UseSettingsIpcSyncParams {
syncAppearanceFromStorage: () => void;
@@ -59,10 +66,13 @@ interface UseSettingsIpcSyncParams {
applyIncomingCustomKeyBindings: (incoming: { bindings: CustomKeyBindings; version: number; origin: string }) => void;
setIsHotkeyRecordingState: Dispatch<SetStateAction<boolean>>;
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
setWindowOpacity: Dispatch<SetStateAction<number>>;
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
setSftpAutoOpenSidebar: Dispatch<SetStateAction<boolean>>;
setSftpFollowTerminalCwd: Dispatch<SetStateAction<boolean>>;
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
setSftpTransferConcurrencyState: Dispatch<SetStateAction<number>>;
}
@@ -88,10 +98,13 @@ export function useSettingsIpcSync({
applyIncomingCustomKeyBindings,
setIsHotkeyRecordingState,
setGlobalHotkeyEnabled,
setWindowOpacity,
setAutoUpdateEnabled,
setSftpAutoOpenSidebar,
setSftpFollowTerminalCwd,
setSftpDefaultViewMode,
setWorkspaceFocusStyleState,
setShowHostTreeSidebarState,
setSftpTransferConcurrencyState,
}: UseSettingsIpcSyncParams) {
// Listen for settings changes from other windows via IPC
@@ -191,12 +204,19 @@ export function useSettingsIpcSync({
if (key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && typeof value === 'boolean') {
setGlobalHotkeyEnabled((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_WINDOW_OPACITY && (typeof value === 'number' || typeof value === 'string')) {
const nextOpacity = clampWindowOpacity(value);
setWindowOpacity((prev) => (prev === nextOpacity ? prev : nextOpacity));
}
if (key === STORAGE_KEY_AUTO_UPDATE_ENABLED && typeof value === 'boolean') {
setAutoUpdateEnabled((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && typeof value === 'boolean') {
setSftpAutoOpenSidebar((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD && typeof value === 'boolean') {
setSftpFollowTerminalCwd((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE && typeof value === 'string') {
if (value === 'list' || value === 'tree') {
setSftpDefaultViewMode((prev) => (prev === value ? prev : value));
@@ -205,6 +225,9 @@ 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_SFTP_TRANSFER_CONCURRENCY && typeof value === 'number') {
setSftpTransferConcurrencyState((prev) => (prev === value ? prev : value));
}
@@ -223,6 +246,7 @@ export function useSettingsIpcSync({
setEditorWordWrapState,
setFollowAppTerminalThemeState,
setGlobalHotkeyEnabled,
setWindowOpacity,
setHotkeyScheme,
setIsHotkeyRecordingState,
setSessionLogsDir,
@@ -231,7 +255,9 @@ export function useSettingsIpcSync({
setSessionLogsTimestampsEnabled,
setSshDebugLogsEnabled,
setSftpAutoOpenSidebar,
setSftpFollowTerminalCwd,
setSftpDefaultViewMode,
setShowHostTreeSidebarState,
setSftpTransferConcurrencyState,
setTerminalFontFamilyId,
setTerminalFontSize,

View File

@@ -8,6 +8,12 @@ import { localStorageAdapter } from '../../infrastructure/persistence/localStora
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export const DEFAULT_THEME: 'light' | 'dark' | 'system' = 'dark';
export const DEFAULT_WINDOW_OPACITY = 1;
export function clampWindowOpacity(opacity: unknown): number {
const value = Number(opacity);
if (!Number.isFinite(value)) return DEFAULT_WINDOW_OPACITY;
return Math.min(1, Math.max(0.5, value));
}
/** Resolve the current OS color scheme preference. */
export const getSystemPreference = (): 'light' | 'dark' =>
@@ -52,10 +58,12 @@ export const DEFAULT_SFTP_AUTO_SYNC = false;
export const DEFAULT_SFTP_SHOW_HIDDEN_FILES = false;
export const DEFAULT_SFTP_USE_COMPRESSED_UPLOAD = true;
export const DEFAULT_SFTP_AUTO_OPEN_SIDEBAR = false;
export const DEFAULT_SFTP_FOLLOW_TERMINAL_CWD = false;
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;
// Editor defaults
export const DEFAULT_EDITOR_WORD_WRAP = false;
@@ -122,11 +130,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

@@ -17,6 +17,7 @@ import {
STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED,
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
STORAGE_KEY_SFTP_AUTO_SYNC,
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
@@ -26,6 +27,7 @@ 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_TERM_FOLLOW_APP_THEME,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_FONT_SIZE,
@@ -39,8 +41,10 @@ import {
STORAGE_KEY_UI_THEME_DARK,
STORAGE_KEY_UI_THEME_LIGHT,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_WINDOW_OPACITY,
} from '../../infrastructure/config/storageKeys';
import {
clampWindowOpacity,
isValidHslToken,
isValidTheme,
isValidUiFontId,
@@ -67,10 +71,12 @@ interface UseSettingsStorageSyncParams {
sftpShowHiddenFiles: boolean;
sftpUseCompressedUpload: boolean;
sftpAutoOpenSidebar: boolean;
sftpFollowTerminalCwd: boolean;
sftpDefaultViewMode: 'list' | 'tree';
showRecentHosts: boolean;
showOnlyUngroupedHostsInRoot: boolean;
showSftpTab: boolean;
showHostTreeSidebar: boolean;
editorWordWrap: boolean;
sessionLogsEnabled: boolean;
sessionLogsDir: string;
@@ -79,6 +85,7 @@ interface UseSettingsStorageSyncParams {
sshDebugLogsEnabled: boolean;
globalHotkeyEnabled: boolean;
autoUpdateEnabled: boolean;
windowOpacity: number;
setTheme: Dispatch<SetStateAction<'dark' | 'light' | 'system'>>;
setLightUiThemeId: Dispatch<SetStateAction<string>>;
setDarkUiThemeId: Dispatch<SetStateAction<string>>;
@@ -99,10 +106,12 @@ interface UseSettingsStorageSyncParams {
setSftpShowHiddenFiles: Dispatch<SetStateAction<boolean>>;
setSftpUseCompressedUpload: Dispatch<SetStateAction<boolean>>;
setSftpAutoOpenSidebar: Dispatch<SetStateAction<boolean>>;
setSftpFollowTerminalCwd: Dispatch<SetStateAction<boolean>>;
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
setShowRecentHostsState: Dispatch<SetStateAction<boolean>>;
setShowOnlyUngroupedHostsInRootState: Dispatch<SetStateAction<boolean>>;
setShowSftpTabState: Dispatch<SetStateAction<boolean>>;
setShowHostTreeSidebarState: Dispatch<SetStateAction<boolean>>;
setEditorWordWrapState: Dispatch<SetStateAction<boolean>>;
setSessionLogsEnabled: Dispatch<SetStateAction<boolean>>;
setSessionLogsDir: Dispatch<SetStateAction<string>>;
@@ -110,6 +119,7 @@ interface UseSettingsStorageSyncParams {
setSessionLogsTimestampsEnabled: Dispatch<SetStateAction<boolean>>;
setSshDebugLogsEnabled: Dispatch<SetStateAction<boolean>>;
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
setWindowOpacity: Dispatch<SetStateAction<number>>;
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
setSftpTransferConcurrencyState: Dispatch<SetStateAction<number>>;
@@ -122,19 +132,19 @@ export function useSettingsStorageSync({
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpDefaultViewMode,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState,
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled,
setGlobalHotkeyEnabled, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setGlobalHotkeyEnabled, setWindowOpacity, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
}: UseSettingsStorageSyncParams) {
// Fix 4: Keep a ref snapshot of current settings so the storage event handler
@@ -145,20 +155,20 @@ export function useSettingsStorageSync({
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
});
settingsSnapshotRef.current = {
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
};
// Listen for storage changes from other windows (cross-window sync)
@@ -334,6 +344,12 @@ export function useSettingsStorageSync({
setSftpAutoOpenSidebar(newValue);
}
}
if (e.key === STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.sftpFollowTerminalCwd) {
setSftpFollowTerminalCwd(newValue);
}
}
// Sync SFTP default view mode from other windows
if (e.key === STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE && e.newValue) {
if ((e.newValue === 'list' || e.newValue === 'tree') && e.newValue !== s.sftpDefaultViewMode) {
@@ -358,6 +374,12 @@ 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);
}
}
// Sync global hotkey enabled setting from other windows
if (e.key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && e.newValue !== null) {
const newValue = e.newValue === 'true';
@@ -372,6 +394,12 @@ export function useSettingsStorageSync({
setAutoUpdateEnabled(newValue);
}
}
if (e.key === STORAGE_KEY_WINDOW_OPACITY && e.newValue !== null) {
const newValue = clampWindowOpacity(e.newValue);
if (newValue !== s.windowOpacity) {
setWindowOpacity(newValue);
}
}
// Sync workspace focus style from other windows
if (e.key === STORAGE_KEY_WORKSPACE_FOCUS_STYLE && e.newValue !== null) {
if (e.newValue === 'dim' || e.newValue === 'border') {
@@ -400,6 +428,7 @@ export function useSettingsStorageSync({
setEditorWordWrapState,
setFollowAppTerminalThemeState,
setGlobalHotkeyEnabled,
setWindowOpacity,
setHotkeyScheme,
setLightUiThemeId,
setSessionLogsDir,
@@ -408,6 +437,7 @@ export function useSettingsStorageSync({
setSessionLogsTimestampsEnabled,
setSshDebugLogsEnabled,
setSftpAutoOpenSidebar,
setSftpFollowTerminalCwd,
setSftpAutoSync,
setSftpDefaultViewMode,
setSftpDoubleClickBehavior,
@@ -415,6 +445,7 @@ export function useSettingsStorageSync({
setSftpTransferConcurrencyState,
setSftpUseCompressedUpload,
setShowOnlyUngroupedHostsInRootState,
setShowHostTreeSidebarState,
setShowRecentHostsState,
setShowSftpTabState,
setTerminalFontFamilyId,

View File

@@ -1,18 +1,21 @@
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import type { FileWatchErrorEvent, FileWatchSyncedEvent, SftpStateOptions } from "./types";
export const useSftpFileWatch = (options?: SftpStateOptions) => {
const optionsRef = useRef(options);
optionsRef.current = options;
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onFileWatchSynced || !bridge?.onFileWatchError) return;
const unsubscribeSynced = bridge.onFileWatchSynced((payload: FileWatchSyncedEvent) => {
options?.onFileWatchSynced?.(payload);
optionsRef.current?.onFileWatchSynced?.(payload);
});
const unsubscribeError = bridge.onFileWatchError((payload: FileWatchErrorEvent) => {
options?.onFileWatchError?.(payload);
optionsRef.current?.onFileWatchError?.(payload);
});
return () => {
@@ -23,5 +26,5 @@ export const useSftpFileWatch = (options?: SftpStateOptions) => {
// ignore cleanup errors
}
};
}, [options]);
}, []);
};

View File

@@ -4,6 +4,7 @@ import {
STORAGE_KEY_CLOSE_TO_TRAY,
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
STORAGE_KEY_WINDOW_OPACITY,
} from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
@@ -12,6 +13,7 @@ interface UseSystemSettingsEffectsParams {
toggleWindowHotkey: string;
globalHotkeyEnabled: boolean;
closeToTray: boolean;
windowOpacity: number;
autoUpdateEnabled: boolean;
persistMountedRef: MutableRefObject<boolean>;
setHotkeyRegistrationError: (error: string | null) => void;
@@ -23,6 +25,7 @@ export function useSystemSettingsEffects({
toggleWindowHotkey,
globalHotkeyEnabled,
closeToTray,
windowOpacity,
autoUpdateEnabled,
persistMountedRef,
setHotkeyRegistrationError,
@@ -89,6 +92,17 @@ export function useSystemSettingsEffects({
notifySettingsChanged(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray);
}, [closeToTray, notifySettingsChanged, persistMountedRef]);
// Persist and sync window opacity
useEffect(() => {
const bridge = netcattyBridge.get();
bridge?.setWindowOpacity?.(windowOpacity).catch((err) => {
console.warn('[WindowOpacity] Failed to apply window opacity:', err);
});
localStorageAdapter.writeString(STORAGE_KEY_WINDOW_OPACITY, String(windowOpacity));
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_WINDOW_OPACITY, windowOpacity);
}, [windowOpacity, notifySettingsChanged, persistMountedRef]);
// Hydrate auto-update state from the main-process preference file on mount.
// This reconciles localStorage (renderer) with auto-update-pref.json (main)
// in case localStorage was cleared or is stale.

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

@@ -0,0 +1,84 @@
import { useCallback, useSyncExternalStore } from 'react';
import { STORAGE_KEY_TERMINAL_HOST_TREE_COLLAPSED } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
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.
if (stored === 'true') return false;
if (stored === 'false') return true;
return false;
}
class TerminalHostTreeStore {
private isOpen = readIsOpen();
/** Live sidebar width (0 when collapsed) for top-tab alignment. */
private layoutWidth = 0;
private listeners = new Set<Listener>();
getIsOpen = () => this.isOpen;
getLayoutWidth = () => this.layoutWidth;
setIsOpen = (open: boolean) => {
if (this.isOpen === open) return;
this.isOpen = open;
localStorageAdapter.writeString(
STORAGE_KEY_TERMINAL_HOST_TREE_COLLAPSED,
open ? 'false' : 'true',
);
this.listeners.forEach((listener) => listener());
};
setLayoutWidth = (width: number) => {
const next = Math.max(0, Math.round(width));
if (this.layoutWidth === next) return;
this.layoutWidth = next;
this.listeners.forEach((listener) => listener());
};
toggle = () => {
this.setIsOpen(!this.isOpen);
};
subscribe = (listener: Listener) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
}
export const terminalHostTreeStore = new TerminalHostTreeStore();
export const useTerminalHostTreeOpen = () => {
return useSyncExternalStore(
terminalHostTreeStore.subscribe,
terminalHostTreeStore.getIsOpen,
terminalHostTreeStore.getIsOpen,
);
};
export const useToggleTerminalHostTree = () => {
return useCallback(() => terminalHostTreeStore.toggle(), []);
};
export const useTerminalHostTreeLayoutWidth = () => {
return useSyncExternalStore(
terminalHostTreeStore.subscribe,
terminalHostTreeStore.getLayoutWidth,
terminalHostTreeStore.getLayoutWidth,
);
};

View File

@@ -0,0 +1,16 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { terminalLayoutSuppressStore } from './terminalLayoutSuppressStore';
test('terminalLayoutSuppressStore tracks nested begin/end', () => {
assert.equal(terminalLayoutSuppressStore.getActive(), false);
terminalLayoutSuppressStore.begin();
assert.equal(terminalLayoutSuppressStore.getActive(), true);
terminalLayoutSuppressStore.begin();
assert.equal(terminalLayoutSuppressStore.getActive(), true);
terminalLayoutSuppressStore.end();
assert.equal(terminalLayoutSuppressStore.getActive(), true);
terminalLayoutSuppressStore.end();
assert.equal(terminalLayoutSuppressStore.getActive(), false);
});

View File

@@ -0,0 +1,40 @@
import { useSyncExternalStore } from 'react';
type Listener = () => void;
let suppressDepth = 0;
const listeners = new Set<Listener>();
function emit() {
listeners.forEach((listener) => listener());
}
export const terminalLayoutSuppressStore = {
getActive: () => suppressDepth > 0,
subscribe: (listener: Listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
begin: () => {
suppressDepth += 1;
emit();
},
end: () => {
const wasActive = suppressDepth > 0;
suppressDepth = Math.max(0, suppressDepth - 1);
if (wasActive) {
emit();
}
},
};
export function useTerminalLayoutSuppressActive(): boolean {
return useSyncExternalStore(
terminalLayoutSuppressStore.subscribe,
terminalLayoutSuppressStore.getActive,
terminalLayoutSuppressStore.getActive,
);
}

View File

@@ -0,0 +1,76 @@
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);
});

View File

@@ -0,0 +1,61 @@
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;
type DocumentWithViewTransition = Document & {
startViewTransition?: (callback: () => void | Promise<void>) => {
finished: Promise<void>;
skipTransition: () => void;
};
};
let cancelThemeTransitionReset: (() => void) | null = null;
export function runThemeTransition(
apply: () => void,
root: HTMLElement = document.documentElement,
): void {
cancelThemeTransitionReset?.();
const cleanup = () => {
root.removeAttribute(THEME_TRANSITION_ATTR);
cancelThemeTransitionReset = null;
};
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 {
root.setAttribute(THEME_TRANSITION_ATTR, 'true');
apply();
const timer = globalThis.setTimeout(cleanup, THEME_TRANSITION_MS + 40);
cancelThemeTransitionReset = () => {
globalThis.clearTimeout(timer);
cleanup();
};
return;
}
cancelThemeTransitionReset = () => {
transition?.skipTransition();
cleanup();
};
void transition.finished.finally(cleanup);
return;
}
root.setAttribute(THEME_TRANSITION_ATTR, 'true');
apply();
const timer = globalThis.setTimeout(cleanup, THEME_TRANSITION_MS + 40);
cancelThemeTransitionReset = () => {
globalThis.clearTimeout(timer);
cleanup();
};
}

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import {
STORAGE_KEY_AI_PROVIDERS,
@@ -941,7 +941,7 @@ export function useAIState() {
// ── Computed ──
const activeProvider = providers.find(p => p.id === activeProviderId) ?? null;
return {
return useMemo(() => ({
providers,
setProviders,
addProvider,
@@ -996,5 +996,60 @@ export function useAIState() {
updateMessageById,
clearSessionMessages,
cleanupOrphanedSessions,
};
}), [
providers,
setProviders,
addProvider,
updateProvider,
removeProvider,
activeProviderId,
setActiveProviderId,
activeModelId,
setActiveModelId,
activeProvider,
globalPermissionMode,
setGlobalPermissionMode,
toolIntegrationMode,
setToolIntegrationMode,
hostPermissions,
setHostPermissions,
externalAgents,
setExternalAgents,
defaultAgentId,
setDefaultAgentId,
commandBlocklist,
setCommandBlocklist,
commandTimeout,
setCommandTimeout,
maxIterations,
setMaxIterations,
agentModelMap,
setAgentModel,
agentProviderMap,
setAgentProvider,
webSearchConfig,
setWebSearchConfig,
sessions,
activeSessionIdMap,
draftsByScope,
panelViewByScope,
setActiveSessionId,
ensureDraftForScope,
updateDraft,
showDraftView,
showSessionView,
clearDraftForScope,
addDraftFiles,
removeDraftFile,
createSession,
deleteSession,
deleteSessionsByTarget,
updateSessionTitle,
updateSessionExternalSessionId,
addMessageToSession,
updateLastMessage,
updateMessageById,
clearSessionMessages,
cleanupOrphanedSessions,
]);
}

View File

@@ -0,0 +1,49 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
scheduleChromeLayoutAnimation,
} from "./useActiveChromeTheme.ts";
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();
});

View File

@@ -0,0 +1,258 @@
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);
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) return;
if (activeTheme) {
applyActiveChromeTheme(activeTheme);
return;
}
clearTopTabsChromeThemeVars();
runThemeTransition(() => {
removeActiveChromeTheme();
applyAppTheme();
});
}
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

@@ -13,7 +13,9 @@ function getBridge(): NetcattyBridge | undefined {
export function useAgentDiscovery(
externalAgents: ExternalAgentConfig[],
setExternalAgents?: (value: ExternalAgentConfig[] | ((prev: ExternalAgentConfig[]) => ExternalAgentConfig[])) => void,
options?: { enabled?: boolean },
) {
const enabled = options?.enabled ?? true;
const [discoveredAgents, setDiscoveredAgents] = useState<DiscoveredAgent[]>([]);
const [isDiscovering, setIsDiscovering] = useState(false);
@@ -32,10 +34,28 @@ export function useAgentDiscovery(
}
}, []);
// Discover on mount
useEffect(() => {
discover();
}, [discover]);
if (!enabled) return;
let cancelled = false;
const runDiscover = () => {
if (!cancelled) void discover();
};
if (typeof requestIdleCallback === 'function') {
const idleId = requestIdleCallback(runDiscover, { timeout: 2000 });
return () => {
cancelled = true;
cancelIdleCallback(idleId);
};
}
const timeoutId = setTimeout(runDiscover, 0);
return () => {
cancelled = true;
clearTimeout(timeoutId);
};
}, [discover, enabled]);
// Auto-update args for already-configured discovered agents when
// the canonical args from discovery change (e.g. after an app update).

View File

@@ -109,11 +109,16 @@ interface RemoteVersionCheckOptions {
export const useAutoSync = (config: AutoSyncConfig) => {
const { t } = useI18n();
const tRef = useRef(t);
useEffect(() => {
tRef.current = t;
}, [t]);
const sync = useCloudSync();
const { onApplyPayload } = config;
const syncTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastSyncedDataRef = useRef<string>('');
const hasCheckedRemoteRef = useRef(false);
const inspectFailureToastShownRef = useRef(false);
/** True once checkRemoteVersion has completed (success or failure). Until
* this is set, the debounced auto-sync effect will not fire, preventing
* an empty local vault from racing ahead and overwriting a non-empty
@@ -513,7 +518,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
});
skipNextSyncRef.current = true;
startupConsistent = true;
notify.success(t('sync.autoSync.restoredMessage'), t('sync.autoSync.restoredTitle'));
notify.success(tRef.current('sync.autoSync.restoredMessage'), tRef.current('sync.autoSync.restoredTitle'));
} else {
// User chose to keep the empty vault. Deliberately do NOT advance
// the anchor or base — the next sync must still treat remote as
@@ -521,7 +526,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// keeps protecting the cloud copy. startupConsistent stays false
// so hasCheckedRemoteRef is not latched and the next startup will
// re-prompt if the user still has not added anything.
notify.info(t('sync.autoSync.keptLocalMessage'), t('sync.autoSync.keptLocalTitle'));
notify.info(tRef.current('sync.autoSync.keptLocalMessage'), tRef.current('sync.autoSync.keptLocalTitle'));
}
return;
}
@@ -555,7 +560,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
} else if (!roundTripFullySynced) {
console.warn('[AutoSync] Cloud-wins round-trip did not update every provider; leaving next auto-sync enabled for retry.');
}
notify.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle'));
notify.success(tRef.current('sync.autoSync.syncedMessage'), tRef.current('sync.autoSync.syncedTitle'));
return;
}
@@ -590,7 +595,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
await manager.commitRemoteInspection(connectedProvider, remoteFile, remotePayload);
startupConsistent = true;
markCurrentDataSynced = false;
notify.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle'));
notify.success(tRef.current('sync.autoSync.syncedMessage'), tRef.current('sync.autoSync.syncedTitle'));
// If the three-way merge introduced any local-only additions that the
// remote does not yet have, we MUST round-trip those to the cloud.
@@ -637,14 +642,13 @@ export const useAutoSync = (config: AutoSyncConfig) => {
}
} catch (error) {
console.error('[AutoSync] Failed to check remote version:', error);
if (notifyOnFailure) {
// Surface a degraded-sync hint to the user rather than silently
// opening the auto-sync gate. Auto-sync will still retry on next
// data change (see finally block), but without this toast the user
// has no visible signal that startup reconciliation failed.
if (notifyOnFailure && !inspectFailureToastShownRef.current) {
// Surface a degraded-sync hint once per session. Retries and
// incidental re-triggers (e.g. effect restarts) must not spam toasts.
inspectFailureToastShownRef.current = true;
notify.error(
t('sync.autoSync.inspectFailedMessage'),
t('sync.autoSync.inspectFailedTitle'),
tRef.current('sync.autoSync.inspectFailedMessage'),
tRef.current('sync.autoSync.inspectFailedTitle'),
);
}
// Leave hasCheckedRemoteRef=false so the next startup (or the next
@@ -677,7 +681,14 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// identity flips (every vault edit produces a fresh `buildPayload`
// and a fresh AutoSyncConfig literal) cannot re-memoize this
// callback and restart the retry-timer's exponential backoff.
}, [t]);
// `t` is read through tRef so locale updates don't rebuild this
// callback and re-fire the startup retry effect on unrelated renders.
}, []);
const checkRemoteVersionRef = useRef(checkRemoteVersion);
useEffect(() => {
checkRemoteVersionRef.current = checkRemoteVersion;
}, [checkRemoteVersion]);
// Debounced auto-sync when data changes
useEffect(() => {
@@ -789,7 +800,10 @@ export const useAutoSync = (config: AutoSyncConfig) => {
const tick = () => {
if (cancelled) return;
void (async () => {
await checkRemoteVersion();
const notifyOnFailure = attempt === 0;
await checkRemoteVersionRef.current(
notifyOnFailure ? undefined : { notifyOnFailure: false },
);
if (cancelled || hasCheckedRemoteRef.current) return;
// Cap retries at ~5 minutes total (30s + 60s + 120s + 240s). A
// persistent failure beyond that is almost certainly a
@@ -824,7 +838,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
cancelled = true;
if (timerId) clearTimeout(timerId);
};
}, [sync.hasAnyConnectedProvider, sync.isUnlocked, config.startupReady, checkRemoteVersion]);
}, [sync.hasAnyConnectedProvider, sync.isUnlocked, config.startupReady]);
const runRuntimeRemoteCheck = useCallback(async (options?: { force?: boolean }) => {
const now = Date.now();

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

@@ -12,6 +12,91 @@ export type { UploadedFile } from '../../infrastructure/ai/types';
/** Reject only known binary blobs that AI models can't process */
const REJECTED_MIME_PREFIXES = ['video/', 'audio/'];
/**
* Infer MIME type from file extension when the browser/Electron doesn't
* provide one (common for .yaml, .sh, .toml, and other code/text files).
*/
const EXTENSION_MIME_TYPES: Record<string, string> = {
// Code & Scripts — all use text/plain for maximum provider compatibility
js: 'text/plain',
mjs: 'text/plain',
cjs: 'text/plain',
jsx: 'text/plain',
ts: 'text/plain',
tsx: 'text/plain',
py: 'text/plain',
rb: 'text/plain',
rs: 'text/plain',
go: 'text/plain',
java: 'text/plain',
c: 'text/plain',
h: 'text/plain',
cpp: 'text/plain',
hpp: 'text/plain',
cs: 'text/plain',
swift: 'text/plain',
kt: 'text/plain',
scala: 'text/plain',
php: 'text/plain',
pl: 'text/plain',
sh: 'text/plain',
bash: 'text/plain',
zsh: 'text/plain',
fish: 'text/plain',
ps1: 'text/plain',
bat: 'text/plain',
cmd: 'text/plain',
sql: 'text/plain',
r: 'text/plain',
lua: 'text/plain',
dart: 'text/plain',
// Web
html: 'text/html',
htm: 'text/html',
css: 'text/css',
scss: 'text/plain',
sass: 'text/plain',
less: 'text/plain',
vue: 'text/plain',
svelte: 'text/plain',
// Config / Data
yaml: 'text/plain',
yml: 'text/plain',
json: 'application/json',
jsonc: 'application/json',
jsonl: 'application/jsonl',
xml: 'application/xml',
toml: 'application/toml',
csv: 'text/csv',
tsv: 'text/tab-separated-values',
ini: 'text/plain',
cfg: 'text/plain',
conf: 'text/plain',
env: 'text/plain',
// Docs
md: 'text/markdown',
markdown: 'text/markdown',
txt: 'text/plain',
tex: 'text/x-tex',
rst: 'text/x-rst',
log: 'text/plain',
// Other typed files
pdf: 'application/pdf',
dockerfile: 'text/plain',
};
function getExtension(fileName: string): string {
const dot = fileName.lastIndexOf('.');
if (dot === -1) return fileName.toLowerCase(); // e.g. "Dockerfile", "Makefile"
return fileName.slice(dot + 1).toLowerCase();
}
function inferMediaType(fileName: string, fileType: string): string {
if (fileType) return fileType;
const ext = getExtension(fileName);
return EXTENSION_MIME_TYPES[ext] || 'application/octet-stream';
}
function isSupportedFile(file: File): boolean {
// Allow files with empty MIME (common in Electron for .sh, .yaml, etc.)
if (!file.type) return true;
@@ -39,7 +124,7 @@ export async function convertFilesToUploads(inputFiles: File[]): Promise<Uploade
supported.map(async (file) => {
const id = crypto.randomUUID();
const filename = file.name || `file-${Date.now()}`;
const mediaType = file.type || 'application/octet-stream';
const mediaType = inferMediaType(filename, file.type);
try {
const result = await fileToDataUrl(file);
const filePath = getPathForFile(file);

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

@@ -16,6 +16,7 @@ SplitDirection,
SplitHint,
updateWorkspaceSplitSizes,
} from '../../domain/workspace';
import { buildOrderedWorkTabIds } from '../app/workTabSurface';
import { activeTabStore } from './activeTabStore';
import {
createCopiedTerminalSessionClone,
@@ -857,31 +858,33 @@ 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 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 allTabIds = [...baseWorkTabIds, ...additionalTabIds];
const allTabIdSet = new Set(allTabIds);
// Build current effective order: existing order + new tabs at end
@@ -913,7 +916,7 @@ export const useSessionState = () => {
return currentOrder;
});
}, [orphanSessions, workspaces, logViews]);
}, [baseWorkTabIds]);
return {
sessions,
@@ -958,6 +961,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,
@@ -25,6 +27,7 @@ import {
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY,
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
STORAGE_KEY_EDITOR_WORD_WRAP,
@@ -36,11 +39,13 @@ import {
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
STORAGE_KEY_CLOSE_TO_TRAY,
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
STORAGE_KEY_WINDOW_OPACITY,
STORAGE_KEY_AUTO_UPDATE_ENABLED,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_SHOW_RECENT_HOSTS,
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
STORAGE_KEY_SHOW_SFTP_TAB,
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
} from '../../infrastructure/config/storageKeys';
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
import {
@@ -72,6 +77,7 @@ import {
DEFAULT_SESSION_LOGS_FORMAT,
DEFAULT_SESSION_LOGS_TIMESTAMPS_ENABLED,
DEFAULT_SFTP_AUTO_OPEN_SIDEBAR,
DEFAULT_SFTP_FOLLOW_TERMINAL_CWD,
DEFAULT_SFTP_AUTO_SYNC,
DEFAULT_SFTP_DEFAULT_VIEW_MODE,
DEFAULT_SFTP_DOUBLE_CLICK_BEHAVIOR,
@@ -80,9 +86,12 @@ import {
DEFAULT_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
DEFAULT_SHOW_RECENT_HOSTS,
DEFAULT_SHOW_SFTP_TAB,
DEFAULT_SHOW_HOST_TREE_SIDEBAR,
DEFAULT_SSH_DEBUG_LOGS_ENABLED,
DEFAULT_TERMINAL_THEME,
DEFAULT_THEME,
DEFAULT_WINDOW_OPACITY,
clampWindowOpacity,
applyThemeTokens,
areTerminalSettingsEqual,
createCustomKeyBindingsSyncOrigin,
@@ -99,6 +108,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 =
@@ -204,6 +214,10 @@ export const useSettingsState = () => {
const stored = readStoredString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
return stored === 'true' ? true : DEFAULT_SFTP_AUTO_OPEN_SIDEBAR;
});
const [sftpFollowTerminalCwd, setSftpFollowTerminalCwd] = useState<boolean>(() => {
const stored = readStoredString(STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD);
return stored === 'true' ? true : DEFAULT_SFTP_FOLLOW_TERMINAL_CWD;
});
const [sftpDefaultViewMode, setSftpDefaultViewMode] = useState<'list' | 'tree'>(() => {
const stored = readStoredString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE);
return (stored === 'list' || stored === 'tree') ? stored : DEFAULT_SFTP_DEFAULT_VIEW_MODE;
@@ -220,6 +234,10 @@ 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 [sftpTransferConcurrency, setSftpTransferConcurrencyState] = useState<number>(() => {
const stored = localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY);
return stored != null && stored >= 1 && stored <= 16 ? stored : 4;
@@ -278,6 +296,19 @@ export const useSettingsState = () => {
if (stored === null) return true; // Default to enabled
return stored === 'true';
});
const [windowOpacity, setWindowOpacityState] = useState<number>(() => {
const stored = readStoredString(STORAGE_KEY_WINDOW_OPACITY);
if (stored === null) return DEFAULT_WINDOW_OPACITY;
return clampWindowOpacity(stored);
});
const setWindowOpacity = useCallback((nextValue: SetStateAction<number>) => {
setWindowOpacityState((prev) => {
const candidate = typeof nextValue === 'function'
? (nextValue as (prevState: number) => number)(prev)
: nextValue;
return clampWindowOpacity(candidate);
});
}, []);
const incomingTerminalSettingsSignatureRef = useRef<string | null>(null);
const localTerminalSettingsVersionRef = useRef(0);
const broadcastedLocalTerminalSettingsVersionRef = useRef(0);
@@ -419,7 +450,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(() => {
@@ -489,6 +522,10 @@ export const useSettingsState = () => {
if (storedCompress === 'true' || storedCompress === 'false') setSftpUseCompressedUpload(storedCompress === 'true');
const storedAutoOpenSidebar = readStoredString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
if (storedAutoOpenSidebar === 'true' || storedAutoOpenSidebar === 'false') setSftpAutoOpenSidebar(storedAutoOpenSidebar === 'true');
const storedFollowTerminalCwd = readStoredString(STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD);
if (storedFollowTerminalCwd === 'true' || storedFollowTerminalCwd === 'false') {
setSftpFollowTerminalCwd(storedFollowTerminalCwd === 'true');
}
const storedDefaultViewMode = readStoredString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE);
if (storedDefaultViewMode === 'list' || storedDefaultViewMode === 'tree') setSftpDefaultViewMode(storedDefaultViewMode);
const storedShowRecentHosts = localStorageAdapter.readBoolean(STORAGE_KEY_SHOW_RECENT_HOSTS);
@@ -497,6 +534,8 @@ 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);
// Workspace focus style
const storedFocusStyle = readStoredString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
@@ -508,7 +547,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);
@@ -576,10 +620,13 @@ export const useSettingsState = () => {
applyIncomingCustomKeyBindings,
setIsHotkeyRecordingState,
setGlobalHotkeyEnabled,
setWindowOpacity,
setAutoUpdateEnabled,
setSftpAutoOpenSidebar,
setSftpFollowTerminalCwd,
setSftpDefaultViewMode,
setWorkspaceFocusStyleState,
setShowHostTreeSidebarState,
setSftpTransferConcurrencyState,
});
@@ -605,19 +652,19 @@ export const useSettingsState = () => {
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
setFollowAppTerminalThemeState, setTerminalFontFamilyId, setTerminalFontSize,
setSftpDoubleClickBehavior, setSftpAutoSync, setSftpShowHiddenFiles,
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpDefaultViewMode,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState,
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled,
setGlobalHotkeyEnabled, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setGlobalHotkeyEnabled, setWindowOpacity, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
});
@@ -722,16 +769,16 @@ 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]);
// 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;
@@ -773,6 +820,13 @@ export const useSettingsState = () => {
notifySettingsChanged(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, sftpAutoOpenSidebar);
}, [sftpAutoOpenSidebar, notifySettingsChanged]);
// Persist SFTP follow terminal cwd setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD, sftpFollowTerminalCwd ? 'true' : 'false');
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD, sftpFollowTerminalCwd);
}, [sftpFollowTerminalCwd, notifySettingsChanged]);
// Persist SFTP default view mode
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE, sftpDefaultViewMode);
@@ -815,6 +869,7 @@ export const useSettingsState = () => {
toggleWindowHotkey,
globalHotkeyEnabled,
closeToTray,
windowOpacity,
autoUpdateEnabled,
persistMountedRef,
setHotkeyRegistrationError,
@@ -887,8 +942,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]);
@@ -948,6 +1002,8 @@ export const useSettingsState = () => {
setSftpUseCompressedUpload,
sftpAutoOpenSidebar,
setSftpAutoOpenSidebar,
sftpFollowTerminalCwd,
setSftpFollowTerminalCwd,
sftpDefaultViewMode,
setSftpDefaultViewMode,
showRecentHosts,
@@ -956,6 +1012,8 @@ export const useSettingsState = () => {
setShowOnlyUngroupedHostsInRoot,
showSftpTab,
setShowSftpTab,
showHostTreeSidebar,
setShowHostTreeSidebar,
sftpTransferConcurrency,
setSftpTransferConcurrency,
// Editor Settings
@@ -986,8 +1044,10 @@ export const useSettingsState = () => {
hotkeyRegistrationError,
globalHotkeyEnabled,
setGlobalHotkeyEnabled,
windowOpacity,
setWindowOpacity,
rehydrateAllFromStorage,
reapplyCurrentTheme,
applyAppTheme,
workspaceFocusStyle,
setWorkspaceFocusStyle,
// Opaque version that changes when any synced setting changes, used by useAutoSync.
@@ -997,8 +1057,8 @@ export const useSettingsState = () => {
uiFontFamilyId, uiLanguage, customCSS,
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
customKeyBindings, editorWordWrap,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar,
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
]),
};

View File

@@ -170,10 +170,10 @@ export const useTerminalBackend = () => {
return bridge.listSerialPorts();
}, []);
const getSessionPwd = useCallback(async (sessionId: string) => {
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) => {

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
export const useTreeExpandedState = (storageKey: string) => {
@@ -20,28 +20,40 @@ export const useTreeExpandedState = (storageKey: string) => {
localStorageAdapter.writeString(storageKey, JSON.stringify(pathsArray));
}, [storageKey, expandedPaths]);
const togglePath = (path: string) => {
const newExpanded = new Set(expandedPaths);
if (newExpanded.has(path)) {
newExpanded.delete(path);
} else {
newExpanded.add(path);
}
setExpandedPaths(newExpanded);
};
const togglePath = useCallback((path: string) => {
setExpandedPaths((current) => {
const next = new Set(current);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
return next;
});
}, []);
const expandAll = (allPaths: string[]) => {
const expandAll = useCallback((allPaths: string[]) => {
setExpandedPaths(new Set(allPaths));
};
}, []);
const collapseAll = () => {
const collapseAll = useCallback(() => {
setExpandedPaths(new Set());
};
}, []);
const ensurePathExpanded = useCallback((path: string) => {
setExpandedPaths((current) => {
if (current.has(path)) return current;
const next = new Set(current);
next.add(path);
return next;
});
}, []);
return {
expandedPaths,
togglePath,
expandAll,
collapseAll,
ensurePathExpanded,
};
};

View File

@@ -109,6 +109,29 @@ const safeParse = <T,>(value: string | null): T | null => {
}
};
/**
* Strip the bulky `terminalData` replay buffer from transient (unsaved)
* connection logs before persisting. `terminalData` is the full terminal
* scrollback for a session; with up to 500 logs it grew the
* `netcatty_connection_logs_v1` localStorage blob to ~11 MB, and every
* add/update re-serialized + wrote the whole thing synchronously
* (5073 ms on the main thread), causing freezes on connect/disconnect.
*
* The full `terminalData` stays in the in-memory React state (so in-session
* replay still works); only explicitly *saved* logs keep it on disk. This
* keeps the persisted blob small and writes fast.
*/
const pruneConnectionLogsForStorage = (logs: ConnectionLog[]): ConnectionLog[] => {
let changed = false;
const next = logs.map((log) => {
if (log.saved || log.terminalData === undefined) return log;
changed = true;
const { terminalData: _omitted, ...rest } = log;
return rest;
});
return changed ? next : logs;
};
export const useVaultState = () => {
const [isInitialized, setIsInitialized] = useState(false);
const [hosts, setHosts] = useState<Host[]>([]);
@@ -318,7 +341,7 @@ export const useVaultState = () => {
const final = [...updated, ...savedLogs].sort(
(a, b) => b.startTime - a.startTime
);
localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, final);
localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, pruneConnectionLogsForStorage(final));
return final;
});
return newLog.id;
@@ -332,7 +355,7 @@ export const useVaultState = () => {
const updated = prev.map((log) =>
log.id === id ? { ...log, ...updates } : log
);
localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, updated);
localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, pruneConnectionLogsForStorage(updated));
return updated;
});
},
@@ -360,7 +383,7 @@ export const useVaultState = () => {
const clearUnsavedConnectionLogs = useCallback(() => {
setConnectionLogs((prev) => {
const saved = prev.filter((log) => log.saved);
localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, saved);
localStorageAdapter.write(STORAGE_KEY_CONNECTION_LOGS, pruneConnectionLogsForStorage(saved));
return saved;
});
}, []);

View File

@@ -0,0 +1,50 @@
import { useSyncExternalStore } from 'react';
import type { Host } from '../../types';
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;
managedGroupPaths?: Set<string>;
onUnmanageGroup?: (groupPath: string) => void;
}
type Listener = () => void;
class VaultHostTreeActionsStore {
private actions: VaultHostTreeActions | null = null;
private listeners = new Set<Listener>();
getActions = () => this.actions;
setActions = (actions: VaultHostTreeActions | null) => {
this.actions = actions;
this.listeners.forEach((listener) => listener());
};
subscribe = (listener: Listener) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
}
export const vaultHostTreeActionsStore = new VaultHostTreeActionsStore();
export const useVaultHostTreeActions = () => {
return useSyncExternalStore(
vaultHostTreeActionsStore.subscribe,
vaultHostTreeActionsStore.getActions,
vaultHostTreeActionsStore.getActions,
);
};

View File

@@ -143,6 +143,14 @@ test("buildSyncPayload includes AI configuration settings", () => {
});
});
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 },
@@ -228,6 +236,24 @@ test("applySyncPayload restores AI configuration settings", async () => {
assert.deepEqual(JSON.parse(localStorage.getItem(storageKeys.STORAGE_KEY_AI_WEB_SEARCH)!), webSearch);
});
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 () => {
// Without this nudge, the apply path writes to localStorage but
// `useAIState` (listening for `storage` events) never sees the changes

View File

@@ -56,12 +56,14 @@ import {
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS,
STORAGE_KEY_CUSTOM_THEMES,
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_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_AI_PROVIDERS,
STORAGE_KEY_AI_ACTIVE_PROVIDER,
@@ -220,6 +222,7 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD,
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS,
STORAGE_KEY_SHOW_RECENT_HOSTS,
@@ -386,6 +389,8 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
if (compress === 'true' || compress === 'false') settings.sftpUseCompressedUpload = compress === 'true';
const autoOpenSidebar = localStorageAdapter.readString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
if (autoOpenSidebar === 'true' || autoOpenSidebar === 'false') settings.sftpAutoOpenSidebar = autoOpenSidebar === 'true';
const followTerminalCwd = localStorageAdapter.readString(STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD);
if (followTerminalCwd === 'true' || followTerminalCwd === 'false') settings.sftpFollowTerminalCwd = followTerminalCwd === 'true';
const defaultViewMode = localStorageAdapter.readString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE);
if (defaultViewMode === 'list' || defaultViewMode === 'tree') settings.sftpDefaultViewMode = defaultViewMode;
@@ -400,6 +405,8 @@ 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 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;
@@ -512,6 +519,7 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
if (settings.sftpShowHiddenFiles != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, String(settings.sftpShowHiddenFiles));
if (settings.sftpUseCompressedUpload != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, String(settings.sftpUseCompressedUpload));
if (settings.sftpAutoOpenSidebar != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, String(settings.sftpAutoOpenSidebar));
if (settings.sftpFollowTerminalCwd != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_FOLLOW_TERMINAL_CWD, String(settings.sftpFollowTerminalCwd));
if (settings.sftpDefaultViewMode != null) {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE, settings.sftpDefaultViewMode);
}
@@ -519,7 +527,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(
@@ -530,6 +537,9 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
if (settings.showSftpTab != null) {
localStorageAdapter.writeBoolean(STORAGE_KEY_SHOW_SFTP_TAB, settings.showSftpTab);
}
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);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 645 B

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@@ -9,6 +9,11 @@ import ChatInput from './ai/ChatInput';
import ChatMessageList from './ai/ChatMessageList';
import ConversationExport from './ai/ConversationExport';
import { SessionHistoryDrawer, formatRelativeTime } from './AIChatSessionHistoryDrawer';
import {
getAIPanelDiagnosticHiddenParts,
getAIPanelProfilerProps,
isAIPanelDiagnosticPartHidden,
} from './ai/aiPanelDiagnostics';
type Translate = (key: string) => string;
type ExportFormat = 'md' | 'json' | 'txt';
@@ -111,138 +116,163 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
removeSelectedUserSkill,
globalPermissionMode,
setGlobalPermissionMode
}) => (
}) => {
const hiddenParts = getAIPanelDiagnosticHiddenParts();
const hideHeader = isAIPanelDiagnosticPartHidden('header', hiddenParts);
const hideHistory = isAIPanelDiagnosticPartHidden('history', hiddenParts);
const hideMessages = isAIPanelDiagnosticPartHidden('messages', hiddenParts);
const hideRecent = isAIPanelDiagnosticPartHidden('recent', hiddenParts);
const hideInput = isAIPanelDiagnosticPartHidden('input', hiddenParts);
return (
<div className="flex flex-col h-full bg-background" data-section="ai-chat-panel">
{/* ── Header ── */}
<div className="px-2.5 py-1.5 flex items-center justify-between border-b border-border/50 shrink-0">
<AgentSelector
currentAgentId={currentAgentId}
externalAgents={externalAgents}
discoveredAgents={discoveredAgents}
isDiscovering={isDiscovering}
onSelectAgent={handleAgentChange}
onEnableDiscoveredAgent={handleEnableDiscoveredAgent}
onRediscover={rediscover}
onManageAgents={handleOpenSettings}
/>
<div className="flex items-center gap-0.5">
<ConversationExport
session={activeSession}
onExport={handleExport}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground"
onClick={() => setShowHistory(!showHistory)}
>
<History size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('ai.chat.sessionHistory')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-md text-primary/82 hover:bg-primary/[0.10] hover:text-primary"
onClick={handleNewChat}
>
<Plus size={15} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('ai.chat.newChat')}</TooltipContent>
</Tooltip>
</div>
</div>
{!hideHeader && (
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Header')}>
<div className="px-2.5 py-1.5 flex items-center justify-between border-b border-border/50 shrink-0">
<AgentSelector
currentAgentId={currentAgentId}
externalAgents={externalAgents}
discoveredAgents={discoveredAgents}
isDiscovering={isDiscovering}
onSelectAgent={handleAgentChange}
onEnableDiscoveredAgent={handleEnableDiscoveredAgent}
onRediscover={rediscover}
onManageAgents={handleOpenSettings}
/>
<div className="flex items-center gap-0.5">
<ConversationExport
session={activeSession}
onExport={handleExport}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-md text-muted-foreground/62 hover:bg-white/[0.05] hover:text-foreground"
onClick={() => setShowHistory(!showHistory)}
>
<History size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('ai.chat.sessionHistory')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-md text-primary/82 hover:bg-primary/[0.10] hover:text-primary"
onClick={handleNewChat}
>
<Plus size={15} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('ai.chat.newChat')}</TooltipContent>
</Tooltip>
</div>
</div>
</React.Profiler>
)}
{/* ── Main content ── */}
{showHistory ? (
<SessionHistoryDrawer
sessions={historySessions}
activeSessionId={activeSessionId}
onSelect={handleSelectSession}
onDelete={handleDeleteSession}
onClose={() => setShowHistory(false)}
/>
{showHistory && !hideHistory ? (
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.History')}>
<SessionHistoryDrawer
sessions={historySessions}
activeSessionId={activeSessionId}
onSelect={handleSelectSession}
onDelete={handleDeleteSession}
onClose={() => setShowHistory(false)}
/>
</React.Profiler>
) : (
<>
{/* Chat messages */}
<ChatMessageList
messages={messages}
isStreaming={isStreaming}
activeSessionId={activeSessionId}
/>
{!hideMessages && (
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Messages')}>
<ChatMessageList
messages={messages}
isStreaming={isStreaming}
activeSessionId={activeSessionId}
/>
</React.Profiler>
)}
{/* Recent sessions (Zed-style, shown when no messages) */}
{messages.length === 0 && historySessions.length > 0 && (
<div className="shrink-0 px-4 pb-1">
<div className="flex items-baseline justify-between mb-2">
<span className="text-[11px] text-muted-foreground/30 tracking-wide">{t('ai.chat.recent')}</span>
<button
onClick={() => setShowHistory(true)}
className="text-[11px] text-muted-foreground/30 hover:text-muted-foreground/50 transition-colors cursor-pointer"
>
{t('ai.chat.viewAll')}
</button>
{messages.length === 0 && historySessions.length > 0 && !hideRecent && (
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Recent')}>
<div className="shrink-0 px-4 pb-1">
<div className="flex items-baseline justify-between mb-2">
<span className="text-[11px] text-muted-foreground/30 tracking-wide">{t('ai.chat.recent')}</span>
<button
onClick={() => setShowHistory(true)}
className="text-[11px] text-muted-foreground/30 hover:text-muted-foreground/50 transition-colors cursor-pointer"
>
{t('ai.chat.viewAll')}
</button>
</div>
{historySessions.slice(0, 3).map((session) => (
<button
key={session.id}
onClick={() => handleSelectSession(session.id)}
className="w-full flex items-baseline justify-between py-1.5 text-left hover:text-foreground transition-colors cursor-pointer"
>
<span className="text-[13px] text-foreground/60 truncate pr-4">
{session.title || t('ai.chat.untitled')}
</span>
<span className="text-[11px] text-muted-foreground/25 shrink-0">
{formatRelativeTime(new Date(session.updatedAt), t)}
</span>
</button>
))}
</div>
{historySessions.slice(0, 3).map((session) => (
<button
key={session.id}
onClick={() => handleSelectSession(session.id)}
className="w-full flex items-baseline justify-between py-1.5 text-left hover:text-foreground transition-colors cursor-pointer"
>
<span className="text-[13px] text-foreground/60 truncate pr-4">
{session.title || t('ai.chat.untitled')}
</span>
<span className="text-[11px] text-muted-foreground/25 shrink-0">
{formatRelativeTime(new Date(session.updatedAt), t)}
</span>
</button>
))}
</div>
</React.Profiler>
)}
{/* Input area */}
<ChatInput
value={inputValue}
onChange={setInputValue}
onSend={handleSend}
onStop={handleStop}
isStreaming={isStreaming}
disabled={!canSendCurrentAgent}
providerName={providerDisplayName}
modelName={modelDisplayName}
agentName={currentAgentId === 'catty' ? 'Catty Agent' : externalAgents.find(a => a.id === currentAgentId)?.name}
modelPresets={agentModelPresets}
selectedModelId={selectedAgentModel}
onModelSelect={handleAgentModelSelect}
providerSwitcher={
currentAgentId === 'catty' && cattyConfiguredProviders.length > 0
? {
providers: cattyConfiguredProviders,
selectedProviderId: effectiveActiveProvider?.id,
selectedModelId: effectiveActiveModelId || undefined,
onSelect: handleAgentProviderModelSelect,
}
: undefined
}
files={files}
onAddFiles={addFiles}
onRemoveFile={removeFile}
hosts={terminalSessions.map(s => ({ sessionId: s.sessionId, hostname: s.hostname, label: s.label, connected: s.connected }))}
selectedUserSkills={selectedUserSkills}
userSkills={userSkillOptions}
onAddUserSkill={addSelectedUserSkill}
onRemoveUserSkill={removeSelectedUserSkill}
permissionMode={globalPermissionMode}
onPermissionModeChange={setGlobalPermissionMode}
/>
{!hideInput && (
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Input')}>
<ChatInput
value={inputValue}
onChange={setInputValue}
onSend={handleSend}
onStop={handleStop}
isStreaming={isStreaming}
disabled={!canSendCurrentAgent}
providerName={providerDisplayName}
modelName={modelDisplayName}
agentName={currentAgentId === 'catty' ? 'Catty Agent' : externalAgents.find(a => a.id === currentAgentId)?.name}
modelPresets={agentModelPresets}
selectedModelId={selectedAgentModel}
onModelSelect={handleAgentModelSelect}
providerSwitcher={
currentAgentId === 'catty' && cattyConfiguredProviders.length > 0
? {
providers: cattyConfiguredProviders,
selectedProviderId: effectiveActiveProvider?.id,
selectedModelId: effectiveActiveModelId || undefined,
onSelect: handleAgentProviderModelSelect,
}
: undefined
}
files={files}
onAddFiles={addFiles}
onRemoveFile={removeFile}
hosts={terminalSessions.map(s => ({ sessionId: s.sessionId, hostname: s.hostname, label: s.label, connected: s.connected }))}
selectedUserSkills={selectedUserSkills}
userSkills={userSkillOptions}
onAddUserSkill={addSelectedUserSkill}
onRemoveUserSkill={removeSelectedUserSkill}
permissionMode={globalPermissionMode}
onPermissionModeChange={setGlobalPermissionMode}
/>
</React.Profiler>
)}
</>
)}
</div>
);
);
};

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import { Trash2, X } from 'lucide-react';
import type { AISession } from '../infrastructure/ai/types';
import { useI18n } from '../application/i18n/I18nProvider';
@@ -19,6 +19,9 @@ interface SessionHistoryDrawerProps {
onClose: () => void;
}
const SESSION_RENDER_BATCH = 80;
const SESSION_RENDER_STEP = 60;
export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
sessions,
activeSessionId,
@@ -27,6 +30,15 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
onClose,
}) => {
const { t } = useI18n();
const [renderCount, setRenderCount] = useState(SESSION_RENDER_BATCH);
useEffect(() => {
setRenderCount(SESSION_RENDER_BATCH);
}, [sessions]);
const displayedSessions = sessions.slice(0, renderCount);
const hiddenSessionCount = Math.max(0, sessions.length - renderCount);
return (
<div className="flex-1 flex flex-col min-h-0">
<div className="px-4 py-2.5 flex items-center justify-between shrink-0 border-b border-border/30">
@@ -47,7 +59,17 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
</p>
</div>
) : (
sessions.map((session) => {
<>
{hiddenSessionCount > 0 && (
<button
type="button"
onClick={() => setRenderCount((count) => count + SESSION_RENDER_STEP)}
className="w-full py-2 text-center text-[12px] text-muted-foreground/50 hover:text-muted-foreground transition-colors cursor-pointer"
>
{t('ai.chat.loadMoreSessions').replace('{n}', String(hiddenSessionCount))}
</button>
)}
{displayedSessions.map((session) => {
const isActive = session.id === activeSessionId;
const time = new Date(session.updatedAt);
const timeStr = formatRelativeTime(time, t);
@@ -85,7 +107,8 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
</div>
</div>
);
})
})}
</>
)}
</div>
</ScrollArea>

View File

@@ -0,0 +1,102 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { AIDraft, AISession } from '../infrastructure/ai/types';
import {
hasAIChatSidePanelRetainedContent,
shouldKeepAIChatSidePanelMounted,
} from './AIChatSidePanel.tsx';
import type { AIChatSidePanelProps } from './AIChatSidePanel.types.ts';
const draft = (overrides: Partial<AIDraft> = {}): AIDraft => ({
text: '',
agentId: 'catty',
attachments: [],
selectedUserSkillSlugs: [],
updatedAt: 1,
...overrides,
});
const session = (overrides: Partial<AISession> = {}): AISession => ({
id: 'session-1',
title: 'Session',
agentId: 'catty',
scope: { type: 'terminal', targetId: 'terminal-1' },
messages: [],
createdAt: 1,
updatedAt: 1,
...overrides,
});
const baseProps = (overrides: Partial<AIChatSidePanelProps> = {}): AIChatSidePanelProps => ({
sessions: [],
activeSessionIdMap: {},
draftsByScope: {},
panelViewByScope: {},
setActiveSessionId: () => undefined,
ensureDraftForScope: () => undefined,
updateDraft: () => undefined,
showDraftView: () => undefined,
showSessionView: () => undefined,
clearDraftForScope: () => undefined,
addDraftFiles: async () => undefined,
removeDraftFile: () => undefined,
createSession: () => session(),
deleteSession: () => undefined,
updateSessionTitle: () => undefined,
updateSessionExternalSessionId: () => undefined,
addMessageToSession: () => undefined,
updateLastMessage: () => undefined,
updateMessageById: () => undefined,
providers: [],
activeProviderId: '',
activeModelId: '',
defaultAgentId: 'catty',
toolIntegrationMode: 'mcp',
externalAgents: [],
agentModelMap: {},
setAgentModel: () => undefined,
agentProviderMap: {},
setAgentProvider: () => undefined,
globalPermissionMode: 'autonomous',
scopeType: 'terminal',
scopeTargetId: 'terminal-1',
isVisible: false,
...overrides,
});
test('hidden empty AI side panel can release its subtree', () => {
const props = baseProps();
assert.equal(hasAIChatSidePanelRetainedContent(props), false);
assert.equal(shouldKeepAIChatSidePanelMounted(props), false);
});
test('hidden AI side panel is retained when it has draft text', () => {
const props = baseProps({
draftsByScope: {
'terminal:terminal-1': draft({ text: 'hello' }),
},
});
assert.equal(hasAIChatSidePanelRetainedContent(props), true);
assert.equal(shouldKeepAIChatSidePanelMounted(props), true);
});
test('hidden AI side panel is retained when it has session messages', () => {
const props = baseProps({
activeSessionIdMap: { 'terminal:terminal-1': 'session-1' },
sessions: [
session({
messages: [{ id: 'm1', role: 'user', content: 'hello', timestamp: 1 }],
}),
],
});
assert.equal(hasAIChatSidePanelRetainedContent(props), true);
assert.equal(shouldKeepAIChatSidePanelMounted(props), true);
});
test('visible AI side panel is always mounted even when empty', () => {
assert.equal(shouldKeepAIChatSidePanelMounted(baseProps({ isVisible: true })), true);
});

View File

@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useDeferredValue, useMemo, useRef, useState } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { useWindowControls } from '../application/state/useWindowControls';
import type {
@@ -29,7 +29,6 @@ import {
endDraftSend,
tryBeginDraftSend,
} from './ai/draftSendGate';
import { getSessionScopeMatchRank } from './ai/sessionScopeMatch';
import { selectDraftForAgentSwitch } from '../application/state/aiDraftState';
import {
buildPromptWithTerminalSelectionAttachments,
@@ -39,8 +38,10 @@ import type { CodexIntegrationStatus } from './settings/tabs/ai/types';
import {
useAIChatStreaming,
getNetcattyBridge,
isAIChatSessionStreaming,
type DefaultTargetSessionHint,
} from './ai/hooks/useAIChatStreaming';
import { getScopedHistorySessions } from './ai/scopedHistorySessions';
import { buildExternalAgentHistoryMessagesForBridge } from './ai/externalAgentHistory';
import { canSendWithAgent, findEnabledExternalAgent } from './ai/agentSendEligibility';
import { clearAllPendingApprovals } from '../infrastructure/ai/shared/approvalGate';
@@ -48,8 +49,47 @@ import { useConversationExport } from './ai/hooks/useConversationExport';
import type { AIChatSidePanelProps } from './AIChatSidePanel.types';
import { generateId, isCopilotAgentConfig, modelPresetsContainId } from './AIChatSidePanelHelpers';
import { AIChatPanelContent } from './AIChatPanelContent';
import {
getAIPanelProfilerProps,
profileAIPanelCalculation,
} from './ai/aiPanelDiagnostics';
const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
export function hasAIChatSidePanelRetainedContent(props: Pick<
AIChatSidePanelProps,
'activeSessionIdMap' | 'draftsByScope' | 'sessions' | 'scopeTargetId' | 'scopeType'
>): boolean {
const scopeKey = `${props.scopeType}:${props.scopeTargetId ?? ''}`;
const sessionId = props.activeSessionIdMap[scopeKey] ?? null;
const activeSession = sessionId
? props.sessions.find((session) => session.id === sessionId)
: null;
if (activeSession && activeSession.messages.length > 0) {
return true;
}
const draft = props.draftsByScope[scopeKey] ?? null;
return Boolean(
draft
&& (
draft.text.trim().length > 0
|| draft.attachments.length > 0
|| draft.selectedUserSkillSlugs.length > 0
),
);
}
export function shouldKeepAIChatSidePanelMounted(props: AIChatSidePanelProps): boolean {
if (props.isVisible ?? true) {
return true;
}
const scopeKey = `${props.scopeType}:${props.scopeTargetId ?? ''}`;
const sessionId = props.activeSessionIdMap[scopeKey] ?? null;
if (hasAIChatSidePanelRetainedContent(props)) {
return true;
}
return isAIChatSessionStreaming(sessionId);
}
const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
sessions,
activeSessionIdMap,
draftsByScope,
@@ -134,23 +174,19 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
return sessionIds;
}, [activeSessionIdMap, scopeKey]);
const deferredSessions = useDeferredValue(sessions);
const historySessions = useMemo(
() =>
sessions
.map((session) => ({
session,
matchRank: getSessionScopeMatchRank(
session,
scopeType,
scopeTargetId,
scopeHostIds,
activeTerminalSessionIds,
),
}))
.filter(({ matchRank }) => matchRank > 0)
.sort((a, b) => b.matchRank - a.matchRank || b.session.updatedAt - a.session.updatedAt)
.map(({ session }) => session),
[sessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalSessionIds],
() => profileAIPanelCalculation(
'AIChatSidePanel.historySessions',
() => getScopedHistorySessions(
deferredSessions,
scopeType,
scopeTargetId,
scopeHostIds,
activeTerminalSessionIds,
),
),
[deferredSessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalSessionIds],
);
const explicitPanelView = panelViewByScope[scopeKey];
@@ -201,16 +237,24 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
}, [terminalSessions, scopeType, scopeTargetId]);
useEffect(() => {
if (!isVisible) return;
const bridge = getNetcattyBridge();
if (bridge?.aiMcpUpdateSessions) {
if (!bridge?.aiMcpUpdateSessions) return;
const timeoutId = window.setTimeout(() => {
void bridge.aiMcpUpdateSessions(terminalSessions, activeSessionId ?? undefined);
}
}, [terminalSessions, scopeKey, activeSessionId]);
}, 250);
return () => {
window.clearTimeout(timeoutId);
};
}, [isVisible, terminalSessions, activeSessionId]);
useEffect(() => {
if (!isVisible) return;
if (!explicitPanelView || normalizedPanelView === explicitPanelView) return;
showDraftView(scopeKey);
}, [normalizedPanelView, explicitPanelView, scopeKey, showDraftView]);
}, [isVisible, normalizedPanelView, explicitPanelView, scopeKey, showDraftView]);
useEffect(() => {
if (!activeSession) return;
@@ -342,30 +386,27 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
}, [isVisible, scopeKey, toolIntegrationMode, updateScopeDraft]);
useEffect(() => {
if (!isVisible) return;
const bridge = getNetcattyBridge();
if (bridge?.aiSyncProviders && providers.length > 0) {
void bridge.aiSyncProviders(providers);
}
}, [providers]);
}, [isVisible, providers]);
useEffect(() => {
if (!isVisible) return;
const bridge = getNetcattyBridge();
if (bridge?.aiSyncWebSearch) {
void bridge.aiSyncWebSearch(webSearchConfig?.apiHost || null, webSearchConfig?.apiKey || null);
}
}, [webSearchConfig?.apiHost, webSearchConfig?.apiKey, webSearchConfig?.enabled]);
useEffect(() => {
return () => {
};
}, []);
}, [isVisible, webSearchConfig?.apiHost, webSearchConfig?.apiKey, webSearchConfig?.enabled]);
const {
discoveredAgents,
isDiscovering,
rediscover,
enableAgent,
} = useAgentDiscovery(externalAgents, setExternalAgents);
} = useAgentDiscovery(externalAgents, setExternalAgents, { enabled: isVisible });
const handleEnableDiscoveredAgent = useCallback(
(agent: DiscoveredAgent) => {
@@ -456,6 +497,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
const [codexConfigModel, setCodexConfigModel] = useState<string | null>(null);
const [codexCustomConfigResolved, setCodexCustomConfigResolved] = useState(false);
useEffect(() => {
if (!isVisible) return;
setCodexCustomConfigResolved(false);
if (!isCodexManagedAgent) {
setCodexConfigModel(null);
@@ -478,12 +520,13 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
}
});
return () => { cancelled = true; };
}, [isCodexManagedAgent, currentAgentId]);
}, [isVisible, isCodexManagedAgent, currentAgentId]);
const agentModelMapRef = useRef(agentModelMap);
agentModelMapRef.current = agentModelMap;
useEffect(() => {
if (!isVisible) return;
const sdkBackend = getExternalAgentSdkBackend(currentAgentConfig);
if (!sdkBackend) return;
if (!isCopilotExternalAgent && !isClaudeManagedAgent && !isCodexManagedAgent) return;
@@ -526,7 +569,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
return () => {
cancelled = true;
};
}, [currentAgentConfig, currentAgentId, isCopilotExternalAgent, isClaudeManagedAgent, isCodexManagedAgent, setAgentModel]);
}, [isVisible, currentAgentConfig, currentAgentId, isCopilotExternalAgent, isClaudeManagedAgent, isCodexManagedAgent, setAgentModel]);
const hasCodexCustomConfig = codexCustomConfigResolved && isCodexManagedAgent;
@@ -866,60 +909,130 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
}, [ensureScopeDraft, showScopeDraftView, updateScopeDraft]);
if (!isVisible) return null;
return (
<AIChatPanelContent
t={t}
currentAgentId={currentAgentId}
externalAgents={externalAgents}
discoveredAgents={discoveredAgents}
isDiscovering={isDiscovering}
handleAgentChange={handleAgentChange}
handleEnableDiscoveredAgent={handleEnableDiscoveredAgent}
rediscover={rediscover}
handleOpenSettings={handleOpenSettings}
activeSession={activeSession}
handleExport={handleExport}
showHistory={showHistory}
setShowHistory={setShowHistory}
handleNewChat={handleNewChat}
historySessions={historySessions}
activeSessionId={activeSessionId}
handleSelectSession={handleSelectSession}
handleDeleteSession={handleDeleteSession}
messages={messages}
isStreaming={isStreaming}
inputValue={inputValue}
setInputValue={setInputValue}
handleSend={handleSend}
handleStop={handleStop}
canSendCurrentAgent={canSendCurrentAgent}
providerDisplayName={providerDisplayName}
modelDisplayName={modelDisplayName}
agentModelPresets={agentModelPresets}
selectedAgentModel={selectedAgentModel}
handleAgentModelSelect={handleAgentModelSelect}
cattyConfiguredProviders={cattyConfiguredProviders}
effectiveActiveProvider={effectiveActiveProvider}
effectiveActiveModelId={effectiveActiveModelId}
handleAgentProviderModelSelect={handleAgentProviderModelSelect}
files={files}
addFiles={addFiles}
removeFile={removeFile}
terminalSessions={terminalSessions}
selectedUserSkills={selectedUserSkills}
userSkillOptions={userSkillOptions}
addSelectedUserSkill={addSelectedUserSkill}
removeSelectedUserSkill={removeSelectedUserSkill}
globalPermissionMode={globalPermissionMode}
setGlobalPermissionMode={setGlobalPermissionMode}
/>
<React.Profiler {...getAIPanelProfilerProps('AIChatSidePanel.Active')}>
<AIChatPanelContent
t={t}
currentAgentId={currentAgentId}
externalAgents={externalAgents}
discoveredAgents={discoveredAgents}
isDiscovering={isDiscovering}
handleAgentChange={handleAgentChange}
handleEnableDiscoveredAgent={handleEnableDiscoveredAgent}
rediscover={rediscover}
handleOpenSettings={handleOpenSettings}
activeSession={activeSession}
handleExport={handleExport}
showHistory={showHistory}
setShowHistory={setShowHistory}
handleNewChat={handleNewChat}
historySessions={historySessions}
activeSessionId={activeSessionId}
handleSelectSession={handleSelectSession}
handleDeleteSession={handleDeleteSession}
messages={messages}
isStreaming={isStreaming}
inputValue={inputValue}
setInputValue={setInputValue}
handleSend={handleSend}
handleStop={handleStop}
canSendCurrentAgent={canSendCurrentAgent}
providerDisplayName={providerDisplayName}
modelDisplayName={modelDisplayName}
agentModelPresets={agentModelPresets}
selectedAgentModel={selectedAgentModel}
handleAgentModelSelect={handleAgentModelSelect}
cattyConfiguredProviders={cattyConfiguredProviders}
effectiveActiveProvider={effectiveActiveProvider}
effectiveActiveModelId={effectiveActiveModelId}
handleAgentProviderModelSelect={handleAgentProviderModelSelect}
files={files}
addFiles={addFiles}
removeFile={removeFile}
terminalSessions={terminalSessions}
selectedUserSkills={selectedUserSkills}
userSkillOptions={userSkillOptions}
addSelectedUserSkill={addSelectedUserSkill}
removeSelectedUserSkill={removeSelectedUserSkill}
globalPermissionMode={globalPermissionMode}
setGlobalPermissionMode={setGlobalPermissionMode}
/>
</React.Profiler>
);
};
const AIChatSidePanel = React.memo(AIChatSidePanelInner);
const AI_CHAT_SIDE_PANEL_AI_STATE_KEYS = [
'sessions',
'activeSessionIdMap',
'draftsByScope',
'panelViewByScope',
'setActiveSessionId',
'ensureDraftForScope',
'updateDraft',
'showDraftView',
'showSessionView',
'clearDraftForScope',
'addDraftFiles',
'removeDraftFile',
'createSession',
'deleteSession',
'updateSessionTitle',
'updateSessionExternalSessionId',
'addMessageToSession',
'updateLastMessage',
'updateMessageById',
'providers',
'activeProviderId',
'activeModelId',
'defaultAgentId',
'toolIntegrationMode',
'externalAgents',
'setExternalAgents',
'agentModelMap',
'setAgentModel',
'agentProviderMap',
'setAgentProvider',
'globalPermissionMode',
'setGlobalPermissionMode',
'commandBlocklist',
'maxIterations',
'webSearchConfig',
] as const satisfies readonly (keyof AIChatSidePanelProps)[];
function aiChatSidePanelPropsAreEqual(
prev: AIChatSidePanelProps,
next: AIChatSidePanelProps,
): boolean {
const prevKeep = shouldKeepAIChatSidePanelMounted(prev);
const nextKeep = shouldKeepAIChatSidePanelMounted(next);
if (!prevKeep && !nextKeep) {
return true;
}
if (prevKeep !== nextKeep) {
return false;
}
if (prev.scopeType !== next.scopeType) return false;
if (prev.scopeTargetId !== next.scopeTargetId) return false;
if (prev.scopeLabel !== next.scopeLabel) return false;
if (prev.scopeHostIds !== next.scopeHostIds) return false;
if (prev.terminalSessions !== next.terminalSessions) return false;
if (prev.resolveExecutorContext !== next.resolveExecutorContext) return false;
for (const key of AI_CHAT_SIDE_PANEL_AI_STATE_KEYS) {
if (prev[key] !== next[key]) return false;
}
return true;
}
const AIChatSidePanel = React.memo(function AIChatSidePanel(props: AIChatSidePanelProps) {
if (!shouldKeepAIChatSidePanelMounted(props)) return null;
// Keep hidden panels alive only when they contain real work (messages, draft
// content, or an active stream). Empty hidden panels can drop their heavy
// input/agent-picker subtree and remount cheaply when shown again.
return <AIChatSidePanelActive {...props} />;
}, aiChatSidePanelPropsAreEqual);
AIChatSidePanel.displayName = 'AIChatSidePanel';
export default AIChatSidePanel;

View File

@@ -1,16 +1,17 @@
import {
Bookmark,
ChevronDown,
CircleUserRound,
Server,
Terminal,
Trash2,
Usb,
User,
} from "lucide-react";
import React, { memo, useCallback, useMemo, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { cn } from "../lib/utils";
import { ConnectionLog, Host } from "../types";
import { DistroAvatar } from "./DistroAvatar";
import { ScrollArea } from "./ui/scroll-area";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
@@ -66,6 +67,7 @@ const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) =
const { t, resolvedLocale } = useI18n();
const isLocal = log.protocol === "local" || log.hostname === "localhost";
const isSerial = log.protocol === "serial";
const hasPersistedHostIcon = !isLocal && !isSerial && !!log.hostDistro;
return (
<div
@@ -82,8 +84,8 @@ const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) =
{/* User column */}
<div className="flex items-center gap-2 w-56 shrink-0">
<div className="h-8 w-8 rounded-full bg-primary/10 text-primary flex items-center justify-center shrink-0">
<User size={14} />
<div className="h-9 w-9 rounded-xl bg-emerald-600 text-white dark:bg-emerald-400 dark:text-slate-950 flex items-center justify-center shrink-0">
<CircleUserRound size={18} strokeWidth={2.25} />
</div>
<div className="min-w-0">
<div className="text-sm font-medium truncate">{log.localUsername}</div>
@@ -93,12 +95,28 @@ const LogItem = memo<LogItemProps>(({ log, onToggleSaved, onDelete, onClick }) =
{/* Host column */}
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className={cn(
"h-8 w-8 rounded-lg flex items-center justify-center shrink-0",
isSerial ? "bg-amber-500/10 text-amber-500" : isLocal ? "bg-emerald-500/10 text-emerald-500" : "bg-blue-500/10 text-blue-500"
)}>
{isSerial ? <Usb size={14} /> : isLocal ? <Terminal size={14} /> : <Server size={14} />}
</div>
{hasPersistedHostIcon ? (
<DistroAvatar
host={{
os: log.hostOs ?? "linux",
distro: log.hostDistro,
distroMode: "auto",
}}
fallback={(log.hostOs ?? "linux")[0].toUpperCase()}
size="log"
/>
) : (
<div className={cn(
"h-9 w-9 rounded-xl flex items-center justify-center shrink-0",
isSerial
? "bg-amber-600 text-white dark:bg-amber-400 dark:text-slate-950"
: isLocal
? "bg-slate-600 text-white dark:bg-slate-300 dark:text-slate-950"
: "bg-primary text-primary-foreground"
)}>
{isSerial ? <Usb size={17} /> : isLocal ? <Terminal size={17} /> : <Server size={17} />}
</div>
)}
<div className="min-w-0">
<div className="text-sm font-medium truncate">{isLocal ? t("logs.localTerminal") : log.hostLabel}</div>
<div className="text-xs text-muted-foreground truncate">

View File

@@ -68,10 +68,12 @@ export const DISTRO_COLORS: Record<string, string> = {
};
type DistroAvatarProps = {
host: Host;
host: Pick<Host, "distro" | "manualDistro" | "distroMode" | "os"> &
Partial<Pick<Host, "protocol">>;
fallback: string;
className?: string;
size?: "sm" | "md" | "lg";
/** xs matches top tab bar icons (h-4 rounded rect) */
size?: "xs" | "sm" | "md" | "tree" | "log" | "lg";
};
const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
@@ -85,16 +87,22 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
const [errored, setErrored] = React.useState(false);
const bg = DISTRO_COLORS[distro] || DISTRO_COLORS.default;
// Size variants - all use rounded corners for consistency
// Size variants — rounded rects (same corner style as SessionTabIcon in TopTabItems)
const sizeClasses = {
sm: "h-6 w-6 rounded",
md: "h-11 w-11 rounded-lg",
lg: "h-14 w-14 rounded-xl",
xs: "h-4 w-4 rounded",
sm: "h-5 w-5 rounded",
md: "h-8 w-8 rounded",
tree: "h-8 w-8 rounded-lg",
log: "h-9 w-9 rounded-xl",
lg: "h-11 w-11 rounded-xl",
};
const iconSizes = {
sm: "h-3.5 w-3.5",
md: "h-5 w-5",
lg: "h-6 w-6",
xs: "h-2.5 w-2.5",
sm: "h-3 w-3",
md: "h-4 w-4",
tree: "h-4 w-4",
log: "h-5 w-5",
lg: "h-5 w-5",
};
const containerClass = sizeClasses[size];
@@ -105,8 +113,8 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
return (
<div
className={cn(
"shrink-0 rounded flex items-center justify-center bg-amber-600 text-white dark:bg-amber-400 dark:text-slate-950",
containerClass,
"flex items-center justify-center bg-amber-500/15 text-amber-500",
className,
)}
>
@@ -119,8 +127,8 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
return (
<div
className={cn(
"shrink-0 rounded flex items-center justify-center overflow-hidden",
containerClass,
"flex items-center justify-center overflow-hidden",
bg,
className,
)}
@@ -138,8 +146,8 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
return (
<div
className={cn(
"shrink-0 rounded flex items-center justify-center bg-primary text-primary-foreground",
containerClass,
"flex items-center justify-center bg-primary/15 text-primary",
className,
)}
>

View File

@@ -2,10 +2,10 @@
* FileOpenerDialog - Dialog for choosing how to open a file
*/
import { Edit2, FolderOpen } from 'lucide-react';
import React, { useCallback, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import type { FileOpenerType, SystemAppInfo } from '../lib/sftpFileUtils';
import { getFileExtension, isKnownBinaryFile } from '../lib/sftpFileUtils';
import { getFileExtension, hasFileExtension, isKnownBinaryFile } from '../lib/sftpFileUtils';
import { Button } from './ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog';
@@ -26,13 +26,17 @@ const FileOpenerDialog: React.FC<FileOpenerDialogProps> = ({
}) => {
const { t } = useI18n();
const [isSelectingApp, setIsSelectingApp] = useState(false);
const [rememberChoice, setRememberChoice] = useState(true);
const [rememberChoice, setRememberChoice] = useState(() => hasFileExtension(fileName));
useEffect(() => {
if (open) {
setRememberChoice(hasFileExtension(fileName));
}
}, [open, fileName]);
const extension = getFileExtension(fileName);
// Show edit option for files that are not known binary formats
const canEdit = !isKnownBinaryFile(fileName);
// For files without extension, we use 'file' as virtual extension
// So we always allow setting default (hasExtension is always true)
const displayExtension = extension === 'file' ? t('sftp.opener.noExtension') : `.${extension}`;
const handleSelectBuiltIn = useCallback((openerType: FileOpenerType) => {

View File

@@ -1,6 +1,11 @@
import { CheckSquare, ChevronRight, Edit2, FileSymlink, Folder, FolderOpen, Monitor, Server, Square, Expand, Minimize2 } from 'lucide-react';
import React, { useMemo } from 'react';
import { CheckSquare, ChevronRight, Edit2, FileSymlink, Folder, FolderOpen, Server, Square, Expand, Minimize2 } from 'lucide-react';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import {
hostTreeInlineGroupEditStore,
useHostTreeInlineGroupEdit,
} from '../application/state/hostTreeInlineGroupEditStore';
import { useVaultHostTreeActions } from '../application/state/vaultHostTreeActionsStore';
import { useTreeExpandedState } from '../application/state/useTreeExpandedState';
import { applyGroupDefaults, resolveGroupDefaults } from '../domain/groupConfig';
import { resolveTelnetPort, resolveTelnetUsername, sanitizeHost } from '../domain/host';
@@ -8,7 +13,9 @@ import { STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED } from '../infrastructure/config/
import { cn } from '../lib/utils';
import { GroupConfig, GroupNode, Host } from '../types';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from './ui/context-menu';
import { HostTreeGroupContextMenuContent, HostTreeHostContextMenuContent } from './host/HostTreeContextMenus';
import { HostTreeGroupInlineRenameInput } from './host/HostTreeGroupInlineRenameInput';
import { ContextMenu, ContextMenuTrigger } from './ui/context-menu';
import { DistroAvatar } from './DistroAvatar';
import { HostNotesIndicator } from './host/HostNotesIndicator';
import { Button } from './ui/button';
@@ -26,12 +33,14 @@ interface HostTreeViewProps {
onDuplicateHost: (host: Host) => void;
onDeleteHost: (host: Host) => void;
onCopyCredentials: (host: Host) => void;
onNewHost: (groupPath?: string) => void;
onNewGroup: (parentPath?: string) => void;
onRenameGroup: (groupPath: string) => void;
onEditGroup: (groupPath: string) => void;
onDeleteGroup: (groupPath: string) => void;
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
moveGroup: (sourcePath: string, targetPath: string) => void;
moveGroup: (sourcePath: string, targetParent: string | null) => void;
commitInlineGroupRename?: (name: string) => void;
cancelInlineGroupEdit?: () => void;
managedGroupPaths?: Set<string>;
onUnmanageGroup?: (groupPath: string) => void;
@@ -54,12 +63,14 @@ interface TreeNodeProps {
onDuplicateHost: (host: Host) => void;
onDeleteHost: (host: Host) => void;
onCopyCredentials: (host: Host) => void;
onNewHost: (groupPath?: string) => void;
onNewGroup: (parentPath?: string) => void;
onRenameGroup: (groupPath: string) => void;
onEditGroup: (groupPath: string) => void;
onDeleteGroup: (groupPath: string) => void;
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
moveGroup: (sourcePath: string, targetPath: string) => void;
moveGroup: (sourcePath: string, targetParent: string | null) => void;
commitInlineGroupRename?: (name: string) => void;
cancelInlineGroupEdit?: () => void;
managedGroupPaths?: Set<string>;
onUnmanageGroup?: (groupPath: string) => void;
@@ -83,14 +94,16 @@ const TreeNode: React.FC<TreeNodeProps> = ({
onDuplicateHost,
onDeleteHost,
onCopyCredentials,
onNewHost,
onNewGroup,
onRenameGroup,
onEditGroup,
onDeleteGroup,
moveHostToGroup,
moveGroup,
managedGroupPaths,
onUnmanageGroup,
commitInlineGroupRename,
cancelInlineGroupEdit,
isMultiSelectMode,
selectedHostIds,
@@ -99,8 +112,22 @@ const TreeNode: React.FC<TreeNodeProps> = ({
setDragOverDropTarget,
groupConfigs,
}) => {
const { t } = useI18n();
const inlineEdit = useHostTreeInlineGroupEdit();
const vaultTreeActions = useVaultHostTreeActions();
const commitRename = commitInlineGroupRename ?? vaultTreeActions?.commitInlineGroupRename;
const cancelRename = cancelInlineGroupEdit ?? vaultTreeActions?.cancelInlineGroupEdit;
const isInlineEditing = inlineEdit?.groupPath === node.path;
const groupRowRef = useRef<HTMLDivElement>(null);
const isExpanded = expandedPaths.has(node.path);
useEffect(() => {
if (!isInlineEditing || !inlineEdit?.shouldScrollIntoView) return;
const frame = requestAnimationFrame(() => {
groupRowRef.current?.scrollIntoView({ block: 'nearest' });
hostTreeInlineGroupEditStore.markScrollHandled();
});
return () => cancelAnimationFrame(frame);
}, [inlineEdit?.groupPath, inlineEdit?.shouldScrollIntoView, isInlineEditing]);
const hasChildren = node.children && Object.keys(node.children).length > 0;
const paddingLeft = `${depth * 20 + 12}px`;
const isManaged = managedGroupPaths?.has(node.path) ?? false;
@@ -144,18 +171,31 @@ const TreeNode: React.FC<TreeNodeProps> = ({
return (
<div>
{/* Group Node */}
<Collapsible open={isExpanded} onOpenChange={() => onToggle(node.path)}>
<Collapsible
open={isExpanded}
onOpenChange={() => {
if (isInlineEditing) return;
onToggle(node.path);
}}
>
<ContextMenu>
<ContextMenuTrigger>
<CollapsibleTrigger asChild>
<div
ref={groupRowRef}
className={cn(
"flex items-center py-2 pr-3 text-sm font-medium cursor-pointer transition-colors select-none group hover:bg-secondary/60 rounded-lg",
getDropTargetClasses?.(node.path),
)}
style={{ paddingLeft }}
draggable
onDragStart={(e) => e.dataTransfer.setData("group-path", node.path)}
data-section="host-tree-row"
data-row-type="group"
data-group-path={node.path}
draggable={!isInlineEditing}
onDragStart={(e) => {
if (isInlineEditing) return;
e.dataTransfer.setData("group-path", node.path);
}}
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
@@ -185,10 +225,23 @@ const TreeNode: React.FC<TreeNodeProps> = ({
</div>
)}
</div>
<div className="mr-3 text-primary/80 group-hover:text-primary transition-colors">
{isExpanded ? <FolderOpen size={18} /> : <Folder size={18} />}
<div className="mr-3 flex h-8 w-8 shrink-0 items-center justify-center text-primary transition-colors dark:text-primary">
{isExpanded ? (
<FolderOpen size={21} strokeWidth={2.35} />
) : (
<Folder size={21} strokeWidth={2.35} />
)}
</div>
<span className="truncate flex-1 font-semibold">{node.name}</span>
{isInlineEditing && commitRename && cancelRename ? (
<HostTreeGroupInlineRenameInput
initialName={inlineEdit.initialName}
onCommit={commitRename}
onCancel={cancelRename}
className="flex-1 font-semibold"
/>
) : (
<span className="truncate flex-1 font-semibold">{node.name}</span>
)}
{isManaged && (
<span className="inline-flex items-center gap-1 text-[10px] font-medium px-1.5 py-0.5 rounded bg-primary/15 text-primary shrink-0 mr-1.5">
<FileSymlink size={10} />
@@ -212,28 +265,14 @@ const TreeNode: React.FC<TreeNodeProps> = ({
</div>
</CollapsibleTrigger>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => onNewHost(node.path)}>
<Server className="mr-2 h-4 w-4" /> {t("vault.hosts.newHost")}
</ContextMenuItem>
<ContextMenuItem onClick={() => onNewGroup(node.path)}>
<Folder className="mr-2 h-4 w-4" /> {t("vault.hosts.newGroup")}
</ContextMenuItem>
<ContextMenuItem onClick={() => onEditGroup(node.path)}>
<FolderOpen className="mr-2 h-4 w-4" /> {t("vault.groups.rename")}
</ContextMenuItem>
<ContextMenuItem
onClick={() => onDeleteGroup(node.path)}
className="text-destructive focus:text-destructive"
>
<FolderOpen className="mr-2 h-4 w-4" /> {t("vault.groups.delete")}
</ContextMenuItem>
{isManaged && onUnmanageGroup && (
<ContextMenuItem onClick={() => onUnmanageGroup(node.path)}>
<FileSymlink className="mr-2 h-4 w-4" /> {t("vault.managedSource.unmanage")}
</ContextMenuItem>
)}
</ContextMenuContent>
<HostTreeGroupContextMenuContent
groupPath={node.path}
isManaged={isManaged}
onNewGroup={onNewGroup}
onRenameGroup={onRenameGroup}
onDeleteGroup={onDeleteGroup}
onUnmanageGroup={onUnmanageGroup}
/>
</ContextMenu>
<CollapsibleContent>
@@ -251,14 +290,16 @@ const TreeNode: React.FC<TreeNodeProps> = ({
onDuplicateHost={onDuplicateHost}
onDeleteHost={onDeleteHost}
onCopyCredentials={onCopyCredentials}
onNewHost={onNewHost}
onNewGroup={onNewGroup}
onRenameGroup={onRenameGroup}
onEditGroup={onEditGroup}
onDeleteGroup={onDeleteGroup}
moveHostToGroup={moveHostToGroup}
moveGroup={moveGroup}
managedGroupPaths={managedGroupPaths}
onUnmanageGroup={onUnmanageGroup}
commitInlineGroupRename={commitInlineGroupRename}
cancelInlineGroupEdit={cancelInlineGroupEdit}
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
@@ -344,7 +385,6 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
toggleHostSelection,
groupConfigs,
}) => {
const { t } = useI18n();
const paddingLeft = `${depth * 20 + 12}px`;
const safeHost = sanitizeHost(host);
const tags = host.tags || [];
@@ -366,6 +406,9 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
isSelected ? "bg-primary/10" : "",
)}
style={{ paddingLeft }}
data-section="host-tree-row"
data-row-type="host"
data-host-id={host.id}
draggable={!isMultiSelectMode}
onDragStart={(e) => e.dataTransfer.setData("host-id", host.id)}
onClick={() => {
@@ -390,7 +433,7 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
)}
{!isMultiSelectMode && <div className="mr-2 flex-shrink-0 w-4 h-4" />}
<div className="mr-3 flex-shrink-0">
<DistroAvatar host={host} fallback={(host.os || "L")[0].toUpperCase()} size="sm" />
<DistroAvatar host={host} fallback={(host.os || "L")[0].toUpperCase()} size="tree" />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium truncate flex items-center gap-1.5">
@@ -425,26 +468,13 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => onConnect(safeHost)}>
<Monitor className="mr-2 h-4 w-4" /> {t("vault.hosts.connect")}
</ContextMenuItem>
<ContextMenuItem onClick={() => onEditHost(host)}>
<Server className="mr-2 h-4 w-4" /> {t("action.edit")}
</ContextMenuItem>
<ContextMenuItem onClick={() => onDuplicateHost(host)}>
<Server className="mr-2 h-4 w-4" /> {t("action.duplicate")}
</ContextMenuItem>
<ContextMenuItem onClick={() => onCopyCredentials(host)}>
<Server className="mr-2 h-4 w-4" /> {t("vault.hosts.copyCredentials")}
</ContextMenuItem>
<ContextMenuItem
onClick={() => onDeleteHost(host)}
className="text-destructive focus:text-destructive"
>
<Server className="mr-2 h-4 w-4" /> {t("action.delete")}
</ContextMenuItem>
</ContextMenuContent>
<HostTreeHostContextMenuContent
host={host}
onConnect={onConnect}
onDuplicateHost={onDuplicateHost}
onCopyCredentials={onCopyCredentials}
onDeleteHost={onDeleteHost}
/>
</ContextMenu>
);
};
@@ -462,14 +492,16 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
onDuplicateHost,
onDeleteHost,
onCopyCredentials,
onNewHost,
onNewGroup,
onRenameGroup,
onEditGroup,
onDeleteGroup,
moveHostToGroup,
moveGroup,
managedGroupPaths,
onUnmanageGroup,
commitInlineGroupRename,
cancelInlineGroupEdit,
isMultiSelectMode,
selectedHostIds,
@@ -479,6 +511,20 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
groupConfigs = [],
}) => {
const { t } = useI18n();
const inlineEdit = useHostTreeInlineGroupEdit();
const vaultTreeActions = useVaultHostTreeActions();
const cancelRename = cancelInlineGroupEdit ?? vaultTreeActions?.cancelInlineGroupEdit;
const handleTreePointerDownCapture = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
if (!inlineEdit?.groupPath || !cancelRename) return;
const target = event.target;
if (!(target instanceof Element)) return;
if (target.closest('[data-inline-group-edit="true"]')) return;
const row = target.closest('[data-section="host-tree-row"]');
if (!row) return;
if (row.getAttribute('data-group-path') === inlineEdit.groupPath) return;
cancelRename();
}, [cancelRename, inlineEdit?.groupPath]);
// Use external state if provided, otherwise use local persistent state
const localTreeState = useTreeExpandedState(STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED);
@@ -550,7 +596,7 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
}, [groupTree, sortMode]);
return (
<div className="space-y-1">
<div className="space-y-1" onPointerDownCapture={handleTreePointerDownCapture}>
{/* Expand/Collapse controls */}
{groupTree.length > 0 && (
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-border/30">
@@ -589,14 +635,16 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
onDuplicateHost={onDuplicateHost}
onDeleteHost={onDeleteHost}
onCopyCredentials={onCopyCredentials}
onNewHost={onNewHost}
onNewGroup={onNewGroup}
onRenameGroup={onRenameGroup}
onEditGroup={onEditGroup}
onDeleteGroup={onDeleteGroup}
moveHostToGroup={moveHostToGroup}
moveGroup={moveGroup}
managedGroupPaths={managedGroupPaths}
onUnmanageGroup={onUnmanageGroup}
commitInlineGroupRename={commitInlineGroupRename}
cancelInlineGroupEdit={cancelInlineGroupEdit}
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}

View File

@@ -48,6 +48,7 @@ import {
VaultHeaderSearch,
VaultPageHeader,
vaultHeaderIconButtonClass,
vaultSectionTitleClass,
} from "./vault/VaultPageHeader";
// Import utilities and components from keychain module
@@ -678,7 +679,7 @@ echo $3 >> "$FILE"`);
{/* Keys Section */}
<div className="space-y-3 p-3">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-muted-foreground">
<h2 className={vaultSectionTitleClass}>
{t("keychain.section.keys")}
</h2>
<span className="text-xs text-muted-foreground">
@@ -743,7 +744,7 @@ echo $3 >> "$FILE"`);
{activeFilter === "key" && filteredIdentities.length > 0 && (
<div className="space-y-3 px-3 pb-3">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-muted-foreground">
<h2 className={vaultSectionTitleClass}>
{t("keychain.section.identities")}
</h2>
<span className="text-xs text-muted-foreground">

View File

@@ -44,6 +44,7 @@ import {
vaultHeaderIconButtonClass,
vaultHeaderSecondaryButtonClass,
} from "./vault/VaultPageHeader";
import { VaultEntityIcon, vaultPrimaryIconClass } from "./vault/VaultEntityIcon";
interface KnownHostsManagerProps {
knownHosts: KnownHost[];
@@ -167,9 +168,10 @@ const HostItem = React.memo<HostItemProps>(
</Tooltip>
</div>
<div className="flex items-center gap-3 h-full">
<div className="h-11 w-11 rounded-xl bg-primary/10 text-primary flex items-center justify-center flex-shrink-0">
<Server size={18} />
</div>
<VaultEntityIcon
className={vaultPrimaryIconClass}
icon={<Server size={18} />}
/>
<div className="flex-1 min-w-0">
<span className="text-sm font-semibold truncate block">
{knownHost.hostname}
@@ -205,9 +207,10 @@ const HostItem = React.memo<HostItemProps>(
converted && "opacity-60",
)}
>
<div className="h-11 w-11 rounded-xl bg-primary/10 text-primary flex items-center justify-center flex-shrink-0">
<Server size={18} />
</div>
<VaultEntityIcon
className={vaultPrimaryIconClass}
icon={<Server size={18} />}
/>
<div className="flex-1 min-w-0">
<span className="text-sm font-semibold truncate block">
{knownHost.hostname}

View File

@@ -247,34 +247,34 @@ const LogViewComponent: React.FC<LogViewProps> = ({
return (
<div className="h-full w-full flex flex-col bg-background">
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50 bg-secondary/30 shrink-0">
<div className="flex items-center gap-3">
<div className="flex h-9 items-center justify-between gap-3 px-3 py-1 border-b border-border/50 bg-secondary/30 shrink-0">
<div className="flex min-w-0 flex-1 items-center gap-2">
<div
className={cn(
"h-8 w-8 rounded-lg flex items-center justify-center",
"h-6 w-6 shrink-0 rounded-md flex items-center justify-center",
isLocal
? "bg-emerald-500/10 text-emerald-500"
: "bg-blue-500/10 text-blue-500"
)}
>
<FileText size={16} />
<FileText size={14} />
</div>
<div>
<div className="text-sm font-medium">
<div className="flex min-w-0 flex-1 items-baseline gap-2">
<div className="min-w-0 text-sm font-medium leading-none truncate">
{isLocal ? t("logs.localTerminal") : log.hostname}
</div>
<div className="text-xs text-muted-foreground">
<div className="text-xs leading-none text-muted-foreground truncate">
{formattedDate} {log.localUsername}@{log.localHostname}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex h-7 shrink-0 items-center gap-1.5">
{/* Export button */}
{log.terminalData && (
<Button
variant="ghost"
size="sm"
className="gap-1.5 h-8 px-2"
className="gap-1.5 h-7 px-2 text-xs"
onClick={handleExport}
disabled={isExporting}
>
@@ -287,18 +287,18 @@ const LogViewComponent: React.FC<LogViewProps> = ({
<Button
variant="ghost"
size="sm"
className="gap-1.5 h-8 px-2"
className="gap-1.5 h-7 px-2 text-xs"
onClick={() => setThemeModalOpen(true)}
>
<Palette size={14} />
<span className="text-xs">{t("logView.appearance")}</span>
</Button>
<span className="text-xs text-muted-foreground bg-secondary px-2 py-1 rounded">
<span className="h-6 inline-flex items-center rounded bg-secondary px-2 text-xs text-muted-foreground">
{t("logView.readOnly")}
</span>
<Button variant="ghost" size="sm" onClick={onClose}>
<X size={16} />
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onClose}>
<X size={14} />
</Button>
</div>
</div>

View File

@@ -47,6 +47,7 @@ import {
VaultPageHeader,
vaultHeaderIconButtonClass,
vaultHeaderSecondaryButtonClass,
vaultSectionTitleClass,
} from "./vault/VaultPageHeader";
// Import components and utilities from port-forwarding module
@@ -690,9 +691,9 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
</VaultPageHeader>
{/* Rules List */}
<div className="flex-1 overflow-auto p-4">
<div className="flex-1 overflow-y-auto">
{!hasRules ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<div className="flex h-full flex-col items-center justify-center p-3 text-muted-foreground">
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
<Zap size={32} className="opacity-60" />
</div>
@@ -704,9 +705,9 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
</p>
</div>
) : (
<div className="space-y-3">
<div className="space-y-3 p-3">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold">{t("pf.title")}</h2>
<h2 className={vaultSectionTitleClass}>{t("pf.title")}</h2>
<span className="text-xs text-muted-foreground">
{t("pf.rulesCount", { count: filteredRules.length })}
</span>

View File

@@ -59,7 +59,14 @@ import {
VaultPageHeader,
vaultHeaderIconButtonClass,
vaultHeaderSecondaryButtonClass,
vaultSectionTitleClass,
} from "./vault/VaultPageHeader";
import {
VaultEntityIcon,
vaultProxyCommandIconClass,
vaultProxyHttpIconClass,
vaultProxySocksIconClass,
} from "./vault/VaultEntityIcon";
interface ProxyProfilesManagerProps {
proxyProfiles: ProxyProfile[];
@@ -99,17 +106,17 @@ const proxyProtocolMeta = {
http: {
label: "HTTP",
Icon: Globe,
iconClassName: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
iconClassName: vaultProxyHttpIconClass,
},
socks5: {
label: "SOCKS5",
Icon: Route,
iconClassName: "bg-sky-500/10 text-sky-600 dark:text-sky-400",
iconClassName: vaultProxySocksIconClass,
},
command: {
labelKey: "hostDetails.proxyPanel.command",
Icon: SquareTerminal,
iconClassName: "bg-violet-500/10 text-violet-600 dark:text-violet-400",
iconClassName: vaultProxyCommandIconClass,
},
} satisfies Record<ProxyConfig["type"], {
label?: string;
@@ -163,15 +170,11 @@ const ProxyProfileCard: React.FC<ProxyProfileCardProps> = ({
onClick={onClick}
>
<div className="flex items-center gap-3 h-full">
<div
className={cn(
"h-11 w-11 rounded-xl flex items-center justify-center",
protocol.iconClassName,
)}
<VaultEntityIcon
className={protocol.iconClassName}
title={protocolLabel}
>
<ProtocolIcon size={18} />
</div>
icon={<ProtocolIcon size={18} />}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 min-w-0">
<div className="text-sm font-semibold truncate">{profile.label}</div>
@@ -397,7 +400,7 @@ export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
<div className="flex-1 overflow-y-auto">
<div className="space-y-3 p-3">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-muted-foreground">
<h2 className={vaultSectionTitleClass}>
{t("proxyProfiles.section.proxies")}
</h2>
<span className="text-xs text-muted-foreground">

View File

@@ -46,6 +46,7 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
const [label, setLabel] = useState('');
const [command, setCommand] = useState('');
const [packagePath, setPackagePath] = useState('');
const [noAutoRun, setNoAutoRun] = useState(false);
const [editing, setEditing] = useState<Snippet | null>(null);
const labelInputRef = useRef<HTMLInputElement>(null);
@@ -58,6 +59,7 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
setLabel('');
setCommand('');
setPackagePath('');
setNoAutoRun(false);
setOpen(true);
};
window.addEventListener('netcatty:snippets:add', handler);
@@ -75,6 +77,7 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
setLabel(snippet.label ?? '');
setCommand(snippet.command ?? '');
setPackagePath(snippet.package ?? '');
setNoAutoRun(snippet.noAutoRun ?? false);
setOpen(true);
};
window.addEventListener('netcatty:snippets:edit', handler);
@@ -121,6 +124,7 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
label: label.trim(),
command,
package: trimmedPackage || '',
noAutoRun: noAutoRun || undefined,
});
} else {
onCreateSnippet({
@@ -130,10 +134,11 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
tags: [],
package: trimmedPackage || '',
targets: [],
noAutoRun: noAutoRun || undefined,
});
}
setOpen(false);
}, [canSave, packagePath, packages, onCreatePackage, onCreateSnippet, onUpdateSnippet, editing, label, command]);
}, [canSave, packagePath, packages, onCreatePackage, onCreateSnippet, onUpdateSnippet, editing, label, command, noAutoRun]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
@@ -199,6 +204,16 @@ export const QuickAddSnippetDialog: React.FC<QuickAddSnippetDialogProps> = ({
createText={t('snippets.field.createPackage')}
/>
</div>
<label className="flex items-center gap-2 cursor-pointer px-1">
<input
type="checkbox"
checked={noAutoRun}
onChange={(e) => setNoAutoRun(e.target.checked)}
className="rounded border-input"
/>
<span className="text-xs text-muted-foreground">{t('snippets.field.noAutoRun')}</span>
</label>
</div>
<DialogFooter className="shrink-0">

View File

@@ -18,10 +18,17 @@ import {
ContextMenuItem,
ContextMenuTrigger,
} from './ui/context-menu';
import { FixedSizeVirtualList } from './ui/FixedSizeVirtualList';
import { Input } from './ui/input';
import { ScrollArea } from './ui/scroll-area';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
const SCRIPT_ROW_HEIGHT = 34;
const isRootPackagePath = (path: string): boolean => {
const body = path.startsWith('/') ? path.slice(1) : path;
return body.length > 0 && !body.includes('/');
};
interface ScriptsSidePanelProps {
snippets: Snippet[];
packages: string[];
@@ -69,6 +76,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
// Normalize the package list + derive ancestor packages implied by each path
// (e.g. package "a/b/c" implies roots "a" and "a/b" even when not listed).
const normalizedPackages = useMemo(() => {
if (!isVisible) return new Set<string>();
const set = new Set<string>();
const addWithAncestors = (raw: string) => {
const path = raw.trim();
@@ -87,7 +95,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
if (s.package) addWithAncestors(s.package);
});
return set;
}, [packages, snippets]);
}, [packages, snippets, isVisible]);
// Track every package we've ever observed so we can tell "new" from
// "previously-seen-but-user-collapsed". Without this, any unrelated refresh
@@ -99,6 +107,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
// everything without drilling in. After that, respect the user's collapse
// choices across unrelated refreshes.
useEffect(() => {
if (!isVisible) return;
const seen = seenPackagesRef.current;
const newlySeen: string[] = [];
normalizedPackages.forEach((p) => {
@@ -110,10 +119,53 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
if (newlySeen.length === 0) return;
setExpandedPaths((prev) => {
const next = new Set(prev);
newlySeen.forEach((p) => next.add(p));
// Only auto-expand root packages on first sight — expanding the full
// tree upfront was freezing the panel on large snippet libraries.
newlySeen.filter(isRootPackagePath).forEach((p) => next.add(p));
return next;
});
}, [normalizedPackages]);
}, [normalizedPackages, isVisible]);
const snippetIndex = useMemo(() => {
if (!isVisible) {
return {
snippetsByPackage: new Map<string, Snippet[]>(),
descendantCountByPackage: new Map<string, number>(),
};
}
const snippetsByPackage = new Map<string, Snippet[]>();
const descendantCountByPackage = new Map<string, number>();
const bumpCount = (path: string) => {
descendantCountByPackage.set(path, (descendantCountByPackage.get(path) ?? 0) + 1);
};
for (const snippet of snippets) {
const pkg = snippet.package || '';
const bucket = snippetsByPackage.get(pkg);
if (bucket) bucket.push(snippet);
else snippetsByPackage.set(pkg, [snippet]);
if (pkg === '') {
bumpCount('');
continue;
}
let path = pkg;
while (true) {
bumpCount(path);
const slash = path.lastIndexOf('/');
if (slash < 0) break;
path = path.slice(0, slash);
}
}
for (const bucket of snippetsByPackage.values()) {
bucket.sort((a, b) => a.label.localeCompare(b.label));
}
return { snippetsByPackage, descendantCountByPackage };
}, [snippets, isVisible]);
const togglePackage = useCallback((path: string) => {
setExpandedPaths((prev) => {
@@ -126,6 +178,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
// When search is active, flatten everything (no tree, no packages).
const searchMatches = useMemo(() => {
if (!isVisible) return null;
const q = search.trim().toLowerCase();
if (!q) return null;
return snippets.filter(
@@ -133,9 +186,10 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
s.label.toLowerCase().includes(q) ||
s.command.toLowerCase().includes(q),
);
}, [snippets, search]);
}, [snippets, search, isVisible]);
const rows = useMemo<TreeRow[]>(() => {
if (!isVisible) return [];
if (searchMatches !== null) return [];
const out: TreeRow[] = [];
@@ -159,15 +213,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
};
const snippetsIn = (pkg: string | null): Snippet[] =>
snippets
.filter((s) => (s.package || '') === (pkg ?? ''))
.sort((a, b) => a.label.localeCompare(b.label));
const countDescendants = (pkg: string): number =>
snippets.filter((s) => {
const sp = s.package || '';
return sp === pkg || sp.startsWith(pkg + '/');
}).length;
snippetIndex.snippetsByPackage.get(pkg ?? '') ?? [];
const walk = (pkg: string, depth: number) => {
const children = childPackagesOf(pkg);
@@ -181,7 +227,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
path: pkg,
name: pkgDisplayName(pkg),
depth,
count: countDescendants(pkg),
count: snippetIndex.descendantCountByPackage.get(pkg) ?? 0,
hasChildren,
isExpanded,
});
@@ -200,7 +246,38 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
childPackagesOf(null).forEach((root) => walk(root, 0));
return out;
}, [normalizedPackages, snippets, expandedPaths, searchMatches]);
}, [normalizedPackages, snippetIndex, expandedPaths, searchMatches, isVisible]);
type ScriptsListItem =
| { key: string; kind: 'search'; snippet: Snippet }
| { key: string; kind: 'package'; row: Extract<TreeRow, { type: 'package' }>; countLabel: string }
| { key: string; kind: 'snippet'; row: Extract<TreeRow, { type: 'snippet' }> };
const listItems = useMemo((): ScriptsListItem[] => {
if (!isVisible) return [];
if (searchMatches !== null) {
return searchMatches.map((snippet) => ({
key: `search:${snippet.id}`,
kind: 'search',
snippet,
}));
}
return rows.flatMap((row): ScriptsListItem[] => {
if (row.type === 'package') {
return [{
key: `pkg:${row.id}`,
kind: 'package',
row,
countLabel: t('snippets.package.count', { count: row.count }),
}];
}
return [{
key: `snip:${row.id}`,
kind: 'snippet',
row,
}];
});
}, [rows, searchMatches, t, isVisible]);
const handleSnippetClick = useCallback(
(snippet: Snippet) => {
@@ -265,62 +342,62 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
</div>
{/* Content */}
<ScrollArea className="flex-1">
<div className="py-1">
{!hasAnyContent && (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<Zap size={24} className="opacity-40 mb-2" />
<span className="text-xs">{t('terminal.toolbar.noSnippets')}</span>
</div>
)}
{/* Search flat list */}
{searchMatches !== null && searchMatches.length > 0 &&
searchMatches.map((s) => (
<SnippetRow
key={s.id}
snippet={s}
depth={0}
subtitle={s.package || t('terminal.toolbar.library')}
onClick={() => handleSnippetClick(s)}
onEdit={() => handleEditSnippet(s)}
onDelete={() => handleDeleteSnippet(s.id)}
editLabel={t('action.edit')}
deleteLabel={t('action.delete')}
/>
))}
{/* Tree */}
{searchMatches === null &&
rows.map((row) =>
row.type === 'package' ? (
<PackageRow
key={`pkg:${row.id}`}
row={row}
countLabel={t('snippets.package.count', { count: row.count })}
onToggle={() => togglePackage(row.path)}
/>
) : (
<div className="flex-1 min-h-0">
{!hasAnyContent ? (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
<Zap size={24} className="opacity-40 mb-2" />
<span className="text-xs">{t('terminal.toolbar.noSnippets')}</span>
</div>
) : hasAnyContent && searchMatches !== null && searchMatches.length === 0 ? (
<div className="px-3 py-4 text-xs text-muted-foreground italic text-center">
{t('common.noResultsFound')}
</div>
) : (
<FixedSizeVirtualList
className="h-full"
contentClassName="py-1"
items={listItems}
itemHeight={SCRIPT_ROW_HEIGHT}
getItemKey={(item) => item.key}
renderItem={(item) => {
if (item.kind === 'search') {
return (
<SnippetRow
snippet={item.snippet}
depth={0}
subtitle={item.snippet.package || t('terminal.toolbar.library')}
onClick={() => handleSnippetClick(item.snippet)}
onEdit={() => handleEditSnippet(item.snippet)}
onDelete={() => handleDeleteSnippet(item.snippet.id)}
editLabel={t('action.edit')}
deleteLabel={t('action.delete')}
/>
);
}
if (item.kind === 'package') {
return (
<PackageRow
row={item.row}
countLabel={item.countLabel}
onToggle={() => togglePackage(item.row.path)}
/>
);
}
return (
<SnippetRow
key={`snip:${row.id}`}
snippet={row.snippet}
depth={row.depth}
onClick={() => handleSnippetClick(row.snippet)}
onEdit={() => handleEditSnippet(row.snippet)}
onDelete={() => handleDeleteSnippet(row.snippet.id)}
snippet={item.row.snippet}
depth={item.row.depth}
onClick={() => handleSnippetClick(item.row.snippet)}
onEdit={() => handleEditSnippet(item.row.snippet)}
onDelete={() => handleDeleteSnippet(item.row.snippet.id)}
editLabel={t('action.edit')}
deleteLabel={t('action.delete')}
/>
),
)}
{hasAnyContent && searchMatches !== null && searchMatches.length === 0 && (
<div className="px-3 py-4 text-xs text-muted-foreground italic text-center">
{t('common.noResultsFound')}
</div>
)}
</div>
</ScrollArea>
);
}}
/>
)}
</div>
</div>
</TooltipProvider>
);
@@ -332,7 +409,7 @@ interface PackageRowProps {
onToggle: () => void;
}
const PackageRow: React.FC<PackageRowProps> = ({ row, countLabel, onToggle }) => (
const PackageRow = memo<PackageRowProps>(({ row, countLabel, onToggle }) => (
<button
type="button"
onClick={onToggle}
@@ -351,7 +428,8 @@ const PackageRow: React.FC<PackageRowProps> = ({ row, countLabel, onToggle }) =>
<span className="flex-1 min-w-0 truncate text-xs font-medium">{row.name}</span>
<span className="shrink-0 text-[10px] text-muted-foreground tabular-nums">{countLabel}</span>
</button>
);
));
PackageRow.displayName = 'PackageRow';
interface SnippetRowProps {
snippet: Snippet;
@@ -364,7 +442,7 @@ interface SnippetRowProps {
deleteLabel: string;
}
const SnippetRow: React.FC<SnippetRowProps> = ({
const SnippetRow = memo<SnippetRowProps>(({
snippet,
depth,
subtitle,
@@ -415,7 +493,8 @@ const SnippetRow: React.FC<SnippetRowProps> = ({
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
));
SnippetRow.displayName = 'SnippetRow';
export const ScriptsSidePanel = memo(ScriptsSidePanelInner);
ScriptsSidePanel.displayName = 'ScriptsSidePanel';

View File

@@ -343,7 +343,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
<DistroAvatar
host={host}
fallback={host.os[0].toUpperCase()}
className="h-8 w-8 rounded-md"
size="md"
/>
<div className="flex-1 min-w-0">
<Tooltip>

View File

@@ -16,28 +16,49 @@ type AppInfo = {
};
const REPO_URL = "https://github.com/binaricat/Netcatty";
const BUG_REPORT_TEMPLATE = "bug_report.yml";
const buildIssueUrl = (appInfo: AppInfo) => {
const title = "Bug: ";
const bodyLines = [
"## Describe the problem",
"",
"## Steps to reproduce",
"1.",
"",
"## Expected behavior",
"",
"## Actual behavior",
"",
"## Environment",
`- App: ${appInfo.name} ${appInfo.version}`,
`- Platform: ${appInfo.platform || "unknown"}`,
`- UA: ${typeof navigator !== "undefined" ? navigator.userAgent : "unknown"}`,
];
const mapIssuePlatform = (platform?: string) => {
switch (platform) {
case "darwin":
return "macOS";
case "win32":
return "Windows";
case "linux":
return "Linux";
default:
return undefined;
}
};
/** Opens GitHub's Bug Report issue form with fields prefilled from the running app. */
export const buildIssueUrl = (appInfo: AppInfo) => {
const params = new URLSearchParams({
title,
body: bodyLines.join("\n"),
template: BUG_REPORT_TEMPLATE,
title: "[Bug] ",
});
if (appInfo.version) {
params.set("version", appInfo.version);
}
const platform = mapIssuePlatform(appInfo.platform);
if (platform) {
params.set("platform", platform);
}
const installSource =
appInfo.version === "0.0.0"
? "Built from source (npm run dev / pack)"
: "GitHub Release (.dmg / .exe / .AppImage / .deb)";
params.set("install_source", installSource);
const ua = typeof navigator !== "undefined" ? navigator.userAgent : "unknown";
params.set(
"logs",
`Reported from Netcatty Settings (${appInfo.name} ${appInfo.version || "unknown"}).\n\nUser-Agent: ${ua}`,
);
return `${REPO_URL}/issues/new?${params.toString()}`;
};

View File

@@ -18,9 +18,10 @@ import SettingsApplicationTab from "./SettingsApplicationTab";
import SettingsAppearanceTab from "./settings/tabs/SettingsAppearanceTab";
import SettingsFileAssociationsTab from "./settings/tabs/SettingsFileAssociationsTab";
import SettingsShortcutsTab from "./settings/tabs/SettingsShortcutsTab";
import SettingsAITab from "./settings/tabs/SettingsAITab";
import SettingsSyncTab from "./settings/tabs/SettingsSyncTab";
import SettingsTerminalTab from "./settings/tabs/SettingsTerminalTab";
import SettingsSystemTab from "./settings/tabs/SettingsSystemTab";
const SettingsAITab = React.lazy(() => import("./settings/tabs/SettingsAITab"));
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
@@ -50,74 +51,111 @@ class AITabErrorBoundary extends React.Component<
type SettingsState = ReturnType<typeof useSettingsState>;
const SettingsSyncTab = React.lazy(() => import("./settings/tabs/SettingsSyncTab"));
const settingsTabTriggerClassName =
"w-full justify-start gap-2 px-3 py-2 text-sm data-[state=active]:bg-background hover:bg-background/60 rounded-md transition-colors overflow-hidden";
const settingsTabIconClassName = "shrink-0";
const settingsTabLabelClassName = "min-w-0 truncate";
const SettingsTerminalTabContainer: React.FC<{ settings: SettingsState }> = ({ settings }) => {
type TerminalTabSettingsProps = Pick<
SettingsState,
| 'terminalThemeId'
| 'setTerminalThemeId'
| 'followAppTerminalTheme'
| 'setFollowAppTerminalTheme'
| 'terminalThemeDarkId'
| 'setTerminalThemeDarkId'
| 'terminalThemeLightId'
| 'setTerminalThemeLightId'
| 'lightUiThemeId'
| 'darkUiThemeId'
| 'terminalFontFamilyId'
| 'setTerminalFontFamilyId'
| 'terminalFontSize'
| 'setTerminalFontSize'
| 'terminalSettings'
| 'updateTerminalSetting'
| 'workspaceFocusStyle'
| 'setWorkspaceFocusStyle'
>;
const SettingsTerminalTabContainer = React.memo<TerminalTabSettingsProps>(function SettingsTerminalTabContainer({
terminalThemeId,
setTerminalThemeId,
followAppTerminalTheme,
setFollowAppTerminalTheme,
terminalThemeDarkId,
setTerminalThemeDarkId,
terminalThemeLightId,
setTerminalThemeLightId,
lightUiThemeId,
darkUiThemeId,
terminalFontFamilyId,
setTerminalFontFamilyId,
terminalFontSize,
setTerminalFontSize,
terminalSettings,
updateTerminalSetting,
workspaceFocusStyle,
setWorkspaceFocusStyle,
}) {
const availableFonts = useAvailableFonts();
return (
<SettingsTerminalTab
terminalThemeId={settings.terminalThemeId}
setTerminalThemeId={settings.setTerminalThemeId}
followAppTerminalTheme={settings.followAppTerminalTheme}
setFollowAppTerminalTheme={settings.setFollowAppTerminalTheme}
terminalThemeDarkId={settings.terminalThemeDarkId}
setTerminalThemeDarkId={settings.setTerminalThemeDarkId}
terminalThemeLightId={settings.terminalThemeLightId}
setTerminalThemeLightId={settings.setTerminalThemeLightId}
lightUiThemeId={settings.lightUiThemeId}
darkUiThemeId={settings.darkUiThemeId}
terminalFontFamilyId={settings.terminalFontFamilyId}
setTerminalFontFamilyId={settings.setTerminalFontFamilyId}
terminalFontSize={settings.terminalFontSize}
setTerminalFontSize={settings.setTerminalFontSize}
terminalSettings={settings.terminalSettings}
updateTerminalSetting={settings.updateTerminalSetting}
terminalThemeId={terminalThemeId}
setTerminalThemeId={setTerminalThemeId}
followAppTerminalTheme={followAppTerminalTheme}
setFollowAppTerminalTheme={setFollowAppTerminalTheme}
terminalThemeDarkId={terminalThemeDarkId}
setTerminalThemeDarkId={setTerminalThemeDarkId}
terminalThemeLightId={terminalThemeLightId}
setTerminalThemeLightId={setTerminalThemeLightId}
lightUiThemeId={lightUiThemeId}
darkUiThemeId={darkUiThemeId}
terminalFontFamilyId={terminalFontFamilyId}
setTerminalFontFamilyId={setTerminalFontFamilyId}
terminalFontSize={terminalFontSize}
setTerminalFontSize={setTerminalFontSize}
terminalSettings={terminalSettings}
updateTerminalSetting={updateTerminalSetting}
availableFonts={availableFonts}
workspaceFocusStyle={settings.workspaceFocusStyle}
setWorkspaceFocusStyle={settings.setWorkspaceFocusStyle}
workspaceFocusStyle={workspaceFocusStyle}
setWorkspaceFocusStyle={setWorkspaceFocusStyle}
/>
);
};
});
const SettingsAITabContainer: React.FC = () => {
const aiState = useAIState();
return (
<AITabErrorBoundary>
<React.Suspense fallback={<div className="flex-1 px-6 py-5 text-sm text-muted-foreground">Loading AI settings...</div>}>
<SettingsAITab
providers={aiState.providers}
addProvider={aiState.addProvider}
updateProvider={aiState.updateProvider}
removeProvider={aiState.removeProvider}
activeProviderId={aiState.activeProviderId}
setActiveProviderId={aiState.setActiveProviderId}
activeModelId={aiState.activeModelId}
setActiveModelId={aiState.setActiveModelId}
globalPermissionMode={aiState.globalPermissionMode}
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
toolIntegrationMode={aiState.toolIntegrationMode}
setToolIntegrationMode={aiState.setToolIntegrationMode}
externalAgents={aiState.externalAgents}
setExternalAgents={aiState.setExternalAgents}
defaultAgentId={aiState.defaultAgentId}
setDefaultAgentId={aiState.setDefaultAgentId}
commandBlocklist={aiState.commandBlocklist}
setCommandBlocklist={aiState.setCommandBlocklist}
commandTimeout={aiState.commandTimeout}
setCommandTimeout={aiState.setCommandTimeout}
maxIterations={aiState.maxIterations}
setMaxIterations={aiState.setMaxIterations}
webSearchConfig={aiState.webSearchConfig}
setWebSearchConfig={aiState.setWebSearchConfig}
/>
</React.Suspense>
<SettingsAITab
providers={aiState.providers}
addProvider={aiState.addProvider}
updateProvider={aiState.updateProvider}
removeProvider={aiState.removeProvider}
activeProviderId={aiState.activeProviderId}
setActiveProviderId={aiState.setActiveProviderId}
activeModelId={aiState.activeModelId}
setActiveModelId={aiState.setActiveModelId}
globalPermissionMode={aiState.globalPermissionMode}
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
toolIntegrationMode={aiState.toolIntegrationMode}
setToolIntegrationMode={aiState.setToolIntegrationMode}
externalAgents={aiState.externalAgents}
setExternalAgents={aiState.setExternalAgents}
defaultAgentId={aiState.defaultAgentId}
setDefaultAgentId={aiState.setDefaultAgentId}
commandBlocklist={aiState.commandBlocklist}
setCommandBlocklist={aiState.setCommandBlocklist}
commandTimeout={aiState.commandTimeout}
setCommandTimeout={aiState.setCommandTimeout}
maxIterations={aiState.maxIterations}
setMaxIterations={aiState.setMaxIterations}
webSearchConfig={aiState.webSearchConfig}
setWebSearchConfig={aiState.setWebSearchConfig}
/>
</AITabErrorBoundary>
);
};
@@ -325,11 +363,34 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
setShowOnlyUngroupedHostsInRoot={settings.setShowOnlyUngroupedHostsInRoot}
showSftpTab={settings.showSftpTab}
setShowSftpTab={settings.setShowSftpTab}
/>
showHostTreeSidebar={settings.showHostTreeSidebar}
setShowHostTreeSidebar={settings.setShowHostTreeSidebar}
windowOpacity={settings.windowOpacity}
setWindowOpacity={settings.setWindowOpacity}
/>
)}
{mountedTabs.has("terminal") && (
<SettingsTerminalTabContainer settings={settings} />
<SettingsTerminalTabContainer
terminalThemeId={settings.terminalThemeId}
setTerminalThemeId={settings.setTerminalThemeId}
followAppTerminalTheme={settings.followAppTerminalTheme}
setFollowAppTerminalTheme={settings.setFollowAppTerminalTheme}
terminalThemeDarkId={settings.terminalThemeDarkId}
setTerminalThemeDarkId={settings.setTerminalThemeDarkId}
terminalThemeLightId={settings.terminalThemeLightId}
setTerminalThemeLightId={settings.setTerminalThemeLightId}
lightUiThemeId={settings.lightUiThemeId}
darkUiThemeId={settings.darkUiThemeId}
terminalFontFamilyId={settings.terminalFontFamilyId}
setTerminalFontFamilyId={settings.setTerminalFontFamilyId}
terminalFontSize={settings.terminalFontSize}
setTerminalFontSize={settings.setTerminalFontSize}
terminalSettings={settings.terminalSettings}
updateTerminalSetting={settings.updateTerminalSetting}
workspaceFocusStyle={settings.workspaceFocusStyle}
setWorkspaceFocusStyle={settings.setWorkspaceFocusStyle}
/>
)}
{mountedTabs.has("shortcuts") && (
@@ -353,9 +414,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
)}
{mountedTabs.has("sync") && (
<React.Suspense fallback={null}>
<SettingsSyncTabWithVault onSettingsApplied={settings.rehydrateAllFromStorage} />
</React.Suspense>
<SettingsSyncTabWithVault onSettingsApplied={settings.rehydrateAllFromStorage} />
)}
{mountedTabs.has("system") && (

View File

@@ -108,15 +108,13 @@ test("clipboard files become path-backed upload entries", () => {
]);
});
test("clipboard upload ignores directories until recursive paste is supported", () => {
test("clipboard upload keeps directories for recursive folder paste", () => {
const files: ClipboardLocalFile[] = [
{ path: "/Users/me/Desktop/report.txt", name: "report.txt", isDirectory: false, size: 42 },
{ path: "/Users/me/Desktop/folder", name: "folder", isDirectory: true, size: 0 },
];
assert.deepEqual(getSupportedClipboardUploadFiles(files), [
{ path: "/Users/me/Desktop/report.txt", name: "report.txt", isDirectory: false, size: 42 },
]);
assert.deepEqual(getSupportedClipboardUploadFiles(files), files);
});
test("SFTP paste keydown lets the native paste event handle OS clipboard files", () => {

View File

@@ -10,7 +10,8 @@
* Used in TerminalLayer to provide SFTP alongside terminal sessions.
*/
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import React, { memo, useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
import { SftpSidePanelDeferredMount } from "./SftpSidePanelDeferredMount";
import { formatHostPort } from "../domain/host";
import { useI18n } from "../application/i18n/I18nProvider";
import { useSftpState } from "../application/state/useSftpState";
@@ -39,6 +40,7 @@ import { useSftpKeyboardShortcuts } from "./sftp/hooks/useSftpKeyboardShortcuts"
import { sftpFocusStore } from "./sftp/hooks/useSftpFocusedPane";
import { keepOnlyPaneSelections } from "./sftp/hooks/selectionScope";
import { KeyBinding, HotkeyScheme } from "../domain/models";
import { shouldFollowTerminalCwdNavigate } from "./sftp/sftpFollowTerminalCwd";
interface SftpSidePanelProps {
hosts: Host[];
@@ -72,7 +74,10 @@ interface SftpSidePanelProps {
keyBindings: KeyBinding[];
editorWordWrap: boolean;
setEditorWordWrap: (value: boolean) => void;
onGetTerminalCwd?: () => Promise<string | null>;
onGetTerminalCwd?: (options?: { preferFreshBackend?: boolean }) => Promise<string | null>;
activeTerminalCwd?: string | null;
sftpFollowTerminalCwd?: boolean;
onSftpFollowTerminalCwdChange?: (enabled: boolean) => void;
onRequestTerminalFocus?: () => void;
terminalSettings?: { keepaliveInterval: number; keepaliveCountMax: number };
}
@@ -102,6 +107,9 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
editorWordWrap,
setEditorWordWrap,
onGetTerminalCwd,
activeTerminalCwd = null,
sftpFollowTerminalCwd = false,
onSftpFollowTerminalCwdChange,
onRequestTerminalFocus,
terminalSettings,
}) => {
@@ -190,199 +198,34 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
const autoSyncRef = useRef(sftpAutoSync);
autoSyncRef.current = sftpAutoSync;
const panelRootRef = useRef<HTMLDivElement>(null);
const dialogActionScopeIdRef = useRef(`sftp-side-panel:${crypto.randomUUID()}`);
const [hasPaneFocus, setHasPaneFocus] = useState(false);
useSftpKeyboardShortcuts({
keyBindings,
hotkeyScheme,
sftpRef,
dialogActionScopeId: dialogActionScopeIdRef.current,
isActive: isVisible && hasPaneFocus,
});
const { getOpenerForFile, setOpenerForExtension } = useSftpFileAssociations();
const getOpenerForFileRef = useRef(getOpenerForFile);
getOpenerForFileRef.current = getOpenerForFile;
const handleToggleHiddenFiles = useCallback((paneId: string) => {
const pane = sftpRef.current.leftTabs.tabs.find((tab) => tab.id === paneId);
if (!pane) return;
sftpRef.current.setShowHiddenFiles("left", paneId, !pane.showHiddenFiles);
}, []);
const syncFocusedSelection = useCallback((tabId: string | null) => {
if (tabId) {
keepOnlyPaneSelections(sftpRef.current, { side: "left", tabId });
return;
}
keepOnlyPaneSelections(sftpRef.current, null);
}, []);
const handlePaneFocus = useCallback(() => {
sftpFocusStore.setFocusedSide("left");
setHasPaneFocus(true);
syncFocusedSelection(sftpRef.current.getActiveTabId("left"));
}, [syncFocusedSelection]);
// NOTE: We intentionally do NOT sync to activeTabStore here.
// activeTabStore is a global singleton shared with SftpView.
// Writing to it here would corrupt SftpView's left pane visibility.
useEffect(() => {
if (!isVisible) {
setHasPaneFocus(false);
syncFocusedSelection(null);
}
}, [isVisible, syncFocusedSelection]);
useEffect(() => {
if (!isVisible) return;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as Node | null;
const elementTarget = target instanceof Element ? target : null;
const isPortalInteraction = !!elementTarget?.closest(
'#netcatty-context-menu-root, [role="dialog"], [data-radix-popper-content-wrapper]',
);
if (isPortalInteraction) {
return;
}
if (panelRootRef.current?.contains(target)) {
sftpFocusStore.setFocusedSide("left");
setHasPaneFocus(true);
syncFocusedSelection(sftpRef.current.getActiveTabId("left"));
} else {
setHasPaneFocus(false);
syncFocusedSelection(null);
}
};
document.addEventListener("pointerdown", handlePointerDown, true);
return () => {
document.removeEventListener("pointerdown", handlePointerDown, true);
};
}, [isVisible, syncFocusedSelection]);
const {
leftCallbacks,
rightCallbacks,
dragCallbacks,
draggedFiles,
permissionsState,
setPermissionsState,
showTextEditor,
setShowTextEditor,
textEditorTarget,
setTextEditorTarget,
textEditorContent,
setTextEditorContent,
showFileOpenerDialog,
setShowFileOpenerDialog,
fileOpenerTarget,
setFileOpenerTarget,
handleSaveTextFile,
onPromoteToTab,
handleFileOpenerSelect,
handleSelectSystemApp,
} = useSftpViewPaneCallbacks({
sftpRef,
behaviorRef,
autoSyncRef,
getOpenerForFileRef,
setOpenerForExtension,
t,
listSftp,
mkdirLocal,
deleteLocalFile,
showSaveDialog,
selectDirectory,
startStreamTransfer,
getSftpIdForConnection: sftp.getSftpIdForConnection,
listLocalFiles: listLocalDir,
listDrives,
});
const {
leftPanes,
showHostPickerLeft,
showHostPickerRight,
hostSearchLeft,
hostSearchRight,
setShowHostPickerLeft,
setShowHostPickerRight,
setHostSearchLeft,
setHostSearchRight,
handleHostSelectLeft,
handleHostSelectRight,
} = useSftpViewTabs({ sftp, sftpRef });
// Auto-connect when activeHost changes.
// Uses sftpRef to avoid re-triggering on every sftp state change.
const connectedKeyRef = useRef<string | null>(null);
// Store the Host object used for the current connection so the header
// can show session-time overrides even during deferred host switches.
const connectedHostObjRef = useRef<Host | null>(null);
const lastAppliedInitialLocationKeyRef = useRef<string | null>(null);
const handledPendingUploadIdRef = useRef<string | null>(null);
// Maps tab IDs to the connectionKey used to create them, so we can
// correctly identify tabs when the same host ID has different overrides.
const tabConnectionKeyMapRef = useRef<Map<string, string>>(new Map());
const [interactiveWorkActive, setInteractiveWorkActive] = useState(false);
const [sftpUiReady, setSftpUiReady] = useState(false);
// NOTE: We intentionally do NOT reset lastAppliedInitialLocationKeyRef on
// visibility changes. When the user switches terminal tabs, the panel
// toggles isVisible but should preserve its navigation state (the user may
// have navigated away from initialLocation). When the panel is truly
// closed, the component unmounts and all refs are naturally reset.
// Navigate SFTP to the terminal's current working directory
const handleGoToTerminalCwd = useCallback(async () => {
if (!onGetTerminalCwd) return;
const cwd = await onGetTerminalCwd();
if (cwd) {
sftpRef.current.navigateTo("left", cwd);
}
}, [onGetTerminalCwd]);
// Track whether there's active work that should block connection switching.
// Computed outside the effect so it can be in the dependency array.
// Block host-following while any connection-sensitive interactive UI is
// active: text editor, permissions dialog, file-opener dialog, or
// auto-synced external file watches.
// Note: transfers are NOT included here — they run on their own sftpId
// independent of the active tab, and forceNewTab preserves old connections.
const hasActiveWork = showTextEditor || !!permissionsState || showFileOpenerDialog
|| (sftp.activeFileWatchCountRef?.current ?? 0) > 0;
useEffect(() => {
const runAutoConnect = useCallback(() => {
if (!activeHost) return;
const s = sftpRef.current;
const hasActiveWork = interactiveWorkActive
|| (s.activeFileWatchCountRef?.current ?? 0) > 0;
// Serial terminals don't support SFTP — disconnect any existing
// connection (remote or local) so the panel doesn't remain bound to
// a previous host.
const proto = activeHost.protocol;
if (proto === 'serial' || activeHost.id?.startsWith('serial-')) {
// Serial terminals don't support SFTP. Just clear the tracked
// connection key so switching back to a remote terminal will
// trigger auto-connect. Don't disconnect existing tabs — they
// may be reused when focus returns.
connectedKeyRef.current = null;
return;
}
// Local terminals connect to the local file browser
if (proto === 'local' || activeHost.id?.startsWith('local-')) {
if (hasActiveWork) return;
const leftConn = s.leftPane.connection;
if (leftConn?.isLocal) {
// Already connected locally
connectedKeyRef.current = "local";
return;
}
// Check for an existing local tab to reuse
const existingLocalTab = s.leftTabs.tabs.find((tab) =>
tab.connection?.isLocal && tab.connection.status === "connected",
);
@@ -392,27 +235,28 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
return;
}
connectedKeyRef.current = "local";
// Preserve existing remote tab when switching to local
const needsNewTab = !!(leftConn && leftConn.status === "connected");
if (needsNewTab) {
s.connect("left", "local", { forceNewTab: true });
} else if (leftConn) {
// Await disconnect before connecting locally to avoid the async
// disconnect wiping out the fresh local connection.
void s.disconnect("left").then(() => s.connect("left", "local"));
} else {
s.connect("left", "local");
}
return;
}
// Build a connection key that accounts for session-time overrides
// (same host ID may have different port/protocol in different workspace panes).
// Uses buildCacheKey to stay consistent with the key recorded on upload tasks.
const connectionKey = buildCacheKey(activeHost.id, activeHost.hostname, activeHost.port, activeHost.protocol, activeHost.sftpSudo, activeHost.username);
if (connectedKeyRef.current === connectionKey) return;
// Don't switch connections while transfers or editor are active
const connectionKey = buildCacheKey(
activeHost.id,
activeHost.hostname,
activeHost.port,
activeHost.protocol,
activeHost.sftpSudo,
activeHost.username,
);
if (connectedKeyRef.current === connectionKey) return;
if (hasActiveWork) return;
logger.info("[SftpSidePanel] Auto-connect triggered", {
hostId: activeHost.id,
hostLabel: activeHost.label,
@@ -420,14 +264,9 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
hostname: activeHost.hostname,
});
// Check if an existing SFTP tab matches this exact endpoint.
// We track which connectionKey was used to create each tab so that
// tabs for the same host ID with different session-time overrides
// (port/protocol) are not incorrectly reused.
const tabs = s.leftTabs.tabs;
const existingTab = tabs.find((tab) => {
if (!tab.connection || tab.connection.hostId !== activeHost.id) return false;
// Don't reuse errored tabs — they need a fresh connection
if (tab.connection.status === "error" || tab.connection.status === "disconnected") return false;
return tabConnectionKeyMapRef.current.get(tab.id) === connectionKey;
});
@@ -438,11 +277,6 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
return;
}
// Create a new tab when there's already an active connection, so the
// previous tab is preserved for instant switching on focus change.
// This covers both different hosts AND same host with different
// session-time overrides (port/protocol), preventing the old SFTP
// session from being closed while it may have in-flight transfers.
const currentConn = s.leftPane.connection;
const needsNewTab = !!(currentConn && currentConn.status === "connected");
@@ -455,12 +289,21 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
tabConnectionKeyMapRef.current.set(tabId, connectionKey);
},
});
}, [activeHost, activeSessionId, hasActiveWork]);
}, [activeHost, activeSessionId, interactiveWorkActive]);
useEffect(() => {
if (!activeHost || !isVisible) return;
let cancelled = false;
const frameId = requestAnimationFrame(() => {
if (!cancelled) runAutoConnect();
});
return () => {
cancelled = true;
cancelAnimationFrame(frameId);
};
}, [activeHost, activeSessionId, interactiveWorkActive, isVisible, runAutoConnect]);
// Clear the remembered connection key when the pane disconnects or the
// session is lost, so re-opening SFTP for the same terminal reconnects.
// Also reset the file-watch counter — watches are bound to the SFTP session,
// so they stop when the session disconnects.
useEffect(() => {
const connection = sftp.leftPane.connection;
if (!connection || connection.status === "error" || connection.status === "disconnected") {
@@ -480,8 +323,6 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
if (!connection || connection.isLocal || connection.hostId !== activeHost.id) return;
if (connection.status !== "connected") return;
// Include full endpoint key so that same-hostId sessions with
// different overrides each get their initial location applied.
const locationKey = `${connectedKeyRef.current}:${initialLocation.path}`;
if (lastAppliedInitialLocationKeyRef.current === locationKey) return;
@@ -563,6 +404,321 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
t,
]);
return (
<SftpSidePanelDeferredMount ready={sftpUiReady} onReady={() => setSftpUiReady(true)}>
<SftpSidePanelInteractiveBody
hosts={hosts}
hostWriteSource={hostWriteSource}
updateHosts={updateHosts}
sftp={sftp}
sftpRef={sftpRef}
sftpDefaultViewMode={sftpDefaultViewMode}
activeHost={activeHost}
showWorkspaceHostHeader={showWorkspaceHostHeader}
renderOverlays={renderOverlays}
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
sftpAutoSync={sftpAutoSync}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
editorWordWrap={editorWordWrap}
setEditorWordWrap={setEditorWordWrap}
onGetTerminalCwd={onGetTerminalCwd}
activeTerminalCwd={activeTerminalCwd}
sftpFollowTerminalCwd={sftpFollowTerminalCwd}
onSftpFollowTerminalCwdChange={onSftpFollowTerminalCwdChange}
onRequestTerminalFocus={onRequestTerminalFocus}
isVisible={isVisible}
behaviorRef={behaviorRef}
autoSyncRef={autoSyncRef}
connectedHostObjRef={connectedHostObjRef}
connectedKeyRef={connectedKeyRef}
onInteractiveWorkChange={setInteractiveWorkActive}
listSftp={listSftp}
mkdirLocal={mkdirLocal}
deleteLocalFile={deleteLocalFile}
showSaveDialog={showSaveDialog}
selectDirectory={selectDirectory}
startStreamTransfer={startStreamTransfer}
listLocalDir={listLocalDir}
listDrives={listDrives}
openPath={openPath}
t={t}
/>
</SftpSidePanelDeferredMount>
);
};
type SftpSidePanelInteractiveBodyProps = {
hosts: Host[];
hostWriteSource: Host[];
updateHosts: (hosts: Host[]) => void;
sftp: ReturnType<typeof useSftpState>;
sftpRef: MutableRefObject<ReturnType<typeof useSftpState>>;
sftpDefaultViewMode: "list" | "tree";
activeHost: Host | null;
showWorkspaceHostHeader: boolean;
renderOverlays: boolean;
sftpDoubleClickBehavior: "open" | "transfer";
sftpAutoSync: boolean;
hotkeyScheme: HotkeyScheme;
keyBindings: KeyBinding[];
editorWordWrap: boolean;
setEditorWordWrap: (value: boolean) => void;
onGetTerminalCwd?: (options?: { preferFreshBackend?: boolean }) => Promise<string | null>;
activeTerminalCwd?: string | null;
sftpFollowTerminalCwd: boolean;
onSftpFollowTerminalCwdChange?: (enabled: boolean) => void;
onRequestTerminalFocus?: () => void;
isVisible: boolean;
behaviorRef: MutableRefObject<"open" | "transfer">;
autoSyncRef: MutableRefObject<boolean>;
connectedHostObjRef: MutableRefObject<Host | null>;
connectedKeyRef: MutableRefObject<string | null>;
onInteractiveWorkChange: (active: boolean) => void;
listSftp: ReturnType<typeof useSftpBackend>["listSftp"];
mkdirLocal: ReturnType<typeof useSftpBackend>["mkdirLocal"];
deleteLocalFile: ReturnType<typeof useSftpBackend>["deleteLocalFile"];
showSaveDialog: ReturnType<typeof useSftpBackend>["showSaveDialog"];
selectDirectory: ReturnType<typeof useSftpBackend>["selectDirectory"];
startStreamTransfer: ReturnType<typeof useSftpBackend>["startStreamTransfer"];
listLocalDir: ReturnType<typeof useSftpBackend>["listLocalDir"];
listDrives: ReturnType<typeof useSftpBackend>["listDrives"];
openPath: ReturnType<typeof useSftpBackend>["openPath"];
t: ReturnType<typeof useI18n>["t"];
};
const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps> = ({
hosts,
hostWriteSource,
updateHosts,
sftp,
sftpRef,
sftpDefaultViewMode,
activeHost,
showWorkspaceHostHeader,
renderOverlays,
hotkeyScheme,
keyBindings,
editorWordWrap,
setEditorWordWrap,
onGetTerminalCwd,
activeTerminalCwd = null,
sftpFollowTerminalCwd,
onSftpFollowTerminalCwdChange,
onRequestTerminalFocus,
isVisible,
behaviorRef,
autoSyncRef,
connectedHostObjRef,
connectedKeyRef,
onInteractiveWorkChange,
listSftp,
mkdirLocal,
deleteLocalFile,
showSaveDialog,
selectDirectory,
startStreamTransfer,
listLocalDir,
listDrives,
openPath,
t,
}) => {
const panelRootRef = useRef<HTMLDivElement>(null);
const dialogActionScopeIdRef = useRef(`sftp-side-panel:${crypto.randomUUID()}`);
const [hasPaneFocus, setHasPaneFocus] = useState(false);
useSftpKeyboardShortcuts({
keyBindings,
hotkeyScheme,
sftpRef,
dialogActionScopeId: dialogActionScopeIdRef.current,
isActive: hasPaneFocus,
});
const { getOpenerForFile, setOpenerForExtension } = useSftpFileAssociations();
const getOpenerForFileRef = useRef(getOpenerForFile);
getOpenerForFileRef.current = getOpenerForFile;
const handleToggleHiddenFiles = useCallback((paneId: string) => {
const pane = sftpRef.current.leftTabs.tabs.find((tab) => tab.id === paneId);
if (!pane) return;
sftpRef.current.setShowHiddenFiles("left", paneId, !pane.showHiddenFiles);
}, [sftpRef]);
const syncFocusedSelection = useCallback((tabId: string | null) => {
if (tabId) {
keepOnlyPaneSelections(sftpRef.current, { side: "left", tabId });
return;
}
keepOnlyPaneSelections(sftpRef.current, null);
}, [sftpRef]);
const handlePaneFocus = useCallback(() => {
sftpFocusStore.setFocusedSide("left");
setHasPaneFocus(true);
syncFocusedSelection(sftpRef.current.getActiveTabId("left"));
}, [sftpRef, syncFocusedSelection]);
// NOTE: We intentionally do NOT sync to activeTabStore here.
// activeTabStore is a global singleton shared with SftpView.
// Writing to it here would corrupt SftpView's left pane visibility.
useEffect(() => {
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as Node | null;
const elementTarget = target instanceof Element ? target : null;
const isPortalInteraction = !!elementTarget?.closest(
'#netcatty-context-menu-root, [role="dialog"], [data-radix-popper-content-wrapper]',
);
if (isPortalInteraction) {
return;
}
if (panelRootRef.current?.contains(target)) {
sftpFocusStore.setFocusedSide("left");
setHasPaneFocus(true);
syncFocusedSelection(sftpRef.current.getActiveTabId("left"));
} else {
setHasPaneFocus(false);
syncFocusedSelection(null);
}
};
document.addEventListener("pointerdown", handlePointerDown, true);
return () => {
document.removeEventListener("pointerdown", handlePointerDown, true);
};
}, [sftpRef, syncFocusedSelection]);
const {
leftCallbacks,
rightCallbacks,
dragCallbacks,
draggedFiles,
permissionsState,
setPermissionsState,
showTextEditor,
setShowTextEditor,
textEditorTarget,
setTextEditorTarget,
textEditorContent,
setTextEditorContent,
showFileOpenerDialog,
setShowFileOpenerDialog,
fileOpenerTarget,
setFileOpenerTarget,
handleSaveTextFile,
onPromoteToTab,
handleFileOpenerSelect,
handleSelectSystemApp,
} = useSftpViewPaneCallbacks({
sftpRef,
behaviorRef,
autoSyncRef,
getOpenerForFileRef,
setOpenerForExtension,
t,
listSftp,
mkdirLocal,
deleteLocalFile,
showSaveDialog,
selectDirectory,
startStreamTransfer,
getSftpIdForConnection: sftp.getSftpIdForConnection,
listLocalFiles: listLocalDir,
listDrives,
});
const {
leftPanes,
showHostPickerLeft,
showHostPickerRight,
hostSearchLeft,
hostSearchRight,
setShowHostPickerLeft,
setShowHostPickerRight,
setHostSearchLeft,
setHostSearchRight,
handleHostSelectLeft,
handleHostSelectRight,
} = useSftpViewTabs({ sftp, sftpRef });
useEffect(() => {
onInteractiveWorkChange(showTextEditor || !!permissionsState || showFileOpenerDialog);
}, [onInteractiveWorkChange, permissionsState, showFileOpenerDialog, showTextEditor]);
const canFollowTerminalCwd = useMemo(() => {
if (!onGetTerminalCwd || !activeHost) return false;
const proto = activeHost.protocol;
if (proto === "local" || proto === "serial") return false;
if (activeHost.id?.startsWith("local-") || activeHost.id?.startsWith("serial-")) return false;
return true;
}, [activeHost, onGetTerminalCwd]);
const hasActiveWork = showTextEditor || !!permissionsState || showFileOpenerDialog
|| (sftp.activeFileWatchCountRef?.current ?? 0) > 0;
const handleGoToTerminalCwd = useCallback(async () => {
if (!onGetTerminalCwd) return;
const cwd = await onGetTerminalCwd({ preferFreshBackend: true });
if (cwd) {
sftpRef.current.navigateTo("left", cwd);
}
}, [onGetTerminalCwd, sftpRef]);
const syncFollowToTerminalCwd = useCallback(async () => {
if (!onGetTerminalCwd || !sftpFollowTerminalCwd || !canFollowTerminalCwd) {
return;
}
let terminalCwd = activeTerminalCwd;
if (!terminalCwd) {
terminalCwd = await onGetTerminalCwd({ preferFreshBackend: true });
}
if (!terminalCwd) return;
const connection = sftpRef.current.leftPane.connection;
if (!shouldFollowTerminalCwdNavigate({
followEnabled: sftpFollowTerminalCwd,
isVisible,
terminalCwd,
currentPath: connection?.currentPath,
hasActiveWork,
isConnected: Boolean(connection && !connection.isLocal && connection.status === "connected"),
})) {
return;
}
await sftpRef.current.navigateTo("left", terminalCwd);
}, [
activeTerminalCwd,
canFollowTerminalCwd,
hasActiveWork,
isVisible,
onGetTerminalCwd,
sftpRef,
sftpFollowTerminalCwd,
]);
const handleToggleFollowTerminalCwd = useCallback(() => {
onSftpFollowTerminalCwdChange?.(!sftpFollowTerminalCwd);
}, [onSftpFollowTerminalCwdChange, sftpFollowTerminalCwd]);
useEffect(() => {
if (!sftpFollowTerminalCwd || !canFollowTerminalCwd || !isVisible || hasActiveWork) return;
void syncFollowToTerminalCwd();
}, [
activeTerminalCwd,
canFollowTerminalCwd,
hasActiveWork,
isVisible,
sftpFollowTerminalCwd,
sftp.leftPane.connection?.currentPath,
sftp.leftPane.connection?.status,
sftp.leftPane.connection?.isLocal,
syncFollowToTerminalCwd,
]);
const MAX_VISIBLE_TRANSFERS = 5;
const visibleTransfers = useMemo(() => {
const connection = sftp.leftPane.connection;
@@ -600,7 +756,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
await sftpRef.current.navigateTo("left", revealPath, { force: true });
},
[openPath, t],
[openPath, sftpRef, t],
);
const canRevealTransferTarget = useCallback(
@@ -627,7 +783,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
return connection.id === task.targetConnectionId;
},
[sftp.leftPane.connection],
[connectedKeyRef, sftp.leftPane.connection],
);
const canCopyTransferTargetPath = useCallback(
@@ -663,7 +819,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
return hosts.find((h) => h.id === conn.hostId) ?? activeHost;
}
return activeHost;
}, [sftp.leftPane.connection, hosts, activeHost]);
}, [activeHost, connectedHostObjRef, hosts, sftp.leftPane.connection]);
// Determine the active pane to render (without using global activeTabStore)
const activeLeftPaneId = sftp.leftTabs.activeTabId;
@@ -681,12 +837,14 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
<div
ref={panelRootRef}
className="h-full flex flex-col bg-background overflow-hidden"
style={isVisible ? undefined : { display: "none" }}
aria-hidden={!isVisible}
data-section="terminal-sftp-panel"
onClick={handlePaneFocus}
>
{showWorkspaceHostHeader && displayHost && (
<div className="shrink-0 border-b border-border/50 bg-muted/20 px-3 py-1.5">
<div
className="shrink-0 border-b border-border/50 bg-muted/20 px-3 py-1.5"
data-section="terminal-sftp-host-header"
>
<div className="flex items-center gap-2 min-w-0">
<DistroAvatar
host={displayHost}
@@ -728,13 +886,15 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
side="left"
pane={pane}
dialogActionScopeId={dialogActionScopeIdRef.current}
isPaneFocused={isVisible && hasPaneFocus}
isPaneFocused={hasPaneFocus}
sftpDefaultViewMode={sftpDefaultViewMode}
showHeader
showEmptyHeader
forceActive
onToggleShowHiddenFiles={() => handleToggleHiddenFiles(pane.id)}
onGoToTerminalCwd={onGetTerminalCwd ? handleGoToTerminalCwd : undefined}
followTerminalCwd={canFollowTerminalCwd ? sftpFollowTerminalCwd : undefined}
onToggleFollowTerminalCwd={canFollowTerminalCwd ? handleToggleFollowTerminalCwd : undefined}
/>
</div>
);
@@ -807,6 +967,7 @@ const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps):
prev.updateHosts === next.updateHosts &&
prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
prev.activeHost === next.activeHost &&
prev.activeSessionId === next.activeSessionId &&
prev.showWorkspaceHostHeader === next.showWorkspaceHostHeader &&
prev.isVisible === next.isVisible &&
prev.renderOverlays === next.renderOverlays &&
@@ -821,6 +982,9 @@ const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps):
prev.editorWordWrap === next.editorWordWrap &&
prev.setEditorWordWrap === next.setEditorWordWrap &&
prev.onGetTerminalCwd === next.onGetTerminalCwd &&
prev.activeTerminalCwd === next.activeTerminalCwd &&
prev.sftpFollowTerminalCwd === next.sftpFollowTerminalCwd &&
prev.onSftpFollowTerminalCwdChange === next.onSftpFollowTerminalCwdChange &&
prev.onRequestTerminalFocus === next.onRequestTerminalFocus &&
prev.initialLocation?.hostId === next.initialLocation?.hostId &&
prev.initialLocation?.path === next.initialLocation?.path &&

View File

@@ -0,0 +1,38 @@
import React, { startTransition, useEffect } from 'react';
type SftpSidePanelDeferredMountProps = {
children: React.ReactNode;
ready: boolean;
onReady: () => void;
};
export const SftpSidePanelDeferredMount: React.FC<SftpSidePanelDeferredMountProps> = ({
children,
ready,
onReady,
}) => {
useEffect(() => {
if (ready) return;
let cancelled = false;
const frameId = requestAnimationFrame(() => {
if (cancelled) return;
startTransition(() => onReady());
});
return () => {
cancelled = true;
cancelAnimationFrame(frameId);
};
}, [ready, onReady]);
if (!ready) {
return (
<div className="absolute inset-0 z-10 flex h-full items-center justify-center bg-background text-xs text-muted-foreground">
Loading
</div>
);
}
return <>{children}</>;
};

View File

@@ -24,7 +24,6 @@ import { HotkeyScheme, KeyBinding } from "../domain/models";
import { logger } from "../lib/logger";
import { useRenderTracker } from "../lib/useRenderTracker";
import { cn } from "../lib/utils";
import { useInstantThemeSwitch } from "../lib/useInstantThemeSwitch";
import { Host, Identity, ProxyProfile, SSHKey, TransferTask } from "../types";
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
@@ -93,8 +92,6 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
const rootRef = useRef<HTMLDivElement>(null);
const dialogActionScopeIdRef = useRef("sftp-main-view");
useInstantThemeSwitch(rootRef);
// File watch event handlers (stable refs to avoid re-creating the useSftpState options)
const fileWatchHandlers = useMemo(() => ({
onFileWatchSynced: (payload: { remotePath: string }) => {

View File

@@ -19,7 +19,13 @@ import {
VaultPageHeader,
vaultHeaderIconButtonClass,
vaultHeaderSecondaryButtonClass,
vaultSectionTitleClass,
} from './vault/VaultPageHeader';
import {
VaultEntityIcon,
vaultPrimaryIconClass,
vaultSnippetIconClass,
} from './vault/VaultEntityIcon';
interface SnippetsManagerProps {
snippets: Snippet[];
@@ -790,7 +796,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
{displayedPackages.length > 0 && !search.trim() && (
<>
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-muted-foreground">{t('snippets.section.packages')}</h3>
<h3 className={vaultSectionTitleClass}>{t('snippets.section.packages')}</h3>
</div>
<div className={cn(
viewMode === 'grid'
@@ -823,9 +829,10 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
onClick={() => setSelectedPackage(pkg.path)}
>
<div className="flex items-center gap-3 h-full min-w-0">
<div className="h-11 w-11 rounded-xl bg-primary/15 text-primary flex items-center justify-center flex-shrink-0">
<Package size={18} />
</div>
<VaultEntityIcon
className={vaultPrimaryIconClass}
icon={<Package size={18} />}
/>
<div className="w-0 flex-1">
<div className="text-sm font-semibold truncate">{pkg.name}</div>
<div className="text-[11px] text-muted-foreground">{t('snippets.package.count', { count: pkg.count })}</div>
@@ -846,7 +853,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
{displayedSnippets.length > 0 && (
<div className="space-y-2">
<h3 className="text-sm font-semibold text-muted-foreground">{t('snippets.section.snippets')}</h3>
<h3 className={vaultSectionTitleClass}>{t('snippets.section.snippets')}</h3>
<div className={cn(
viewMode === 'grid'
? "grid gap-3 grid-cols-1 md:grid-cols-2 xl:grid-cols-3"
@@ -870,9 +877,10 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
onClick={() => handleEdit(snippet)}
>
<div className="flex items-center gap-3 h-full min-w-0">
<div className="h-11 w-11 rounded-xl bg-primary/15 text-primary flex items-center justify-center flex-shrink-0">
<FileCode size={18} />
</div>
<VaultEntityIcon
className={vaultSnippetIconClass}
icon={<FileCode size={18} />}
/>
<div className="w-0 flex-1">
<div className="text-sm font-semibold truncate">{snippet.label}</div>
<Tooltip>

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