Compare commits

...

79 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
bincxz
03ba9595c0 fix(terminal): hint reliably on explicit [sudo] prompts (#1284)
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
Sudo hints were flaky for manually typed commands: arming depends on
recognizing the submitted line as a command (recordedCommand), which is
unreliable while echo round-trips over SSH — so the hint sometimes didn't
fire for "sudo -i" / "sudo -s".

Explicit "[sudo] …" prompts are sudo-specific, so hint on them without
requiring an arm — reliable regardless of command recording. Bare
"Password:" still needs the arm window to avoid noise on unrelated prompts
(ssh, mysql). Filling still requires explicit Enter, so showing the hint
without arming stays safe. Added a colon fast-path so bulk output skips the
detection regex.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 13:47:08 +08:00
bincxz
4b07b4826a fix(terminal): resolve sudo autofill password through identity references (#1284)
Sudo autofill only read host.password and never resolved a host's reference
to a Keychain identity (host.identityId). When the account password lived in
a referenced identity, the autofill got nothing — while SSH login worked
because it goes through resolveHostAuth, which resolves the identity.

Add domain resolveHostAutofillPassword (same resolveHostAuth resolution:
identity.password ?? host.password, honoring savePassword and dropping
undecryptable placeholders) and use it as the terminal autofill password
source. Login and autofill now share one resolution path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 13:21:54 +08:00
陈大猫
80d9b33c59 feat(terminal): confirm-to-fill sudo password hint (#1281)
Rework sudo password autofill from auto-fill to a Tabby-style hint + Enter to confirm. When a sudo command is armed and a password prompt appears, show a dimmed inline hint instead of sending the password; Enter pastes the saved password and submits, any other key dismisses it.

Confirmation removes the credential-leak class (nothing is sent without the user pressing Enter at a visible hint), so detection is relaxed to a broad match (Ubuntu/PAM bare "Password:", "[sudo] password for…", localized prompts) and the per-host toggle is removed — always available when the host has a saved password.

Safety guards:
- don't arm when the hint can't render (no overlay) so Enter isn't silently intercepted;
- swallow Escape/Backspace so the byte never reaches the no-echo prompt;
- clear the pending hint once output moves past the prompt (sudo timeout/failure/returns to shell) so a later Enter can't leak the password to the shell.

Implementation ~140 lines; full suite green; manually verified on a real Linux host.
2026-06-07 12:39:06 +08:00
陈大猫
3be3c14912 fix(terminal): simplify sudo password autofill, scoped to real sudo prompts (#1281)
Replace the command-rewriting scheme (inject sudo -p marker, sanitize the echo, Ctrl-U retype) with passive observation: arm a short window on a sudo command and fill the password when a real sudo prompt appears.

- Fixes the Ubuntu/PAM no-fill, the cursor jump below the prompt, and the typed-vs-autocomplete discrepancy from #1281.
- Detection requires the [sudo] tag, or a whole-line bare "Password:" / "密码:"; prefixed prompts (mysql -p "Enter password:", ssh "x@h's password:", psql "Password for user x:") are rejected so the sudo password can't leak to a child program when sudo's creds are warm.
- Disarms when a non-sudo command follows, so a stale window can't fill a later prompt.

Implementation: 322 -> ~140 lines.
2026-06-07 03:11:25 +08:00
陈大猫
4171f85c73 Merge pull request #1279 from binaricat/perf/connection-startup
Some checks failed
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-x64' || 'build-linux-x64' }} (push) Has been cancelled
build-packages / ${{ needs.dedupe.outputs.skip_heavy_ci == 'true' && 'deduped build-linux-arm64' || 'build-linux-arm64' }} (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / dedupe push run (push) Has been cancelled
build-packages / dedupe result (push) Has been cancelled
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
perf: speed up single & batch SSH connect, stop the main-thread freeze (#1276)
2026-06-06 23:23:14 +08:00
bincxz
5a78ebcf7c test(ssh): pin default-key equivalence against the functions actually used
The dedupe in startSession.cjs runs under `with(ctx)`, where ctx is wired
with sshBridge.cjs's own local findDefaultPrivateKey /
findAllDefaultPrivateKeys — not the sshAuthHelper.cjs copies. The
characterization test targeted the helper's exports, which the connect
path never calls (sshAuthHelper.findDefaultPrivateKey has no production
consumers at all), so it gave false confidence and the in-code comment
pointed at the wrong test.

Expose the local pair as _findDefaultPrivateKey / _findAllDefaultPrivateKeys
(matching the existing _-prefixed test-export convention) and retarget the
test at them, so it actually guards the path the optimization depends on.
Behavior is unchanged; the two local functions are verified equivalent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 23:20:22 +08:00
bincxz
9294a7130f perf(terminal): defer WebGL renderer for hidden panes until visible
Each terminal that loads the WebGL addon holds a live WebGL context for
its whole lifetime, and all session panes stay mounted (hidden ones
off-screen). Batch connect therefore created a WebGL context per host up
front, contending for the GPU on the main thread — also the root of the
"garbled / 花屏" corruption in #1049/#1063. Defer WebGL creation for panes
that mount hidden (background tabs of a batch) and upgrade them on first
visibility via an idempotent ensureWebglRenderer(); a hidden pane renders
through xterm's DOM renderer until shown. Visible panes (single connect,
the active tab) keep creating WebGL immediately — unchanged behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 22:52:35 +08:00
bincxz
9ce3abc2b4 perf(connect): stagger batch host connects across frames
Batch-connecting N hosts called onConnect() in a synchronous forEach, so
all N terminals mounted in one React commit and each createXTermRuntime()
(which spins up a live WebGL context) ran back-to-back on the main
thread, freezing the UI until the whole batch finished (~2-3s per host,
linear). Spread the connects across frames via a small injectable-scheduler
helper: the first host still connects synchronously so its tab appears
immediately, the rest are deferred one step apart so no two heavy mounts
land on the same frame.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 22:52:35 +08:00
bincxz
327594a598 perf(ssh): scan ~/.ssh once and overlap it with key prep on connect
Every SSH connect ran two separate ~/.ssh scans back-to-back:
findDefaultPrivateKey() then findAllDefaultPrivateKeys(). They share
identical filter/sort/encrypted-skip logic, so the first scan's result
is exactly findAllDefaultPrivateKeys()[0]. Derive the preferred default
key from the full list (scanned once) instead, and kick that single scan
off before the identity-file / inline-key preparation so the filesystem
work overlaps the key prep instead of running serially after it.

Behavior is unchanged: auth order and fallback keys are identical. The
equivalence the dedupe relies on is pinned by a new characterization
test against a faked ~/.ssh.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 22:52:35 +08:00
陈大猫
31cccdec03 Fix duplicate-tab clone payload dropped on renderer readiness timeout (#1278) 2026-06-06 22:44:39 +08:00
陈大猫
29a6172120 Add duplicate tab to new window
Adds a tab context menu action to duplicate a terminal into an independent peer window, with per-window active-tab titles and multi-window lifecycle safeguards.
2026-06-06 22:13:47 +08:00
陈大猫
06486e06dd Merge pull request #1274 from binaricat/codex/fix-sudo-autofill-prompt-verification
[codex] Fix sudo autofill prompt verification
2026-06-06 21:27:02 +08:00
bincxz
ada55ab461 Fix sudo autofill prompt verification 2026-06-06 21:26:01 +08:00
陈大猫
a9e4de65a9 Merge pull request #1273 from binaricat/codex/host-sudo-password-autofill
Add per-host sudo password autofill
2026-06-06 20:50:52 +08:00
bincxz
2867262e4d Add per-host sudo password autofill 2026-06-06 20:47:16 +08:00
陈大猫
779c09186c Merge pull request #1272 from binaricat/codex/terminal-selection-ai-attach
Add terminal selection AI attachments
2026-06-06 19:08:38 +08:00
bincxz
6a0408b942 Add terminal selection AI attachments 2026-06-06 18:59:48 +08:00
陈大猫
43e094c345 Merge pull request #1269 from binaricat/codex/terminal-log-timestamps
Add terminal and log timestamps
2026-06-06 17:36:03 +08:00
bincxz
7d30b19421 Harden timestamp edge cases 2026-06-06 17:31:41 +08:00
bincxz
e9e8c35178 Add terminal and log timestamps 2026-06-06 17:04:33 +08:00
320 changed files with 23393 additions and 6181 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],
});

146
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,18 +57,24 @@ 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, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleToggleThemeImpl, handleRootContextMenuImpl } from './application/app/AppHandlers';
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';
// Initialize fonts eagerly at app startup
initializeFonts();
initializeUIFonts();
type SettingsState = ReturnType<typeof useSettingsState>;
type OpenSessionInNewWindowPayload = {
title?: string;
sourceSession?: TerminalSession;
localShellType?: TerminalSession['shellType'];
};
const IS_DEV = import.meta.env.DEV;
const HOTKEY_DEBUG =
@@ -100,6 +103,7 @@ function App({ settings }: { settings: SettingsState }) {
const [keyboardInteractiveQueue, setKeyboardInteractiveQueue] = useState<KeyboardInteractiveRequest[]>([]);
// Passphrase request queue for encrypted SSH keys
const [passphraseQueue, setPassphraseQueue] = useState<PassphraseRequest[]>([]);
const [pendingNewWindowSession, setPendingNewWindowSession] = useState<OpenSessionInNewWindowPayload | null>(null);
const {
theme,
@@ -125,13 +129,16 @@ function App({ settings }: { settings: SettingsState }) {
sftpShowHiddenFiles,
sftpUseCompressedUpload,
sftpAutoOpenSidebar,
sftpFollowTerminalCwd,
setSftpFollowTerminalCwd,
sftpDefaultViewMode,
editorWordWrap,
setEditorWordWrap,
sessionLogsEnabled,
sessionLogsDir,
sessionLogsFormat,
reapplyCurrentTheme,
sessionLogsTimestampsEnabled,
applyAppTheme,
workspaceFocusStyle,
} = settings;
@@ -237,6 +244,7 @@ function App({ settings }: { settings: SettingsState }) {
runSnippet,
orphanSessions,
orderedTabs,
getOrderedWorkTabs,
reorderTabs,
toggleBroadcast,
isBroadcastEnabled,
@@ -244,6 +252,7 @@ function App({ settings }: { settings: SettingsState }) {
openLogView,
closeLogView,
copySession,
createSessionFromCloneSource,
} = useSessionState();
const handleRunSnippet = useCallback(
@@ -259,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],
@@ -290,59 +291,25 @@ 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;
// activeTabId-derived chrome (window title, sftp guard) is owned by
// <AppActiveTabChrome/> so switching tabs does not re-render App.
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);
};
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onOpenSessionInNewWindow) return undefined;
return bridge.onOpenSessionInNewWindow((payload) => {
if (!payload?.sourceSession) return;
setPendingNewWindowSession(payload);
});
}, []);
// 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,
});
useEffect(() => {
if (!isVaultInitialized || !pendingNewWindowSession?.sourceSession) return;
createSessionFromCloneSource(pendingNewWindowSession.sourceSession, {
localShellType: pendingNewWindowSession.localShellType,
});
setPendingNewWindowSession(null);
}, [createSessionFromCloneSource, isVaultInitialized, pendingNewWindowSession]);
// Get port forwarding rules and import function
const { rules: portForwardingRules, importRules: importPortForwardingRules, startTunnel, stopTunnel } = usePortForwardingState();
@@ -714,6 +681,8 @@ function App({ settings }: { settings: SettingsState }) {
const copySessionWithCurrentShell = useCallback((sessionId: string) => { return copySessionWithCurrentShellImpl(() => ({ classifyLocalShellType, copySession, discoveredShells, resolveShellSetting, sessionId, terminalSettings }), sessionId); }, [copySession, terminalSettings, discoveredShells]);
const copySessionToNewWindowWithCurrentShell = useCallback((sessionId: string) => { return copySessionToNewWindowWithCurrentShellImpl(() => ({ classifyLocalShellType, discoveredShells, netcattyBridge, resolveShellSetting, sessions, terminalSettings, t, toast }), sessionId); }, [sessions, terminalSettings, discoveredShells, t]);
const closeTabKeyStr = useMemo(() => {
if (hotkeyScheme === 'disabled') return null;
const closeTabBinding = keyBindings.find((binding) => binding.action === 'closeTab');
@@ -728,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).
@@ -755,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),
@@ -773,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?.(() => {
@@ -985,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, 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, 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,153 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { TerminalSession } from "../domain/models";
import { copySessionToNewWindowWithCurrentShellImpl } from "./app/AppHandlers";
const sourceSession = (overrides: Partial<TerminalSession> = {}): TerminalSession => ({
id: "session-1",
hostId: "host-1",
hostLabel: "Prod SSH",
hostname: "prod.example.com",
username: "deploy",
status: "connected",
protocol: "ssh",
port: 22,
...overrides,
});
test("copySessionToNewWindowWithCurrentShellImpl asks Electron to open a peer window for the selected session", async () => {
const openedPayloads: unknown[] = [];
await copySessionToNewWindowWithCurrentShellImpl(
() => ({
classifyLocalShellType: () => "zsh",
discoveredShells: [],
netcattyBridge: {
get: () => ({
openSessionInNewWindow: async (payload: unknown) => {
openedPayloads.push(payload);
return { success: true };
},
}),
},
resolveShellSetting: () => ({ command: "/bin/zsh" }),
sessions: [sourceSession()],
terminalSettings: { localShell: "system-default" },
}),
"session-1",
);
assert.equal(openedPayloads.length, 1);
assert.deepEqual(openedPayloads[0], {
title: "Prod SSH",
sourceSession: sourceSession(),
localShellType: "zsh",
});
});
test("copySessionToNewWindowWithCurrentShellImpl does nothing when the source session is gone", async () => {
let called = false;
await copySessionToNewWindowWithCurrentShellImpl(
() => ({
classifyLocalShellType: () => "zsh",
discoveredShells: [],
netcattyBridge: {
get: () => ({
openSessionInNewWindow: async () => {
called = true;
return { success: true };
},
}),
},
resolveShellSetting: () => ({ command: "/bin/zsh" }),
sessions: [],
terminalSettings: { localShell: "system-default" },
}),
"missing-session",
);
assert.equal(called, false);
});
test("copySessionToNewWindowWithCurrentShellImpl shows an error when Electron cannot open the window", async () => {
const errors: string[] = [];
const result = await copySessionToNewWindowWithCurrentShellImpl(
() => ({
classifyLocalShellType: () => "zsh",
discoveredShells: [],
netcattyBridge: {
get: () => ({
openSessionInNewWindow: async () => ({ success: false }),
}),
},
resolveShellSetting: () => ({ command: "/bin/zsh" }),
sessions: [sourceSession()],
terminalSettings: { localShell: "system-default" },
t: (key: string) => key === "tabs.copyTabToNewWindowFailed" ? "Could not open" : key,
toast: {
error: (message: string) => errors.push(message),
},
}),
"session-1",
);
assert.equal(result, false);
assert.deepEqual(errors, ["Could not open"]);
});
test("copySessionToNewWindowWithCurrentShellImpl shows an error when the bridge is unavailable", async () => {
const errors: string[] = [];
const result = await copySessionToNewWindowWithCurrentShellImpl(
() => ({
classifyLocalShellType: () => "zsh",
discoveredShells: [],
netcattyBridge: {
get: () => ({}),
},
resolveShellSetting: () => ({ command: "/bin/zsh" }),
sessions: [sourceSession()],
terminalSettings: { localShell: "system-default" },
t: (key: string) => key === "tabs.copyTabToNewWindowFailed" ? "Could not open" : key,
toast: {
error: (message: string) => errors.push(message),
},
}),
"session-1",
);
assert.equal(result, false);
assert.deepEqual(errors, ["Could not open"]);
});
test("copySessionToNewWindowWithCurrentShellImpl shows an error when the bridge throws", async () => {
const errors: string[] = [];
const result = await copySessionToNewWindowWithCurrentShellImpl(
() => ({
classifyLocalShellType: () => "zsh",
discoveredShells: [],
netcattyBridge: {
get: () => ({
openSessionInNewWindow: async () => {
throw new Error("boom");
},
}),
},
resolveShellSetting: () => ({ command: "/bin/zsh" }),
sessions: [sourceSession()],
terminalSettings: { localShell: "system-default" },
t: (key: string) => key === "tabs.copyTabToNewWindowFailed" ? "Could not open" : key,
toast: {
error: (message: string) => errors.push(message),
},
}),
"session-1",
);
assert.equal(result, false);
assert.deepEqual(errors, ["Could not open"]);
});

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,
@@ -203,7 +211,7 @@ export function handleKeyboardInteractiveSubmitImpl(getCtx: AppContextGetter, re
if (session?.hostId && (!request.hostname || request.hostname === session.hostname)) {
const host = hosts.find(h => h.id === session.hostId);
if (host) {
updateHosts(hosts.map(h => h.id === host.id ? { ...h, password: savePassword } : h));
updateHosts(hosts.map(h => h.id === host.id ? { ...h, password: savePassword, savePassword: true } : h));
}
}
}
@@ -319,6 +327,36 @@ export function copySessionWithCurrentShellImpl(getCtx: AppContextGetter, sessio
}
}
export async function copySessionToNewWindowWithCurrentShellImpl(getCtx: AppContextGetter, sessionId: string) {
const { classifyLocalShellType, discoveredShells, netcattyBridge, resolveShellSetting, sessions, terminalSettings, t, toast } = getCtx();
{
const sourceSession = sessions.find((session: { id: string }) => session.id === sessionId);
if (!sourceSession) return false;
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells);
const bridge = netcattyBridge.get();
if (!bridge?.openSessionInNewWindow) {
toast?.error?.(t?.('tabs.copyTabToNewWindowFailed') ?? 'Failed to open tab in a new window');
return false;
}
const userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : '';
try {
const result = await bridge.openSessionInNewWindow({
title: sourceSession.hostLabel,
sourceSession,
localShellType: classifyLocalShellType(resolved?.command || terminalSettings.localShell, userAgent),
});
const success = result?.success === true;
if (!success) toast?.error?.(t?.('tabs.copyTabToNewWindowFailed') ?? 'Failed to open tab in a new window');
return success;
} catch {
toast?.error?.(t?.('tabs.copyTabToNewWindowFailed') ?? 'Failed to open tab in a new window');
return false;
}
}
}
export async function confirmIfBusyLocalTerminalImpl(getCtx: AppContextGetter, sessionIds: string[]) {
const { netcattyBridge, sessions, t } = getCtx();
{
@@ -678,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,
@@ -696,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,8 +32,8 @@ type AppViewContext = Record<string, any>;
export function AppView({ ctx }: { ctx: AppViewContext }) {
const {
accentMode, activeTabId, activeTerminalTheme, addShellHistoryEntry, addSessionToWorkspace, addToWorkspaceDialog, appendHostToWorkspace, appendLocalTerminalToWorkspace,
clearAndRemoveSource, clearAndRemoveSources, clearUnsavedConnectionLogs, closeLogView, closeSession, closeTabsBatch, closeWorkspace, copySessionWithCurrentShell,
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,
followAppTerminalTheme, groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDeleteHost,
@@ -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,
resetWorkspaceRename, resolveEmptyVaultConflict, resolvedTheme, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionRenameTarget, sshDebugLogsEnabled,
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}
@@ -121,6 +120,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
onCloseSession={closeSession}
onRenameSession={startSessionRename}
onCopySession={copySessionWithCurrentShell}
onCopySessionToNewWindow={copySessionToNewWindowWithCurrentShell}
onRenameWorkspace={startWorkspaceRename}
onCloseWorkspace={closeWorkspace}
onCloseLogView={closeLogView}
@@ -128,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}
@@ -214,6 +230,7 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
<TerminalLayerMount
hosts={hosts}
customGroups={customGroups}
groupConfigs={groupConfigs}
proxyProfiles={proxyProfiles}
keys={keys}
@@ -258,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}
@@ -267,12 +286,16 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
sftpShowHiddenFiles={sftpShowHiddenFiles}
sftpUseCompressedUpload={sftpUseCompressedUpload}
sftpAutoOpenSidebar={sftpAutoOpenSidebar}
sftpFollowTerminalCwd={sftpFollowTerminalCwd}
setSftpFollowTerminalCwd={setSftpFollowTerminalCwd}
editorWordWrap={editorWordWrap}
setEditorWordWrap={setEditorWordWrap}
sessionLogsEnabled={sessionLogsEnabled}
sessionLogsDir={sessionLogsDir}
sessionLogsFormat={sessionLogsFormat}
sessionLogsTimestampsEnabled={sessionLogsTimestampsEnabled}
sshDebugLogsEnabled={sshDebugLogsEnabled}
showHostTreeSidebar={settings.showHostTreeSidebar}
toggleScriptsSidePanelRef={toggleScriptsSidePanelRef}
toggleSidePanelRef={toggleSidePanelRef}
/>
@@ -298,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,13 +233,33 @@ 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',
'ai.chat.attach': 'Attach',
'ai.chat.terminalSelectionAttachment': 'Terminal selection',
'ai.chat.terminalSelectionLines': 'lines: {count}',
'ai.chat.collapse': 'Collapse',
'ai.chat.expand': 'Expand',
'ai.chat.enableAgent': 'Enable {name}',

View File

@@ -159,6 +159,8 @@ export const enCoreMessages: Messages = {
'settings.sessionLogs.formatTxt': 'Plain Text (.txt)',
'settings.sessionLogs.formatRaw': 'Raw with ANSI (.log)',
'settings.sessionLogs.formatHtml': 'HTML (.html)',
'settings.sessionLogs.timestamps': 'Add timestamps',
'settings.sessionLogs.timestampsDesc': 'Prefix each line in plain text and HTML logs with the local time.',
'settings.sessionLogs.hint': 'Session logs capture all terminal output for troubleshooting and auditing purposes.',
// Settings > SSH Debug Logs
@@ -223,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',
@@ -262,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',
@@ -437,6 +442,8 @@ export const enCoreMessages: Messages = {
'settings.terminal.rendering.renderer': 'Renderer',
'settings.terminal.rendering.renderer.desc': 'Choose the terminal rendering technology. Auto will use DOM on low-memory devices. Changes take effect on new terminal sessions.',
'settings.terminal.rendering.auto': 'Auto',
'settings.terminal.rendering.lineTimestamps': 'Prefix output with timestamps',
'settings.terminal.rendering.lineTimestamps.desc': 'Insert local time before terminal output lines. The timestamp becomes part of the visible terminal content.',
// Settings > Terminal > Workspace Focus Indicator
'settings.terminal.section.workspaceFocus': 'Workspace Focus Indicator',
@@ -589,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',
@@ -661,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

@@ -1,6 +1,7 @@
import type { Messages } from '../types';
export const enTerminalMessages: Messages = {
'terminal.sudoHint.pressEnter': 'Press Enter to paste sudo password',
// Terminal toolbar / search / context menu / auth
'terminal.toolbar.openSftp': 'Open SFTP',
'terminal.toolbar.availableAfterConnect': 'Available after connect',
@@ -20,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',
@@ -70,6 +79,7 @@ export const enTerminalMessages: Messages = {
'terminal.search.nextMatch': 'Next match (Enter)',
'terminal.menu.copy': 'Copy',
'terminal.menu.paste': 'Paste',
'terminal.menu.addSelectionToAI': 'Add to Conversation',
'terminal.menu.pasteSelection': 'Paste Selection',
'terminal.menu.selectAll': 'Select All',
'terminal.menu.reconnect': 'Reconnect',
@@ -77,6 +87,8 @@ export const enTerminalMessages: Messages = {
'terminal.menu.splitVertical': 'Split Vertical',
'terminal.menu.clearBuffer': 'Clear Buffer',
'terminal.menu.closeTerminal': 'Close terminal',
'terminal.selection.addToAI': 'Add to Conversation',
'terminal.selection.addToAIDesc': 'Attach selected terminal output to the AI draft',
'terminal.auth.password': 'Password',
'terminal.auth.sshKey': 'SSH Key',
'terminal.auth.username': 'Username',
@@ -492,6 +504,8 @@ export const enTerminalMessages: Messages = {
'tabs.logPrefix': 'Log:',
'tabs.logLocal': 'Local',
'tabs.copyTab': 'Copy Tab',
'tabs.copyTabToNewWindow': 'Copy Tab to New Window',
'tabs.copyTabToNewWindowFailed': 'Failed to open tab in a new window',
'tabs.closeOthers': 'Close Others',
'tabs.closeToRight': 'Close Tabs to the Right',
'tabs.closeAll': 'Close All',

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,13 +233,26 @@ 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': 'История сессий',
'ai.chat.attach': 'Прикрепить',
'ai.chat.terminalSelectionAttachment': 'Выделение терминала',
'ai.chat.terminalSelectionLines': 'строк: {count}',
'ai.chat.collapse': 'Свернуть',
'ai.chat.expand': 'Развернуть',
'ai.chat.enableAgent': 'Включить {name}',

View File

@@ -159,6 +159,8 @@ export const ruCoreMessages: Messages = {
'settings.sessionLogs.formatTxt': 'Обычный текст (.txt)',
'settings.sessionLogs.formatRaw': 'Сырые данные с ANSI (.log)',
'settings.sessionLogs.formatHtml': 'HTML (.html)',
'settings.sessionLogs.timestamps': 'Добавлять метки времени',
'settings.sessionLogs.timestampsDesc': 'Добавлять локальное время в начало каждой строки в текстовых и HTML-журналах.',
'settings.sessionLogs.hint': 'Журналы сессий сохраняют весь вывод терминала для диагностики и аудита.',
// Settings > SSH Debug Logs
@@ -223,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': 'Доступно обновление',
@@ -262,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': 'Выберите тему',
@@ -437,6 +442,8 @@ export const ruCoreMessages: Messages = {
'settings.terminal.rendering.renderer': 'Рендерер',
'settings.terminal.rendering.renderer.desc': 'Выберите технологию рендеринга терминала. В режиме "Авто" на устройствах с малым объёмом памяти будет использоваться DOM. Изменения применяются к новым терминальным сессиям.',
'settings.terminal.rendering.auto': 'Авто',
'settings.terminal.rendering.lineTimestamps': 'Добавлять время к выводу',
'settings.terminal.rendering.lineTimestamps.desc': 'Вставлять локальное время перед строками вывода терминала. Метка времени становится частью видимого содержимого терминала.',
// Settings > Terminal > Workspace Focus Indicator
'settings.terminal.section.workspaceFocus': 'Индикатор фокуса рабочей области',

View File

@@ -1,6 +1,7 @@
import type { Messages } from '../types';
export const ruTerminalMessages: Messages = {
'terminal.sudoHint.pressEnter': 'Нажмите Enter, чтобы вставить пароль sudo',
// Connection logs
'logs.table.date': 'Дата',
'logs.table.user': 'Пользователь',
@@ -41,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': 'Кодировка терминала',
@@ -91,6 +100,7 @@ export const ruTerminalMessages: Messages = {
'terminal.search.nextMatch': 'Следующее совпадение (Enter)',
'terminal.menu.copy': 'Копировать',
'terminal.menu.paste': 'Вставить',
'terminal.menu.addSelectionToAI': 'Добавить в чат',
'terminal.menu.pasteSelection': 'Вставить выделенное',
'terminal.menu.selectAll': 'Выбрать всё',
'terminal.menu.reconnect': 'Переподключиться',
@@ -98,6 +108,8 @@ export const ruTerminalMessages: Messages = {
'terminal.menu.splitVertical': 'Разделить по вертикали',
'terminal.menu.clearBuffer': 'Очистить буфер',
'terminal.menu.closeTerminal': 'Закрыть терминал',
'terminal.selection.addToAI': 'Добавить в чат',
'terminal.selection.addToAIDesc': 'Прикрепить выбранный вывод терминала к черновику AI',
'terminal.auth.password': 'Пароль',
'terminal.auth.sshKey': 'SSH-ключ',
'terminal.auth.username': 'Имя пользователя',
@@ -507,6 +519,8 @@ export const ruTerminalMessages: Messages = {
'tabs.logPrefix': 'Журнал:',
'tabs.logLocal': 'Локальный',
'tabs.copyTab': 'Копировать вкладку',
'tabs.copyTabToNewWindow': 'Копировать вкладку в новое окно',
'tabs.copyTabToNewWindowFailed': 'Не удалось открыть вкладку в новом окне',
'tabs.closeOthers': 'Закрыть остальные',
'tabs.closeToRight': 'Закрыть вкладки справа',
'tabs.closeAll': 'Закрыть все',

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,13 +233,33 @@ 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': '会话历史',
'ai.chat.attach': '附件',
'ai.chat.terminalSelectionAttachment': '终端选区',
'ai.chat.terminalSelectionLines': '{count} 行',
'ai.chat.collapse': '收起',
'ai.chat.expand': '展开',
'ai.chat.enableAgent': '启用 {name}',

View File

@@ -143,6 +143,8 @@ export const zhCNCoreMessages: Messages = {
'settings.sessionLogs.formatTxt': '纯文本 (.txt)',
'settings.sessionLogs.formatRaw': '原始格式 (.log)',
'settings.sessionLogs.formatHtml': 'HTML (.html)',
'settings.sessionLogs.timestamps': '添加时间戳',
'settings.sessionLogs.timestampsDesc': '为纯文本和 HTML 日志的每一行添加本地时间。',
'settings.sessionLogs.hint': '会话日志用于记录终端输出,便于故障排查和审计。',
// Settings > SSH Debug Logs
@@ -207,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': '发现新版本',
@@ -246,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': '新建文件夹',
@@ -365,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': '创建根分组',
@@ -437,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': '保存主机以快速连接到你的服务器、虚拟机和容器。',
@@ -537,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': '暂无收藏路径',
@@ -567,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': '文件夹',
@@ -611,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

@@ -1,6 +1,7 @@
import type { Messages } from '../types';
export const zhCNTerminalMessages: Messages = {
'terminal.sudoHint.pressEnter': '按 Enter 粘贴 sudo 密码',
'terminal.connection.protocol.et': 'EternalTerminal',
'terminal.et.proxyUnsupported': 'EternalTerminal 目前不支持 Netcatty 的代理设置。请改用 SSH或移除该主机的代理。',
'terminal.et.multiJumpUnsupported': 'EternalTerminal 目前在 Netcatty 中最多支持一个跳板机。',
@@ -79,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': '列表视图',
@@ -286,6 +292,8 @@ export const zhCNTerminalMessages: Messages = {
'settings.terminal.rendering.renderer': '渲染器',
'settings.terminal.rendering.renderer.desc': '选择终端渲染技术。自动模式会在低内存设备上使用 DOM 渲染。更改将在新终端会话中生效。',
'settings.terminal.rendering.auto': '自动',
'settings.terminal.rendering.lineTimestamps': '给输出加时间戳',
'settings.terminal.rendering.lineTimestamps.desc': '在终端输出行前插入本地时间,时间戳会成为终端可见内容的一部分。',
// Settings > Terminal > Autocomplete
'settings.terminal.section.autocomplete': '自动补全',
@@ -479,6 +487,8 @@ export const zhCNTerminalMessages: Messages = {
'tabs.logPrefix': '日志:',
'tabs.logLocal': '本地',
'tabs.copyTab': '复制标签页',
'tabs.copyTabToNewWindow': '复制标签页到新窗口',
'tabs.copyTabToNewWindowFailed': '无法在新窗口打开标签页',
'tabs.closeOthers': '关闭其他标签',
'tabs.closeToRight': '关闭右侧标签',
'tabs.closeAll': '关闭所有标签',

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': '终端编码',
@@ -279,6 +287,7 @@ export const zhCNVaultMessages: Messages = {
'terminal.search.nextMatch': '下一个匹配 (Enter)',
'terminal.menu.copy': '复制',
'terminal.menu.paste': '粘贴',
'terminal.menu.addSelectionToAI': '添加到对话',
'terminal.menu.pasteSelection': '粘贴选中文本',
'terminal.menu.selectAll': '全选',
'terminal.menu.reconnect': '重新连接',
@@ -286,6 +295,8 @@ export const zhCNVaultMessages: Messages = {
'terminal.menu.splitVertical': '垂直分屏',
'terminal.menu.clearBuffer': '清空缓冲区',
'terminal.menu.closeTerminal': '关闭终端',
'terminal.selection.addToAI': '添加到对话',
'terminal.selection.addToAIDesc': '将选中的终端输出作为附件加入 AI 草稿',
'terminal.auth.password': '密码',
'terminal.auth.sshKey': 'SSH Key',
'terminal.auth.username': '用户名',

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

@@ -15,8 +15,10 @@ import {
STORAGE_KEY_SESSION_LOGS_DIR,
STORAGE_KEY_SESSION_LOGS_ENABLED,
STORAGE_KEY_SESSION_LOGS_FORMAT,
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,
@@ -32,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;
@@ -52,15 +60,19 @@ interface UseSettingsIpcSyncParams {
setSessionLogsEnabled: Dispatch<SetStateAction<boolean>>;
setSessionLogsDir: Dispatch<SetStateAction<string>>;
setSessionLogsFormat: Dispatch<SetStateAction<SessionLogFormat>>;
setSessionLogsTimestampsEnabled: Dispatch<SetStateAction<boolean>>;
setSshDebugLogsEnabled: Dispatch<SetStateAction<boolean>>;
setHotkeyScheme: Dispatch<SetStateAction<HotkeyScheme>>;
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>>;
}
@@ -80,15 +92,19 @@ export function useSettingsIpcSync({
setSessionLogsEnabled,
setSessionLogsDir,
setSessionLogsFormat,
setSessionLogsTimestampsEnabled,
setSshDebugLogsEnabled,
setHotkeyScheme,
applyIncomingCustomKeyBindings,
setIsHotkeyRecordingState,
setGlobalHotkeyEnabled,
setWindowOpacity,
setAutoUpdateEnabled,
setSftpAutoOpenSidebar,
setSftpFollowTerminalCwd,
setSftpDefaultViewMode,
setWorkspaceFocusStyleState,
setShowHostTreeSidebarState,
setSftpTransferConcurrencyState,
}: UseSettingsIpcSyncParams) {
// Listen for settings changes from other windows via IPC
@@ -167,6 +183,9 @@ export function useSettingsIpcSync({
) {
setSessionLogsFormat((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED && typeof value === 'boolean') {
setSessionLogsTimestampsEnabled((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED && typeof value === 'boolean') {
setSshDebugLogsEnabled((prev) => (prev === value ? prev : value));
}
@@ -185,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));
@@ -199,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));
}
@@ -217,14 +246,18 @@ export function useSettingsIpcSync({
setEditorWordWrapState,
setFollowAppTerminalThemeState,
setGlobalHotkeyEnabled,
setWindowOpacity,
setHotkeyScheme,
setIsHotkeyRecordingState,
setSessionLogsDir,
setSessionLogsEnabled,
setSessionLogsFormat,
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;
@@ -63,6 +71,7 @@ export const DEFAULT_EDITOR_WORD_WRAP = false;
// Session Logs defaults
export const DEFAULT_SESSION_LOGS_ENABLED = false;
export const DEFAULT_SESSION_LOGS_FORMAT: SessionLogFormat = 'txt';
export const DEFAULT_SESSION_LOGS_TIMESTAMPS_ENABLED = false;
export const DEFAULT_SSH_DEBUG_LOGS_ENABLED = false;
export const readStoredString = (key: string): string | null => {
@@ -121,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

@@ -14,8 +14,10 @@ import {
STORAGE_KEY_SESSION_LOGS_DIR,
STORAGE_KEY_SESSION_LOGS_ENABLED,
STORAGE_KEY_SESSION_LOGS_FORMAT,
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,
@@ -25,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,
@@ -38,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,
@@ -66,17 +71,21 @@ 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;
sessionLogsFormat: SessionLogFormat;
sessionLogsTimestampsEnabled: boolean;
sshDebugLogsEnabled: boolean;
globalHotkeyEnabled: boolean;
autoUpdateEnabled: boolean;
windowOpacity: number;
setTheme: Dispatch<SetStateAction<'dark' | 'light' | 'system'>>;
setLightUiThemeId: Dispatch<SetStateAction<string>>;
setDarkUiThemeId: Dispatch<SetStateAction<string>>;
@@ -97,16 +106,20 @@ 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>>;
setSessionLogsFormat: Dispatch<SetStateAction<SessionLogFormat>>;
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>>;
@@ -119,19 +132,19 @@ export function useSettingsStorageSync({
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
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,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSshDebugLogsEnabled,
setGlobalHotkeyEnabled, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled,
setGlobalHotkeyEnabled, setWindowOpacity, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
}: UseSettingsStorageSyncParams) {
// Fix 4: Keep a ref snapshot of current settings so the storage event handler
@@ -142,20 +155,20 @@ export function useSettingsStorageSync({
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
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,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
};
// Listen for storage changes from other windows (cross-window sync)
@@ -305,6 +318,12 @@ export function useSettingsStorageSync({
setSessionLogsFormat(e.newValue);
}
}
if (e.key === STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.sessionLogsTimestampsEnabled) {
setSessionLogsTimestampsEnabled(newValue);
}
}
if (e.key === STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.sshDebugLogsEnabled) {
@@ -325,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) {
@@ -349,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';
@@ -363,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') {
@@ -391,13 +428,16 @@ export function useSettingsStorageSync({
setEditorWordWrapState,
setFollowAppTerminalThemeState,
setGlobalHotkeyEnabled,
setWindowOpacity,
setHotkeyScheme,
setLightUiThemeId,
setSessionLogsDir,
setSessionLogsEnabled,
setSessionLogsFormat,
setSessionLogsTimestampsEnabled,
setSshDebugLogsEnabled,
setSftpAutoOpenSidebar,
setSftpFollowTerminalCwd,
setSftpAutoSync,
setSftpDefaultViewMode,
setSftpDoubleClickBehavior,
@@ -405,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,52 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
TERMINAL_SELECTION_ATTACHMENT_MEDIA_TYPE,
buildPromptWithTerminalSelectionAttachments,
createTerminalSelectionAttachment,
decodeTerminalSelectionAttachment,
} from "./terminalSelectionAttachment.ts";
test("createTerminalSelectionAttachment returns null for blank selections", () => {
assert.equal(createTerminalSelectionAttachment(" \n\t"), null);
});
test("createTerminalSelectionAttachment creates a compact terminal log attachment", () => {
const attachment = createTerminalSelectionAttachment("line one\nline two");
assert.ok(attachment);
assert.equal(attachment.mediaType, TERMINAL_SELECTION_ATTACHMENT_MEDIA_TYPE);
assert.match(attachment.filename, /^terminal-selection-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}\.log$/);
assert.equal(attachment.terminalSelection, true);
assert.equal(attachment.previewText, "line one");
assert.equal(attachment.lineCount, 2);
assert.equal(decodeTerminalSelectionAttachment(attachment), "line one\nline two");
});
test("createTerminalSelectionAttachment preserves utf-8 terminal output", () => {
const attachment = createTerminalSelectionAttachment("错误: 权限不足\n路径: /tmp/测试");
assert.ok(attachment);
assert.equal(decodeTerminalSelectionAttachment(attachment), "错误: 权限不足\n路径: /tmp/测试");
});
test("buildPromptWithTerminalSelectionAttachments expands terminal selections into prompt text", () => {
const attachment = createTerminalSelectionAttachment("docker ps -a\npermission denied");
assert.ok(attachment);
assert.equal(
buildPromptWithTerminalSelectionAttachments("帮我看看", [attachment]),
`帮我看看\n\n[Terminal selection: ${attachment.filename}]\ndocker ps -a\npermission denied`,
);
});
test("buildPromptWithTerminalSelectionAttachments supports terminal-only prompts", () => {
const attachment = createTerminalSelectionAttachment("systemctl status nginx");
assert.ok(attachment);
assert.equal(
buildPromptWithTerminalSelectionAttachments("", [attachment]),
`[Terminal selection: ${attachment.filename}]\nsystemctl status nginx`,
);
});

View File

@@ -0,0 +1,101 @@
import type { ChatMessageAttachment, UploadedFile } from "../../infrastructure/ai/types";
export const TERMINAL_SELECTION_ATTACHMENT_MEDIA_TYPE = "text/plain";
const MAX_PREVIEW_CHARS = 80;
function bytesToBase64(bytes: Uint8Array): string {
let binary = "";
const chunkSize = 0x8000;
for (let i = 0; i < bytes.length; i += chunkSize) {
const chunk = bytes.subarray(i, i + chunkSize);
binary += String.fromCharCode(...chunk);
}
return btoa(binary);
}
function base64ToText(base64Data: string): string {
const binary = atob(base64Data);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return new TextDecoder().decode(bytes);
}
function buildTimestamp(date: Date): string {
const pad = (value: number) => String(value).padStart(2, "0");
return [
date.getFullYear(),
pad(date.getMonth() + 1),
pad(date.getDate()),
pad(date.getHours()),
pad(date.getMinutes()),
pad(date.getSeconds()),
].join("-");
}
function getPreviewText(text: string): string {
const firstLine = text.split(/\r?\n/).find((line) => line.trim().length > 0) ?? "";
return firstLine.length > MAX_PREVIEW_CHARS
? `${firstLine.slice(0, MAX_PREVIEW_CHARS - 1)}...`
: firstLine;
}
export function createTerminalSelectionAttachment(
selection: string,
now: Date = new Date(),
): UploadedFile | null {
const text = selection.trim();
if (!text) return null;
const base64Data = bytesToBase64(new TextEncoder().encode(text));
const filename = `terminal-selection-${buildTimestamp(now)}.log`;
return {
id: crypto.randomUUID(),
filename,
dataUrl: `data:${TERMINAL_SELECTION_ATTACHMENT_MEDIA_TYPE};base64,${base64Data}`,
base64Data,
mediaType: TERMINAL_SELECTION_ATTACHMENT_MEDIA_TYPE,
terminalSelection: true,
previewText: getPreviewText(text),
lineCount: text.split(/\r?\n/).length,
};
}
export function decodeTerminalSelectionAttachment(
attachment: Pick<UploadedFile | ChatMessageAttachment, "base64Data" | "terminalSelection">,
): string | null {
if (!attachment.terminalSelection) return null;
return base64ToText(attachment.base64Data);
}
export function isTerminalSelectionAttachment(
attachment: Pick<UploadedFile | ChatMessageAttachment, "terminalSelection">,
): boolean {
return attachment.terminalSelection === true;
}
export function buildPromptWithTerminalSelectionAttachments(
prompt: string,
attachments: Array<ChatMessageAttachment | UploadedFile>,
): string {
const terminalBlocks = attachments
.filter(isTerminalSelectionAttachment)
.map((attachment, index) => {
const text = decodeTerminalSelectionAttachment(attachment);
if (!text) return null;
const label = attachment.filename || `terminal-selection-${index + 1}.log`;
return `\n\n[Terminal selection: ${label}]\n${text}`;
})
.filter((block): block is string => block !== null);
if (terminalBlocks.length === 0) return prompt;
if (!prompt.trim()) return terminalBlocks.join("").trimStart();
return `${prompt}${terminalBlocks.join("")}`;
}

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,
@@ -820,6 +821,25 @@ export const useSessionState = () => {
});
}, [orphanSessions, workspaces, logViews, setActiveTabId]);
const createSessionFromCloneSource = useCallback((sourceSession: TerminalSession, options?: {
localShellType?: TerminalSession['shellType'];
}) => {
const newSessionId = crypto.randomUUID();
const newSession = createCopiedTerminalSessionClone(sourceSession, {
id: newSessionId,
localShellType: options?.localShellType,
});
delete newSession.workspaceId;
setSessions(prevSessions => {
if (prevSessions.some(session => session.id === newSessionId)) return prevSessions;
return [...prevSessions, newSession];
});
setTabOrder(prevTabOrder => [...prevTabOrder, newSessionId]);
setActiveTabId(newSessionId);
return newSessionId;
}, [setActiveTabId]);
// Toggle broadcast mode for a workspace
const toggleBroadcast = useCallback((workspaceId: string) => {
setBroadcastWorkspaceIds(prev => {
@@ -838,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
@@ -894,7 +916,7 @@ export const useSessionState = () => {
return currentOrder;
});
}, [orphanSessions, workspaces, logViews]);
}, [baseWorkTabIds]);
return {
sessions,
@@ -939,6 +961,7 @@ export const useSessionState = () => {
toggleBroadcast,
isBroadcastEnabled,
orderedTabs,
getOrderedWorkTabs,
reorderTabs,
// Log views
logViews,
@@ -946,5 +969,6 @@ export const useSessionState = () => {
closeLogView,
// Copy session
copySession,
createSessionFromCloneSource,
};
};

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,21 +27,25 @@ 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,
STORAGE_KEY_SESSION_LOGS_ENABLED,
STORAGE_KEY_SESSION_LOGS_DIR,
STORAGE_KEY_SESSION_LOGS_FORMAT,
STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED,
STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED,
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 {
@@ -69,7 +75,9 @@ import {
DEFAULT_LIGHT_UI_THEME,
DEFAULT_SESSION_LOGS_ENABLED,
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,
@@ -78,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,
@@ -97,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 =
@@ -202,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;
@@ -218,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;
@@ -242,6 +262,10 @@ export const useSettingsState = () => {
if (stored === 'txt' || stored === 'raw' || stored === 'html') return stored;
return DEFAULT_SESSION_LOGS_FORMAT;
});
const [sessionLogsTimestampsEnabled, setSessionLogsTimestampsEnabled] = useState<boolean>(() => {
const stored = readStoredString(STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED);
return stored === 'true' ? true : DEFAULT_SESSION_LOGS_TIMESTAMPS_ENABLED;
});
const [sshDebugLogsEnabled, setSshDebugLogsEnabled] = useState<boolean>(() => {
const stored = readStoredString(STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED);
return stored === 'true' ? true : DEFAULT_SSH_DEBUG_LOGS_ENABLED;
@@ -272,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);
@@ -413,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(() => {
@@ -483,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);
@@ -491,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);
@@ -502,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);
@@ -564,15 +614,19 @@ export const useSettingsState = () => {
setSessionLogsEnabled,
setSessionLogsDir,
setSessionLogsFormat,
setSessionLogsTimestampsEnabled,
setSshDebugLogsEnabled,
setHotkeyScheme,
applyIncomingCustomKeyBindings,
setIsHotkeyRecordingState,
setGlobalHotkeyEnabled,
setWindowOpacity,
setAutoUpdateEnabled,
setSftpAutoOpenSidebar,
setSftpFollowTerminalCwd,
setSftpDefaultViewMode,
setWorkspaceFocusStyleState,
setShowHostTreeSidebarState,
setSftpTransferConcurrencyState,
});
@@ -598,19 +652,19 @@ export const useSettingsState = () => {
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, followAppTerminalTheme, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sshDebugLogsEnabled,
globalHotkeyEnabled, autoUpdateEnabled,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
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,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSshDebugLogsEnabled,
setGlobalHotkeyEnabled, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode,
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState,
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled,
setGlobalHotkeyEnabled, setWindowOpacity, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
});
@@ -715,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;
@@ -766,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);
@@ -792,6 +853,12 @@ export const useSettingsState = () => {
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_FORMAT, sessionLogsFormat);
}, [sessionLogsFormat, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED, sessionLogsTimestampsEnabled ? 'true' : 'false');
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_TIMESTAMPS_ENABLED, sessionLogsTimestampsEnabled);
}, [sessionLogsTimestampsEnabled, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SSH_DEBUG_LOGS_ENABLED, sshDebugLogsEnabled ? 'true' : 'false');
if (!persistMountedRef.current) return;
@@ -802,6 +869,7 @@ export const useSettingsState = () => {
toggleWindowHotkey,
globalHotkeyEnabled,
closeToTray,
windowOpacity,
autoUpdateEnabled,
persistMountedRef,
setHotkeyRegistrationError,
@@ -874,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]);
@@ -935,6 +1002,8 @@ export const useSettingsState = () => {
setSftpUseCompressedUpload,
sftpAutoOpenSidebar,
setSftpAutoOpenSidebar,
sftpFollowTerminalCwd,
setSftpFollowTerminalCwd,
sftpDefaultViewMode,
setSftpDefaultViewMode,
showRecentHosts,
@@ -943,6 +1012,8 @@ export const useSettingsState = () => {
setShowOnlyUngroupedHostsInRoot,
showSftpTab,
setShowSftpTab,
showHostTreeSidebar,
setShowHostTreeSidebar,
sftpTransferConcurrency,
setSftpTransferConcurrency,
// Editor Settings
@@ -959,6 +1030,8 @@ export const useSettingsState = () => {
setSessionLogsDir,
sessionLogsFormat,
setSessionLogsFormat,
sessionLogsTimestampsEnabled,
setSessionLogsTimestampsEnabled,
sshDebugLogsEnabled,
setSshDebugLogsEnabled,
// Global Toggle Window (Quake Mode)
@@ -971,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.
@@ -982,9 +1057,9 @@ export const useSettingsState = () => {
uiFontFamilyId, uiLanguage, customCSS,
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
customKeyBindings, editorWordWrap,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
customThemes, workspaceFocusStyle, sshDebugLogsEnabled,
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,
@@ -190,7 +192,7 @@ const SYNCABLE_TERMINAL_KEYS = [
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
'keepaliveInterval', 'keepaliveCountMax', 'disableBracketedPaste', 'clearWipesScrollback',
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'showServerStats',
'preserveSelectionOnInput', 'forcePromptNewLine', 'osc52Clipboard', 'showServerStats', 'showLineTimestamps',
'serverStatsRefreshInterval', 'rendererType',
'autocompleteEnabled', 'autocompleteGhostText', 'autocompletePopupMenu',
'autocompleteDebounceMs', 'autocompleteMinChars', 'autocompleteMaxSuggestions',
@@ -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,14 +29,19 @@ import {
endDraftSend,
tryBeginDraftSend,
} from './ai/draftSendGate';
import { getSessionScopeMatchRank } from './ai/sessionScopeMatch';
import { selectDraftForAgentSwitch } from '../application/state/aiDraftState';
import {
buildPromptWithTerminalSelectionAttachments,
isTerminalSelectionAttachment,
} from '../application/state/terminalSelectionAttachment';
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';
@@ -44,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,
@@ -130,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];
@@ -197,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;
@@ -338,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) => {
@@ -452,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);
@@ -474,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;
@@ -522,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;
@@ -650,18 +697,24 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
const currentSessionView = activeSessionRef.current;
const trimmed = draft?.text.trim() ?? '';
const sendScopeKey = scopeKey;
if (!trimmed || isStreaming) return;
const sendAgentId = currentSessionView?.agentId ?? draft?.agentId ?? currentAgentId;
const agentConfig = sendAgentId !== 'catty' ? findEnabledExternalAgent(externalAgents, sendAgentId) : undefined;
if (sendAgentId !== 'catty' && !agentConfig) return;
const selectedSkillSlugs = draft?.selectedUserSkillSlugs ?? [];
const attachments = (draft?.attachments ?? []).map((file) => ({
base64Data: file.base64Data,
mediaType: file.mediaType,
filename: file.filename,
filePath: file.filePath,
terminalSelection: file.terminalSelection,
previewText: file.previewText,
lineCount: file.lineCount,
}));
const hasTerminalSelectionAttachments = attachments.some(isTerminalSelectionAttachment);
if ((!trimmed && !hasTerminalSelectionAttachments) || isStreaming) return;
const sendAgentId = currentSessionView?.agentId ?? draft?.agentId ?? currentAgentId;
const agentConfig = sendAgentId !== 'catty' ? findEnabledExternalAgent(externalAgents, sendAgentId) : undefined;
if (sendAgentId !== 'catty' && !agentConfig) return;
const selectedSkillSlugs = draft?.selectedUserSkillSlugs ?? [];
const modelPrompt = buildPromptWithTerminalSelectionAttachments(trimmed, attachments);
const modelAttachments = attachments.filter((attachment) => !isTerminalSelectionAttachment(attachment));
const isDraftMode = currentPanelView.mode === 'draft';
if (isDraftMode && !tryBeginDraftSend(draftSendInFlightRef)) {
@@ -691,7 +744,11 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
const sendActiveModelId = isExternalAgent ? activeModelId : effectiveActiveModelId;
if (!isExternalAgent && !sendActiveProvider) {
addMessageToSession(sessionId, { id: generateId(), role: 'user', content: trimmed, timestamp: Date.now() });
addMessageToSession(sessionId, {
id: generateId(), role: 'user', content: trimmed,
...(attachments.length > 0 ? { attachments } : {}),
timestamp: Date.now(),
});
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProvider'), timestamp: Date.now() });
if (currentPanelView.mode === 'session') {
clearScopeDraft();
@@ -701,7 +758,11 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
}
if (!isExternalAgent && !sendActiveModelId.trim()) {
addMessageToSession(sessionId, { id: generateId(), role: 'user', content: trimmed, timestamp: Date.now() });
addMessageToSession(sessionId, {
id: generateId(), role: 'user', content: trimmed,
...(attachments.length > 0 ? { attachments } : {}),
timestamp: Date.now(),
});
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProviderModel'), timestamp: Date.now() });
if (currentPanelView.mode === 'session') {
clearScopeDraft();
@@ -741,7 +802,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
}
try {
const existingExternalSessionId = currentSession?.externalSessionId;
await sendToExternalAgent(sessionId, trimmed, agentConfig, abortController, attachments, {
await sendToExternalAgent(sessionId, modelPrompt, agentConfig, abortController, modelAttachments, {
existingSessionId: existingExternalSessionId,
updateExternalSessionId: updateSessionExternalSessionId,
historyMessages: buildExternalAgentHistoryMessagesForBridge(currentSession?.messages ?? [], existingExternalSessionId),
@@ -765,7 +826,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
targetId: scopeTargetId,
label: scopeLabel,
} as const;
await sendToCattyAgent(sessionId, sendScopeKey, trimmed, abortController, currentSession ?? undefined, assistantMsgId, {
await sendToCattyAgent(sessionId, sendScopeKey, modelPrompt, abortController, currentSession ?? undefined, assistantMsgId, {
activeProvider: sendActiveProvider,
activeModelId: sendActiveModelId,
scopeType,
@@ -778,7 +839,8 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
getExecutorContext: () => buildExecutorContextForScope(toolScope),
autoTitleSession,
selectedUserSkillSlugs: selectedSkillSlugs,
}, attachments.length > 0 ? attachments : undefined);
titleText: trimmed,
}, modelAttachments.length > 0 ? modelAttachments : undefined);
}
} finally {
if (isDraftMode) {
@@ -847,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

@@ -45,7 +45,8 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
distroOptions,
effectiveFormDistro,
getDistroOptionLabel,
}) => (
}) => {
return (
<>
<HostDetailsSection
icon={<MapPin size={14} className="text-muted-foreground" />}
@@ -732,4 +733,5 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
</HostDetailsSection>
)}
</>
);
);
};

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") && (
@@ -366,6 +425,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
setSessionLogsDir={settings.setSessionLogsDir}
sessionLogsFormat={settings.sessionLogsFormat}
setSessionLogsFormat={settings.setSessionLogsFormat}
sessionLogsTimestampsEnabled={settings.sessionLogsTimestampsEnabled}
setSessionLogsTimestampsEnabled={settings.setSessionLogsTimestampsEnabled}
sshDebugLogsEnabled={settings.sshDebugLogsEnabled}
setSshDebugLogsEnabled={settings.setSshDebugLogsEnabled}
toggleWindowHotkey={settings.toggleWindowHotkey}

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", () => {

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