Compare commits

...

208 Commits

Author SHA1 Message Date
陈大猫
c49346f6cc fix: 编辑器查找/替换输入框无法粘贴内容 (#512) (#515)
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
自定义粘贴处理器拦截了所有 Ctrl+V 事件,包括查找/替换控件内的输入框。
当焦点在 .find-widget 内时,改为读取剪贴板并直接插入到输入框中,
而非将内容粘贴到编辑器主体。

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 02:59:52 +08:00
陈大猫
39a398aa2b SFTP 右键菜单添加「复制文件路径」功能 (#514)
* feat(sftp): add "Copy file path" to right-click context menu (#507)

Add a context menu item that copies the full remote file/directory path
to clipboard using navigator.clipboard.writeText(). Works for both
files and directories.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: 使用 joinPath 构建复制路径,修复 Windows 路径分隔符问题

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: joinPath 去除 Unix 路径尾部多余斜杠

避免 currentPath 带 trailing slash 时产生双斜杠路径(如 /var/log//syslog)。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 02:57:59 +08:00
陈大猫
0b7c52523e feat: 终端沉浸模式 (#517)
* feat: add terminal immersive mode

When enabled, the UI chrome (tab bar, sidebar, status bar) adapts its
colors to match the active terminal's theme, creating a visually
cohesive experience. Colors are derived from the terminal theme's
hex values and converted to HSL for CSS custom property overrides.

- Add useImmersiveMode hook with hex-to-HSL conversion and token derivation
- Add reapplyCurrentTheme to useSettingsState for restoring original theme
- Integrate with App.tsx to resolve active terminal's effective theme
- Add immersive mode toggle in Appearance settings with i18n (en/zh-CN)
- Add CSS transition class for smooth 300ms color changes
- Support cross-window sync via IPC for Settings window toggle
- Handle per-host theme overrides and workspace focused sessions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: 沉浸模式多项改进与 bug 修复

- 修复 primaryForeground 硬编码白色导致浅色 cursor 对比度不足
- 修复 SettingsPage 直接导入 infrastructure 层违反架构约束
- 修复 TerminalSession 类型未导入导致 TS 编译错误
- 修复 TopTabs memo 缺少 logViews 导致 logView 变化不触发重渲染
- 重构 useImmersiveMode 为纯 effect hook,状态由 useSettingsState 统一管理
- Workspace 多终端主题不一致时禁用沉浸模式
- 排除 logView tab 误触发沉浸模式
- 沉浸模式下禁用 dark/light 切换按钮
- Agent 图标使用 CSS mask 跟随文字颜色
- Agent 下拉菜单 overflow-hidden 修复 hover 溢出
- 退出沉浸模式使用 overlay 淡出避免闪烁
- immersive-transition class 仅在沉浸实际生效时添加

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: 沉浸模式默认开启

新用户默认启用沉浸模式,已有设置的用户不受影响。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* perf: 沉浸模式主题切换性能优化

- 启动时预计算所有内置主题的 CSS 字符串,切换时 O(1) 查表
- 自定义主题懒计算并缓存,后续切换同样 O(1)
- useLayoutEffect 替代 useEffect,paint 前完成避免闪烁
- 跳过无效的 dark/light class 切换
- apply 和 restore 逻辑拆分为独立 effect
- 去掉主题列表 hover 渐变动画

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: 修复 Codex review 提出的三个问题

- [P1] base UI theme 变化时不再覆盖沉浸模式的 dark/light class
- [P2] fingerprint 加入 theme.type,检测自定义主题 dark↔light 编辑
- [P2] 沉浸模式设置接入 sync pipeline (collect/apply/rehydrate)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: focus 模式 workspace 支持沉浸 + settingsVersion 加入 immersiveMode

- focus 模式 workspace 使用 focusedSessionId 的主题,不再要求所有 session 一致
- settingsVersion 加入 immersiveMode 依赖,确保 auto-sync 能检测到变化

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: 沉浸模式 sync 一致性修复

- 初始化时将默认值写入 localStorage,确保 collectSyncableSettings 能收集到
- rehydrateAllFromStorage 后通过 IPC 广播给其他窗口

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: focus 模式关闭 focused session 后 fallback 到剩余 session 的主题

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: 沉浸模式加入 storage event 跨窗口同步

将 immersiveMode 加入 settingsSnapshotRef 和 handleStorageChange,
确保 web/preview 场景下多窗口间沉浸模式状态同步。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: 沉浸模式同步 Electron 原生窗口背景色

切换沉浸主题时同步调用 setTheme/setBackgroundColor,
使 Windows 上的窗口边框颜色与沉浸主题一致。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 02:47:50 +08:00
陈大猫
cb63f105aa Merge pull request #513 from crawt/feat/remove-root-paint-polling-use-renderer-ready
Feat/remove root paint polling use renderer ready
2026-03-26 00:20:21 +08:00
panwk
316e46de4b Mod:Removed waitForRootPaint polling helper from electron/bridges/windowManager.cjs.
Removed did-finish-load polling trigger that called markRendererReady via DOM child count checks.
Kept deferred show behavior based on:
ready-to-show
renderer-ready IPC from renderer
timeout fallback (dev and prod values unchanged)
2026-03-25 23:48:56 +08:00
panwk
1af5182b59 Merge branch 'main' of https://github.com/crawt/Netcatty 2026-03-25 23:42:06 +08:00
陈大猫
35194036cb Merge pull request #502 from crawt/perf/settings-window-prewarm-hide-on-close
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
perf(settings): prewarm settings window and hide on close
2026-03-25 01:24:48 +08:00
陈大猫
6a077a3855 Merge pull request #501 from binaricat/codex/optimize-ai-panel-tab-switch
Optimize AI panel tab switching
2026-03-25 01:19:30 +08:00
bincxz
43f4687bb9 Keep AI panel UI inside side panel layout 2026-03-25 01:13:49 +08:00
bincxz
bbb888ae1e Keep AI state mounted when side panels close 2026-03-25 01:09:36 +08:00
bincxz
c74b78a49d Reconcile AI session state with live sessions 2026-03-25 01:03:34 +08:00
panwk
7b2590e54e Merge branch 'main' of https://github.com/crawt/Netcatty 2026-03-25 01:03:00 +08:00
bincxz
a7f42ec93e Avoid dropping unflushed AI sessions during cleanup 2026-03-25 00:57:48 +08:00
panwk
a886d509f8 perf(settings): prewarm settings window and hide on close
Instead of creating a new BrowserWindow on each user click, the settings window is now:
1. Pre-warmed silently 3 s after app startup (showOnLoad: false)
2. Hidden instead of destroyed when the user closes it
3. Instantly shown/focused on subsequent opens
2026-03-25 00:54:32 +08:00
bincxz
d6fea6c328 Preserve AI session state and cleanup across panel unmounts 2026-03-25 00:52:33 +08:00
bincxz
b6169f1735 Optimize AI panel tab switching 2026-03-25 00:46:59 +08:00
陈大猫
c97470a085 Merge pull request #500 from binaricat/codex/preserve-vault-hosts-state
Preserve vault hosts state across section switches
2026-03-25 00:38:10 +08:00
bincxz
98cb9d09df Preserve vault hosts state across vault section switches 2026-03-25 00:37:56 +08:00
陈大猫
9deb39dec2 Merge pull request #499 from binaricat/codex/jump-host-proxy-support
Support proxy config on jump hosts
2026-03-25 00:32:09 +08:00
bincxz
bb45279d4e Track jump-host proxy socket during chain setup 2026-03-25 00:23:55 +08:00
bincxz
6b1d9ee409 Gate jump-proxy checks on usable endpoints 2026-03-25 00:16:16 +08:00
bincxz
c0c0378df0 Ignore incomplete jump-host proxy configs 2026-03-25 00:09:26 +08:00
bincxz
093951150c Only validate first-hop jump proxies 2026-03-25 00:06:00 +08:00
bincxz
a0418039c4 Prefer jump-host proxy over target proxy guards 2026-03-25 00:04:35 +08:00
bincxz
559e71cfcc Block jump-host proxy auth placeholders 2026-03-25 00:02:59 +08:00
bincxz
a0a2567fa5 Validate jump-host proxy credentials early 2026-03-25 00:01:24 +08:00
陈大猫
d080a43ae6 Merge pull request #497 from crawt/feat/electron-v8-cache-lazy-bridges
feat(electron): enable V8 code cache and lazy-load non-critical bridges
2026-03-25 00:00:21 +08:00
bincxz
2c551cf5e8 Sanitize proxy credentials for jump hosts 2026-03-24 23:58:35 +08:00
bincxz
c54aa52191 Support proxy config on jump hosts 2026-03-24 23:56:28 +08:00
陈大猫
b8c838059a Merge pull request #496 from binaricat/codex/port-forward-jump-hosts
Support jump hosts for port forwarding
2026-03-24 23:55:00 +08:00
bincxz
007b4bd389 Treat cancelled port-forward setup as non-error 2026-03-24 23:50:00 +08:00
bincxz
13fd198243 Allow cancelling proxy setup for port forwarding 2026-03-24 23:48:29 +08:00
bincxz
2c562463c4 Respect cancellation during port-forward startup 2026-03-24 23:47:45 +08:00
bincxz
859d4b8156 Fix auto-start auth readiness checks 2026-03-24 23:45:54 +08:00
bincxz
c6e07cf149 Clean up port forwarding auto-start lint 2026-03-24 23:45:26 +08:00
bincxz
0ab18ce186 Fix port forwarding startup and cleanup races 2026-03-24 23:45:02 +08:00
bincxz
f814719b32 Fix jump-host port forwarding edge cases 2026-03-24 23:43:03 +08:00
bincxz
ee6b05892d Support jump hosts for port forwarding 2026-03-24 23:36:13 +08:00
陈大猫
0f98ffd4f7 Merge pull request #494 from binaricat/codex/ai-command-exec-fixes
Fix AI terminal execution completion and tool UI
2026-03-24 23:22:44 +08:00
bincxz
7ca5d0c832 Track pending ACP cancels during startup 2026-03-24 23:04:08 +08:00
bincxz
1a76d34696 Handle ACP startup cancellation and cmd echo 2026-03-24 23:01:41 +08:00
bincxz
0b2d1b613b Tighten prompt fallback matching 2026-03-24 22:59:35 +08:00
bincxz
ded989b374 Harden cmd tool-call echo handling 2026-03-24 22:57:18 +08:00
bincxz
04c6348bc0 Fix cmd wrapper variable expansion 2026-03-24 22:55:42 +08:00
bincxz
54297859e3 Fix AI cancellation and shell wrapper edge cases 2026-03-24 22:54:17 +08:00
panwk
d236adcd48 1.Enable V8 code caching for BrowserWindow instances by setting webPreferences.v8CacheOptions to bypassHeatCheck
2.Reduce eager main-process module loading by replacing several top-level bridge require() calls in main.cjs with lazy module getters
2026-03-24 22:48:15 +08:00
bincxz
4971f18bbe Fix AI terminal execution completion and tool UI 2026-03-24 22:41:40 +08:00
panwk
15687bd56e Merge branch 'main' of https://github.com/crawt/Netcatty 2026-03-24 22:00:14 +08:00
陈大猫
76675ec515 Merge pull request #492 from binaricat/fix/smooth-scroll-default-off-490
fix: default smooth scrolling to off to prevent scroll freeze
2026-03-24 19:51:50 +08:00
bincxz
7c6304c355 fix: default smooth scrolling to off to prevent scroll freeze (#490)
When smooth scrolling is enabled (smoothScrollDuration: 120ms) and
an AI agent produces high-throughput output, the scroll animation
can't keep up with incoming data, causing the viewport to get stuck
mid-buffer. Users can't scroll to the bottom or Ctrl+C to interrupt.

Default to false. Users who prefer smooth scrolling can still enable
it in Settings > Terminal.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 19:50:28 +08:00
陈大猫
8fdcbf87c2 Merge pull request #487 from binaricat/fix/empty-password-crash-482
fix: prevent crash on ECONNRESET from embedded SSH devices
2026-03-24 19:45:57 +08:00
bincxz
0326ba7556 fix: prevent duplicate exit events when conn.close fires before stream.close
ssh2 emits conn.once("close") before stream.on("close") during
transport drops. The conn.close handler was sending exit + deleting
the session, then stream.close would send a second misleading exit.

Now stream.close checks sessions.has() before sending exit, while
still flushing the data buffer unconditionally. This ensures:
- Buffer flush always happens (no data loss)
- Exit event is sent exactly once
- Transport errors are correctly reported

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 19:19:02 +08:00
bincxz
964230a737 fix: always use dynamic authHandler, detect encrypted PPK keys
P1: Change authMethods.length condition from > 1 to >= 1 so the
dynamic authHandler (which includes 'none' probing) is always used,
even when only keyboard-interactive is available. Fixes the
passwordless embedded device case when no keys/agent are discovered.

P1: Add PPK encryption detection to isKeyEncrypted() — check for
"Encryption:" header in PuTTY PPK format. Without this, encrypted
.ppk files were treated as unencrypted and attempted without a
passphrase, failing silently instead of triggering the passphrase
retry flow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 19:09:37 +08:00
bincxz
5d551ee8e9 fix: address codex P1/P2 — agent none auth, PPK support, FIFO safety
P1: Add "none" to the agent-mode simple array auth path so passwordless
devices work even when agent forwarding is configured.

P1: Extend looksLikePrivateKey() to recognize PuTTY PPK format
("PuTTY-User-Key-File" prefix) so PPK keys in ~/.ssh/ are not
incorrectly filtered out.

P2: Add stat().isFile() check before readFile() in all key discovery
paths to skip FIFOs, sockets, directories, and other non-regular files
that would block readFile() indefinitely.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 18:40:27 +08:00
bincxz
ec4e209972 fix: address codex P1s — transport error in stream close, key content validation
P1: Transport errors on established sessions now surface correctly.
The stream.on("close") handler (which fires before conn close and
after buffer flush) checks session._transportError and sends exit
with exitCode:1 and the error message instead of a misleading
exitCode:0 "closed".

P1: Add looksLikePrivateKey() content validation to all key discovery
functions. Files matching id_* that don't start with "-----BEGIN" or
"openssh-key-v1" are skipped, preventing non-key files from being
passed to ssh2 as privateKey (which would abort connect before
password/agent fallback could run).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 18:00:47 +08:00
bincxz
c141fbc11e fix: defer post-settle exit event to preserve buffered stream data
Codex P2: when a transport error (ECONNRESET) arrives after the session
is established, the error handler was immediately sending netcatty:exit,
causing preload to remove data listeners before the stream close handler
could flush the 8ms data buffer. Users would lose the last chunk of
terminal output.

Now the error handler stores the error message on the session object
(_transportError) instead of sending exit immediately. The close handler
(which fires after stream close + buffer flush) checks for this flag
and sends the exit event with the transport error info.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:48:07 +08:00
bincxz
8e61ccac91 fix: address agent review — double exit event, array none auth, label consistency
Medium: Close handler now checks sessions.has(sessionId) before sending
netcatty:exit, preventing a misleading exitCode:0 "closed" event after
the error handler already reported the real transport failure.

Medium: Array-based auth path in buildAuthHandler now includes "none"
as the first method, matching the dynamic handler behavior.

Low: Set lastAttemptedLabel to "none (no credentials)" so the rejection
message is consistent with the initial onAuthAttempt callback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:32:00 +08:00
bincxz
7c5047f22e feat: scan ~/.ssh/ for all id_* keys instead of hardcoded list
Replace the fixed DEFAULT_KEY_NAMES array ("id_ed25519", "id_ecdsa",
"id_rsa") with a directory scan using /^id_[\w-]+$/ regex, matching
Tabby's PrivateKeyLocator behavior. This discovers keys like
id_ed25519_work, id_dsa, or any custom-named key automatically.

Preferred keys (ed25519, ecdsa, rsa) are still tried first, followed
by any additional keys found in alphabetical order.

Applied to both sshBridge.cjs and sshAuthHelper.cjs (all four
key discovery functions + the get-default-keys IPC handler).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:25:18 +08:00
bincxz
c10100a314 feat: always try SSH 'none' auth first (matches OpenSSH and Tabby)
Restore unconditional 'none' auth as the first method tried. Per
RFC 4252, the 'none' request is the standard way for clients to
discover which auth methods the server supports. It also enables
passwordless login on embedded devices (#482).

This matches the behavior of OpenSSH (which always sends 'none'
first) and Tabby (which unconditionally adds { type: 'none' } as
the first element of allAuthMethods). Most SSH servers do not count
'none' toward MaxAuthTries per the RFC.

Applied to both the main SSH authHandler and the shared
buildAuthHandler used by SFTP/chain/exec connections.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:19:18 +08:00
bincxz
5a294aa306 revert: remove automatic 'none' auth probing (needs separate feature)
Codex review identified P1 issues: automatic 'none' auth before any
other method can exhaust MaxAuthTries on hardened servers, breaking
connections that previously worked. The 'none' auth support for
embedded devices should be a user-facing option, not automatic.

This commit reverts the 'none' auth additions while keeping the
crash prevention fixes (settled guard, conn.destroy, error wrapping).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:09:50 +08:00
bincxz
54b3ba2c01 fix: address Codex review — conditional none auth and post-ready error handling
P2: Only try 'none' auth when no explicit credentials (password/key/agent)
are configured. Avoids wasting an auth attempt on servers with low
MaxAuthTries.

P2: Post-settle errors on active sessions now send netcatty:exit to the
renderer instead of being silently swallowed, so transport failures
(keepalive timeout, ECONNRESET) are correctly reported as errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:55:38 +08:00
bincxz
f25822fdae feat: support SSH 'none' auth for embedded devices with no password
The SSH protocol's 'none' auth method allows login without any
credentials — common on embedded devices (routers, switches) where
root has no password. ssh2 tries this by default, but Netcatty's
custom authHandler and buildAuthHandler overrode the default behavior
and never attempted 'none', making it impossible to connect to these
devices.

Now both authHandlers try 'none' as the first method (before any
other auth) on the initial call (methodsLeft === null). If the server
accepts it, the connection succeeds immediately. If rejected, the
normal auth flow continues with publickey/password/keyboard-interactive.

This is the root cause of #482: the user's embedded device needed
'none' auth, but Netcatty never tried it, then the auth failure +
ECONNRESET combination crashed the app.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:44:22 +08:00
bincxz
69f433c161 fix: prevent crash on ECONNRESET from embedded devices with empty password (#482)
When connecting to embedded devices with legacy algorithms and no password,
the SSH connection could crash the app with an uncaught ECONNRESET exception.

Three fixes:
1. Guard against duplicate error handling in conn.on("error") — once the
   promise is settled, late errors (e.g. ECONNRESET after auth failure)
   are logged but no longer re-reject or re-notify the renderer.
2. Destroy the SSH connection on error/timeout to prevent the underlying
   TCP socket from emitting further uncaught errors.
3. Wrap non-auth errors in startSSHSessionWrapper with clean Error objects
   so Electron's ipcMain.handle can serialize them back to the renderer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:39:29 +08:00
陈大猫
6087343203 Merge pull request #489 from binaricat/fix/restore-npm-rebuild-macos-474
fix: restore npmRebuild for macOS/Windows to fix local terminal crash
2026-03-24 16:37:54 +08:00
bincxz
bb63de2658 fix: restore npmRebuild for macOS/Windows to fix posix_spawnp crash (#474)
PR #449 set npmRebuild: false in electron-builder.config.cjs to fix a
Linux architecture mismatch. But this also disabled native module
recompilation for macOS and Windows builds, causing node-pty to ship
with the wrong ABI (Node.js instead of Electron). On macOS, this
manifests as "posix_spawnp failed" when opening a local terminal.

Restore npmRebuild: true. Linux builds are unaffected because they
already run ensure-node-pty-linux.sh before packaging with explicit
npm_config_arch, and the redundant rebuild uses the same arch setting.

User confirmed: 1.0.62 works, 1.0.63 (first release after #449) fails.

Closes #474

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:32:54 +08:00
陈大猫
fd938a84e4 Merge pull request #485 from yaotiancheng-ola/feature/macos_stats
feat(terminal): support server stats on macOS
2026-03-24 16:29:15 +08:00
陈大猫
c2e629ad61 Merge pull request #488 from binaricat/fix/sftp-permissions-not-displayed-480
fix: SFTP permissions dialog shows empty (000) instead of actual file permissions
2026-03-24 16:21:08 +08:00
bincxz
4bf61c02a0 fix: pass permissions field from SFTP listing to frontend (#480)
The remote file listing mapper in useSftpDirectoryListing.ts was
dropping the `permissions` field returned by the backend. This caused
the permissions dialog to show all checkboxes unchecked (000) and the
file list to show "--" in the permissions column.

One-line fix: add `permissions: f.permissions` to the mapped object.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:17:22 +08:00
陈大猫
4747217929 Merge pull request #486 from binaricat/fix/sftp-filename-tooltip-480
fix(sftp): show full filename tooltip on hover
2026-03-24 16:15:42 +08:00
bincxz
fb3cdd0661 fix(sftp): show full filename tooltip on hover in file list (#480)
Add title attribute to the file name span so truncated names reveal
their full text via native browser tooltip on hover.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:02:41 +08:00
陈大猫
11ca8fba87 Merge pull request #484 from binaricat/feat/unified-auth-logs-and-sftp-progress
feat: unified auth logging for SSH and SFTP connections
2026-03-24 15:55:52 +08:00
bincxz
7ffc4b4c7f fix: address Codex round 4 — keyboard-interactive progress for all paths
P2: Wrap keyboard-interactive handlers in SSH chain, SFTP chain, and
SFTP main connections to emit "waiting for user input..." and "user
responded" progress events, matching the SSH main connection behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:47:14 +08:00
bincxz
fe27dd8a9d fix: address Codex round 3 — accurate auth logs and clean state
P2: Remove premature onAuthAttempt calls from buildAuthHandler's array
branch — methods are listed before connect(), making logs inaccurate.

P2: Handle "waiting for user input..." and "user responded" as literal
log messages, not as "Trying X..." format, in both SSH and SFTP.

P3: Clear connectionLogs after successful SFTP connect so directory
navigation doesn't replay stale auth transcript in the loading overlay.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:40:05 +08:00
bincxz
eca11e9d2a fix: address Codex round 2 — array auth logging, cached overlay, stale listener
P2: Emit onAuthAttempt notifications from buildAuthHandler's array
branch so single-method SFTP connections (e.g. password-only) show
auth method logs in the connection panel.

P3: Show connectionLogs in the cached-files loading overlay so repeat
connections still display auth progress during reconnect.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:26:52 +08:00
徐三
779aa31ef8 chore(terminal): clarify server stats scope comment
- update Terminal server-stats comment to reflect Linux/macOS support
- no runtime behavior changes
2026-03-24 15:21:47 +08:00
徐三
2c8670a6c6 fix(terminal): stop server-stats polling on unsupported OS
- add explicit Linux/macOS guard in server-stats hook
- return UNSUPPORTED_OS from ssh bridge when uname is not Linux/Darwin
- fail fast when stats payload cannot be parsed to avoid futile polling
- wire Terminal to pass supported-OS hint to useServerStats
2026-03-24 15:18:12 +08:00
bincxz
a94293d31e fix: address Codex review — scoped progress, local reset, connected event
P2: Guard SFTP progress callback with navSeqRef check to prevent stale
auth logs from leaking into a reused tab after retry/disconnect.

P3: Reset connectionLogs when connecting to local filesystem, avoiding
stale remote auth logs showing in the local pane.

P3: Emit 'connected' progress event when the final SFTP SSH session
is ready, so the log confirms the connection completed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:11:42 +08:00
徐三
04b62f7ba3 feat(terminal): support server stats on macOS via remote OS auto-detection
- auto-detect remote OS in sshBridge using uname -s
- add macOS stats collection path (CPU, memory, swap, processes, disk, network)
- keep existing Linux stats pipeline and parsing logic
- remove Linux-only gating in useServerStats and Terminal display logic
- show server stats whenever connected (not restricted by host.os)
- add CPU hover fallback UI when per-core data is unavailable (e.g. macOS)
- update bridge type docs in global.d.ts to reflect cross-OS stats support
2026-03-24 15:00:33 +08:00
bincxz
45794b7f6f feat: unified auth logging for SSH and SFTP connections
Add detailed authentication method logs to both SSH terminal and SFTP
connection flows, giving users visibility into which methods are tried,
rejected, or require input.

Backend (shared):
- sshAuthHelper buildAuthHandler: track lastAttemptedLabel, log method
  rejections and "all methods exhausted" via onAuthAttempt callback
- sftpBridge: add sendSftpProgress helper, wire onAuthAttempt to both
  chain and main buildAuthHandler calls, emit connecting/authenticating/
  connected/error progress events via new IPC channel

Backend (SSH-specific):
- sshBridge: log method rejections in custom authHandler, log
  keyboard-interactive prompt/response and all-methods-exhausted

IPC/Bridge:
- preload: register netcatty:sftp:connection-progress listener, expose
  onSftpConnectionProgress in bridge API
- global.d.ts: add onSftpConnectionProgress type

Frontend (SFTP):
- types.ts: add connectionLogs to SftpPane
- useSftpConnections: subscribe to progress events during connect,
  convert to human-readable log lines, accumulate in pane state
- SftpPaneFileList: show logs below spinner during connecting, show
  expandable "Show logs" in error view with collapsible log panel

Frontend (SSH):
- createTerminalSessionStarters: format rejected methods with ✗ prefix
  and "all methods exhausted" message

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 14:34:25 +08:00
陈大猫
314072a631 Merge pull request #479 from binaricat/feat/ssh-config-identity-file
feat: support IdentityFile from SSH config import
2026-03-24 14:07:08 +08:00
bincxz
c9f1951e28 fix: address Codex review — quoted paths, stale keys, managed source round-trip
P1: serializeHostsToSshConfig now emits IdentityFile directives so
managed ssh_config sources preserve key paths on sync. Paths with
spaces are automatically quoted.

P2: Unquote IdentityFile paths during import — ssh_config allows
quoted paths for filenames with spaces, but the quotes were stored
literally and caused fs.readFile to fail.

P2: Clear identityFilePaths when applying an identity profile, and
only forward them at connection time when no vault key is selected.
Prevents stale local key paths from triggering unrelated passphrase
prompts after switching to a different credential source.

P1 (SFTP): Forward identityFilePaths for jump hosts in SFTP credentials.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:59:36 +08:00
bincxz
7f83b22c95 fix: address Codex review — SFTP jump host identity files and skip handling
P1: Pass identityFilePaths for jump hosts in SFTP credentials so chain
connections can load IdentityFile keys for bastion hosts.

P2: When the passphrase dialog is skipped or times out (not just
cancelled), clear the encrypted key and continue to the next identity
file. Previously skip/timeout fell through and left the encrypted key
in connOpts, causing the same stall this feature is meant to fix.

Applies to all 4 identity file loading paths (SSH chain, SSH main,
SFTP chain, SFTP main).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:53:05 +08:00
bincxz
b7082ab198 feat: add native file picker for local key file selection
Replace the manual-only text input with a file picker button that opens
the system file dialog (showOpenDialog with showHiddenFiles enabled so
~/.ssh/ keys are visible). Users can still type a path manually or use
the browse button.

Changes:
- electron/main.cjs: add netcatty:selectFile IPC handler
- electron/preload.cjs: expose selectFile on bridge
- global.d.ts: add selectFile type
- HostDetailsPanel.tsx: add FolderOpen browse button next to path input

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:51:08 +08:00
bincxz
9369495e22 feat: add local key file path UI in host editor
Add "Local Key File" option in the host credential type selector.
Users can specify local SSH key file paths (e.g. ~/.ssh/id_ed25519)
as an alternative to selecting a key from the vault. This is the
primary UI for keys imported via SSH config's IdentityFile directive.

UI behavior:
- Credential selector now shows three options: Key, Certificate,
  Local Key File
- Local key file paths are displayed as a list with delete buttons
- Text input with Enter/Add support for adding new paths
- Selecting a vault key clears local key paths (and vice versa)
- Paths are stored as host.identityFilePaths and resolved at
  connection time

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:51:08 +08:00
bincxz
e3fdb1f7ff feat: support IdentityFile from SSH config import (#463)
SSH config import now parses the `IdentityFile` directive and stores
the file paths on the host as `identityFilePaths`. At connection time,
the SSH and SFTP bridges resolve these paths, read the key file content,
and use it for authentication — matching the behavior of OpenSSH and
Tabby.

If the key file is encrypted, a passphrase dialog is shown before
connecting. If the user cancels, the key is skipped and auth falls back
to other methods. If the file doesn't exist, a warning is logged and
the next key path is tried.

Changes:
- domain/models.ts: add `identityFilePaths` to Host interface
- domain/vaultImport.ts: parse `IdentityFile`, expand `~`, store paths
- global.d.ts: add `identityFilePaths` to NetcattySSHOptions and
  NetcattyJumpHost types
- createTerminalSessionStarters.ts: pass identityFilePaths for both
  main connection and jump hosts
- useSftpHostCredentials.ts: pass identityFilePaths for SFTP
- sshBridge.cjs: read identity files at connection time for both main
  and chain connections, with encrypted key passphrase prompting
- sftpBridge.cjs: same for SFTP main and chain connections

Closes #463

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:51:08 +08:00
陈大猫
b9bc6b95e5 Merge pull request #477 from binaricat/fix/chain-encrypted-key-passphrase-463
fix: prompt passphrase for encrypted keys on jump hosts and SFTP
2026-03-24 13:48:40 +08:00
bincxz
5cbaae8d2f fix: throw auth-level error on SFTP passphrase cancel for password fallback
Address Codex P2: when the passphrase dialog is cancelled, the thrown
error now includes 'authentication' in the message and sets
level='client-authentication'. This allows the SFTP frontend's
isAuthError() check to recognize it and fall back to the password
retry path, preserving the key-first-then-password behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:43:05 +08:00
bincxz
915e571c63 fix: use readable host/key label in passphrase dialog
Address Codex P3: the passphrase modal was showing UUIDs or generic
placeholders like "private-key" / "hop-1-key" instead of the host
label or hostname. Now pass the human-readable label/hostname as
keyName so users can identify which key needs the passphrase.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:37:06 +08:00
bincxz
86a43655e1 fix: destroy proxy socket when SFTP passphrase is cancelled
Address Codex P2: when using a proxy and an encrypted key, cancelling
the passphrase dialog cleaned up chain connections but leaked the
proxy socket in connectionSocket. Now explicitly destroy it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:29:07 +08:00
bincxz
e47d86874f fix: clean up chain connections when SFTP passphrase is cancelled
Address Codex P2: when the passphrase dialog is cancelled for the
final SFTP host, any already-open proxy/jump-host connections were
leaked because the throw bypassed the cleanup path. Now explicitly
end all chain connections before throwing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:22:39 +08:00
bincxz
369de6fff2 fix: clear encrypted key when passphrase is skipped or times out
Address Codex P1 review: when the passphrase dialog is skipped or
times out, the encrypted key was left in connOpts.privateKey without
a passphrase. buildAuthHandler would still attempt it as publickey-user,
causing the same stall this PR fixes. Now delete connOpts.privateKey
in all non-success paths so auth falls back to password/keyboard-interactive.

Applies to SSH chain, SFTP chain, and SFTP main connection paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:16:05 +08:00
陈大猫
3aa414ad05 Merge pull request #478 from binaricat/codex/fix-sidebar-snippet-execution-order
fix: restore proper snippet paste semantics for sidebar clicks
2026-03-24 13:13:36 +08:00
bincxz
356c27d0fb fix: send auto-run Enter outside bracketed paste markers
Codex review caught a P1 regression: when a multi-line snippet had
noAutoRun=false, the \r was appended before wrapping in bracketed
paste, causing shells to treat the Enter as pasted text instead of a
submit action. Now the bracketed paste wraps only the command text,
and \r is appended afterward so it is sent as a real keypress.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:51:22 +08:00
bincxz
ae94e7e529 fix: register snippet executor only after terminal is connected
Address Codex review feedback: the snippet executor was registered on
mount before the session was ready, causing sidebar snippet clicks to
be silently dropped during the connecting/reconnecting window instead
of falling through to TerminalLayer's raw writeToSession fallback.

Now the executor is only published when status === "connected" and is
cleared back to null on disconnect so the fallback path is used for
sessions that aren't ready.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:45:23 +08:00
bincxz
5828503ffc fix: restore proper snippet paste semantics for sidebar clicks 2026-03-24 11:48:02 +08:00
bincxz
1c0f45e410 fix: prompt passphrase for encrypted keys on jump hosts and SFTP (#463)
When an SSH config specifies an encrypted IdentityFile for a jump host
(e.g. `IdentityFile ~/.ssh/id_ed25519` with passphrase protection),
the chain connection passed the encrypted key to ssh2 without a
passphrase. ssh2 failed to parse it and the auth hung until timeout,
with no user-visible prompt.

The same issue existed for SFTP connections using encrypted keys.

Now detect encrypted keys via `isKeyEncrypted()` before connecting and
prompt the user for the passphrase via the existing passphrase dialog.
If the user cancels, a clear error is shown. If skipped, auth falls
back to other methods (password, keyboard-interactive, default keys).

Closes #463

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 11:23:07 +08:00
陈大猫
5c791cebe5 Merge pull request #476 from binaricat/fix/ssh-error-crash-452
fix: prevent SSH connection errors from crashing the entire app
2026-03-24 10:42:23 +08:00
bincxz
0ce6b0f777 fix: expand non-fatal network error coverage in safety net
Add EHOSTDOWN, ENETDOWN, EPROTO, EPERM to the isNonFatalNetworkError
check. Also refactor to switch/case for readability.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 10:40:33 +08:00
bincxz
6fca38a209 fix: prevent SSH connection errors from crashing the entire app (#452)
ssh2 emits multiple error events per failed connection (e.g. ECONNRESET
followed by "Connection lost before handshake"). Several code paths used
`.once("error")` which removed the listener after the first event,
leaving the second error unhandled and crashing the process via the
uncaughtException handler's re-throw.

Root cause: `runDistroDetection` ran unconditionally after connection
attempts (including failures), creating a new SSHClient to the same
unreachable host. Its `execCommand` used `.once("error")`, so the
second ssh2 error event had no listener and became an uncaught exception.

Fixes:
- execCommand: `.once("error")` → `.on("error")` with settled guard and
  explicit `conn.end()` cleanup
- runDistroDetection: move into try block so it only runs after
  successful connections
- portForwardingBridge: same `.once` → `.on` fix
- sftpBridge: add catch-all error listener after cleanup() removes the
  pre-ready listeners
- main.cjs: suppress non-fatal SSH/network errors in uncaughtException
  and unhandledRejection handlers as defense-in-depth (log to crash
  bridge, do not re-throw)

Closes #452

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 10:31:51 +08:00
Leo Pan
52541a6066 将 SSH 已有的 8ms / 16KB PTY 缓冲策略移植到 Local、Telnet、Mosh (#473)
抽出共享 createPtyBuffer helper,减少高吞吐场景下的 IPC 压力

Co-authored-by: panwukan <panwukan@yco.pet>
2026-03-24 09:35:48 +08:00
panwukan
6d35301436 将 SSH 已有的 8ms / 16KB PTY 缓冲策略移植到 Local、Telnet、Mosh
抽出共享 createPtyBuffer helper,减少高吞吐场景下的 IPC 压力
2026-03-24 06:40:12 +08:00
陈大猫
5d29c8d91a fix: support IPv6 addresses in quick connect and fix display formatting (#472)
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
* fix: support bare IPv6 addresses in quick connect and fix IPv6 display

- Accept un-bracketed IPv6 addresses (e.g. 2607:f130::4f06) in quick
  connect input. The main regex requires brackets for IPv6+port, but now
  falls back to detecting bare IPv6 (2+ colons, hex-only) when the
  primary pattern fails.
- Add formatHostPort() helper that wraps IPv6 addresses in brackets
  when appending a port, preventing ambiguous displays like
  "2607:f130::4f06:22"
- Apply formatHostPort in QuickConnectWizard, TerminalConnectionDialog,
  and SftpSidePanel
- Fix hop label formatting in sshBridge and sftpBridge for IPv6 jump
  hosts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: truncate long hostnames in connection dialog

Add truncate to the host label and protocol subtitle in the connection
dialog so long IPv6 addresses don't overflow into the action buttons.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: constrain connection dialog header so truncate works correctly

Add min-w-0/flex-1 to the left side of the header flex container and
shrink-0 to the avatar so long hostnames truncate instead of pushing
into the Show logs / close buttons.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: prevent action buttons from being squeezed by long hostname

Add shrink-0 and left margin to the right-side button group so truncated
text doesn't crowd into Show logs / close buttons.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: tighten bare IPv6 detection to avoid MAC address false positives

Only accept bare (un-bracketed) hex:colon strings as IPv6 if they
contain '::' (unambiguously IPv6) or have exactly 7 colons (full
8-group notation). This rejects MAC addresses like aa:bb:cc:dd:ee:ff
(5 colons) which would otherwise trigger quick-connect mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: avoid double-wrapping already-bracketed IPv6 hop labels

Add !startsWith('[') guard so hostnames that are already bracketed
(e.g. from URL-imported hosts) don't produce malformed labels like
[[2607:f130::4f06]]:22.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 23:13:58 +08:00
陈大猫
196b1f8dbb feat: add terminal smooth scrolling setting (#471)
- Add smoothScrolling boolean to TerminalSettings (default: true)
- Wire setting to xterm.js smoothScrollDuration (120ms when on, 0 when off)
- Add toggle in terminal settings UI
- Include in sync payload and i18n strings (en, zh-CN)

Inspired by #467 (@crawt).

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 22:39:03 +08:00
陈大猫
f1065745bc perf(keyword-highlight): skip cellMap for ASCII lines and share empty result array (#470)
- Use a regex ASCII test to detect lines where string indices equal cell
  columns, skipping the buildStringToCellMap buffer walk entirely. Most
  terminal output is ASCII, so this avoids the majority of cell API calls.
- Share a frozen empty array for non-matching lines instead of allocating
  a new array per scanLine call, reducing GC pressure during scrollback.

Inspired by #466 (@crawt).

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 22:24:39 +08:00
陈大猫
c67befa0e9 perf(keyword-highlight): reduce latency with throttled rAF and line cache (#469)
* perf(keyword-highlight): reduce highlight latency with throttled rAF and line cache

Based on #464 by @crawt with fixes for review feedback:

- Split triggerRefresh into immediate (rAF) and debounced (setTimeout) modes
  so onWriteParsed highlights land with fresh content instead of trailing
  by 200ms
- Throttle the immediate path (50ms min interval) to prevent heavy output
  like tail -f from refreshing every frame
- Add per-line match result cache (LRU, bounded by cacheEntries config)
  so repeated or scrolled-back lines skip regex scanning entirely
- Lazily build cellMap only when a regex match is found, avoiding
  unnecessary work on non-matching lines
- Fix buildStringToCellMap to handle empty cells (codepoint 0) which
  translateToString() renders as spaces — keeps the map aligned with
  the string and makes lineText a safe cache key
- Clean up animationFrameId and matchCache on dispose/rule change

Co-Authored-By: Leo Pan <crawt@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: guard rAF callback against stale state and add debounce fallback

- Re-check enabled/alternate-buffer inside the rAF callback so a
  pending frame doesn't resurrect decorations after the user disables
  highlighting or enters an alternate-buffer app
- Schedule a debounce timer alongside rAF so background/hidden tabs
  (where Chromium suspends rAF) still get highlight updates

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: prevent fallback timer from being cleared on rAF-pending path

- Don't clear debounceTimer at the start of immediate mode — in hidden
  tabs rAF stays pending indefinitely, so repeated onWriteParsed calls
  were clearing the only timer that could actually fire
- Cancel debounceTimer inside the rAF callback instead, so foreground
  tabs don't get a redundant second refreshViewport() 200ms later
- Only arm a new fallback timer if one isn't already pending

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: clear stale rAF in fallback timer and add alternate buffer guard

- Cancel the pending rAF and clear animationFrameId in the fallback
  timer callback so hidden-tab refreshes don't leave animationFrameId
  stuck, which would block all future immediate refreshes
- Add enabled/alternate-buffer re-check in the fallback callback,
  matching the guard already present in the rAF callback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: extract executeRefresh to ensure all timer paths clear stale rAF

A debounced-path timer (from scroll/resize) could fire without clearing
a stale animationFrameId left by an earlier immediate-path rAF that
never executed (hidden tab). This left the immediate path permanently
blocked.

Extract executeRefresh() with rAF cleanup + state guards, used by all
three callback sites (rAF, immediate fallback, debounced timer).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Leo Pan <crawt@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 22:17:01 +08:00
陈大猫
cea83d6cb1 Revert "Mod:perf(keyword-highlight): reduce highlight latency and redundant regex scanning (#464)" (#468)
This reverts commit 293ee46b26.
2026-03-23 21:46:04 +08:00
Leo Pan
293ee46b26 Mod:perf(keyword-highlight): reduce highlight latency and redundant regex scanning (#464)
* perf(keyword-highlight): reduce highlight latency and redundant regex scanning

- Split triggerRefresh into two modes: "immediate" (rAF, for new output
  and rule changes) and "debounced" (setTimeout, for scroll/resize),
  eliminating the fixed 200ms delay after each write that caused visible
  highlight lag on commands like `ls`.
- Add per-line match result cache (LRU, bounded by cacheEntries config)
  so repeated or scrolled-back lines skip regex scanning entirely.
- Lazily build the string-to-cell column map only when a regex match is
  actually found, avoiding unnecessary work on non-matching lines.
- Clean up animationFrameId and matchCache on dispose/rule change to
  prevent leaks and stale results.

* fix: include cell layout in highlight cache key to prevent misplaced decorations

Two IBufferLines can produce identical translateToString() output but
differ in cell layout (e.g. empty cells vs real space characters after
tab stops). Using lineText alone as the cache key could return cached
x/width ranges computed from a different cell layout, producing
misplaced or truncated highlights.

Build the cellMap eagerly and include it in the cache key so lines with
different cell structures get separate cache entries. Pass the pre-built
cellMap into scanLine to avoid redundant work.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: panwk <panwukan@suangoo.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:43:29 +08:00
陈大猫
a6af1dffed fix: resolve SSH chain connection hang and improve connection progress (#465)
* fix: resolve SSH chain connection hang and improve connection progress

- Fix Promise never settling when conn 'close' fires before 'ready'
  during chain connections, which caused "reply was never sent" error
- Replace fake timed progress animation with real backend events
- Send granular connection progress for all SSH connections (not just
  chain), including: connecting, key exchange, auth attempts, forwarding,
  shell opening
- Surface auth method attempts (SSH agent, key names, password) in
  progress logs so users can diagnose authentication failures
- Include error details in progress events for better error visibility

Closes #463

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: scope progress events by sessionId, prevent duplicate errors, hide chain UI for direct SSH

- Add sessionId to chain progress payload so events are scoped per session (P1)
- Set settled=true in error/timeout handlers to prevent close handler from
  emitting a second misleading 'closed unexpectedly' error (P2)
- Only show chain progress UI when total > 1 so direct SSH connections
  don't render as 'Chain 1/1' (P3)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: mark shell-open failure as settled before closing connection

The conn.shell() error branch calls conn.end() which triggers the close
handler, but settled was not set yet, causing a duplicate 'closed
unexpectedly' error to overwrite the real shell-open failure message.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:28:44 +08:00
陈大猫
0a3e61af4b Merge pull request #462 from binaricat/fix/snippet-execution-order
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
fix: normalize line endings and bracket-paste multi-line snippets
2026-03-23 17:51:06 +08:00
bincxz
9e4a79acd7 fix: remove unconditional bracket paste from sidebar, fix broadcast
- TerminalLayer: remove bracket paste wrapping since we can't check
  term.modes.bracketedPasteMode here — keep only normalizeLineEndings
- createXTermRuntime: broadcast un-wrapped data before applying
  bracket paste, so target sessions don't receive literal escape
  sequences meant for the source terminal's paste mode state

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 17:44:49 +08:00
bincxz
a62353bb41 fix: respect bracketedPasteMode and disableBracketedPaste for snippets
Only wrap multi-line snippets in bracket paste sequences when:
- createXTermRuntime: term.modes.bracketedPasteMode is active AND
  disableBracketedPaste setting is false (matches paste handler)
- TerminalLayer: disableBracketedPaste setting is false (no access
  to term.modes, but respects user opt-out)

Prevents sending literal ^[[200~ escape sequences to shells that
don't support or have disabled bracketed paste mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 17:39:48 +08:00
bincxz
d2ab27ab92 fix: normalize line endings and bracket-paste multi-line snippets
Snippet execution via sidebar click was missing normalizeLineEndings()
and bracket paste wrapping that the paste handler and shortkey handler
already apply. On Windows ConPTY/PowerShell, sending raw multi-line
input without bracket paste can cause out-of-order line execution
because the shell processes lines individually and asynchronously.

- Add normalizeLineEndings() to sidebar snippet click handler
- Wrap multi-line snippets in bracketed paste sequences (\e[200~...\e[201~)
  so the shell treats them as a single atomic paste
- Apply same fix to shortkey snippet handler for consistency
- Fix broadcast payload to use the processed data

Fixes #455

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 17:33:36 +08:00
陈大猫
65f62983b6 Merge pull request #461 from binaricat/fix/sftp-home-dir
fix: detect actual home directory for SFTP auto-open
2026-03-23 17:21:16 +08:00
bincxz
56d3109d23 fix: abort timed-out exec channel, treat realpath '/' as ambiguous
- Close/destroy the SSH exec stream when the 5s timeout fires to
  avoid leaking session slots (MaxSessions).
- Treat SFTP realpath('.') returning '/' as non-authoritative so
  non-root users fall through to the candidate probe chain instead
  of incorrectly opening at root.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 17:15:13 +08:00
bincxz
34ab6c0e98 fix: add 5s timeout to SSH echo ~ home dir probe
Prevent indefinite blocking when the remote shell init hangs or a
forced command never exits. Falls through to SFTP realpath after
timeout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 17:07:32 +08:00
bincxz
3db9b0aa26 fix: restore listSftp fallback when statSftp is unavailable
Preserve the original fallback behavior for bridges that don't expose
statSftp — probe candidate directories via listSftp instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 17:03:06 +08:00
陈大猫
fe49ea74e2 Merge pull request #460 from binaricat/fix/update-metadata-verify
ci: verify and recover update metadata after artifact merge
2026-03-23 16:59:38 +08:00
bincxz
be91740582 fix: add actions:read permission for artifact recovery in release job
gh run download requires actions:read scope. Without it, the recovery
step would fail silently when trying to re-download individual artifacts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 16:56:27 +08:00
bincxz
ad15d8ceb5 fix: detect actual home directory for SFTP instead of hardcoding /home
Query the remote server for the real home directory using two methods:
1. SSH exec `echo ~` — works for any user regardless of home path
2. SFTP realpath('.') — fallback, SFTP cwd is typically home dir

Falls back to the previous hardcoded /home/{username} candidates if
both methods fail. This fixes SFTP auto-open sidebar not navigating
to the correct directory for users with non-standard home paths
(e.g. /usr/home, /export/home, custom paths).

Fixes #458

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 16:54:36 +08:00
bincxz
c37fe8f9e0 ci: verify and recover update metadata after artifact merge
download-artifact@v4 merge-multiple can silently drop files when
multiple artifacts contain same-named files (builder-debug.yml).
This caused latest-mac.yml to be missing from v1.0.64 release.

Add a verification step that checks all platform update yml files
exist after merge. If any are missing, re-downloads individual
artifacts to recover them. Fails the release if recovery fails.

Fixes #456

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 16:44:52 +08:00
陈大猫
b0924c14b1 Merge pull request #454 from binaricat/feat/crash-logs
feat: crash log capture and viewer in Settings
2026-03-23 15:56:12 +08:00
bincxz
774c25086e fix: truncate crash log env info with tooltip on overflow
Replace flex-wrap layout with single-line truncate + title tooltip
for the environment metadata row, preventing awkward wrapping when
the settings window is narrow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:45:45 +08:00
bincxz
05c0d43bc4 feat: enrich crash logs with error metadata and process details
- Extract error properties (code, errno, syscall, hostname, port,
  signal, level) into errorMeta field for system-level diagnostics.
- Add extra field for structured context (e.g. render-process-gone
  reason and exitCode as separate fields, not just a string).
- Add process PID for correlating with OS-level logs.
- Accept optional extra parameter in captureError() for callers to
  attach structured context data.
- Display errorMeta and extra as tagged badges in the crash log viewer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:41:45 +08:00
bincxz
baac8670d3 feat: enrich crash log entries with environment diagnostics
Add electronVersion, osVersion, memoryUsage (RSS/heap in MB),
activeSessionCount, and process uptime to each crash log entry.
Display these fields inline in the Settings crash log viewer.

These extra fields help diagnose issues like #452 where knowing
the session count and memory state at crash time is critical.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:34:02 +08:00
bincxz
c84bf497f2 fix: address codex review round 6 — stream line counting, tail-read logs
- listLogs: stream-count newlines instead of reading entire file content
  just to compute entryCount.
- readLog: read only the last 256KB of large files and parse the tail,
  avoiding O(file_size) memory/CPU for crash-loop scenarios.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:23:14 +08:00
bincxz
ac5f708eba fix: address codex review round 5 — filter benign rejections and clean exits
- Skip EPIPE/ERR_STREAM_DESTROYED in unhandledRejection handler to
  avoid false positives in crash logs.
- Skip render-process-gone events with reason 'clean-exit' since
  those are normal shutdowns, not crashes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:12:46 +08:00
bincxz
ecba2560c9 fix: address codex review round 4 — skip benign errors, check openPath result
- Move EPIPE/ERR_STREAM_DESTROYED check before captureError so benign
  stream teardown errors don't pollute crash logs.
- Check shell.openPath return value (error string) instead of always
  returning success: true.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:03:27 +08:00
bincxz
ff638c64cd fix: address codex review round 3 — dedupe logs, reload after clear
- Mark re-thrown unhandledRejection errors so uncaughtException handler
  skips duplicate logging.
- Reload crash log list after clearing instead of blindly emptying,
  so partial delete failures still show remaining files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:54:23 +08:00
bincxz
3db6465340 fix: address codex review round 2 — early require, stale request guard
- Move crashLogBridge require before process error handlers so it is
  available if a bridge import throws during startup.
- Add request ID ref to handleExpandCrashLog to discard out-of-order
  results when the user clicks different log files in quick succession.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:21:50 +08:00
bincxz
2b4f8d33c9 fix: address codex review — re-throw unhandled rejections, early crash capture
- P1: Re-throw in unhandledRejection handler to preserve default fatal
  semantics instead of silently swallowing rejections.
- P2: Fall back to require('electron').app.getPath('userData') in
  ensureLogDir() so crash logs work even before init() is called,
  catching early startup failures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:14:04 +08:00
bincxz
bc6c0a2ef6 feat: add crash log capture and viewer in Settings > System
Capture main-process errors (uncaughtException, unhandledRejection,
render-process-gone) to JSONL log files in userData/crash-logs/ with
30-day auto-rotation. Users can view, expand, and clear crash logs
from Settings > System to help diagnose issues like #452.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:05:56 +08:00
陈大猫
9cccc943ff Merge pull request #451 from tces1/patch-1 2026-03-23 12:31:30 +08:00
Eric Chan
cecda50ce2 Add 'meslolgs nf' to local fonts list
Fixes an issue on macOS where MesloLGS NF was incorrectly filtered out of the terminal font list
2026-03-23 12:28:30 +08:00
bincxz
c136006108 fix: prevent x64 build from producing arm64 packages with wrong native modules
Some checks failed
build-packages / release (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
The linux target config specified arch: ['x64', 'arm64'] for each format,
causing the x64 build job to also produce arm64 packages. These packages
contained x86-64 native modules (node-pty, serialport) since the x64 job
only rebuilds for x64. When artifacts were merged in the release job,
the incorrect arm64 deb from the x64 build could overwrite the correct
one from the arm64 build.

Remove arch from linux target config so the CLI flags (--x64/--arm64)
control which architecture is built per job.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 10:25:12 +08:00
陈大猫
ba073219e5 Merge pull request #450 from binaricat/fix/linux-native-module-arch-verification
ci(linux): enhance native module arch verification
2026-03-23 09:43:41 +08:00
li88iioo
034e5ea3bc ci(linux): enhance artifact verification and architecture handling
- Added environment variables for npm configuration to specify architecture in CI jobs for both x64 and arm64 builds.
- Implemented verification steps for downloaded Linux deb artifacts, ensuring both amd64 and arm64 versions are checked for integrity.
- Updated the `ensure-node-pty-linux.sh` script to resolve and verify serialport prebuilds, ensuring compatibility with the specified architecture.
- Enhanced the `verify-linux-deb-artifact.sh` script to allow optional deb file input and improved error handling for missing artifacts.

These changes improve the reliability of the build process and ensure that the correct native modules are used for each architecture.
2026-03-23 09:40:56 +08:00
陈大猫
6b24e38326 Merge pull request #447 from li88iioo/fix/linux-deb-final-verification
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
ci(linux): verify final deb artifact before publish
2026-03-22 22:33:25 +08:00
陈大猫
b972866c8e Merge pull request #449 from binaricat/fix/linux-node-pty-arch-mismatch
fix: pin native module architecture in Linux builds
2026-03-22 22:33:19 +08:00
bincxz
8c541fb6e2 fix: pin native module architecture in Linux builds
The v1.0.62 amd64 deb/AppImage shipped with an aarch64 node-pty binary
because the build pipeline never explicitly locked the target architecture:

1. `electron-rebuild` was called without `--arch`, relying on auto-detection
2. electron-builder's default `npmRebuild` re-compiled native modules during
   packaging, adding a second uncontrolled rebuild that could override the
   prepare script's output
3. The x64 job did not set `npm_config_arch`, unlike the arm64 job

Changes:
- Pass `--arch` explicitly to `electron-rebuild` in ensure-node-pty-linux.sh
- Set `npm_config_arch: x64` in the x64 CI job (prepare + build steps)
- Disable `npmRebuild` in electron-builder config so only the prepare script
  controls native module compilation

Closes #446, closes #448

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 22:30:59 +08:00
li88iioo
b73e60fb6d ci(linux): verify final deb artifact before publish 2026-03-22 19:42:32 +08:00
bincxz
a40e2f1ca7 fix: add i18n for transfer preparing state
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
Add 'sftp.transfer.preparing' key to en.ts and zh-CN.ts so the
indeterminate transfer state shows localized text instead of the
raw i18n key.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 21:36:19 +08:00
陈大猫
834a677cfe chore: remove debug console.log and unused exports (#445)
* chore: remove 65 debug console.log statements from production code

Remove bracketed debug traces ([SFTP navigateTo], [SFTPBackend],
[ManagedSourceSync], [AutoSync], [CloudSync], [Settings], etc.)
across 16 files. These were development logging that shipped to
production, creating noise in the console.

Also clean up dead variables left behind after log removal
(hotkeyDebug, results, verification reads).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: remove 43 unused exports and dead type definitions

Remove export keywords from symbols that are never imported outside
their defining file. Symbols still used internally keep their
definitions; symbols not used at all are removed entirely.

Removed entirely: TerminalLine, SessionLogsSettings, KDFParams,
SyncManagerConfig, GoogleTokenResponse, OneDriveTokenResponse,
getSyncStatusColor, resolveHostTerminalAppearance,
TerminalAppearanceDefaults.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 21:29:58 +08:00
bincxz
55ee08315a fix: remove unused useEffect import
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 21:12:37 +08:00
陈大猫
a712b96d57 fix: new hosts should inherit global font size and theme dynamically (#444)
When creating a new host, the global fontSize and theme were copied
into the host config. Since fontSizeOverride/themeOverride were not
set (undefined), the legacy detection logic treated the presence of
these values as an active override, locking the host to the global
values at creation time.

Stop copying fontSize and theme into new host configs. Without these
fields, resolveHostTerminalFontSize/ThemeId correctly falls back to
the current global setting, so hosts dynamically follow global
changes unless the user explicitly sets a per-host override.

Closes #424

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 21:06:47 +08:00
陈大猫
f5b745ec63 fix: resolve SFTP tab connection key race in workspace mode (#443)
* fix: resolve SFTP tab connection key race condition in workspace mode

When rapidly switching focus between workspace panes, the single
pendingConnectionKeyRef could be overwritten before the tracking
effect mapped it to the created tab. This left tabs unmapped in
tabConnectionKeyMapRef, causing duplicate tabs on subsequent switches.

Replace the two-step async mechanism (pendingConnectionKeyRef + deferred
tracking effect) with a synchronous onTabCreated callback on connect().
The callback fires immediately after the tab ID is determined, before
any async SSH work begins, eliminating the race window entirely.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: scope SFTP transfers to active connection and prevent stale session lookups

Two fixes for workspace focus-switching issues:

1. Transfer queue now filters by the active connection's host, so
   switching focus between workspace panes only shows transfers
   relevant to the currently displayed SFTP tab.

2. Move sftpSessionsRef.delete() before the async closeSftp() call
   to close the race window where concurrent code could look up a
   stale sftpId that the backend has already removed, causing
   "SFTP session not found" errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: allow SFTP focus switching during file transfers

Active transfers should not block workspace focus-following. Transfers
run on their own sftpId independent of the active tab, and forceNewTab
preserves old connections, so switching focus is safe.

Only interactive operations (text editor, permissions dialog, file
opener, file watches) still block host switching.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: refresh correct SFTP tab after transfer completes during focus switch

When a transfer completes while focus has switched to a different host,
refresh was targeting the currently active pane instead of the pane that
initiated the transfer.

Add optional tabId parameter to navigateTo() and refresh() so callers
can target a specific tab. Capture the tab ID at transfer start and use
it for the post-transfer refresh, ensuring the correct tab's file list
is updated regardless of which tab is currently focused.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: auto-reconnect SFTP when session is lost during navigation

When navigateTo() detected a missing or expired SFTP session, it
cleared the connection to null, showing the empty "Select a host"
state. Now it delegates to handleSessionError(), which triggers the
existing reconnection mechanism — keeping files visible while
reconnecting in the background.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* perf: eliminate redundant stat calls before file transfers

Before this change, each file transfer performed 3-4 stat calls over
the network before the progress bar started moving:
1. startTransfer: stat to get file size (~100ms)
2. processTransfer: stat again if size was 0 (~100ms)
3. Conflict check: stat source file for mtime (~100ms)
4. Backend: stat again if totalBytes missing (~100ms)

Now:
- Use the source pane's cached file list for size and mtime (zero
  network cost) instead of stat calls in startTransfer
- Store sourceLastModified on TransferTask so the conflict check can
  use it directly instead of a redundant source stat
- Backend already skips stat when totalBytes is provided

This saves ~200-300ms of network round-trips per file before the
progress bar starts moving.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* perf: show immediate progress feedback during transfer setup

The progress bar previously stayed at 0% for ~500ms-1s while the
backend acquired an isolated SFTP channel and waited for the first
data chunk. Users perceived this as the transfer being "sluggish".

Now start simulated progress immediately for all single-file
transfers (not just non-streaming ones). When the first real progress
update arrives from the backend, the simulation is stopped and real
progress takes over seamlessly. This gives instant visual feedback
that the transfer is in progress.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: show accurate transfer progress instead of simulated values

The progress system had fundamental issues:

1. Simulated progress ran for ALL transfers including streaming ones,
   creating fake progress that could reach 95% while real progress
   was at 60%. The Math.max ratchet prevented regression, so users
   saw inflated numbers.

2. Speed and remaining time were based on simulated data during the
   setup phase, giving misleading estimates.

Changes:
- Only use simulated progress for non-streaming transfers (no real
  progress callback available). Streaming transfers get real data.
- Remove the double ratchet (Math.max) from onProgress — the backend
  already enforces monotonic progress, so the frontend should trust
  the reported values directly.
- Show an indeterminate "preparing..." state during the setup phase
  (channel acquisition, conflict check) instead of fake progress.
  This honestly communicates that the transfer is starting.
- Hide speed and remaining time during the indeterminate phase since
  no real data is available yet.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: remove dead progress simulation and non-streaming transfer code

startStreamTransfer is always available in Electron, so:
- Remove the non-streaming fallback path in transferFile() that read
  entire files into memory with no progress reporting
- Remove startProgressSimulation / stopProgressSimulation and all
  related refs (progressIntervalsRef, useSimulatedProgress,
  hasStreamingTransfer)
- Remove the cleanup effect for progress intervals

All transfers now use the streaming path with real backend-reported
progress. The indeterminate "preparing..." state covers the setup
phase until the first real progress arrives.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* perf: reduce SFTP transfer concurrency from 64 to 4

64 parallel SFTP read/write requests overwhelmed servers, causing
the first chunk response to be delayed by 46+ seconds. Reducing to
4 concurrent requests provides a responsive first progress update
(~1-2s) while still offering significant speedup over sequential
streaming.

Also adds timing logs to the transfer pipeline (processTransfer,
transferFile, downloadFile, uploadFile) to aid future diagnostics.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address review findings from PR #443

Critical fixes:
- Fix refresh/navigateTo type signatures to include the tabId option
  parameter — previously it was silently ignored, making tab-targeted
  refresh non-functional
- Fix handleSessionError/reconnection in navigateTo for background tabs:
  when called with explicit tabId, update that specific tab instead of
  the active tab (which could be a different host)
- Fix uploadExternalFiles to capture and pass tabId for post-upload
  refresh (was missing, only uploadExternalEntries was fixed)

Medium fixes:
- Restore Math.max monotonic ratchet on single-file onProgress to guard
  against any non-monotonic backend values
- Add stat fallback in processTransfer to populate sourceLastModified
  when file is not in the pane's visible file list (filtered/search)
- Adjust TRANSFER_CONCURRENCY from 4 to 8 as a better throughput/
  responsiveness balance

Cleanup:
- Remove all debug timing logs (console.log with Transfer/downloadFile/
  uploadFile prefixes) from both frontend and backend

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: prevent background tab navigation from rolling back active tab

Two P1 fixes from automated review:

1. navSeqRef race: navigateTo uses a per-side sequence counter, so a
   background tab refresh would bump it and cause the active tab's
   concurrent navigation to think it was superseded, restoring
   previousPath instead of applying the fetched files. Now when
   navSeqRef is superseded but tabNavSeqRef still matches, the fetched
   result is applied (it's valid for this tab — only a different tab
   bumped the counter).

2. Auto-follow tear down: needsNewTab only checked hostId, so same
   host with different session-time overrides (port/protocol) would
   reuse the tab and close the old SFTP session, aborting any
   in-flight transfer. Now needsNewTab is true whenever the current
   connection is alive, always preserving it with forceNewTab.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 20:33:55 +08:00
陈大猫
3a5dd62791 fix: preserve SFTP directory when switching between terminal tabs (#440) (#442)
When switching terminal tabs, the SFTP side panel would reset to the
initial directory (terminal cwd at open time), discarding user navigation.

Root cause: an effect cleared the initialLocation guard on every
visibility transition (isVisible false→true), causing the initialLocation
effect to re-navigate to the original path. Tab switches toggle
visibility, so every tab switch triggered the reset.

Remove the visibility-based guard reset. When the panel is truly closed,
the component unmounts and refs reset naturally. Tab switches only
hide/show the panel and should preserve navigation state.

Closes #440

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 16:17:41 +08:00
陈大猫
1233277277 fix: provide detailed error messages for cloud sync failures (#439)
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
Wrap download and decryption steps in separate try-catch blocks so
users see whether a sync failure is caused by a download error or a
decryption error (e.g. mismatched master passwords across devices).

Ref #436

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 11:36:46 +08:00
陈大猫
6f5361c715 fix: use gzip compression for deb packages to fix Deepin OS install (#438)
Switch deb package compression from default xz (LZMA) to gzip for
better compatibility with Deepin OS, which reports "lzma error:
compressed data is corrupt" during installation.

Closes #435

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 11:11:17 +08:00
陈大猫
bea785abae fix: allow Unicode characters in snippet package names (#437)
Use Unicode property escapes (\p{L}, \p{N}) in validation regex so
Chinese and other non-ASCII characters are accepted when creating or
renaming snippet packages. Remove the HTML pattern attribute that
doesn't support the Unicode flag.

Closes #434

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 10:50:52 +08:00
bincxz
27829d7a4b fix: include local shell helper in packaged app
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
2026-03-21 04:39:02 +08:00
bincxz
4d09227bed fix: resolve native module path in linux packaging check 2026-03-21 04:15:45 +08:00
bincxz
16415299ae fix: repair linux node-pty packaging workflow 2026-03-21 04:13:31 +08:00
bincxz
dfc9a4efdd fix: use electron-rebuild CLI directly instead of install-app-deps
electron-builder install-app-deps forks a child process via
remote-rebuild.js to run @electron/rebuild. The child's main()
has no .catch() handler, causing unhandled promise rejections
that exit with code 1 even after successful rebuilds.

Replace with direct `npx electron-rebuild` which runs in-process
and avoids the broken fork layer entirely.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 04:07:39 +08:00
bincxz
254c6da4ca fix: use legacy nativeRebuilder to fix Linux build failure
electron-builder 26.7.0's remote-rebuild.js forks a child process to
run @electron/rebuild 4.0.x (ESM), but its main() has no top-level
.catch() handler. Unhandled promise rejections during async cleanup
cause exit code 1 even when all native modules rebuild successfully.

Switch to the legacy rebuilder which uses the app-builder binary
directly, bypassing the broken fork layer entirely.

Also revert the previous workaround in ensure-node-pty-linux.sh.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 03:59:31 +08:00
bincxz
81063419de fix: use set +e to properly catch electron-builder exit code
The || echo approach may not catch all failure modes. Temporarily
disable errexit around npm run rebuild and check the exit code
explicitly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 03:46:08 +08:00
bincxz
fee7da5aad fix: tolerate non-zero exit from electron-builder install-app-deps
electron-builder 26.7.0 returns exit code 1 even when native modules
rebuild successfully. Let the subsequent file existence checks catch
real failures instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 03:42:53 +08:00
陈大猫
66b4908686 fix: PowerShell AI exec markers visible and results not captured (#432)
* Add dismiss option for disconnected terminal dialog

* Refine terminal connection dialog visuals

* Polish terminal connection dialog layout

* fix: PowerShell AI exec markers visible and results not captured

PowerShell wrapped command was sent as 8 separate lines, causing:
1. Markers visible — PS echoes each line with prompt prefix, ^-anchored
   filter regexes couldn't match
2. Line-by-line input — 8 \r\n = 8 Enter keypresses displayed sequentially
3. AI couldn't get results — end marker Write-Output format mismatch
   between generation (format string) and filter (single-quote regex)

Combine into 2 lines (like posix) and use inline regex matching.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use whole-line deletion to strip PowerShell __NCMCP_ marker echoes

PowerShell echoes each input line with the PS prompt prefix (e.g.
`PS C:\...> Write-Output '__NCMCP_..._S'; $env:PAGER=...`), so the
previous per-fragment substitutions left residual content visible in
the terminal after partial replacement.

Replace all PowerShell-specific fragment regexes with a single
whole-line regex that deletes any line containing __NCMCP_, regardless
of leading PS prompt or shell variant.

* fix: apply whole-line deletion to stripMarkers in ptyExec for Catty Agent

Same root cause as preload.cjs: PowerShell echoes the entire wrapper
line with PS prompt prefix (e.g. `PS C:\...> $__NCMCP_rc = if ...`).
The previous regex only stripped from __NCMCP_ onwards, leaving the
PS prompt and partial variable name visible in the AI's stdout capture.

Use the same ^[^\r\n]*__NCMCP_[^\r\n]* whole-line pattern so Catty
Agent also receives clean output without PS wrapper residue.

* fix: use compact if/elseif/else syntax in PowerShell wrapper to prevent >> continuation prompt

PowerShell interactive PTY parses `if (cond) { } elseif ...` with
spaces around braces as a multi-line block, causing >> continuation
prompt after line 2 is submitted. Switch to compact no-space form
`if(cond){...}elseif(...){...}else{...}` which PowerShell evaluates
as a complete expression on a single line.

Also remove the $global:LASTEXITCODE=0 reset on line 1 since it
clobbers $? before line 2 runs, making the -not $? fallback unreliable.

* fix: proper line-level buffering for PowerShell marker filter + remove >> trigger

preload.cjs:
- Replace chunk-based filterMcpMarkers with per-session filterMcpChunk
  that buffers trailing fragments across PTY data events. Previously,
  if __NCMCP_ was split across two IPC chunks (e.g. chunk1 ends with
  '__N', chunk2 starts with 'CMCP_...'), neither chunk matched the
  guard and both leaked to xterm.js. Now the tail of each chunk is held
  and prepended to the next chunk before line-level filtering.
- Clean up per-session buffers on netcatty:exit to prevent memory leaks.

ptyExec.cjs:
- Replace if($LASTEXITCODE){...}elseif...else{...} with a brace-free
  arithmetic expression: [int](-not $?) -bor [Math]::Abs([int]$LASTEXITCODE)
  This eliminates the >> PowerShell continuation prompt that was triggered
  by the interactive parser treating the if-block as an incomplete statement.

* fix: simplify PowerShell Line 2 to bare Write-Output to eliminate >> prompt

Any expression with operators, method calls, or variable assignment
can trigger PowerShell interactive continuation mode (>> prompt).
Use the absolute minimum: just Write-Output with $LASTEXITCODE interpolated
directly. This cannot trigger >>. Null $LASTEXITCODE is handled gracefully
by the execViaPty receiver (defaults to exit code 0).

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 03:31:44 +08:00
yuzifu
9e6e9eab87 fix: log file name and use local time (#416)
* fix: log file name and use local time

* fix: improve SSH txt log sanitization with ANSI/OSC

* fix: log file name and use local time(update)

---------

Co-authored-by: yuzifu <yuzifu@TB16PGen5.Info>
2026-03-21 03:13:22 +08:00
陈大猫
41606eacf0 Merge pull request #431 from binaricat/codex/issue-417-distro-override
Add manual Linux distro override for hosts
2026-03-21 01:50:59 +08:00
bincxz
795970b524 Fix distro auto mode and select accessibility 2026-03-21 01:47:41 +08:00
bincxz
5b52413d97 Add manual Linux distro override for hosts 2026-03-21 01:47:41 +08:00
陈大猫
3c17476809 Merge pull request #430 from binaricat/codex/issue-411-disconnect-dialog
Improve disconnected terminal dialog behavior and visuals
2026-03-21 01:25:27 +08:00
bincxz
874a2b19df Polish terminal connection dialog layout 2026-03-21 01:25:05 +08:00
bincxz
a9c862fe96 Refine terminal connection dialog visuals 2026-03-21 01:25:05 +08:00
bincxz
cbd53ed2a3 Add dismiss option for disconnected terminal dialog 2026-03-21 01:25:05 +08:00
陈大猫
c2b94ea3bd fix: respect global terminal appearance settings (#429)
* fix: respect global terminal appearance settings

* feat: add reset to global terminal appearance

* fix: preserve legacy host appearance overrides

* fix: show legacy appearance reset controls

* refactor: reorder terminal global reset actions

* refactor: present global theme as theme option

* refactor: present global font as font option
2026-03-21 00:56:46 +08:00
陈大猫
6189c31af2 fix: package Linux node-pty runtime for release builds
- prepare Linux `pty.node` and `spawn-helper` before packaging
- verify packaged native module loading with the Electron runtime
- close #420
2026-03-21 00:55:55 +08:00
陈大猫
a0dce5d4a6 feat: support downloading SFTP folders from the new view (#427)
* feat: support SFTP folder downloads in the new view

* refactor: remove unused legacy SFTP modal

* fix: use directory picker for SFTP folder downloads

* fix: wire folder downloads through SFTP side panel

* fix: pre-scan SFTP folders for stable download progress

* feat: show hybrid progress for SFTP folder downloads

* feat: parallelize SFTP folder downloads

* feat: adapt SFTP folder download concurrency by file size

* feat: pool isolated channels for fast SFTP downloads

* fix: address SFTP download review findings

* fix: wait for in-flight fast download channels

* fix: unblock fast channel waiters on cancel
2026-03-21 00:46:37 +08:00
陈大猫
dcaf25ae57 feat: inline approval gate for tool execution (#423)
* feat: inline approval gate for tool execution

Replace SDK-level needsApproval with Promise-based approval gate inside
tool execute functions. The SDK stream stays alive while the UI shows
inline approve/reject buttons on ToolCall blocks.

Changes:
- Add approvalGate.ts: Promise-based approval system with event listeners
- tools.ts: requestApproval() inside execute for confirm mode
- tool-call.tsx: inline approval buttons and keyboard shortcuts
- ChatMessageList.tsx: subscribe to approval events, render approval UI
- useAIChatStreaming.ts: remove old useToolApproval hook integration
- AIChatSidePanel.tsx: remove old approval hook, clean up unused destructuring
- systemPrompt.ts: update confirm mode to not ask for text confirmation
- preload.cjs: filter pager env var prefixes from terminal display
- mcpServerBridge.cjs: add approval gate for ACP/MCP write operations
- aiBridge.cjs: wire IPC for MCP approval response and main window getter
- preload.cjs: add onMcpApprovalRequest/respondMcpApproval APIs

* fix: scope approval gate by chatSessionId and replay for late subscribers

Address Codex PR review comments:
- Add chatSessionId to ApprovalRequest for session isolation
- Scope clearAllPendingApprovals(chatSessionId?) to only clear
  approvals belonging to the target session
- Add replayPendingApprovals() so late-mounting ChatMessageList
  picks up approvals that fired while unmounted
- Scope MCP clearPendingApprovals in aiBridge cancel handler to
  effectiveChatSessionId instead of clearing all
- Pass chatSessionId through MCP approval IPC flow

* chore: remove old approval flow code

- Delete useToolApproval.ts (unused hook)
- Delete InlineApprovalCard.tsx (replaced by ToolCall inline buttons)
- Remove stale comments referencing old hook in AIChatSidePanel
- Remove unused ai.chat.toolApprovalTitle i18n key from en/zh-CN

* fix: session-scoped approval gate and MCP replay survival

- handleStop passes activeSessionId to clearAllPendingApprovals
- setupMcpApprovalBridge stores MCP approvals in pendingApprovals map
  so they survive ChatMessageList unmount/remount cycles
- ChatMessageList accepts activeSessionId prop and filters standalone
  MCP approval blocks to the current session only
- AIChatSidePanel passes activeSessionId to ChatMessageList

* fix: filter PTY exec marker echoes and exit code lines from terminal

Extend filterMcpMarkers in preload.cjs to strip all shell-visible
artifacts from AI command execution:

- Echoed printf start marker: printf '%s\n' '__NCMCP_..._S'
- Echoed exit code restoration: (exit $__nc)
- PowerShell: Write-Output, $global:LASTEXITCODE, $__nc assignment
- Fish: set __nc $status
- Cmd: echo __NCMCP_...
- Widen guard to also trigger on __nc and PAGER=cat strings

* fix: scope SDK approvals, deny MCP on no renderer, fix memo comparator

- createCattyTools accepts chatSessionId and passes it to
  requestApproval so SDK approvals can be matched by
  clearAllPendingApprovals(activeSessionId) on stop
- useAIChatStreaming passes sessionId to createCattyTools
- mcpServerBridge: deny (resolve false) when no renderer window is
  available instead of auto-approving, preserving confirm mode safety
- ChatMessageList: add activeSessionId to React.memo comparator so
  switching sessions triggers re-render for correct MCP approval filter

* fix: MCP listener lifecycle, approval timeout, and UI sync on stop

- Move setupMcpApprovalBridge from ChatMessageList to AIChatSidePanel
  so the IPC listener survives tab/panel switches
- Add 5-minute auto-deny timeout to requestApproval to prevent
  indefinite isStreaming hangs when user walks away
- Add onApprovalCleared listener system: clearAllPendingApprovals now
  notifies UI subscribers so ChatMessageList removes stale cards
- ChatMessageList subscribes to onApprovalCleared to sync local state

* fix: main-process approval timeout and full tool args in payload

- Add 5-minute auto-deny timeout to requestApprovalFromRenderer
  matching the renderer-side requestApproval behavior
- Forward all tool params (excluding chatSessionId) to approval UI
  instead of cherry-picking command/input/path, so sftpRename
  oldPath/newPath and other tool-specific args are visible

* fix: move MCP bridge to TerminalLayer, narrow terminal filter guard

- Move setupMcpApprovalBridge from AIChatSidePanel to TerminalLayer
  so the IPC listener stays alive regardless of side panel tab.
  AIChatSidePanel only mounts when activeSidePanelTab==='ai'.
- Narrow preload.cjs filter guard back to __NCMCP_ only, preventing
  false-positive stripping of user scripts containing __nc or PAGER=cat

* fix: eliminate PTY wrapper echo leakage and duplicate prompts

- Posix wrapper now emits 2 lines instead of 4: start marker + command
  on line 1 (joined with ;), end marker + exit on line 2. This
  eliminates the duplicate prompt echo from the separate start marker.
- Rename __nc to __NCMCP_rc in all shell variants (posix/fish/powershell)
  so every wrapper variable contains the __NCMCP_ prefix. The preload
  guard `data.includes("__NCMCP_")` now reliably catches ALL wrapper
  artifacts regardless of chunk boundaries.
- Update all filterMcpMarkers regex patterns to match the restructured
  wrapper format and renamed variable.

* fix: sync main-process approval timeout with renderer UI cleanup

- When requestApprovalFromRenderer times out, send IPC event
  netcatty:ai:mcp:approval-cleared to renderer so stale approval
  cards are removed
- Add onMcpApprovalCleared preload bridge for the new IPC channel
- setupMcpApprovalBridge now subscribes to cleared events, removes
  timed-out entries from pendingApprovals and notifies clearedListeners
  so ChatMessageList drops the stale card

* fix: surface denied inline approvals as errors in UI

- Detect error or denial payloads ("error" string or "ok: false")
  returned by tools when the user denies an execution
- Set isError: true on the tool-result message so the ToolCall UI
  renders it as a failure (red/rejected) instead of a success (green)
2026-03-20 22:02:21 +08:00
陈大猫
3fd5e1128b Merge pull request #422 from binaricat/codex/fix-windows-codex-cli-login
Fix Windows Codex CLI resolution and login startup
2026-03-20 17:51:36 +08:00
bincxz
cb8c06e152 Avoid shell expansion in agent spawn 2026-03-20 17:45:25 +08:00
bincxz
cabc82e1df Fix Windows Codex CLI resolution 2026-03-20 17:43:27 +08:00
陈大猫
91191d6603 Add AI support for local terminal sessions (#419)
* Add AI support for local terminal sessions

* Fix local AI session metadata and shell safety

* Fix local session cloning and multi-exec errors

* Refactor local shell detection helpers

* Fix local shell helper import path

* Fix CJS imports in renderer

* Use ESM local shell helpers in renderer

* Normalize local shell paths and platform metadata
2026-03-20 17:34:19 +08:00
陈大猫
17e98090ad Add AI support for local terminal sessions (#419)
* Add AI support for local terminal sessions

* Fix local AI session metadata and shell safety

* Fix local session cloning and multi-exec errors

* Refactor local shell detection helpers

* Fix local shell helper import path

* Fix CJS imports in renderer

* Use ESM local shell helpers in renderer

* Normalize local shell paths and platform metadata
2026-03-20 17:32:29 +08:00
bincxz
ab371a53be docs: add AI feature screenshot
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 22:03:06 +08:00
陈大猫
67706e4db3 Replace video links in README.md
Updated video links for server diagnostics and Docker Swarm cluster setup.
2026-03-19 22:01:00 +08:00
bincxz
53aaf06d6c docs: add Catty Agent AI feature showcase to README
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 21:58:30 +08:00
bincxz
ac8e9c0dfc docs: add AI feature demo videos
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 21:50:34 +08:00
bincxz
f4bbe62a1d fix: eliminate scroll bounce when switching tabs with AI chat open
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
StickToBottom was configured with initial="smooth", causing a visible
elastic scroll animation every time the chat panel remounted on tab
switch. Change to initial="instant" so the scroll position snaps
immediately without animation. Streaming and resize still use smooth.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 19:43:06 +08:00
陈大猫
57e131a16e feat: support mouse wheel zoom in image preview (#409)
Scroll up to zoom in, scroll down to zoom out (10% per tick, range
25%-200%). Uses zoomRef to avoid stale closures so wheel + drag
always read the latest zoom level.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 19:34:40 +08:00
bincxz
ea6f9e138c feat: support mouse wheel zoom in image preview
Scroll up to zoom in, scroll down to zoom out (10% per tick, range
25%-200%). Uses zoomRef to avoid stale closures so wheel + drag
always read the latest zoom level.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 19:33:51 +08:00
陈大猫
5177ce2028 feat: image preview enhancements — zoom, drag, reset (#408)
* fix: remove padding around image in preview modal

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add zoom controls and constrain image preview modal size

- Add zoom in/out buttons with percentage display in the title bar
- Zoom range: 25% - 200%, step 25%, resets to 100% on open
- Constrain modal max size to 800x700px to prevent oversized previews
- Scrollable image area when zoomed beyond container

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: improve image preview with aligned controls, drag-pan, animation

- Put filename, zoom controls, and close button in a single flex row
  so they are properly aligned
- Add smooth animation on zoom (width 0.2s ease, transform 0.15s ease)
- Add drag-to-pan when zoomed beyond 100% (pointer capture based)
- Set min-width/min-height on modal to prevent extreme aspect ratios
  from making the dialog too narrow or too short
- Container uses overflow hidden + fixed height to contain the image

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use transform scale for smooth zoom animation

Replace width-based zoom with transform: scale() which is GPU-
accelerated and produces smooth 0.25s ease transitions when clicking
zoom in/out buttons. Drag translation is adjusted for current scale.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: allow drag at any zoom level and add reset button

- Remove zoom > 100 restriction on drag — image can be panned at any
  zoom level
- Add reset button (rotate-ccw icon) left of zoom controls with a
  separator, resets zoom to 100% and position to center
- Reset button is disabled when already at default state
- Cursor shows grab at all times in the image area

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: replace backdrop blur with box-shadow for image preview modal

Drop the dark blurred overlay in favor of a shadow-2xl box-shadow
so the window boundary is clear without obscuring the background.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* perf: use refs for drag state to avoid rerendering chat list

Drag position was stored in React state, causing the entire message
list to rerender on every pointermove frame. Move drag tracking to
refs and update the img transform directly via DOM, so only zoom
button clicks trigger React rerenders.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add aria-labels to image preview controls for accessibility

Add localized aria-label to reset, zoom in, zoom out, and close
buttons. Add i18n keys for common.reset, common.zoomIn, common.zoomOut
in en and zh-CN locales.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: reset button restores drag position and stays enabled after drag

Reset was disabled when zoom was 100%, so dragging without zooming
left no way to restore position. Track drag state separately and
keep reset enabled whenever the image has been dragged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: prevent stuck drag state on pointer cancel or lost capture

If pointerup fires outside the window, dragStart was never cleared
and the image kept following the cursor. Now:
- Check e.buttons in pointermove to bail if primary button released
- Handle onPointerCancel and onLostPointerCapture to end drag

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 19:25:49 +08:00
bincxz
9f44112479 fix: prevent stuck drag state on pointer cancel or lost capture
If pointerup fires outside the window, dragStart was never cleared
and the image kept following the cursor. Now:
- Check e.buttons in pointermove to bail if primary button released
- Handle onPointerCancel and onLostPointerCapture to end drag

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 19:02:57 +08:00
bincxz
6999f362a3 fix: reset button restores drag position and stays enabled after drag
Reset was disabled when zoom was 100%, so dragging without zooming
left no way to restore position. Track drag state separately and
keep reset enabled whenever the image has been dragged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:55:46 +08:00
bincxz
fc546c2430 fix: add aria-labels to image preview controls for accessibility
Add localized aria-label to reset, zoom in, zoom out, and close
buttons. Add i18n keys for common.reset, common.zoomIn, common.zoomOut
in en and zh-CN locales.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:48:19 +08:00
bincxz
f7e4953038 perf: use refs for drag state to avoid rerendering chat list
Drag position was stored in React state, causing the entire message
list to rerender on every pointermove frame. Move drag tracking to
refs and update the img transform directly via DOM, so only zoom
button clicks trigger React rerenders.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 18:04:46 +08:00
bincxz
922376fa06 fix: replace backdrop blur with box-shadow for image preview modal
Drop the dark blurred overlay in favor of a shadow-2xl box-shadow
so the window boundary is clear without obscuring the background.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:50:51 +08:00
bincxz
3d4ca46c9b feat: allow drag at any zoom level and add reset button
- Remove zoom > 100 restriction on drag — image can be panned at any
  zoom level
- Add reset button (rotate-ccw icon) left of zoom controls with a
  separator, resets zoom to 100% and position to center
- Reset button is disabled when already at default state
- Cursor shows grab at all times in the image area

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:49:55 +08:00
bincxz
1d8f203f5b fix: use transform scale for smooth zoom animation
Replace width-based zoom with transform: scale() which is GPU-
accelerated and produces smooth 0.25s ease transitions when clicking
zoom in/out buttons. Drag translation is adjusted for current scale.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:47:10 +08:00
bincxz
41d079a806 feat: improve image preview with aligned controls, drag-pan, animation
- Put filename, zoom controls, and close button in a single flex row
  so they are properly aligned
- Add smooth animation on zoom (width 0.2s ease, transform 0.15s ease)
- Add drag-to-pan when zoomed beyond 100% (pointer capture based)
- Set min-width/min-height on modal to prevent extreme aspect ratios
  from making the dialog too narrow or too short
- Container uses overflow hidden + fixed height to contain the image

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:44:58 +08:00
bincxz
93c95959d3 feat: add zoom controls and constrain image preview modal size
- Add zoom in/out buttons with percentage display in the title bar
- Zoom range: 25% - 200%, step 25%, resets to 100% on open
- Constrain modal max size to 800x700px to prevent oversized previews
- Scrollable image area when zoomed beyond container

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:41:27 +08:00
bincxz
e7300429f8 fix: remove padding around image in preview modal
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:37:54 +08:00
陈大猫
c7743d082a feat: click-to-preview for images in AI chat (#407)
* feat: add click-to-preview for images in AI chat

Uploaded images in AI chat messages can now be clicked to open a
full-size lightbox preview. Clicking the overlay or the image again
dismisses it. Uses the existing Radix Dialog component.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use standard dialog style for image preview with close button

Replace transparent borderless overlay with proper windowed dialog that
has a background, border, and the built-in close button (X) in the
top-right corner. Remove focus ring that caused the blue border.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add title bar with filename and blurred backdrop to image preview

- Show filename in dialog header with border separator
- Add overlayClassName prop to DialogContent for per-instance overlay
  customization (e.g. backdrop blur, custom background)
- Apply semi-transparent black background with backdrop-blur on overlay

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: align title and close button vertically in image preview

Adjust header padding and close button position so the filename and
X button sit on the same visual line.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:23:21 +08:00
陈大猫
56a4fe905d fix: handle Windows spawn for Claude ACP bundled JS binary (#405)
* fix: handle Windows spawn for Claude ACP bundled JS binary

On Windows, child_process.spawn does not interpret shebangs, so spawning
a .js file directly (like claude-agent-acp's dist/index.js) fails with
ENOENT. The @mcpc-tech/acp-ai-provider uses raw spawn() internally.

Change resolveClaudeAcpBinaryPath to return { command, prependArgs } so
that on Windows the resolved .js script is invoked via process.execPath
(Node) with the script path prepended to args. On macOS/Linux the
shebang works natively so the script is spawned directly as before.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use system Node instead of process.execPath on Windows

In packaged Electron builds, process.execPath points to the app binary
(e.g. Netcatty.exe), not a Node runtime. Additionally, main.cjs deletes
ELECTRON_RUN_AS_NODE at startup and the agent spawn handler blocks it
in DANGEROUS_ENV_KEYS.

Resolve the real `node` from PATH instead. If Node is not installed,
fall back to the bare `claude-agent-acp` command name so the system
can find the npm-generated .cmd wrapper.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use script path for display and probe version correctly on Windows

In discovery, when resolveClaudeAcpBinaryPath returns { command: node,
prependArgs: [scriptPath] }, use the script path for UI display and
dedup, and probe version with the full command (node script --version)
instead of running node --version.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 17:00:23 +08:00
陈大猫
b17775307f fix: bundle claude-code-acp to prevent crash when binary is missing (#404)
* fix: bundle claude-code-acp to prevent crash when binary is missing (#400)

When users select Claude Code in the AI module, the app spawns
`claude-code-acp` via ACP. Previously only the `claude` CLI was checked
during agent discovery, so if `claude-code-acp` was not on PATH the
spawn would fail with ENOENT and crash the Electron main process.

- Add `@zed-industries/claude-code-acp` as a bundled dependency
- Add `resolveClaudeAcpBinaryPath()` that checks PATH first, then
  falls back to the npm-bundled binary (mirrors Codex pattern)
- Use the resolver in both the primary and fallback ACP provider paths
- Update agent discovery to detect agents via bundled ACP binary when
  the standalone CLI is not installed

Closes #400

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add claude-code-acp and its deps to asarUnpack

In packaged Electron builds, files inside app.asar cannot be executed
by child_process.spawn. Add claude-code-acp and its runtime dependencies
to asarUnpack so the binary is accessible in production.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: migrate from deprecated claude-code-acp to claude-agent-acp

The @zed-industries/claude-code-acp package has been renamed to
@zed-industries/claude-agent-acp (bin: claude-agent-acp). Update all
references across the codebase:

- package.json: replace dep with @zed-industries/claude-agent-acp@0.22.2
- electron-builder.config.cjs: update asarUnpack entries, remove stale
  deps (diff, minimatch) no longer needed by the new package
- shellUtils.cjs: update binary name and require.resolve path
- aiBridge.cjs: update acpCommand, ALLOWED_AGENT_COMMANDS, isClaudeAgent
- settings types, i18n locales: update command references

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 16:24:29 +08:00
bincxz
be7aa4ae52 fix: resolve eslint warnings in App.tsx and VaultView.tsx
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
- Remove unused sessionLog deps from useCallback in App.tsx
- Wrap countAllHostsInNode in useCallback and add to useMemo deps

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 14:57:19 +08:00
陈大猫
f4872099bd fix: real-time session logging via main process streams (#403)
* fix: implement real-time session logging via main process streams

Fixes #394. Session logs previously only captured ~55 lines (the
xterm serialize buffer) and were written only on session close. This
change intercepts terminal data in the main process and writes it to
a file stream in real-time, capturing the complete session output.

- Add sessionLogStreamManager.cjs: manages per-session write streams
  with 500ms/64KB flush, supports txt/raw/html formats
- sshBridge: start stream on shell open, append on data, stop on close
- terminalBridge: same for local, telnet, mosh, serial sessions
- Thread sessionLog config from renderer settings through IPC options
- Skip old renderer-side auto-save when streaming is active
- Cleanup all streams on app quit

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove stale renderer-side auto-save and async HTML finalization

- Remove dead renderer-side auto-save code (main process handles it)
- Make stopStream async, await writeStream finish before HTML conversion
- Use fs.promises for HTML read/write/unlink

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 14:44:54 +08:00
陈大猫
4e2089d7e2 feat: add option to auto-open sidebar on host connect (#401)
* feat: add option to auto-open sidebar on host connect

Closes #396

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: only auto-open SFTP sidebar for SSH/Mosh connections

Use allowlist (ssh, mosh) instead of blocklist so telnet and other
non-SSH protocols don't trigger SFTP sidebar which would fail.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: support auto-open SFTP for Quick Connect / temporary sessions

Build a minimal Host from session data when hostId is not in the vault,
so Quick Connect sessions also trigger auto-open SFTP sidebar.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: sync SFTP auto-open sidebar setting across windows via IPC

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: skip local terminals and preserve username for temp sessions

- Don't fallback protocol to 'ssh' so local terminals are excluded
- Include session.username in synthesized Host for Quick Connect

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 14:12:53 +08:00
陈大猫
5f28320c57 fix: suppress known_hosts toast on auto-scan at startup (#402)
* fix: suppress known_hosts toast on auto-scan at startup

The auto-scan on first mount now runs silently — no toasts for missing
known_hosts file, no entries, or no new hosts. Users still see toasts
when manually clicking "Scan System".

Closes #398

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: wrap onClick handlers to avoid passing event as silent flag

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 13:44:08 +08:00
陈大猫
4e26852482 feat: support multimodal attachments in AI chat (#397)
* feat: support multimodal attachments (images, PDFs, files) in AI chat

Previously uploaded images were displayed in the UI but never sent to
the AI model, and non-image files (PDF, text) were silently rejected.

- Rename useImageUpload → useFileUpload; accept image/*, PDF, and text/*
- Rename ChatMessageImage → ChatMessageAttachment with filePath support
- Build multimodal SDK messages (ImagePart/FilePart) for Catty Agent
- Fix ACP agent path: images inline, non-image files via local path hint
  so ACP agents (Claude Code, etc.) read them with native file access
- Use Electron webUtils.getPathForFile() for reliable file path capture
- Compact user message bubble padding

Closes #294 (AI file upload issues)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: show real tool names in AI chat instead of ACP wrapper names

- Unwrap ACP dynamic tool calls in serializeStreamChunk to extract
  real tool name, args, and toolCallId from chunk.input
- Simplify MCP tool name prefixes (mcp__server__tool → tool)
- Pass toolCallId from ACP tool-call events to match tool results
- Prevent onToolResult from overwriting correct names with wrapper name
- Build toolCallNames map in ChatMessageList for tool result display

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: backward-compatible fallback for legacy `images` field in chat messages

Persisted sessions may still have `images` instead of `attachments`.
Add `?? m.images` fallback in SDK message builder and renderer so
historical image attachments are not silently dropped after upgrade.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: broaden file type support and handle pasted files without path

- Accept all file types except video/audio (instead of allowlist)
  so .json, .yaml, .sh, etc. are not silently rejected
- For ACP agents, save pasted/virtual files (no filePath) to temp
  directory so the agent can still read them

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use managed temp dir for pasted ACP attachments

Use tempDirBridge.getTempFilePath() instead of manual os.tmpdir() path
so pasted file attachments are tracked by the app's cleanup system.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 11:45:50 +08:00
yuzifu
c4fb19cafb update supported distros (#395) 2026-03-19 09:31:22 +08:00
bincxz
09e6526142 Remove GIFs, align zh-CN and ja-JP READMEs with main
- Delete all GIF files (replaced by mp4/user-attachments)
- Update demo sections to use GitHub video attachments
- Add contributor avatars via contrib.rocks
- Add Star History chart

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 01:59:42 +08:00
陈大猫
7ce110c3fb Update asset links in README.md
Updated asset links for various features in the README.
2026-03-19 01:52:27 +08:00
bincxz
667ee18ed3 Compress demo mp4 files (~52MB → ~2.5MB)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 01:50:23 +08:00
陈大猫
f969b1b73d Add links for SFTP and drag file upload sections
Updated README to include links for SFTP and drag file upload.
2026-03-19 01:43:47 +08:00
陈大猫
58a4bf892a Update video references in README.md
Replaced video tags with links to video assets for better accessibility.
2026-03-19 01:39:38 +08:00
bincxz
5052a8231f Improve README: mp4 demos, contributor avatars, star history
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 01:34:00 +08:00
bincxz
13c9cf16fd Update screenshots and add demo GIFs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 01:26:16 +08:00
176 changed files with 9845 additions and 8721 deletions

View File

@@ -93,6 +93,8 @@ jobs:
name: build-linux-x64
runs-on: ubuntu-22.04
env:
npm_config_arch: x64
npm_config_target_arch: x64
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_SECRET: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_SECRET }}
@@ -121,11 +123,23 @@ jobs:
echo "Setting version to ${VERSION}"
npm pkg set version="${VERSION}"
- name: Prepare node-pty Linux runtime
env:
npm_config_arch: x64
run: bash scripts/ensure-node-pty-linux.sh prepare x64
- name: Build package
env:
npm_config_arch: x64
ELECTRON_BUILDER_PUBLISH: "never"
run: npm run pack:linux-x64
- name: Verify packaged node-pty Linux runtime
run: bash scripts/ensure-node-pty-linux.sh verify x64
- name: Verify packaged deb artifact
run: bash scripts/verify-linux-deb-artifact.sh amd64
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
@@ -147,6 +161,8 @@ jobs:
container:
image: debian:bullseye
env:
npm_config_arch: arm64
npm_config_target_arch: arm64
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_SECRET: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_SECRET }}
@@ -155,7 +171,9 @@ jobs:
- name: Install build dependencies
run: |
apt-get update
apt-get install -y curl build-essential python3 git libfuse2 file rpm
apt-get install -y curl build-essential python3 git libfuse2 file rpm \
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 -
apt-get install -y nodejs
@@ -176,12 +194,23 @@ jobs:
echo "Setting version to ${VERSION}"
npm pkg set version="${VERSION}"
- name: Prepare node-pty Linux runtime
env:
npm_config_arch: arm64
run: bash scripts/ensure-node-pty-linux.sh prepare arm64
- name: Build package
env:
npm_config_arch: arm64
ELECTRON_BUILDER_PUBLISH: "never"
run: npm run pack:linux-arm64
- name: Verify packaged node-pty Linux runtime
run: bash scripts/ensure-node-pty-linux.sh verify arm64
- name: Verify packaged deb artifact
run: bash scripts/verify-linux-deb-artifact.sh arm64
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
@@ -201,6 +230,7 @@ jobs:
if: startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.publish_release)
permissions:
contents: write
actions: read
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -214,6 +244,54 @@ jobs:
- name: List artifacts
run: ls -la artifacts/
- name: Verify update metadata files
run: |
missing=0
for f in latest-mac.yml latest.yml latest-linux.yml latest-linux-arm64.yml; do
if [ ! -f "artifacts/$f" ]; then
echo "::warning::Missing $f in merged artifacts, attempting recovery..."
missing=1
fi
done
if [ "$missing" = "1" ]; then
echo "Re-downloading individual artifacts to recover missing files..."
for name in netcatty-macos netcatty-windows netcatty-linux-x64 netcatty-linux-arm64; do
tmpdir="/tmp/artifact-${name}"
gh run download ${{ github.run_id }} --name "${name}" --dir "${tmpdir}" 2>/dev/null || true
if [ -d "${tmpdir}" ]; then
for yml in "${tmpdir}"/latest*.yml; do
[ -f "$yml" ] && cp -v "$yml" artifacts/
done
fi
done
echo "After recovery:"
ls -la artifacts/*.yml
fi
# Final check — fail if any update yml is still missing
for f in latest-mac.yml latest.yml latest-linux.yml latest-linux-arm64.yml; do
if [ ! -f "artifacts/$f" ]; then
echo "::error::$f is still missing after recovery attempt"
exit 1
fi
done
echo "All update metadata files present."
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Verify downloaded Linux amd64 deb artifact
run: |
deb_file="$(find artifacts -maxdepth 1 -type f -name '*-linux-amd64.deb' -print | sort | head -n 1)"
test -n "${deb_file}"
bash scripts/verify-linux-deb-artifact.sh amd64 "${deb_file}"
- name: Verify downloaded Linux arm64 deb artifact metadata
env:
VERIFY_LOAD: "0"
run: |
deb_file="$(find artifacts -maxdepth 1 -type f -name '*-linux-arm64.deb' -print | sort | head -n 1)"
test -n "${deb_file}"
bash scripts/verify-linux-deb-artifact.sh arm64 "${deb_file}"
- name: Generate Release Body
run: node .github/scripts/generate-release-note.js
env:

3
.gitignore vendored
View File

@@ -37,6 +37,9 @@ coverage
# Claude Code
/.claude/
# Codex
/.codex/
/CLAUDE.md
# AI / Superpowers generated docs (local only)

141
App.tsx
View File

@@ -1,6 +1,7 @@
import React, { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { activeTabStore, useActiveTabId, useIsSftpActive, useIsTerminalLayerVisible, useIsVaultActive } from './application/state/activeTabStore';
import { useAutoSync } from './application/state/useAutoSync';
import { useImmersiveMode } from './application/state/useImmersiveMode';
import { useManagedSourceSync } from './application/state/useManagedSourceSync';
import { usePortForwardingAutoStart } from './application/state/usePortForwardingAutoStart';
import { usePortForwardingState } from './application/state/usePortForwardingState';
@@ -14,6 +15,10 @@ import { initializeUIFonts } from './application/state/uiFontStore';
import { I18nProvider, useI18n } from './application/i18n/I18nProvider';
import { matchesKeyBinding } from './domain/models';
import { resolveHostAuth } from './domain/sshAuth';
import { resolveHostTerminalThemeId } from './domain/terminalAppearance';
import { collectSessionIds } from './domain/workspace';
import { TERMINAL_THEMES } from './infrastructure/config/terminalThemes';
import { useCustomThemes } from './application/state/customThemeStore';
import { applySyncPayload } from './domain/syncPayload';
import { getCredentialProtectionAvailability } from './infrastructure/services/credentialProtection';
import { netcattyBridge } from './infrastructure/services/netcattyBridge';
@@ -28,7 +33,8 @@ import { VaultView, VaultSection } from './components/VaultView';
import { KeyboardInteractiveModal, KeyboardInteractiveRequest } from './components/KeyboardInteractiveModal';
import { PassphraseModal, PassphraseRequest } from './components/PassphraseModal';
import { cn } from './lib/utils';
import { ConnectionLog, Host, HostProtocol, SerialConfig, TerminalTheme } from './types';
import { classifyLocalShellType } from './lib/localShell';
import { ConnectionLog, Host, HostProtocol, SerialConfig, TerminalSession, TerminalTheme } from './types';
import { LogView as LogViewType } from './application/state/useSessionState';
import type { SftpView as SftpViewComponent } from './components/SftpView';
import type { TerminalLayer as TerminalLayerComponent } from './components/TerminalLayer';
@@ -185,11 +191,14 @@ function App({ settings }: { settings: SettingsState }) {
sftpAutoSync,
sftpShowHiddenFiles,
sftpUseCompressedUpload,
sftpAutoOpenSidebar,
editorWordWrap,
setEditorWordWrap,
sessionLogsEnabled,
sessionLogsDir,
sessionLogsFormat,
reapplyCurrentTheme,
immersiveMode,
} = settings;
const {
@@ -269,6 +278,56 @@ function App({ settings }: { settings: SettingsState }) {
// isMacClient is used for window controls styling
const isMacClient = typeof navigator !== 'undefined' && /Mac|Macintosh/.test(navigator.userAgent);
// ---------------------------------------------------------------------------
// Immersive Mode — derive UI chrome colors from the active terminal's theme
// ---------------------------------------------------------------------------
const activeTabId = useActiveTabId();
const customThemes = useCustomThemes();
// Resolve the effective TerminalTheme for the currently focused terminal tab
const activeTerminalTheme = useMemo<TerminalTheme | null>(() => {
if (activeTabId === 'vault' || activeTabId === 'sftp') return null;
const resolveTheme = (s: TerminalSession): TerminalTheme => {
const host = hosts.find(h => h.id === s.hostId) ?? null;
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
return TERMINAL_THEMES.find(t => t.id === themeId)
|| customThemes.find(t => t.id === themeId)
|| currentTerminalTheme;
};
// Workspace
const workspace = workspaces.find(w => w.id === 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 = sessions.find(s => s.id === workspace.focusedSessionId)
?? sessions.find(s => wsSessionIds.includes(s.id));
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 => sessions.find(s => s.id === 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 = sessions.find(s => s.id === activeTabId);
if (!session) return null;
return resolveTheme(session);
}, [activeTabId, sessions, workspaces, hosts, currentTerminalTheme, customThemes]);
useImmersiveMode({
isImmersive: immersiveMode,
activeTabId,
activeTerminalTheme,
restoreOriginalTheme: reapplyCurrentTheme,
});
// Get port forwarding rules and import function
const { rules: portForwardingRules, importRules: importPortForwardingRules, startTunnel, stopTunnel } = usePortForwardingState();
@@ -379,16 +438,11 @@ function App({ settings }: { settings: SettingsState }) {
}
}, [updateState.autoDownloadStatus, updateState.latestRelease?.version, t, installUpdate, openReleasePage]);
// Memoize keys for port forwarding to prevent unnecessary re-renders
const portForwardingKeys = useMemo(
() => keys.map((k) => ({ id: k.id, privateKey: k.privateKey, passphrase: k.passphrase, })),
[keys]
);
// Auto-start port forwarding rules on app launch
usePortForwardingAutoStart({
hosts,
keys: portForwardingKeys,
keys,
identities,
});
// Sync tray menu data + handle tray actions
@@ -450,9 +504,8 @@ function App({ settings }: { settings: SettingsState }) {
return;
}
const keysForPf = keys.map((k) => ({ id: k.id, privateKey: k.privateKey, passphrase: k.passphrase }));
if (start) {
void startTunnel(rule, host, keysForPf, (status, error) => {
void startTunnel(rule, host, hosts, keys, identities, (status, error) => {
if (status === "error" && error) toast.error(error);
}, rule.autoStart);
} else {
@@ -464,7 +517,7 @@ function App({ settings }: { settings: SettingsState }) {
unsubscribeFocus?.();
unsubscribeToggle?.();
};
}, [hosts, keys, portForwardingRules, sessions, setActiveTabId, setWorkspaceFocusedSession, startTunnel, stopTunnel, t]);
}, [hosts, identities, keys, portForwardingRules, sessions, setActiveTabId, setWorkspaceFocusedSession, startTunnel, stopTunnel, t]);
// Tray panel actions (from main process)
useEffect(() => {
@@ -656,6 +709,24 @@ function App({ settings }: { settings: SettingsState }) {
const addConnectionLogRef = useRef(addConnectionLog);
addConnectionLogRef.current = addConnectionLog;
const createLocalTerminalWithCurrentShell = useCallback(() => {
return createLocalTerminal({
shellType: classifyLocalShellType(terminalSettings.localShell, navigator.userAgent),
});
}, [createLocalTerminal, terminalSettings.localShell]);
const splitSessionWithCurrentShell = useCallback((sessionId: string, direction: 'horizontal' | 'vertical') => {
return splitSession(sessionId, direction, {
localShellType: classifyLocalShellType(terminalSettings.localShell, navigator.userAgent),
});
}, [splitSession, terminalSettings.localShell]);
const copySessionWithCurrentShell = useCallback((sessionId: string) => {
return copySession(sessionId, {
localShellType: classifyLocalShellType(terminalSettings.localShell, navigator.userAgent),
});
}, [copySession, terminalSettings.localShell]);
// Shared hotkey action handler - used by both global handler and terminal callback
const executeHotkeyAction = useCallback((action: string, e: KeyboardEvent) => {
switch (action) {
@@ -727,7 +798,7 @@ function App({ settings }: { settings: SettingsState }) {
localHostname: systemInfoRef.current.hostname,
saved: false,
});
createLocalTerminal();
createLocalTerminalWithCurrentShell();
break;
case 'openHosts':
setActiveTabId('vault');
@@ -766,7 +837,7 @@ function App({ settings }: { settings: SettingsState }) {
const activeWs = workspaces.find(w => w.id === currentId);
if (activeSession && !activeSession.workspaceId) {
// Standalone session - split it
splitSession(activeSession.id, 'horizontal');
splitSessionWithCurrentShell(activeSession.id, 'horizontal');
} else if (activeWs) {
// In a workspace - need to determine focused session
// For now, we'll need the terminal to handle this via context menu
@@ -781,7 +852,7 @@ function App({ settings }: { settings: SettingsState }) {
const activeWs = workspaces.find(w => w.id === currentId);
if (activeSession && !activeSession.workspaceId) {
// Standalone session - split it
splitSession(activeSession.id, 'vertical');
splitSessionWithCurrentShell(activeSession.id, 'vertical');
} else if (activeWs) {
// In a workspace - need to determine focused session
if (IS_DEV) console.log('[Hotkey] Split vertical in workspace - use context menu on specific terminal');
@@ -821,7 +892,7 @@ function App({ settings }: { settings: SettingsState }) {
break;
}
}
}, [orderedTabs, sessions, workspaces, setActiveTabId, closeSession, closeWorkspace, createLocalTerminal, splitSession, moveFocusInWorkspace, toggleBroadcast]);
}, [orderedTabs, sessions, workspaces, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast]);
// Callback for terminal to invoke app-level hotkey actions
const handleHotkeyAction = useCallback((action: string, e: KeyboardEvent) => {
@@ -967,7 +1038,7 @@ function App({ settings }: { settings: SettingsState }) {
// Wrapper to create local terminal with logging
const handleCreateLocalTerminal = useCallback(() => {
const { username, hostname } = systemInfoRef.current;
const sessionId = createLocalTerminal();
const sessionId = createLocalTerminalWithCurrentShell();
addConnectionLog({
sessionId,
hostId: '',
@@ -980,7 +1051,7 @@ function App({ settings }: { settings: SettingsState }) {
localHostname: hostname,
saved: false,
});
}, [addConnectionLog, createLocalTerminal]);
}, [addConnectionLog, createLocalTerminalWithCurrentShell]);
// Wrapper to connect to host with logging
const handleConnectToHost = useCallback((host: Host) => {
@@ -1067,31 +1138,12 @@ function App({ settings }: { settings: SettingsState }) {
});
if (IS_DEV) console.log('[handleTerminalDataCapture] Updated log with terminalData');
// Auto-save session log if enabled
if (sessionLogsEnabled && sessionLogsDir && data) {
import('./infrastructure/services/netcattyBridge').then(({ netcattyBridge }) => {
const bridge = netcattyBridge.get();
if (bridge?.autoSaveSessionLog) {
bridge.autoSaveSessionLog({
terminalData: data,
hostLabel: matchingLog.hostLabel,
hostname: matchingLog.hostname,
hostId: matchingLog.hostId,
startTime: matchingLog.startTime,
format: sessionLogsFormat,
directory: sessionLogsDir,
}).then(result => {
if (IS_DEV) console.log('[handleTerminalDataCapture] Auto-save result:', result);
}).catch(err => {
console.error('[handleTerminalDataCapture] Auto-save failed:', err);
});
}
});
}
// Auto-save is now handled by real-time streaming in the main process
// via sessionLogStreamManager. No renderer-side fallback needed.
} else {
if (IS_DEV) console.log('[handleTerminalDataCapture] No matching log found!');
}
}, [sessions, connectionLogs, updateConnectionLog, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat]);
}, [sessions, connectionLogs, updateConnectionLog]);
// Check if host has multiple protocols enabled
const hasMultipleProtocols = useCallback((host: Host) => {
@@ -1209,7 +1261,7 @@ function App({ settings }: { settings: SettingsState }) {
}, []);
return (
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" onContextMenu={handleRootContextMenu}>
<div className={cn("flex flex-col h-screen text-foreground font-sans netcatty-shell", immersiveMode && activeTerminalTheme && "immersive-transition")} onContextMenu={handleRootContextMenu}>
<TopTabs
theme={resolvedTheme}
hosts={hosts}
@@ -1222,7 +1274,7 @@ function App({ settings }: { settings: SettingsState }) {
isMacClient={isMacClient}
onCloseSession={closeSession}
onRenameSession={startSessionRename}
onCopySession={copySession}
onCopySession={copySessionWithCurrentShell}
onRenameWorkspace={startWorkspaceRename}
onCloseWorkspace={closeWorkspace}
onCloseLogView={closeLogView}
@@ -1230,6 +1282,7 @@ function App({ settings }: { settings: SettingsState }) {
onToggleTheme={handleToggleTheme}
onOpenSettings={handleOpenSettings}
onSyncNow={handleSyncNowManual}
isImmersiveActive={immersiveMode && activeTerminalTheme !== null}
onStartSessionDrag={setDraggingSessionId}
onEndSessionDrag={handleEndSessionDrag}
onReorderTabs={reorderTabs}
@@ -1316,7 +1369,7 @@ function App({ settings }: { settings: SettingsState }) {
onSetDraggingSessionId={setDraggingSessionId}
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
onSetWorkspaceFocusedSession={setWorkspaceFocusedSession}
onSplitSession={splitSession}
onSplitSession={splitSessionWithCurrentShell}
isBroadcastEnabled={isBroadcastEnabled}
onToggleBroadcast={toggleBroadcast}
updateHosts={updateHosts}
@@ -1324,8 +1377,12 @@ function App({ settings }: { settings: SettingsState }) {
sftpAutoSync={sftpAutoSync}
sftpShowHiddenFiles={sftpShowHiddenFiles}
sftpUseCompressedUpload={sftpUseCompressedUpload}
sftpAutoOpenSidebar={sftpAutoOpenSidebar}
editorWordWrap={editorWordWrap}
setEditorWordWrap={setEditorWordWrap}
sessionLogsEnabled={sessionLogsEnabled}
sessionLogsDir={sessionLogsDir}
sessionLogsFormat={sessionLogsFormat}
/>
{/* Log Views - readonly terminal replays */}

View File

@@ -59,6 +59,8 @@
- [ビルドとパッケージ](#ビルドとパッケージ)
- [技術スタック](#技術スタック)
- [コントリビューション](#コントリビューション)
- [コントリビューター](#コントリビューター)
- [Star 履歴](#star-履歴)
- [ライセンス](#ライセンス)
---
@@ -110,37 +112,37 @@
<a name="デモ"></a>
# デモ
GIF で機能をさっと確認できます(素材は `screenshots/gifs/`
動画で機能をさっと確認できます(素材は `screenshots/gifs/`
### Vault ビュー:グリッド / リスト / ツリー
状況に合わせて見え方を切り替え。グリッドで全体像、リストで密度、ツリーで階層を扱えます。
![Vault ビュー:グリッド/リスト/ツリー](screenshots/gifs/gird-list-tre-views.gif)
https://github.com/user-attachments/assets/e2742987-3131-404d-bd4b-06423e5bfd99
### 分割ターミナル + セッション管理
複数セッションを分割ペインで並べて作業。関連タスクを横並びにしてコンテキストスイッチを減らします。
![分割ターミナル + セッション管理](screenshots/gifs/dual-terminal--split-manage.gif)
https://github.com/user-attachments/assets/377d0c46-cc5a-4382-aa31-5acfd412ce62
### SFTPドラッグドロップ + 内蔵エディタ
ドラッグ&ドロップでファイルを移動し、内蔵エディタでそのまま編集できます。
![SFTPドラッグドロップ + 内蔵エディタ](screenshots/gifs/sftpview-with-drag-and-built-in-editor.gif)
https://github.com/user-attachments/assets/c6e06af4-b0d5-461c-b0c7-9d6f655af6c7
### ドラッグでアップロード
ファイルをそのままドロップしてアップロードを開始。ダイアログ操作を減らせます。
![ドラッグでアップロード](screenshots/gifs/drag-file-upload.gif)
https://github.com/user-attachments/assets/c8e0c4ff-f020-4e18-9b09-681ec97b003f
### カスタムテーマ
テーマを調整して自分の好みに合わせた見た目に。
![カスタムテーマ](screenshots/gifs/custom-themes.gif)
https://github.com/user-attachments/assets/77e2a693-4ef2-4823-8ca1-9bcbf14ed98b
### キーワードハイライト
重要な出力(エラー/警告/マーカーなど)を見つけやすくするために、ハイライトをカスタマイズできます。
![キーワードハイライト](screenshots/gifs/custom-highlight.gif)
https://github.com/user-attachments/assets/e6516993-ad66-4594-8c28-57426082339b
---
@@ -196,6 +198,7 @@ Netcatty は接続したホストの OS を検出し、ホスト一覧でアイ
<img src="public/distro/opensuse.svg" width="48" alt="openSUSE" title="openSUSE">
<img src="public/distro/oracle.svg" width="48" alt="Oracle Linux" title="Oracle Linux">
<img src="public/distro/kali.svg" width="48" alt="Kali Linux" title="Kali Linux">
<img src="public/distro/almalinux.svg" width="48" alt="AlmaLinux" title="AlmaLinux">
</p>
---
@@ -305,6 +308,17 @@ npm run pack:linux # Linux (AppImage + DEB + RPM)
---
<a name="コントリビューター"></a>
# コントリビューター
貢献してくださったすべての方に感謝します!
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" />
</a>
---
<a name="ライセンス"></a>
# ライセンス
@@ -312,6 +326,19 @@ npm run pack:linux # Linux (AppImage + DEB + RPM)
---
<a name="star-履歴"></a>
# Star 履歴
<a href="https://star-history.com/#binaricat/Netcatty&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date" />
</picture>
</a>
---
<p align="center">
❤️ を込めて作成 by <a href="https://ko-fi.com/binaricat">binaricat</a>
</p>

View File

@@ -5,13 +5,13 @@
<h1 align="center">Netcatty</h1>
<p align="center">
<strong>Modern SSH Client, SFTP Browser & Terminal Manager</strong><br/>
<strong>🔥 AI-Powered SSH Client, SFTP Browser & Terminal Manager 🚀</strong><br/>
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
</p>
<p align="center">
A beautiful, feature-rich SSH workspace built with Electron, React, and xterm.js.<br/>
Split terminals, Vault views, SFTP workflows, custom themes, and keyword highlighting — all in one.
🔥 Built-in AI Agent · Split terminals · Vault views · SFTP workflows · Custom themes — all in one.
</p>
<p align="center">
@@ -42,10 +42,52 @@
[![Netcatty Main Interface](screenshots/main-window-dark.png)](screenshots/main-window-dark.png)
---
<a name="catty-agent"></a>
# 🔥 Catty Agent — Your IT Ops AI Partner
> 🚀 **Boost your IT ops daily work with AI power.** Catty Agent is the built-in AI assistant that understands your servers, executes commands, and handles complex multi-host operations — all through natural conversation.
<p align="center">
<img src="screenshots/ai-feature.png" alt="Catty Agent Interface" width="800">
</p>
### 🔥 What can Catty Agent do?
- 🚀 **Natural language server management** — just tell it what you need, no more memorizing commands
- 🔥 **Real-time server diagnostics** — check status, inspect logs, monitor resources through conversation
- 🚀 **Multi-host orchestration** — coordinate tasks across multiple servers simultaneously
- 🔥 **Intelligent context awareness** — understands your server environment and provides tailored responses
- 🚀 **One-click complex operations** — set up clusters, deploy services, and more with simple instructions
### 🎬 AI in Action
#### 🔥 Single Host — Intelligent Server Diagnostics
Ask Catty Agent to check a server's health, and it runs the right commands, analyzes the output, and gives you a clear summary — all in seconds.
https://github.com/user-attachments/assets/eecf08f1-80bd-49db-886d-b36e93388865
#### 🚀 Multi-Host — Docker Swarm Cluster Setup
Watch Catty Agent orchestrate a Docker Swarm cluster across two servers in one conversation. It handles the init, token exchange, and node joining — you just tell it what you want.
https://github.com/user-attachments/assets/282027aa-5c9e-4bb1-b2c3-5eea9df2b203
---
# Contents <!-- omit in toc -->
- [🔥 Catty Agent — AI Partner](#catty-agent)
- [What is Netcatty](#what-is-netcatty)
- [Why Netcatty](#why-netcatty)
- [Features](#features)
@@ -59,6 +101,8 @@
- [Build & Package](#build--package)
- [Tech Stack](#tech-stack)
- [Contributing](#contributing)
- [Contributors](#contributors)
- [Star History](#star-history)
- [License](#license)
---
@@ -111,37 +155,53 @@ If you regularly work with a fleet of servers, Netcatty is built for speed and f
<a name="demos"></a>
# Demos
GIF previews (stored in `screenshots/gifs/`), rendered inline on GitHub:
Video previews (stored in `screenshots/gifs/`), rendered inline on GitHub:
### Vault views: grid / list / tree
Switch between different Vault views to match your workflow: overview in grid, dense scanning in list, and hierarchical navigation in tree.
![Vault views: grid/list/tree](screenshots/gifs/gird-list-tre-views.gif)
https://github.com/user-attachments/assets/e2742987-3131-404d-bd4b-06423e5bfd99
### Split terminals + session management
Work in multiple sessions at once with split panes. Keep related tasks side-by-side and reduce context switching.
![Split terminals + session management](screenshots/gifs/dual-terminal--split-manage.gif)
https://github.com/user-attachments/assets/377d0c46-cc5a-4382-aa31-5acfd412ce62
### SFTP: drag & drop + built-in editor
Move files with drag & drop, then edit quickly using the built-in editor without leaving the app.
![SFTP: drag & drop + built-in editor](screenshots/gifs/sftpview-with-drag-and-built-in-editor.gif)
https://github.com/user-attachments/assets/c6e06af4-b0d5-461c-b0c7-9d6f655af6c7
### Drag file upload
Drop files into the app to kick off uploads without hunting through dialogs.
![Drag file upload](screenshots/gifs/drag-file-upload.gif)
https://github.com/user-attachments/assets/c8e0c4ff-f020-4e18-9b09-681ec97b003f
### Custom themes
Make Netcatty yours: customize themes and UI appearance.
![Custom themes](screenshots/gifs/custom-themes.gif)
https://github.com/user-attachments/assets/77e2a693-4ef2-4823-8ca1-9bcbf14ed98b
### Keyword highlighting
Highlight important terminal output so errors, warnings, and key events stand out at a glance.
![Keyword highlighting](screenshots/gifs/custom-highlight.gif)
https://github.com/user-attachments/assets/e6516993-ad66-4594-8c28-57426082339b
---
@@ -197,6 +257,7 @@ Netcatty automatically detects and displays OS icons for connected hosts:
<img src="public/distro/opensuse.svg" width="48" alt="openSUSE" title="openSUSE">
<img src="public/distro/oracle.svg" width="48" alt="Oracle Linux" title="Oracle Linux">
<img src="public/distro/kali.svg" width="48" alt="Kali Linux" title="Kali Linux">
<img src="public/distro/almalinux.svg" width="48" alt="AlmaLinux" title="AlmaLinux">
</p>
<a name="getting-started"></a>
@@ -309,7 +370,9 @@ See [agents.md](agents.md) for architecture overview and coding conventions.
Thanks to all the people who contribute!
See: https://github.com/binaricat/Netcatty/graphs/contributors
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" />
</a>
---
@@ -320,6 +383,19 @@ This project is licensed under the **GPL-3.0 License** - see the [LICENSE](LICEN
---
<a name="star-history"></a>
# Star History
<a href="https://star-history.com/#binaricat/Netcatty&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date" />
</picture>
</a>
---
<p align="center">
Made with ❤️ by <a href="https://ko-fi.com/binaricat">binaricat</a>
</p>

View File

@@ -59,6 +59,8 @@
- [构建与打包](#构建与打包)
- [技术栈](#技术栈)
- [参与贡献](#参与贡献)
- [贡献者](#贡献者)
- [Star 历史](#star-历史)
- [开源协议](#开源协议)
---
@@ -111,37 +113,37 @@
<a name="演示"></a>
# 演示
GIF 预览(素材均在 `screenshots/gifs/`),在 GitHub README 中可直接观看:
视频预览(素材均在 `screenshots/gifs/`),在 GitHub README 中可直接观看:
### Vault 视图:网格 / 列表 / 树形
根据不同场景自由切换视图:网格适合总览,列表适合密集浏览,树形适合层级导航与整理。
![Vault 视图:网格/列表/树形](screenshots/gifs/gird-list-tre-views.gif)
https://github.com/user-attachments/assets/e2742987-3131-404d-bd4b-06423e5bfd99
### 分屏终端 + 会话管理
用分屏把多个会话并排放在同一个工作区里,降低来回切换窗口/标签页的成本。
![分屏终端 + 会话管理](screenshots/gifs/dual-terminal--split-manage.gif)
https://github.com/user-attachments/assets/377d0c46-cc5a-4382-aa31-5acfd412ce62
### SFTP拖拽 + 内置编辑器
通过拖拽完成文件传输,并用内置编辑器快速修改文件内容,不用来回切换工具。
![SFTP拖拽 + 内置编辑器](screenshots/gifs/sftpview-with-drag-and-built-in-editor.gif)
https://github.com/user-attachments/assets/c6e06af4-b0d5-461c-b0c7-9d6f655af6c7
### 拖拽文件上传
把文件直接拖进应用即可触发上传流程,省去多层对话框与路径选择。
![拖拽文件上传](screenshots/gifs/drag-file-upload.gif)
https://github.com/user-attachments/assets/c8e0c4ff-f020-4e18-9b09-681ec97b003f
### 自定义主题
按自己的审美与习惯定制主题与界面外观,让日常使用更顺手。
![自定义主题](screenshots/gifs/custom-themes.gif)
https://github.com/user-attachments/assets/77e2a693-4ef2-4823-8ca1-9bcbf14ed98b
### 关键词高亮
让关键输出一眼可见:错误、告警或特定标记被高亮后更容易扫到与定位。
![关键词高亮](screenshots/gifs/custom-highlight.gif)
https://github.com/user-attachments/assets/e6516993-ad66-4594-8c28-57426082339b
---
@@ -197,6 +199,7 @@ Netcatty 会自动识别并在主机列表中展示对应的系统图标:
<img src="public/distro/opensuse.svg" width="48" alt="openSUSE" title="openSUSE">
<img src="public/distro/oracle.svg" width="48" alt="Oracle Linux" title="Oracle Linux">
<img src="public/distro/kali.svg" width="48" alt="Kali Linux" title="Kali Linux">
<img src="public/distro/almalinux.svg" width="48" alt="AlmaLinux" title="AlmaLinux">
</p>
<a name="快速开始"></a>
@@ -309,7 +312,9 @@ npm run pack:linux # Linux (AppImage, deb, rpm)
感谢所有参与贡献的人!
查看:https://github.com/binaricat/Netcatty/graphs/contributors
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" />
</a>
---
@@ -320,6 +325,19 @@ npm run pack:linux # Linux (AppImage, deb, rpm)
---
<a name="star-历史"></a>
# Star 历史
<a href="https://star-history.com/#binaricat/Netcatty&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date" />
</picture>
</a>
---
<p align="center">
用 ❤️ 制作,作者 <a href="https://ko-fi.com/binaricat">binaricat</a>
</p>

View File

@@ -5,6 +5,9 @@ const en: Messages = {
'common.save': 'Save',
'common.cancel': 'Cancel',
'common.close': 'Close',
'common.reset': 'Reset',
'common.zoomIn': 'Zoom in',
'common.zoomOut': 'Zoom out',
'common.settings': 'Settings',
'common.search': 'Search',
'common.searchPlaceholder': 'Search...',
@@ -30,6 +33,7 @@ const en: Messages = {
'common.back': 'Back',
'common.apply': 'Apply',
'common.use': 'Use',
'common.useGlobal': 'Use global',
'common.saveChanges': 'Save Changes',
'common.advanced': 'Advanced',
'common.left': 'Left',
@@ -95,6 +99,21 @@ const en: Messages = {
'settings.system.credentials.unavailableHint': 'Credentials encrypted on another user profile or machine cannot be decrypted here. Re-enter and save credentials on this device.',
'settings.system.credentials.portabilityHint': 'Cloud Sync is portable because it uses your master key encryption. Local safeStorage encryption is device/user scoped.',
// Settings > System > Crash Logs
'settings.system.crashLogs.title': 'Crash Logs',
'settings.system.crashLogs.description': 'View error logs from the main process to help diagnose unexpected behavior.',
'settings.system.crashLogs.noLogs': 'No crash logs found.',
'settings.system.crashLogs.entries': '{count} entries',
'settings.system.crashLogs.clear': 'Clear all logs',
'settings.system.crashLogs.cleared': 'Cleared {count} log file(s).',
'settings.system.crashLogs.source': 'Source',
'settings.system.crashLogs.time': 'Time',
'settings.system.crashLogs.message': 'Message',
'settings.system.crashLogs.stack': 'Stack Trace',
'settings.system.crashLogs.hint': 'Crash logs are retained for 30 days and automatically rotated.',
'settings.system.crashLogs.collapse': 'Collapse',
'settings.system.crashLogs.expand': 'Show details',
// Settings > System > Software Update
'settings.update.title': 'Software Update',
'settings.update.currentVersion': 'Current version',
@@ -212,6 +231,9 @@ const en: Messages = {
'settings.appearance.themeColor.desc': 'Pick a preset palette for each theme',
'settings.appearance.themeColor.light': 'Light palette',
'settings.appearance.themeColor.dark': 'Dark palette',
'settings.appearance.immersiveMode': 'Immersive Mode',
'settings.appearance.immersiveMode.desc':
'When enabled, the UI chrome (tab bar, sidebar, status bar) adapts its colors to match the active terminal theme for a visually cohesive experience.',
'settings.appearance.customCss': 'Custom CSS',
'settings.appearance.customCss.desc':
'Add custom CSS to personalize the app appearance. Changes apply immediately.',
@@ -292,6 +314,9 @@ const en: Messages = {
'settings.terminal.behavior.scrollOnPaste': 'Scroll on paste',
'settings.terminal.behavior.scrollOnPaste.desc':
'Scroll terminal to bottom when pasting text',
'settings.terminal.behavior.smoothScrolling': 'Smooth scrolling',
'settings.terminal.behavior.smoothScrolling.desc':
'Animate terminal viewport scrolling instead of jumping instantly',
'settings.terminal.behavior.linkModifier': 'Link modifier key',
'settings.terminal.behavior.linkModifier.desc': 'Hold this key to click on links in terminal',
'settings.terminal.behavior.linkModifier.none': 'None (click directly)',
@@ -609,12 +634,14 @@ const en: Messages = {
'sftp.path.doubleClickToEdit': 'Double-click to edit path',
'sftp.showHiddenPaths': 'Hidden paths',
'sftp.task.waiting': 'Waiting...',
'sftp.transfer.preparing': 'preparing...',
'sftp.status.loading': 'Loading...',
'sftp.status.uploading': 'Uploading...',
'sftp.status.ready': 'Ready',
'sftp.transfers': 'Transfers',
'sftp.transfers.active': '{count} active',
'sftp.transfers.clearCompleted': 'Clear completed',
'sftp.transfers.calculatingTotal': 'Calculating total size...',
'sftp.goUp': 'Go up',
'sftp.goToTerminalCwd': 'Go to terminal directory',
'sftp.encoding.label': 'Filename Encoding',
@@ -688,6 +715,7 @@ const en: Messages = {
'sftp.upload.phase.compressed': 'Compressed',
// SFTP File Opener
'sftp.context.copyPath': 'Copy file path',
'sftp.context.openWith': 'Open with...',
'sftp.context.edit': 'Edit',
'sftp.context.preview': 'Preview',
@@ -745,6 +773,13 @@ const en: Messages = {
'settings.sftp.autoSync.desc': 'Automatically sync file changes back to the remote server when opening files with external applications',
'settings.sftp.autoSync.enable': 'Enable auto-sync',
'settings.sftp.autoSync.enableDesc': 'When you save a file in an external application, changes will be automatically uploaded to the remote server',
// Settings > SFTP Auto Open Sidebar
'settings.sftp.autoOpenSidebar': 'Auto-open sidebar on connect',
'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',
'sftp.autoSync.success': 'File synced to remote: {fileName}',
'sftp.autoSync.error': 'Failed to sync file: {error}',
@@ -832,6 +867,29 @@ const en: Messages = {
'hostDetails.section.credentials': 'Credentials',
'hostDetails.section.portCredentials': 'Port & Credentials',
'hostDetails.section.appearance': 'Appearance',
'hostDetails.distro.title': 'Linux Distribution',
'hostDetails.distro.desc': 'Auto-detect on connect, or override the distro icon manually.',
'hostDetails.distro.mode': 'Source',
'hostDetails.distro.mode.auto': 'Auto-detect',
'hostDetails.distro.mode.manual': 'Manual override',
'hostDetails.distro.detectedLabel': 'Current',
'hostDetails.distro.manualLabel': 'Override',
'hostDetails.distro.pending': 'Detect after first connection',
'hostDetails.distro.unknown': 'Unknown',
'hostDetails.distro.option.linux': 'Generic Linux',
'hostDetails.distro.option.ubuntu': 'Ubuntu',
'hostDetails.distro.option.debian': 'Debian',
'hostDetails.distro.option.centos': 'CentOS',
'hostDetails.distro.option.rocky': 'Rocky Linux',
'hostDetails.distro.option.fedora': 'Fedora',
'hostDetails.distro.option.arch': 'Arch Linux',
'hostDetails.distro.option.alpine': 'Alpine',
'hostDetails.distro.option.amazon': 'Amazon Linux',
'hostDetails.distro.option.opensuse': 'openSUSE / SLES',
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
'hostDetails.distro.option.almalinux': 'AlmaLinux',
'hostDetails.distro.option.oracle': 'Oracle Linux',
'hostDetails.distro.option.kali': 'Kali Linux',
'hostDetails.section.mosh': 'Mosh',
'hostDetails.username.placeholder': 'Username',
'hostDetails.password.placeholder': 'Password',
@@ -840,9 +898,12 @@ const en: Messages = {
'hostDetails.password.save': 'Save password',
'hostDetails.identity.suggestions': 'Identities',
'hostDetails.identity.missing': 'Identity not found',
'hostDetails.credential.keyCertificate': 'Key, Certificate',
'hostDetails.credential.keyCertificate': 'Key, Certificate, Local Key File',
'hostDetails.credential.key': 'Key',
'hostDetails.credential.certificate': 'Certificate',
'hostDetails.credential.localKeyFile': 'Local Key File',
'hostDetails.credential.localKeyFilePlaceholder': '~/.ssh/id_ed25519',
'hostDetails.credential.browseKeyFile': 'Browse...',
'hostDetails.credential.missing': 'Credential not found',
'hostDetails.keys.search': 'Search keys...',
'hostDetails.keys.empty': 'No keys available',
@@ -1048,6 +1109,7 @@ const en: Messages = {
'terminal.progress.disconnected': 'Disconnected',
'terminal.progress.cancelling': 'Cancelling...',
'terminal.progress.startOver': 'Start over',
'terminal.connection.dismissDisconnectedDialog': 'Dismiss disconnected notice',
'terminal.connection.chainOf': 'Chain {current} of {total}',
'terminal.connection.showLogs': 'Show logs',
'terminal.connection.hideLogs': 'Hide logs',
@@ -1060,6 +1122,8 @@ const en: Messages = {
'terminal.themeModal.tab.theme': 'Theme',
'terminal.themeModal.tab.font': 'Font',
'terminal.themeModal.tab.custom': 'Custom',
'terminal.themeModal.globalTheme': 'Global Theme',
'terminal.themeModal.globalFont': 'Global Font',
'terminal.themeModal.fontSize': 'Font Size',
'terminal.themeModal.livePreview': 'Live Preview',
'terminal.themeModal.themeType': '{type} theme',
@@ -1548,7 +1612,7 @@ const en: Messages = {
// AI Claude Code
'ai.claude.title': 'Claude Code',
'ai.claude.description': "Anthropic's agentic coding assistant. Uses claude-code-acp for ACP protocol streaming.",
'ai.claude.description': "Anthropic's agentic coding assistant. Uses claude-agent-acp for ACP protocol streaming.",
'ai.claude.detecting': 'Detecting...',
'ai.claude.detected': 'Detected',
'ai.claude.notFound': 'Not found',
@@ -1565,7 +1629,6 @@ const en: Messages = {
// AI Chat
'ai.chat.noProvider': 'No AI provider is configured. Go to **Settings → AI → Providers** to add and enable a provider.',
'ai.chat.toolDenied': 'Action was rejected by the user.',
'ai.chat.toolApprovalTitle': 'Permission Required',
'ai.chat.toolApproved': 'Approved',
'ai.chat.toolApprovalHint': 'Press Enter to approve, Escape to reject',
'ai.chat.approve': 'Approve',

View File

@@ -5,6 +5,9 @@ const zhCN: Messages = {
'common.save': '保存',
'common.cancel': '取消',
'common.close': '关闭',
'common.reset': '重置',
'common.zoomIn': '放大',
'common.zoomOut': '缩小',
'common.settings': '设置',
'common.search': '搜索',
'common.connect': '连接',
@@ -20,6 +23,7 @@ const zhCN: Messages = {
'common.back': '返回',
'common.apply': '应用',
'common.use': '使用',
'common.useGlobal': '跟随全局',
'common.left': '左侧',
'common.right': '右侧',
'common.more': '更多',
@@ -79,6 +83,21 @@ const zhCN: Messages = {
'settings.system.credentials.unavailableHint': '在其他用户或机器上加密的凭据无法在此处解密。请在当前设备重新输入并保存凭据。',
'settings.system.credentials.portabilityHint': '云同步可跨设备,因为使用主密钥加密;本地 safeStorage 加密仅绑定当前系统用户/设备。',
// Settings > System > Crash Logs
'settings.system.crashLogs.title': '崩溃日志',
'settings.system.crashLogs.description': '查看主进程错误日志,帮助诊断异常行为。',
'settings.system.crashLogs.noLogs': '未找到崩溃日志。',
'settings.system.crashLogs.entries': '{count} 条记录',
'settings.system.crashLogs.clear': '清除所有日志',
'settings.system.crashLogs.cleared': '已清除 {count} 个日志文件。',
'settings.system.crashLogs.source': '来源',
'settings.system.crashLogs.time': '时间',
'settings.system.crashLogs.message': '消息',
'settings.system.crashLogs.stack': '堆栈跟踪',
'settings.system.crashLogs.hint': '崩溃日志保留 30 天,超期自动清理。',
'settings.system.crashLogs.collapse': '收起',
'settings.system.crashLogs.expand': '查看详情',
// Settings > System > Software Update
'settings.update.title': '软件更新',
'settings.update.currentVersion': '当前版本',
@@ -196,6 +215,9 @@ const zhCN: Messages = {
'settings.appearance.themeColor.desc': '为浅色与深色主题选择预设配色',
'settings.appearance.themeColor.light': '浅色主题',
'settings.appearance.themeColor.dark': '深色主题',
'settings.appearance.immersiveMode': '沉浸模式',
'settings.appearance.immersiveMode.desc':
'启用后UI 外观(标签栏、侧边栏、状态栏)会自动适配当前终端主题的配色,营造视觉一体化的沉浸体验。',
'settings.appearance.customCss': '自定义 CSS',
'settings.appearance.customCss.desc': '使用自定义 CSS 个性化界面,修改会立即生效。',
'settings.appearance.customCss.placeholder':
@@ -436,12 +458,14 @@ const zhCN: Messages = {
'sftp.path.doubleClickToEdit': '双击编辑路径',
'sftp.showHiddenPaths': '隐藏的路径',
'sftp.task.waiting': '等待中...',
'sftp.transfer.preparing': '准备中...',
'sftp.status.loading': '加载中...',
'sftp.status.uploading': '上传中...',
'sftp.status.ready': '就绪',
'sftp.transfers': '传输',
'sftp.transfers.active': '{count} 个进行中',
'sftp.transfers.clearCompleted': '清除已完成',
'sftp.transfers.calculatingTotal': '正在统计总大小...',
'sftp.goUp': '上一级',
'sftp.goToTerminalCwd': '定位到终端当前目录',
'sftp.encoding.label': '文件名编码',
@@ -531,6 +555,29 @@ const zhCN: Messages = {
'hostDetails.section.credentials': '凭据',
'hostDetails.section.portCredentials': '端口与凭据',
'hostDetails.section.appearance': '外观',
'hostDetails.distro.title': 'Linux 发行版',
'hostDetails.distro.desc': '可在连接后自动探测,也可以手动覆盖图标所用的发行版。',
'hostDetails.distro.mode': '来源',
'hostDetails.distro.mode.auto': '自动探测',
'hostDetails.distro.mode.manual': '手动覆盖',
'hostDetails.distro.detectedLabel': '当前值',
'hostDetails.distro.manualLabel': '手动指定',
'hostDetails.distro.pending': '首次连接后自动探测',
'hostDetails.distro.unknown': '未知',
'hostDetails.distro.option.linux': '通用 Linux',
'hostDetails.distro.option.ubuntu': 'Ubuntu',
'hostDetails.distro.option.debian': 'Debian',
'hostDetails.distro.option.centos': 'CentOS',
'hostDetails.distro.option.rocky': 'Rocky Linux',
'hostDetails.distro.option.fedora': 'Fedora',
'hostDetails.distro.option.arch': 'Arch Linux',
'hostDetails.distro.option.alpine': 'Alpine',
'hostDetails.distro.option.amazon': 'Amazon Linux',
'hostDetails.distro.option.opensuse': 'openSUSE / SLES',
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
'hostDetails.distro.option.almalinux': 'AlmaLinux',
'hostDetails.distro.option.oracle': 'Oracle Linux',
'hostDetails.distro.option.kali': 'Kali Linux',
'hostDetails.section.mosh': 'Mosh',
'hostDetails.username.placeholder': '用户名',
'hostDetails.password.placeholder': '密码',
@@ -539,9 +586,12 @@ const zhCN: Messages = {
'hostDetails.password.save': '保存密码',
'hostDetails.identity.suggestions': '身份',
'hostDetails.identity.missing': '身份不存在',
'hostDetails.credential.keyCertificate': '密钥 / 证书',
'hostDetails.credential.keyCertificate': '密钥 / 证书 / 本地密钥',
'hostDetails.credential.key': '密钥',
'hostDetails.credential.certificate': '证书',
'hostDetails.credential.localKeyFile': '本地密钥文件',
'hostDetails.credential.localKeyFilePlaceholder': '~/.ssh/id_ed25519',
'hostDetails.credential.browseKeyFile': '浏览…',
'hostDetails.credential.missing': '凭据不存在',
'hostDetails.keys.search': '搜索密钥…',
'hostDetails.keys.empty': '暂无密钥',
@@ -719,6 +769,7 @@ const zhCN: Messages = {
'terminal.progress.disconnected': '已断开',
'terminal.progress.cancelling': '正在取消...',
'terminal.progress.startOver': '重新开始',
'terminal.connection.dismissDisconnectedDialog': '关闭断连提示',
'terminal.connection.chainOf': 'Chain {current} / {total}',
'terminal.connection.showLogs': '显示日志',
'terminal.connection.hideLogs': '隐藏日志',
@@ -731,6 +782,8 @@ const zhCN: Messages = {
'terminal.themeModal.tab.theme': '主题',
'terminal.themeModal.tab.font': '字体',
'terminal.themeModal.tab.custom': '自定义',
'terminal.themeModal.globalTheme': '全局主题',
'terminal.themeModal.globalFont': '全局字体',
'terminal.themeModal.fontSize': '字体大小',
'terminal.themeModal.livePreview': '实时预览',
'terminal.themeModal.themeType': '{type} 主题',
@@ -1003,6 +1056,7 @@ const zhCN: Messages = {
'sftp.upload.phase.compressed': '压缩传输',
// SFTP File Opener
'sftp.context.copyPath': '复制文件路径',
'sftp.context.openWith': '打开方式...',
'sftp.context.edit': '编辑',
'sftp.context.preview': '预览',
@@ -1060,6 +1114,13 @@ const zhCN: Messages = {
'settings.sftp.autoSync.desc': '使用外部应用程序打开文件时,自动将文件更改同步回远程服务器',
'settings.sftp.autoSync.enable': '启用自动同步',
'settings.sftp.autoSync.enableDesc': '在外部应用程序中保存文件时,更改将自动上传到远程服务器',
// Settings > SFTP 自动打开侧栏
'settings.sftp.autoOpenSidebar': '连接时自动打开侧栏',
'settings.sftp.autoOpenSidebar.desc': '连接到主机时自动打开 SFTP 文件浏览器侧栏',
'settings.sftp.autoOpenSidebar.enable': '启用自动打开侧栏',
'settings.sftp.autoOpenSidebar.enableDesc': '当终端会话连接到远程主机时SFTP 侧栏将自动打开',
'sftp.autoSync.success': '文件已同步到远程:{fileName}',
'sftp.autoSync.error': '同步文件失败:{error}',
@@ -1165,6 +1226,8 @@ const zhCN: Messages = {
'settings.terminal.behavior.scrollOnKeyPress.desc': '按键(例如 Enter时将终端滚动到底部',
'settings.terminal.behavior.scrollOnPaste': '粘贴时自动滚动',
'settings.terminal.behavior.scrollOnPaste.desc': '粘贴文本时将终端滚动到底部',
'settings.terminal.behavior.smoothScrolling': '平滑滚动',
'settings.terminal.behavior.smoothScrolling.desc': '滚动终端视口时使用平滑动画',
'settings.terminal.behavior.linkModifier': '链接修饰键',
'settings.terminal.behavior.linkModifier.desc': '按住此键再点击终端中的链接',
'settings.terminal.behavior.linkModifier.none': '无(直接点击)',
@@ -1563,7 +1626,7 @@ const zhCN: Messages = {
// AI Claude Code
'ai.claude.title': 'Claude Code',
'ai.claude.description': 'Anthropic 的智能编程助手。使用 claude-code-acp 进行 ACP 协议流式传输。',
'ai.claude.description': 'Anthropic 的智能编程助手。使用 claude-agent-acp 进行 ACP 协议流式传输。',
'ai.claude.detecting': '检测中...',
'ai.claude.detected': '已检测到',
'ai.claude.notFound': '未找到',
@@ -1580,7 +1643,6 @@ const zhCN: Messages = {
// AI Chat
'ai.chat.noProvider': '尚未配置 AI 提供商。请前往 **设置 → AI → 提供商** 添加并启用一个提供商。',
'ai.chat.toolDenied': '操作已被用户拒绝。',
'ai.chat.toolApprovalTitle': '需要权限确认',
'ai.chat.toolApproved': '已批准',
'ai.chat.toolApprovalHint': '按回车批准,按 Esc 拒绝',
'ai.chat.approve': '批准',

View File

@@ -7,6 +7,7 @@ export interface SftpPane {
loading: boolean;
reconnecting: boolean;
error: string | null;
connectionLogs: string[];
selectedFiles: Set<string>;
filter: string;
filenameEncoding: SftpFilenameEncoding;
@@ -33,6 +34,7 @@ export const createEmptyPane = (
loading: false,
reconnecting: false,
error: null,
connectionLogs: [],
selectedFiles: new Set(),
filter: "",
filenameEncoding: "auto",

View File

@@ -34,7 +34,7 @@ interface UseSftpConnectionsParams {
}
interface UseSftpConnectionsResult {
connect: (side: "left" | "right", host: Host | "local", options?: { forceNewTab?: boolean }) => Promise<void>;
connect: (side: "left" | "right", host: Host | "local", options?: { forceNewTab?: boolean; onTabCreated?: (tabId: string) => void }) => Promise<void>;
disconnect: (side: "left" | "right") => Promise<void>;
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
@@ -69,7 +69,7 @@ export const useSftpConnections = ({
const { listLocalFiles, listRemoteFiles } = useSftpDirectoryListing();
const connect = useCallback(
async (side: "left" | "right", host: Host | "local", options?: { forceNewTab?: boolean }) => {
async (side: "left" | "right", host: Host | "local", options?: { forceNewTab?: boolean; onTabCreated?: (tabId: string) => void }) => {
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
let activeTabId: string | null = null;
@@ -88,6 +88,11 @@ export const useSftpConnections = ({
if (!activeTabId) return;
// Notify caller of the tab ID synchronously, before any async work.
// This allows callers to map metadata (e.g. connection keys) to the tab
// immediately, avoiding race conditions with deferred effects.
options?.onTabCreated?.(activeTabId);
const connectionId = `${side}-${Date.now()}`;
navSeqRef.current[side] += 1;
@@ -118,12 +123,15 @@ export const useSftpConnections = ({
if (currentPane?.connection && !currentPane.connection.isLocal) {
const oldSftpId = sftpSessionsRef.current.get(currentPane.connection.id);
if (oldSftpId) {
// Delete the mapping BEFORE the async closeSftp call to prevent
// concurrent code from using a stale sftpId that the backend may
// have already removed during the await.
sftpSessionsRef.current.delete(currentPane.connection.id);
try {
await netcattyBridge.get()?.closeSftp(oldSftpId);
} catch {
// Ignore errors when closing stale SFTP sessions
}
sftpSessionsRef.current.delete(currentPane.connection.id);
}
}
}
@@ -151,6 +159,7 @@ export const useSftpConnections = ({
loading: true,
reconnecting: false,
error: null,
connectionLogs: [],
filenameEncoding, // Reset encoding for new connection
}));
@@ -205,13 +214,57 @@ export const useSftpConnections = ({
loading: true,
reconnecting: prev.reconnecting,
error: null,
connectionLogs: [],
files: prev.reconnecting ? prev.files : (sharedHostCache?.files ?? []),
filenameEncoding, // Reset encoding for new connection
}));
// Subscribe to SFTP connection progress events for auth logging
const sftpSessionId = `sftp-${connectionId}`;
let unsubSftpProgress: (() => void) | undefined;
const bridge = netcattyBridge.get();
if (bridge?.onSftpConnectionProgress) {
unsubSftpProgress = bridge.onSftpConnectionProgress((sid, label, status, detail) => {
if (sid !== sftpSessionId) return;
let logLine: string;
switch (status) {
case 'connecting':
logLine = `Connecting to ${label}...`;
break;
case 'authenticating':
logLine = `${label} - Key exchange complete`;
break;
case 'auth-attempt':
if (detail?.endsWith('rejected')) {
logLine = `${label} - ✗ ${detail}`;
} else if (detail === 'all methods exhausted') {
logLine = `${label} - ✗ All authentication methods exhausted`;
} else if (detail === 'waiting for user input...' || detail === 'user responded') {
logLine = `${label} - ${detail}`;
} else {
logLine = `${label} - Trying ${detail}...`;
}
break;
case 'connected':
logLine = `${label} - Connected`;
break;
case 'error':
logLine = `${label} - Error${detail ? `: ${detail}` : ''}`;
break;
default:
logLine = `${label} - ${status}${detail ? `: ${detail}` : ''}`;
}
// Only update if this is still the active request (avoids stale logs leaking)
if (navSeqRef.current[side] !== connectRequestId) return;
updateTab(side, activeTabId, (prev) => ({
...prev,
connectionLogs: [...prev.connectionLogs, logLine],
}));
});
}
try {
const credentials = getHostCredentials(host);
const bridge = netcattyBridge.get();
const openSftp = bridge?.openSftp;
if (!openSftp) throw new Error("SFTP bridge unavailable");
@@ -270,8 +323,24 @@ export const useSftpConnections = ({
let homeDir = sharedHostCache?.homeDir ?? startPath;
if (!sharedHostCache) {
const statSftp = netcattyBridge.get()?.statSftp;
if (statSftp) {
// Detect home directory: SSH exec `echo ~` → SFTP realpath('.') → hardcoded fallback
const bridge = netcattyBridge.get();
let detected = false;
if (bridge?.getSftpHomeDir) {
try {
const result = await bridge.getSftpHomeDir(sftpId);
if (result?.success && result.homeDir) {
startPath = result.homeDir;
homeDir = result.homeDir;
detected = true;
}
} catch {
// Fall through to hardcoded candidates
}
}
if (!detected) {
const candidates: string[] = [];
if (credentials.username === "root") {
candidates.push("/root");
@@ -281,63 +350,33 @@ export const useSftpConnections = ({
} else {
candidates.push("/root");
}
for (const candidate of candidates) {
try {
const stat = await statSftp(sftpId, candidate, filenameEncoding);
if (stat?.type === "directory") {
startPath = candidate;
homeDir = candidate;
break;
}
} catch {
// Ignore missing/permission errors
}
}
} else {
if (credentials.username === "root") {
try {
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
if (rootFiles) {
startPath = "/root";
homeDir = "/root";
}
} catch {
// Fallback path not available
}
} else if (credentials.username) {
try {
const homeFiles = await netcattyBridge.get()?.listSftp(
sftpId,
`/home/${credentials.username}`,
filenameEncoding,
);
if (homeFiles) {
startPath = `/home/${credentials.username}`;
homeDir = startPath;
}
} catch {
// Fall through to /root check
}
if (startPath === "/") {
const statSftp = bridge?.statSftp;
if (statSftp) {
for (const candidate of candidates) {
try {
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
if (rootFiles) {
startPath = "/root";
homeDir = "/root";
const stat = await statSftp(sftpId, candidate, filenameEncoding);
if (stat?.type === "directory") {
startPath = candidate;
homeDir = candidate;
break;
}
} catch {
// Fallback path not available
// Ignore missing/permission errors
}
}
} else {
try {
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
if (rootFiles) {
startPath = "/root";
homeDir = "/root";
// Fallback: probe candidates via listSftp when statSftp is unavailable
for (const candidate of candidates) {
try {
const files = await bridge?.listSftp(sftpId, candidate, filenameEncoding);
if (files) {
startPath = candidate;
homeDir = candidate;
break;
}
} catch {
// Ignore missing/permission errors
}
} catch {
// Fallback path not available
}
}
}
@@ -413,6 +452,7 @@ export const useSftpConnections = ({
files,
loading: false,
reconnecting: false,
connectionLogs: [], // Clear after successful connect to avoid replay during navigation
}));
} catch (err) {
if (navSeqRef.current[side] !== connectRequestId) return;
@@ -430,6 +470,8 @@ export const useSftpConnections = ({
loading: false,
reconnecting: false,
}));
} finally {
unsubSftpProgress?.();
}
}
},

View File

@@ -49,6 +49,7 @@ export const useSftpDirectoryListing = () => {
sizeFormatted: formatFileSize(size),
lastModified,
lastModifiedFormatted: formatDate(lastModified),
permissions: f.permissions,
linkTarget: f.linkTarget as "file" | "directory" | null | undefined,
};
});

View File

@@ -20,7 +20,7 @@ export type { UploadResult };
interface UseSftpExternalOperationsParams {
getActivePane: (side: "left" | "right") => SftpPane | null;
refresh: (side: "left" | "right") => Promise<void>;
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
connectionCacheKeyMapRef: React.MutableRefObject<Map<string, string>>;
clearDirCacheEntry?: (connectionId: string, path: string) => void;
@@ -524,6 +524,7 @@ export const useSftpExternalOperations = (
throw new Error("SFTP session not found");
}
const uploadPaneId = pane.id;
// Create a new upload controller for this upload
const controller = new UploadController();
uploadControllerRef.current = controller;
@@ -550,7 +551,7 @@ export const useSftpExternalOperations = (
controller
);
await refresh(side);
await refresh(side, { tabId: uploadPaneId });
return results;
} catch (error) {
logger.error("[SFTP] Upload failed:", error);
@@ -594,6 +595,9 @@ export const useSftpExternalOperations = (
throw new Error("SFTP session not found");
}
// Capture the pane ID now so we can refresh the correct tab after
// upload, even if focus switches during the transfer.
const uploadPaneId = pane.id;
const controller = new UploadController();
uploadControllerRef.current = controller;
const uploadTargetPath = options?.targetPath || pane.connection.currentPath;
@@ -623,17 +627,14 @@ export const useSftpExternalOperations = (
controller,
);
// Refresh the current directory and invalidate the upload target's
// cache entry. If the user navigated away during the upload, the
// invalidation ensures returning to the target path triggers a fresh
// listing instead of serving stale cached data.
const livePane = getActivePane(side);
if (livePane?.connection) {
if (livePane.connection.currentPath !== uploadTargetPath && clearDirCacheEntry) {
clearDirCacheEntry(livePane.connection.id, uploadTargetPath);
}
await refresh(side);
// Refresh the specific tab that initiated the upload (not whichever
// tab is active now — focus may have switched during the transfer).
// Also invalidate the upload target's cache entry so returning to
// that path triggers a fresh listing.
if (clearDirCacheEntry) {
clearDirCacheEntry(pane.connection.id, uploadTargetPath);
}
await refresh(side, { tabId: uploadPaneId });
return results;
} catch (error) {
logger.error("[SFTP] Upload failed:", error);

View File

@@ -1,5 +1,6 @@
import { useCallback } from "react";
import type { Host, Identity, SSHKey } from "../../../domain/models";
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "../../../domain/credentials";
import { resolveHostAuth } from "../../../domain/sshAuth";
interface UseSftpHostCredentialsParams {
@@ -24,22 +25,32 @@ export const useSftpHostCredentials = ({
host: host.proxyConfig.host,
port: host.proxyConfig.port,
username: host.proxyConfig.username,
password: host.proxyConfig.password,
password: sanitizeCredentialValue(host.proxyConfig.password),
}
: undefined;
let jumpHosts: NetcattyJumpHost[] | undefined;
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
jumpHosts = host.hostChain.hostIds
.map((hostId) => hosts.find((h) => h.id === hostId))
.filter((h): h is Host => !!h)
.map((jumpHost) => {
.map((jumpHost, index) => {
const jumpAuth = resolveHostAuth({
host: jumpHost,
keys,
identities,
});
const jumpKey = jumpAuth.key;
const hasConfiguredJumpProxyEndpoint =
index === 0 &&
!!(jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port);
if (
hasConfiguredJumpProxyEndpoint &&
jumpHost.proxyConfig?.username &&
isEncryptedCredentialPlaceholder(jumpHost.proxyConfig.password) &&
!sanitizeCredentialValue(jumpHost.proxyConfig.password)
) {
throw new Error(`Proxy credentials for jump host "${jumpHost.label || jumpHost.hostname}" cannot be decrypted on this device. Open host settings and re-enter the proxy password.`);
}
return {
hostname: jumpHost.hostname,
port: jumpHost.port || 22,
@@ -52,9 +63,23 @@ export const useSftpHostCredentials = ({
keyId: jumpAuth.keyId,
keySource: jumpKey?.source,
label: jumpHost.label,
proxy: jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port
? {
type: jumpHost.proxyConfig.type,
host: jumpHost.proxyConfig.host,
port: jumpHost.proxyConfig.port,
username: jumpHost.proxyConfig.username,
password: sanitizeCredentialValue(jumpHost.proxyConfig.password),
}
: undefined,
identityFilePaths: jumpHost.identityFilePaths,
};
});
}
const usesTargetProxyForFirstHop = !!proxyConfig && !jumpHosts?.[0]?.proxy;
if (usesTargetProxyForFirstHop && host.proxyConfig?.username && isEncryptedCredentialPlaceholder(host.proxyConfig.password) && !proxyConfig?.password) {
throw new Error("Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.");
}
return {
hostname: host.hostname,
@@ -70,6 +95,7 @@ export const useSftpHostCredentials = ({
proxy: proxyConfig,
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
sudo: host.sftpSudo,
identityFilePaths: host.identityFilePaths,
};
},
[hosts, identities, keys],

View File

@@ -29,8 +29,8 @@ interface UseSftpPaneActionsParams {
}
interface UseSftpPaneActionsResult {
navigateTo: (side: "left" | "right", path: string, options?: { force?: boolean }) => Promise<void>;
refresh: (side: "left" | "right") => Promise<void>;
navigateTo: (side: "left" | "right", path: string, options?: { force?: boolean; tabId?: string }) => Promise<void>;
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
navigateUp: (side: "left" | "right") => Promise<void>;
openEntry: (side: "left" | "right", entry: SftpFileEntry) => Promise<void>;
toggleSelection: (side: "left" | "right", fileName: string, multiSelect: boolean) => void;
@@ -114,23 +114,18 @@ export const useSftpPaneActions = ({
async (
side: "left" | "right",
path: string,
options?: { force?: boolean },
options?: { force?: boolean; tabId?: string },
) => {
console.log("[SFTP navigateTo] called", { side, path, force: options?.force });
const pane = getActivePane(side);
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
const activeTabId = sideTabs.activeTabId;
// When tabId is specified, target that specific tab instead of the active one.
// This allows refreshing a background tab (e.g. after a transfer completes
// while focus has switched to another host).
const targetTabId = options?.tabId ?? sideTabs.activeTabId;
const pane = options?.tabId
? sideTabs.tabs.find((t) => t.id === options.tabId) ?? null
: getActivePane(side);
console.log("[SFTP navigateTo] state check", {
paneId: pane?.id,
hasConnection: !!pane?.connection,
activeTabId,
currentPath: pane?.connection?.currentPath,
});
if (!pane?.connection || !activeTabId) {
console.log("[SFTP navigateTo] No pane/connection/activeTabId, returning early");
if (!pane?.connection || !targetTabId) {
return;
}
@@ -146,15 +141,14 @@ export const useSftpPaneActions = ({
Date.now() - cached.timestamp < dirCacheTtlMs &&
cached.files
) {
console.log("[SFTP navigateTo] Using cached files for path", { path, cacheKey });
tabNavSeqRef.current.set(activeTabId, requestId);
lastConfirmedRef.current.set(activeTabId, {
tabNavSeqRef.current.set(targetTabId, requestId);
lastConfirmedRef.current.set(targetTabId, {
connectionId,
path,
files: cached.files,
selectedFiles: new Set(),
});
updateTab(side, activeTabId, (prev) => ({
updateTab(side, targetTabId, (prev) => ({
...prev,
connection: prev.connection
? { ...prev.connection, currentPath: path }
@@ -180,29 +174,28 @@ export const useSftpPaneActions = ({
return;
}
console.log("[SFTP navigateTo] Fetching files from server for path", { path });
// Re-seed confirmed state whenever the pane is settled (not loading), or
// when the connection has changed. This captures post-mutation state from
// optimistic updates (e.g. deleteFilesAtPath) so that a failed refresh
// doesn't resurrect deleted items.
const existing = lastConfirmedRef.current.get(activeTabId);
const existing = lastConfirmedRef.current.get(targetTabId);
if (!existing || existing.connectionId !== connectionId || !pane.loading) {
lastConfirmedRef.current.set(activeTabId, {
lastConfirmedRef.current.set(targetTabId, {
connectionId,
path: pane.connection.currentPath,
files: pane.files,
selectedFiles: pane.selectedFiles,
});
}
const confirmed = lastConfirmedRef.current.get(activeTabId)!;
const confirmed = lastConfirmedRef.current.get(targetTabId)!;
const previousPath = confirmed.path;
const previousFiles = confirmed.files;
const previousSelection = confirmed.selectedFiles;
tabNavSeqRef.current.set(activeTabId, requestId);
tabNavSeqRef.current.set(targetTabId, requestId);
// Keep existing files visible during loading — the loading overlay
// (pointer-events-none) prevents interaction. This avoids blanking a tab
// that gets superseded by another tab navigating on the same side.
updateTab(side, activeTabId, (prev) => ({
updateTab(side, targetTabId, (prev) => ({
...prev,
connection: prev.connection
? { ...prev.connection, currentPath: path }
@@ -221,16 +214,17 @@ export const useSftpPaneActions = ({
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
clearCacheForConnection(pane.connection.id);
updateTab(side, activeTabId, (prev) => ({
...prev,
connection: null,
files: [],
loading: false,
reconnecting: false,
error: "SFTP session lost. Please reconnect.",
selectedFiles: new Set(),
filter: "",
}));
// For background tabs (explicit tabId), update that tab directly
// instead of handleSessionError which targets the active tab.
if (options?.tabId) {
updateTab(side, targetTabId, (prev) => ({
...prev,
error: "sftp.error.sessionLost",
loading: false,
}));
} else {
handleSessionError(side, new Error("SFTP session lost"));
}
return;
}
@@ -240,16 +234,15 @@ export const useSftpPaneActions = ({
if (isSessionError(err)) {
sftpSessionsRef.current.delete(pane.connection.id);
clearCacheForConnection(pane.connection.id);
updateTab(side, activeTabId, (prev) => ({
...prev,
connection: null,
files: [],
loading: false,
reconnecting: false,
error: "SFTP session expired. Please reconnect.",
selectedFiles: new Set(),
filter: "",
}));
if (options?.tabId) {
updateTab(side, targetTabId, (prev) => ({
...prev,
error: "sftp.error.sessionLost",
loading: false,
}));
} else {
handleSessionError(side, err as Error);
}
return;
}
throw err as Error;
@@ -257,27 +250,15 @@ export const useSftpPaneActions = ({
}
if (navSeqRef.current[side] !== requestId) {
// Another navigation on this side superseded this request.
// Only restore if no newer navigation has occurred on this specific tab
// AND the tab still belongs to the same connection (connect/disconnect
// bump navSeqRef but not tabNavSeqRef).
if (tabNavSeqRef.current.get(activeTabId) !== requestId) {
// Side-level sequence was bumped by another tab's navigation or
// a connect/disconnect. Check if THIS tab's request is still current.
if (tabNavSeqRef.current.get(targetTabId) !== requestId) {
// This tab also has a newer navigation — drop completely.
return;
}
updateTab(side, activeTabId, (prev) => {
if (prev.connection?.id !== connectionId) {
// Tab was reconnected or disconnected; don't restore stale state.
return prev;
}
return {
...prev,
connection: { ...prev.connection, currentPath: previousPath },
files: previousFiles,
selectedFiles: previousSelection,
loading: false,
};
});
return;
// Side was superseded by another tab, but this tab's request is
// still current. The fetched files are valid — fall through to
// apply them instead of restoring previousPath.
}
dirCacheRef.current.set(cacheKey, {
@@ -285,14 +266,14 @@ export const useSftpPaneActions = ({
timestamp: Date.now(),
});
lastConfirmedRef.current.set(activeTabId, {
lastConfirmedRef.current.set(targetTabId, {
connectionId,
path,
files,
selectedFiles: new Set(),
});
updateTab(side, activeTabId, (prev) => ({
updateTab(side, targetTabId, (prev) => ({
...prev,
connection: prev.connection
? { ...prev.connection, currentPath: path }
@@ -311,24 +292,13 @@ export const useSftpPaneActions = ({
}
} catch (err) {
if (navSeqRef.current[side] !== requestId) {
if (tabNavSeqRef.current.get(activeTabId) !== requestId) {
if (tabNavSeqRef.current.get(targetTabId) !== requestId) {
return;
}
updateTab(side, activeTabId, (prev) => {
if (prev.connection?.id !== connectionId) {
return prev;
}
return {
...prev,
connection: { ...prev.connection, currentPath: previousPath },
files: previousFiles,
selectedFiles: previousSelection,
loading: false,
};
});
return;
// Side superseded by another tab, but this tab's request is
// current — fall through to show the error on this tab.
}
updateTab(side, activeTabId, (prev) => {
updateTab(side, targetTabId, (prev) => {
if (prev.connection?.id !== connectionId) {
return prev;
}
@@ -358,16 +328,24 @@ export const useSftpPaneActions = ({
listRemoteFiles,
sftpSessionsRef,
clearCacheForConnection,
handleSessionError,
isSessionError,
],
);
const refresh = useCallback(
async (side: "left" | "right") => {
const pane = getActivePane(side);
async (side: "left" | "right", options?: { tabId?: string }) => {
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
const pane = options?.tabId
? sideTabs.tabs.find((t) => t.id === options.tabId) ?? null
: getActivePane(side);
if (pane?.connection) {
await navigateTo(side, pane.connection.currentPath, { force: true });
await navigateTo(side, pane.connection.currentPath, { force: true, tabId: options?.tabId });
} else if (!pane?.connection && pane?.error) {
// For background tabs, don't trigger reconnection (it operates on
// the active tab). Just leave the error state for the user to see
// when they switch back to that tab.
if (options?.tabId) return;
const lastHost = lastConnectedHostRef.current[side];
if (lastHost && !reconnectingRef.current[side]) {
reconnectingRef.current[side] = true;
@@ -384,7 +362,7 @@ export const useSftpPaneActions = ({
}
}
},
[getActivePane, navigateTo, updateActiveTab, lastConnectedHostRef, reconnectingRef],
[getActivePane, leftTabsRef, rightTabsRef, navigateTo, updateActiveTab, lastConnectedHostRef, reconnectingRef],
);
const navigateUp = useCallback(
@@ -405,42 +383,24 @@ export const useSftpPaneActions = ({
const openEntry = useCallback(
async (side: "left" | "right", entry: SftpFileEntry) => {
console.log("[SFTP openEntry] called", { side, entryName: entry.name, entryType: entry.type });
const pane = getActivePane(side);
console.log("[SFTP openEntry] getActivePane result", {
paneId: pane?.id,
hasConnection: !!pane?.connection,
currentPath: pane?.connection?.currentPath,
});
if (!pane?.connection) {
console.log("[SFTP openEntry] No pane or connection, returning early");
return;
}
if (entry.name === "..") {
const currentPath = pane.connection.currentPath;
const isAtRoot = currentPath === "/" || isWindowsRoot(currentPath);
console.log("[SFTP openEntry] Navigating up from '..'", {
currentPath,
isAtRoot,
isWindowsRoot: isWindowsRoot(currentPath),
});
if (!isAtRoot) {
const parentPath = getParentPath(currentPath);
console.log("[SFTP openEntry] Calculated parent path", { currentPath, parentPath });
await navigateTo(side, parentPath);
} else {
console.log("[SFTP openEntry] Already at root, not navigating");
}
return;
}
if (isNavigableDirectory(entry)) {
const newPath = joinPath(pane.connection.currentPath, entry.name);
console.log("[SFTP openEntry] Navigating into directory", { currentPath: pane.connection.currentPath, entryName: entry.name, newPath });
await navigateTo(side, newPath);
}
},

View File

@@ -2,7 +2,7 @@ import React, { useCallback, useMemo, useRef, useState } from "react";
import { createEmptyPane, EMPTY_LEFT_PANE_ID, EMPTY_RIGHT_PANE_ID, SftpPane, SftpSideTabs } from "./types";
import { logger } from "../../../lib/logger";
export interface SftpTabsState {
interface SftpTabsState {
leftTabs: SftpSideTabs;
rightTabs: SftpSideTabs;
leftTabsRef: React.MutableRefObject<SftpSideTabs>;

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import React, { useCallback, useMemo, useRef, useState } from "react";
import {
FileConflict,
SftpFileEntry,
@@ -14,7 +14,7 @@ import { joinPath } from "./utils";
interface UseSftpTransfersParams {
getActivePane: (side: "left" | "right") => SftpPane | null;
refresh: (side: "left" | "right") => Promise<void>;
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
@@ -64,66 +64,10 @@ export const useSftpTransfers = ({
const [transfers, setTransfers] = useState<TransferTask[]>([]);
const [conflicts, setConflicts] = useState<FileConflict[]>([]);
const progressIntervalsRef = useRef<Map<string, NodeJS.Timeout>>(new Map());
// Track cancelled task IDs for checking during async operations
const cancelledTasksRef = useRef<Set<string>>(new Set());
const completionHandlersRef = useRef<Map<string, (result: TransferResult) => void | Promise<void>>>(new Map());
useEffect(() => {
const intervalsRef = progressIntervalsRef.current;
return () => {
intervalsRef.forEach((interval) => {
clearInterval(interval);
});
intervalsRef.clear();
};
}, []);
const startProgressSimulation = useCallback(
(taskId: string, estimatedBytes: number) => {
const existing = progressIntervalsRef.current.get(taskId);
if (existing) clearInterval(existing);
const baseSpeed = Math.max(50000, Math.min(500000, estimatedBytes / 10));
const variability = 0.3;
let transferred = 0;
const interval = setInterval(() => {
const speedFactor = 1 + (Math.random() - 0.5) * variability;
const chunkSize = Math.floor(baseSpeed * speedFactor * 0.1);
transferred = Math.min(transferred + chunkSize, estimatedBytes);
setTransfers((prev) =>
prev.map((t) => {
if (t.id !== taskId || t.status !== "transferring") return t;
return {
...t,
transferredBytes: transferred,
totalBytes: estimatedBytes,
speed: chunkSize * 10,
};
}),
);
if (transferred >= estimatedBytes * 0.95) {
clearInterval(interval);
progressIntervalsRef.current.delete(taskId);
}
}, 100);
progressIntervalsRef.current.set(taskId, interval);
},
[],
);
const stopProgressSimulation = useCallback((taskId: string) => {
const interval = progressIntervalsRef.current.get(taskId);
if (interval) {
clearInterval(interval);
progressIntervalsRef.current.delete(taskId);
}
}, []);
const clearCancelledTask = useCallback((taskId: string) => {
cancelledTasksRef.current.delete(taskId);
}, []);
@@ -207,114 +151,64 @@ export const useSftpTransfers = ({
throw new Error("Transfer cancelled");
}
if (netcattyBridge.get()?.startStreamTransfer) {
return new Promise((resolve, reject) => {
const options = {
transferId: task.id,
sourcePath: task.sourcePath,
targetPath: task.targetPath,
sourceType: sourceIsLocal ? ("local" as const) : ("sftp" as const),
targetType: targetIsLocal ? ("local" as const) : ("sftp" as const),
sourceSftpId: sourceSftpId || undefined,
targetSftpId: targetSftpId || undefined,
totalBytes: task.totalBytes || undefined,
sourceEncoding: sourceIsLocal ? undefined : sourceEncoding,
targetEncoding: targetIsLocal ? undefined : targetEncoding,
};
return new Promise((resolve, reject) => {
const options = {
transferId: task.id,
sourcePath: task.sourcePath,
targetPath: task.targetPath,
sourceType: sourceIsLocal ? ("local" as const) : ("sftp" as const),
targetType: targetIsLocal ? ("local" as const) : ("sftp" as const),
sourceSftpId: sourceSftpId || undefined,
targetSftpId: targetSftpId || undefined,
totalBytes: task.totalBytes || undefined,
sourceEncoding: sourceIsLocal ? undefined : sourceEncoding,
targetEncoding: targetIsLocal ? undefined : targetEncoding,
};
const onProgress = (
transferred: number,
total: number,
speed: number,
) => {
// Bubble up streaming progress to parent (for directory transfers)
onStreamProgress?.(transferred, total, speed);
const onProgress = (
transferred: number,
total: number,
speed: number,
) => {
// Bubble up streaming progress to parent (for directory transfers)
onStreamProgress?.(transferred, total, speed);
setTransfers((prev) =>
prev.map((t) => {
if (t.id !== task.id) return t;
if (t.status === "cancelled") return t;
const normalizedTotal = total > 0 ? total : t.totalBytes;
const normalizedTransferred = Math.max(
t.transferredBytes,
Math.min(transferred, normalizedTotal > 0 ? normalizedTotal : transferred),
);
return {
...t,
transferredBytes: normalizedTransferred,
totalBytes: normalizedTotal,
speed: Number.isFinite(speed) && speed > 0 ? speed : 0,
};
}),
);
};
const onComplete = () => {
resolve();
};
const onError = (error: string) => {
reject(new Error(error));
};
netcattyBridge.require().startStreamTransfer!(
options,
onProgress,
onComplete,
onError,
).catch(reject);
});
}
let content: ArrayBuffer | string;
if (sourceIsLocal) {
content =
(await netcattyBridge.get()?.readLocalFile?.(task.sourcePath)) ||
new ArrayBuffer(0);
} else if (sourceSftpId) {
if (netcattyBridge.get()?.readSftpBinary) {
content = await netcattyBridge.get()!.readSftpBinary!(
sourceSftpId,
task.sourcePath,
sourceEncoding,
setTransfers((prev) =>
prev.map((t) => {
if (t.id !== task.id) return t;
if (t.status === "cancelled") return t;
const normalizedTotal = total > 0 ? total : t.totalBytes;
// Clamp to [previous, total] — the backend normalizes progress
// but we guard against any non-monotonic edge cases.
const normalizedTransferred = Math.max(
t.transferredBytes,
Math.min(transferred, normalizedTotal > 0 ? normalizedTotal : transferred),
);
return {
...t,
transferredBytes: normalizedTransferred,
totalBytes: normalizedTotal,
speed: Number.isFinite(speed) && speed > 0 ? speed : 0,
};
}),
);
} else {
content =
(await netcattyBridge.get()?.readSftp(sourceSftpId, task.sourcePath, sourceEncoding)) || "";
}
} else {
throw new Error("No source connection");
}
};
if (targetIsLocal) {
if (content instanceof ArrayBuffer) {
await netcattyBridge.get()?.writeLocalFile?.(task.targetPath, content);
} else {
const encoder = new TextEncoder();
await netcattyBridge.get()?.writeLocalFile?.(
task.targetPath,
encoder.encode(content).buffer,
);
}
} else if (targetSftpId) {
if (content instanceof ArrayBuffer && netcattyBridge.get()?.writeSftpBinary) {
await netcattyBridge.get()!.writeSftpBinary!(
targetSftpId,
task.targetPath,
content,
targetEncoding,
);
} else {
const text =
content instanceof ArrayBuffer
? new TextDecoder().decode(content)
: content;
await netcattyBridge.get()?.writeSftp(targetSftpId, task.targetPath, text, targetEncoding);
}
} else {
throw new Error("No target connection");
}
const onComplete = () => {
resolve();
};
const onError = (error: string) => {
reject(new Error(error));
};
netcattyBridge.require().startStreamTransfer!(
options,
onProgress,
onComplete,
onError,
).catch(reject);
});
};
const transferDirectory = async (
@@ -456,6 +350,7 @@ export const useSftpTransfers = ({
// Fall back to the existing estimate below if size discovery fails.
}
} else if (actualFileSize === 0) {
// Fallback stat when file wasn't in the pane's file list (e.g., filtered view)
try {
const sourceSftpId = sourcePane.connection?.isLocal
? null
@@ -463,14 +358,24 @@ export const useSftpTransfers = ({
if (sourcePane.connection?.isLocal) {
const stat = await netcattyBridge.get()?.statLocal?.(task.sourcePath);
if (stat) actualFileSize = stat.size;
if (stat) {
actualFileSize = stat.size;
if (!task.sourceLastModified && stat.lastModified) {
task.sourceLastModified = stat.lastModified;
}
}
} else if (sourceSftpId) {
const stat = await netcattyBridge.get()?.statSftp?.(
sourceSftpId,
task.sourcePath,
sourceEncoding,
);
if (stat) actualFileSize = stat.size;
if (stat) {
actualFileSize = stat.size;
if (!task.sourceLastModified && stat.lastModified) {
task.sourceLastModified = stat.lastModified;
}
}
}
} catch {
// Ignore stat errors
@@ -484,7 +389,6 @@ export const useSftpTransfers = ({
? 1024 * 1024
: 256 * 1024;
const hasStreamingTransfer = !!netcattyBridge.get()?.startStreamTransfer;
const sourceSftpId = sourcePane.connection?.isLocal
? null
@@ -504,8 +408,6 @@ export const useSftpTransfers = ({
throw new Error("Target SFTP session not found");
}
let useSimulatedProgress = false;
try {
if (prescanCancelled) {
throw new Error("Transfer cancelled");
@@ -518,41 +420,14 @@ export const useSftpTransfers = ({
startTime: Date.now(),
});
if (!hasStreamingTransfer && !task.isDirectory) {
useSimulatedProgress = true;
startProgressSimulation(task.id, estimatedSize);
}
if (!task.skipConflictCheck && !task.isDirectory && targetPane.connection) {
let targetExists = false;
let existingStat: { size: number; mtime: number } | null = null;
let sourceStat: { size: number; mtime: number } | null = null;
try {
if (sourcePane.connection.isLocal) {
const stat = await netcattyBridge.get()?.statLocal?.(task.sourcePath);
if (stat) {
sourceStat = {
size: stat.size,
mtime: stat.lastModified || Date.now(),
};
}
} else if (sourceSftpId) {
const stat = await netcattyBridge.get()?.statSftp?.(
sourceSftpId,
task.sourcePath,
sourceEncoding,
);
if (stat) {
sourceStat = {
size: stat.size,
mtime: stat.lastModified || Date.now(),
};
}
}
} catch {
// ignore
}
// Use cached metadata from the task instead of an extra stat round-trip
const sourceStat: { size: number; mtime: number } | null =
(task.totalBytes > 0 || task.sourceLastModified)
? { size: task.totalBytes, mtime: task.sourceLastModified || Date.now() }
: null;
try {
if (targetPane.connection.isLocal) {
@@ -583,8 +458,6 @@ export const useSftpTransfers = ({
}
if (targetExists && existingStat) {
stopProgressSimulation(task.id);
const newConflict: FileConflict = {
transferId: task.id,
fileName: task.fileName,
@@ -654,10 +527,6 @@ export const useSftpTransfers = ({
);
}
if (useSimulatedProgress) {
stopProgressSimulation(task.id);
}
setTransfers((prev) =>
prev.map((t) => {
if (t.id !== task.id) return t;
@@ -671,7 +540,9 @@ export const useSftpTransfers = ({
}),
);
await refresh(targetSide);
// Refresh the specific target tab, not whichever tab happens to be
// active now — focus may have switched during the transfer.
await refresh(targetSide, { tabId: targetPane.id });
const completionHandler = completionHandlersRef.current.get(task.id);
if (completionHandler) {
try {
@@ -687,10 +558,6 @@ export const useSftpTransfers = ({
}
return "completed";
} catch (err) {
if (useSimulatedProgress) {
stopProgressSimulation(task.id);
}
// Check if this was a cancellation
const isCancelled = cancelledTasksRef.current.has(task.id) ||
(err instanceof Error && err.message === "Transfer cancelled");
@@ -754,18 +621,10 @@ export const useSftpTransfers = ({
if (!sourcePane?.connection || !targetPane?.connection) return [];
const sourceEncoding: SftpFilenameEncoding = sourcePane.connection.isLocal
? "auto"
: sourcePane.filenameEncoding || "auto";
const sourcePath = options?.sourcePath ?? sourcePane.connection.currentPath;
const targetPath = targetPane.connection.currentPath;
const sourceConnectionId = options?.sourceConnectionId ?? sourcePane.connection.id;
const sourceSftpId = sourcePane.connection.isLocal
? null
: sftpSessionsRef.current.get(sourceConnectionId);
const newTasks: TransferTask[] = [];
for (const file of sourceFiles) {
@@ -776,25 +635,11 @@ export const useSftpTransfers = ({
? "download"
: "remote-to-remote";
let fileSize = 0;
if (!file.isDirectory) {
try {
const fullPath = joinPath(sourcePath, file.name);
if (sourcePane.connection!.isLocal) {
const stat = await netcattyBridge.get()?.statLocal?.(fullPath);
if (stat) fileSize = stat.size;
} else if (sourceSftpId) {
const stat = await netcattyBridge.get()?.statSftp?.(
sourceSftpId,
fullPath,
sourceEncoding,
);
if (stat) fileSize = stat.size;
}
} catch {
// ignore
}
}
// Use cached metadata from the source pane's file list to avoid
// redundant stat calls over the network.
const fileEntry = sourcePane.files.find((f) => f.name === file.name);
const fileSize = file.isDirectory ? 0 : (fileEntry?.size ?? 0);
const sourceLastModified = fileEntry?.lastModified ?? 0;
newTasks.push({
id: crypto.randomUUID(),
@@ -811,6 +656,7 @@ export const useSftpTransfers = ({
speed: 0,
startTime: Date.now(),
isDirectory: file.isDirectory,
sourceLastModified,
});
}
@@ -845,8 +691,6 @@ export const useSftpTransfers = ({
// Add to cancelled set so async operations can check
cancelledTasksRef.current.add(transferId);
stopProgressSimulation(transferId);
setTransfers((prev) =>
prev.map((t) =>
t.id === transferId
@@ -870,7 +714,7 @@ export const useSftpTransfers = ({
}
},
[stopProgressSimulation],
[],
);
const retryTransfer = useCallback(

View File

@@ -48,39 +48,31 @@ export const joinPath = (base: string, name: string): string => {
return `${normalizedBase}\\${name}`;
}
if (base === "/") return `/${name}`;
return `${base}/${name}`;
return `${base.replace(/\/+$/, "")}/${name}`;
};
export const getParentPath = (path: string): string => {
console.log("[SFTP getParentPath] input", { path, isWindows: isWindowsPath(path) });
if (isWindowsPath(path)) {
const normalized = normalizeWindowsRoot(path).replace(/[\\]+$/, "");
const drive = normalized.slice(0, 2);
if (/^[A-Za-z]:$/.test(normalized) || /^[A-Za-z]:\\$/.test(normalized)) {
console.log("[SFTP getParentPath] Windows root, returning", { result: `${drive}\\` });
return `${drive}\\`;
}
const rest = normalized.slice(2).replace(/^[\\]+/, "");
const parts = rest ? rest.split(/[\\]+/).filter(Boolean) : [];
if (parts.length <= 1) {
console.log("[SFTP getParentPath] Windows near root, returning", { result: `${drive}\\` });
return `${drive}\\`;
}
parts.pop();
const result = `${drive}\\${parts.join("\\")}`;
console.log("[SFTP getParentPath] Windows result", { result });
return result;
}
if (path === "/") {
console.log("[SFTP getParentPath] Unix root, returning /");
return "/";
}
const parts = path.split("/").filter(Boolean);
console.log("[SFTP getParentPath] Unix parts before pop", { parts: [...parts] });
parts.pop();
const result = parts.length ? `/${parts.join("/")}` : "/";
console.log("[SFTP getParentPath] Unix result", { result, partsAfterPop: parts });
return result;
};

View File

@@ -12,6 +12,7 @@ import {
STORAGE_KEY_AI_COMMAND_TIMEOUT,
STORAGE_KEY_AI_MAX_ITERATIONS,
STORAGE_KEY_AI_SESSIONS,
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
STORAGE_KEY_AI_AGENT_MODEL_MAP,
STORAGE_KEY_AI_WEB_SEARCH,
} from '../../infrastructure/config/storageKeys';
@@ -32,6 +33,12 @@ function getAIBridge() {
return (window as unknown as { netcatty?: Record<string, (...args: unknown[]) => unknown> }).netcatty;
}
const AI_STATE_CHANGED_EVENT = 'netcatty:ai-state-changed';
function emitAIStateChanged(key: string) {
window.dispatchEvent(new CustomEvent<{ key: string }>(AI_STATE_CHANGED_EVENT, { detail: { key } }));
}
function cleanupAcpSessions(sessionIds: string[]) {
const bridge = getAIBridge();
if (!bridge?.aiAcpCleanup || sessionIds.length === 0) return;
@@ -40,6 +47,48 @@ function cleanupAcpSessions(sessionIds: string[]) {
}
}
export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
const currentSessions = latestAISessionsSnapshot
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
?? [];
const removedSessionIds = currentSessions
.filter((session) => session.scope.targetId && !activeTargetIds.has(session.scope.targetId))
.map((session) => session.id);
if (removedSessionIds.length === 0) return;
cleanupAcpSessions(removedSessionIds);
const removedSessionIdSet = new Set(removedSessionIds);
const nextSessions = currentSessions.filter((session) => {
if (!session.scope.targetId) return true;
return activeTargetIds.has(session.scope.targetId);
});
setLatestAISessionsSnapshot(nextSessions);
localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, pruneSessionsForStorage(nextSessions));
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
const activeSessionIdMap = latestAIActiveSessionMapSnapshot
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
?? {};
let activeSessionMapChanged = false;
const nextActiveSessionIdMap = { ...activeSessionIdMap };
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
if (sessionId && removedSessionIdSet.has(sessionId)) {
nextActiveSessionIdMap[scopeKey] = null;
activeSessionMapChanged = true;
}
}
if (activeSessionMapChanged) {
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
}
}
/** Maximum number of sessions to keep in localStorage. */
const MAX_STORED_SESSIONS = 50;
@@ -66,6 +115,17 @@ function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
});
}
let latestAISessionsSnapshot: AISession[] | null = null;
let latestAIActiveSessionMapSnapshot: Record<string, string | null> | null = null;
function setLatestAISessionsSnapshot(sessions: AISession[]) {
latestAISessionsSnapshot = sessions;
}
function setLatestAIActiveSessionMapSnapshot(activeSessionIdMap: Record<string, string | null>) {
latestAIActiveSessionMapSnapshot = activeSessionIdMap;
}
export function useAIState() {
// ── Provider Config ──
const [providers, setProvidersRaw] = useState<ProviderConfig[]>(() =>
@@ -117,7 +177,9 @@ export function useAIState() {
sessionsRef.current = sessions;
}, [sessions]);
// Per-scope active session: keyed by `${scopeType}:${scopeTargetId}`
const [activeSessionIdMap, setActiveSessionIdMapRaw] = useState<Record<string, string | null>>({});
const [activeSessionIdMap, setActiveSessionIdMapRaw] = useState<Record<string, string | null>>(() =>
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {}
);
// Per-agent model selection: remembers last selected model per agent
const [agentModelMap, setAgentModelMapRaw] = useState<Record<string, string>>(() =>
@@ -129,8 +191,43 @@ export function useAIState() {
localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null
);
useEffect(() => {
setLatestAISessionsSnapshot(sessions);
}, [sessions]);
useEffect(() => {
setLatestAIActiveSessionMapSnapshot(activeSessionIdMap);
}, [activeSessionIdMap]);
useEffect(() => {
const validSessionIds = new Set(sessions.map((session) => session.id));
let changed = false;
const nextActiveSessionIdMap: Record<string, string | null> = {};
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
const nextSessionId = sessionId && validSessionIds.has(sessionId) ? sessionId : null;
nextActiveSessionIdMap[scopeKey] = nextSessionId;
if (nextSessionId !== sessionId) {
changed = true;
}
}
if (!changed) return;
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
setActiveSessionIdMapRaw(nextActiveSessionIdMap);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
}, [sessions, activeSessionIdMap]);
const setActiveSessionId = useCallback((scopeKey: string, id: string | null) => {
setActiveSessionIdMapRaw(prev => ({ ...prev, [scopeKey]: id }));
setActiveSessionIdMapRaw(prev => {
const next = { ...prev, [scopeKey]: id };
setLatestAIActiveSessionMapSnapshot(next);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
return next;
});
}, []);
const setAgentModel = useCallback((agentId: string, modelId: string) => {
@@ -303,9 +400,22 @@ export function useAIState() {
setHostPermissionsRaw(perms ?? []);
break;
}
case STORAGE_KEY_AI_SESSIONS: {
const nextSessions = localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? [];
setLatestAISessionsSnapshot(nextSessions);
setSessionsRaw(nextSessions);
break;
}
case STORAGE_KEY_AI_AGENT_MODEL_MAP:
setAgentModelMapRaw(localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP) ?? {});
break;
case STORAGE_KEY_AI_ACTIVE_SESSION_MAP: {
const nextActiveSessionIdMap =
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {};
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
setActiveSessionIdMapRaw(nextActiveSessionIdMap);
break;
}
case STORAGE_KEY_AI_WEB_SEARCH:
setWebSearchConfigRaw(localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null);
break;
@@ -315,7 +425,33 @@ export function useAIState() {
}
};
window.addEventListener('storage', handleStorage);
return () => window.removeEventListener('storage', handleStorage);
const handleLocalStateChanged = (event: Event) => {
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
if (!key) return;
switch (key) {
case STORAGE_KEY_AI_SESSIONS:
setSessionsRaw(
latestAISessionsSnapshot
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
?? [],
);
return;
case STORAGE_KEY_AI_ACTIVE_SESSION_MAP:
setActiveSessionIdMapRaw(
latestAIActiveSessionMapSnapshot
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
?? {},
);
return;
default:
handleStorage({ key } as StorageEvent);
}
};
window.addEventListener(AI_STATE_CHANGED_EVENT, handleLocalStateChanged);
return () => {
window.removeEventListener('storage', handleStorage);
window.removeEventListener(AI_STATE_CHANGED_EVENT, handleLocalStateChanged);
};
}, []);
// ── Sync initial safety settings to MCP Server on mount ──
@@ -375,6 +511,7 @@ export function useAIState() {
};
setSessionsRaw(prev => {
const next = [session, ...prev];
setLatestAISessionsSnapshot(next);
persistSessions(next);
return next;
});
@@ -391,12 +528,19 @@ export function useAIState() {
}
setSessionsRaw(prev => {
const next = prev.filter(s => s.id !== sessionId);
setLatestAISessionsSnapshot(next);
persistSessions(next);
return next;
});
if (scopeKey) {
setActiveSessionIdMapRaw(prev => {
if (prev[scopeKey] === sessionId) return { ...prev, [scopeKey]: null };
if (prev[scopeKey] === sessionId) {
const next = { ...prev, [scopeKey]: null };
setLatestAIActiveSessionMapSnapshot(next);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
return next;
}
return prev;
});
}
@@ -415,12 +559,19 @@ export function useAIState() {
const next = prev.filter(s => {
return !(s.scope.type === scopeType && s.scope.targetId === targetId);
});
setLatestAISessionsSnapshot(next);
persistSessions(next);
return next;
});
const scopeKey = `${scopeType}:${targetId}`;
setActiveSessionIdMapRaw(prev => {
if (prev[scopeKey] != null) return { ...prev, [scopeKey]: null };
if (prev[scopeKey] != null) {
const next = { ...prev, [scopeKey]: null };
setLatestAIActiveSessionMapSnapshot(next);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
return next;
}
return prev;
});
}, [persistSessions]);
@@ -428,6 +579,7 @@ export function useAIState() {
const updateSessionTitle = useCallback((sessionId: string, title: string) => {
setSessionsRaw(prev => {
const next = prev.map(s => s.id === sessionId ? { ...s, title, updatedAt: Date.now() } : s);
setLatestAISessionsSnapshot(next);
persistSessions(next);
return next;
});
@@ -440,6 +592,7 @@ export function useAIState() {
? { ...s, externalSessionId, updatedAt: Date.now() }
: s
));
setLatestAISessionsSnapshot(next);
debouncedPersistSessions();
return next;
});
@@ -463,6 +616,7 @@ export function useAIState() {
}
return { ...s, messages: msgs, updatedAt: Date.now() };
});
setLatestAISessionsSnapshot(next);
debouncedPersistSessions();
return next;
});
@@ -476,6 +630,7 @@ export function useAIState() {
msgs[msgs.length - 1] = updater(msgs[msgs.length - 1]);
return { ...s, messages: msgs, updatedAt: Date.now() };
});
setLatestAISessionsSnapshot(next);
debouncedPersistSessions();
return next;
});
@@ -491,6 +646,7 @@ export function useAIState() {
msgs[idx] = updater(msgs[idx]);
return { ...s, messages: msgs, updatedAt: Date.now() };
});
setLatestAISessionsSnapshot(next);
debouncedPersistSessions();
return next;
});
@@ -503,29 +659,21 @@ export function useAIState() {
}
setSessionsRaw(prev => {
const next = prev.map(s => s.id === sessionId ? { ...s, messages: [], updatedAt: Date.now() } : s);
setLatestAISessionsSnapshot(next);
persistSessions(next);
return next;
});
}, [persistSessions]);
const cleanupOrphanedSessions = useCallback((activeTargetIds: Set<string>) => {
const removedSessionIds = sessionsRef.current
.filter(s => s.scope.targetId && !activeTargetIds.has(s.scope.targetId))
.map(s => s.id);
cleanupAcpSessions(removedSessionIds);
setSessionsRaw(prev => {
const next = prev.filter(s => {
// Keep sessions without a targetId (global scope)
if (!s.scope.targetId) return true;
// Keep sessions whose target still exists
return activeTargetIds.has(s.scope.targetId);
});
if (next.length !== prev.length) {
persistSessions(next);
}
return next;
});
}, [persistSessions]);
cleanupOrphanedAISessions(activeTargetIds);
setSessionsRaw(latestAISessionsSnapshot ?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? []);
setActiveSessionIdMapRaw(
latestAIActiveSessionMapSnapshot
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
?? {},
);
}, []);
// ── Provider CRUD helpers ──
const addProvider = useCallback((provider: ProviderConfig) => {

View File

@@ -218,7 +218,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
if (!connectedProvider) return;
try {
console.log('[AutoSync] Checking remote version...');
// Load base BEFORE downloading (downloadFromProvider overwrites the base)
const base = await manager.loadSyncBase(connectedProvider);
const remotePayload = await sync.downloadFromProvider(connectedProvider);
@@ -228,7 +227,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
const localPayload = buildPayload();
const mergeResult = mergeSyncPayloads(base, localPayload, remotePayload);
console.log('[AutoSync] Remote is newer, merged:', mergeResult.summary);
config.onApplyPayload(mergeResult.payload);
// Don't save base or skip auto-sync — let the data-change effect
// naturally trigger an upload of the merged payload (which will
@@ -282,7 +280,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// Debounce sync by 3 seconds
syncTimeoutRef.current = setTimeout(() => {
console.log('[AutoSync] Data changed, syncing...');
syncNow();
}, 3000);

View File

@@ -0,0 +1,79 @@
/**
* useFileUpload - Handle file paste/drop with base64 conversion
*
* Supports images, PDFs, and other document types.
* Ported from 1code's use-agents-file-upload.ts
*/
import { useCallback, useState } from 'react';
import { getPathForFile } from '../../lib/sftpFileUtils';
export interface UploadedFile {
id: string;
filename: string;
dataUrl: string; // data:...;base64,... for preview
base64Data: string; // raw base64 for API
mediaType: string; // MIME type e.g. "image/png", "application/pdf"
filePath?: string; // original filesystem path (Electron only)
}
/** Reject only known binary blobs that AI models can't process */
const REJECTED_MIME_PREFIXES = ['video/', 'audio/'];
function isSupportedFile(file: File): boolean {
// Allow files with empty MIME (common in Electron for .sh, .yaml, etc.)
if (!file.type) return true;
return !REJECTED_MIME_PREFIXES.some(prefix => file.type.startsWith(prefix));
}
async function fileToDataUrl(file: File): Promise<{ dataUrl: string; base64: string }> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
const dataUrl = reader.result as string;
const base64 = dataUrl.split(',')[1] || '';
resolve({ dataUrl, base64 });
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
export function useFileUpload() {
const [files, setFiles] = useState<UploadedFile[]>([]);
const addFiles = useCallback(async (inputFiles: File[]) => {
const supported = inputFiles.filter(isSupportedFile);
if (supported.length === 0) return;
const newFiles: UploadedFile[] = await Promise.all(
supported.map(async (file) => {
const id = crypto.randomUUID();
const filename = file.name || `file-${Date.now()}`;
const mediaType = file.type || 'application/octet-stream';
let dataUrl = '';
let base64Data = '';
try {
const result = await fileToDataUrl(file);
dataUrl = result.dataUrl;
base64Data = result.base64;
} catch (err) {
console.error('[useFileUpload] Failed to convert:', err);
}
const filePath = getPathForFile(file);
return { id, filename, dataUrl, base64Data, mediaType, filePath };
}),
);
setFiles((prev) => [...prev, ...newFiles]);
}, []);
const removeFile = useCallback((id: string) => {
setFiles((prev) => prev.filter((f) => f.id !== id));
}, []);
const clearFiles = useCallback(() => {
setFiles([]);
}, []);
return { files, addFiles, removeFile, clearFiles };
}

View File

@@ -1,66 +0,0 @@
/**
* useImageUpload - Handle image paste/drop with base64 conversion
*
* Ported from 1code's use-agents-file-upload.ts
*/
import { useCallback, useState } from 'react';
export interface UploadedImage {
id: string;
filename: string;
dataUrl: string; // data:image/...;base64,... for preview
base64Data: string; // raw base64 for API
mediaType: string; // MIME type e.g. "image/png"
}
async function fileToDataUrl(file: File): Promise<{ dataUrl: string; base64: string }> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
const dataUrl = reader.result as string;
const base64 = dataUrl.split(',')[1] || '';
resolve({ dataUrl, base64 });
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
export function useImageUpload() {
const [images, setImages] = useState<UploadedImage[]>([]);
const addImages = useCallback(async (files: File[]) => {
const imageFiles = files.filter((f) => f.type.startsWith('image/'));
if (imageFiles.length === 0) return;
const newImages: UploadedImage[] = await Promise.all(
imageFiles.map(async (file) => {
const id = crypto.randomUUID();
const filename = file.name || `screenshot-${Date.now()}.png`;
const mediaType = file.type || 'image/png';
let dataUrl = '';
let base64Data = '';
try {
const result = await fileToDataUrl(file);
dataUrl = result.dataUrl;
base64Data = result.base64;
} catch (err) {
console.error('[useImageUpload] Failed to convert:', err);
}
return { id, filename, dataUrl, base64Data, mediaType };
}),
);
setImages((prev) => [...prev, ...newImages]);
}, []);
const removeImage = useCallback((id: string) => {
setImages((prev) => prev.filter((i) => i.id !== id));
}, []);
const clearImages = useCallback(() => {
setImages([]);
}, []);
return { images, addImages, removeImage, clearImages };
}

View File

@@ -0,0 +1,214 @@
/**
* 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();
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
export function useImmersiveMode({
isImmersive,
activeTabId,
activeTerminalTheme,
restoreOriginalTheme,
}: {
isImmersive: boolean;
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 (isImmersive && 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);
}
}, [isImmersive, isTerminalTab, activeTerminalTheme]);
// RESTORE: useEffect — runs after paint, with fade overlay
useEffect(() => {
if (isImmersive && 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(); };
}, [isImmersive, isTerminalTab, activeTerminalTheme, restoreOriginalTheme]);
// Cleanup on unmount
useEffect(() => {
return () => {
removeImmersiveStyle();
appliedFpRef.current = null;
if (overrideActiveRef.current) {
overrideActiveRef.current = false;
restoreRef.current();
}
};
}, []);
}

View File

@@ -103,8 +103,6 @@ export const useManagedSourceSync = ({
const writeSshConfigToFile = useCallback(
async (source: ManagedSource, managedHosts: Host[]) => {
console.log(`[ManagedSourceSync] writeSshConfigToFile called for ${source.groupName}, hosts:`, managedHosts.length);
const bridge = netcattyBridge.get();
if (!bridge?.writeLocalFile) {
console.warn("[ManagedSourceSync] writeLocalFile not available");
@@ -121,14 +119,9 @@ export const useManagedSourceSync = ({
managedHosts,
hosts,
);
console.log(`[ManagedSourceSync] Final content (${finalContent.length} chars)`);
const encoder = new TextEncoder();
const buffer = encoder.encode(finalContent);
console.log(`[ManagedSourceSync] Writing to ${source.filePath}`);
await bridge.writeLocalFile(source.filePath, buffer.buffer as ArrayBuffer);
console.log(`[ManagedSourceSync] Write successful`);
return true;
} catch (err) {
console.error("[ManagedSourceSync] Failed to write SSH config:", err);
@@ -159,12 +152,8 @@ export const useManagedSourceSync = ({
// This should be called before deleting a managed group to avoid stale entries
const clearAndRemoveSource = useCallback(
async (source: ManagedSource) => {
console.log(`[ManagedSourceSync] Clearing managed block for ${source.groupName}`);
// Write empty hosts list to clear the managed block
const success = await writeSshConfigToFile(source, []);
if (success) {
console.log(`[ManagedSourceSync] Managed block cleared, removing source`);
}
// Remove the source regardless of write success
const updatedSources = managedSourcesRef.current.filter((s) => s.id !== source.id);
onUpdateManagedSources(updatedSources);
@@ -179,19 +168,14 @@ export const useManagedSourceSync = ({
async (sources: ManagedSource[]) => {
if (sources.length === 0) return;
console.log(`[ManagedSourceSync] Clearing ${sources.length} managed blocks`);
// Clear all files in parallel
const results = await Promise.all(
await Promise.all(
sources.map(async (source) => {
const success = await writeSshConfigToFile(source, []);
return { sourceId: source.id, success };
})
);
const successCount = results.filter(r => r.success).length;
console.log(`[ManagedSourceSync] Cleared ${successCount}/${sources.length} managed blocks`);
// Remove all sources atomically in a single update
const sourceIdsToRemove = new Set(sources.map(s => s.id));
const updatedSources = managedSourcesRef.current.filter(
@@ -273,8 +257,6 @@ export const useManagedSourceSync = ({
const prevManaged = prevHostsBySource.get(source.id) || [];
const currManaged = currHostsBySource.get(source.id) || [];
console.log(`[ManagedSourceSync] Source ${source.groupName}: prev=${prevManaged.length}, curr=${currManaged.length}`);
if (prevManaged.length !== currManaged.length) {
changedSourceIds.add(source.id);
continue;
@@ -328,7 +310,6 @@ export const useManagedSourceSync = ({
}
if (changedSourceIds.size > 0) {
console.log(`[ManagedSourceSync] Syncing sources:`, Array.from(changedSourceIds));
syncInProgressRef.current = true;
Promise.all(

View File

@@ -3,8 +3,8 @@
* This should be used at the App level to ensure auto-start happens
* when the application starts, not when the user navigates to the port forwarding page.
*/
import { useEffect, useRef } from "react";
import { Host, PortForwardingRule } from "../../domain/models";
import { useCallback, useEffect, useRef } from "react";
import { Host, Identity, PortForwardingRule, SSHKey } from "../../domain/models";
import { STORAGE_KEY_PORT_FORWARDING } from "../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
import {
@@ -17,7 +17,8 @@ import { logger } from "../../lib/logger";
export interface UsePortForwardingAutoStartOptions {
hosts: Host[];
keys: { id: string; privateKey: string; passphrase: string }[];
keys: SSHKey[];
identities: Identity[];
}
/**
@@ -27,10 +28,37 @@ export interface UsePortForwardingAutoStartOptions {
export const usePortForwardingAutoStart = ({
hosts,
keys,
identities,
}: UsePortForwardingAutoStartOptions): void => {
const autoStartExecutedRef = useRef(false);
const hostsRef = useRef<Host[]>(hosts);
const keysRef = useRef<{ id: string; privateKey: string; passphrase: string }[]>(keys);
const keysRef = useRef<SSHKey[]>(keys);
const identitiesRef = useRef<Identity[]>(identities);
const isHostAuthReady = useCallback((host: Host, seen = new Set<string>()): boolean => {
if (!host || seen.has(host.id)) return true;
seen.add(host.id);
if (host.identityId) {
const identity = identitiesRef.current.find((candidate) => candidate.id === host.identityId);
if (!identity) return false;
if (identity.keyId && !keysRef.current.some((key) => key.id === identity.keyId)) {
return false;
}
}
if (host.identityFileId && !keysRef.current.some((key) => key.id === host.identityFileId)) {
return false;
}
const chainIds = host.hostChain?.hostIds || [];
for (const chainId of chainIds) {
const chainHost = hostsRef.current.find((candidate) => candidate.id === chainId);
if (!chainHost) return false;
if (!isHostAuthReady(chainHost, seen)) return false;
}
return true;
}, []);
// Keep refs in sync
useEffect(() => {
@@ -41,6 +69,10 @@ export const usePortForwardingAutoStart = ({
keysRef.current = keys;
}, [keys]);
useEffect(() => {
identitiesRef.current = identities;
}, [identities]);
// Set up the reconnect callback
useEffect(() => {
const handleReconnect = async (
@@ -62,7 +94,7 @@ export const usePortForwardingAutoStart = ({
return { success: false, error: "Host not found" };
}
return startPortForward(rule, host, keysRef.current, onStatusChange, true);
return startPortForward(rule, host, hostsRef.current, keysRef.current, identitiesRef.current, onStatusChange, true);
};
setReconnectCallback(handleReconnect);
@@ -76,6 +108,17 @@ export const usePortForwardingAutoStart = ({
if (autoStartExecutedRef.current) return;
if (hosts.length === 0) return;
const storedRules = localStorageAdapter.read<PortForwardingRule[]>(
STORAGE_KEY_PORT_FORWARDING,
) ?? [];
const pendingAutoStartRules = storedRules.filter((rule) => rule.autoStart && rule.hostId);
if (pendingAutoStartRules.some((rule) => {
const host = hosts.find((candidate) => candidate.id === rule.hostId);
return !host || !isHostAuthReady(host);
})) {
return;
}
// Mark as executed immediately to prevent duplicate runs
// (React StrictMode or dependency changes could cause re-runs)
autoStartExecutedRef.current = true;
@@ -108,7 +151,9 @@ export const usePortForwardingAutoStart = ({
void startPortForward(
rule,
host,
hosts,
keys,
identities,
(status, error) => {
// Update the rule status in storage
const currentRules = localStorageAdapter.read<PortForwardingRule[]>(
@@ -135,5 +180,5 @@ export const usePortForwardingAutoStart = ({
};
void runAutoStart();
}, [hosts, keys]);
}, [hosts, identities, isHostAuthReady, keys]);
};

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Host, PortForwardingRule } from "../../domain/models";
import { Host, Identity, PortForwardingRule, SSHKey } from "../../domain/models";
import {
STORAGE_KEY_PF_PREFER_FORM_MODE,
STORAGE_KEY_PF_VIEW_MODE,
@@ -63,7 +63,9 @@ export interface UsePortForwardingStateResult {
startTunnel: (
rule: PortForwardingRule,
host: Host,
keys: { id: string; privateKey: string; passphrase: string }[],
hosts: Host[],
keys: SSHKey[],
identities: Identity[],
onStatusChange?: (status: PortForwardingRule["status"], error?: string) => void,
enableReconnect?: boolean,
) => Promise<{ success: boolean; error?: string }>;
@@ -377,14 +379,16 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
async (
rule: PortForwardingRule,
host: Host,
keys: { id: string; privateKey: string; passphrase: string }[],
hosts: Host[],
keys: SSHKey[],
identities: Identity[],
onStatusChange?: (
status: PortForwardingRule["status"],
error?: string,
) => void,
enableReconnect = false,
) => {
return startPortForward(rule, host, keys, (status, error) => {
return startPortForward(rule, host, hosts, keys, identities, (status, error) => {
setRuleStatus(rule.id, status, error);
onStatusChange?.(status, error ?? undefined);
}, enableReconnect);

View File

@@ -38,7 +38,9 @@ export const useSessionState = () => {
// Log views: stores open log replay tabs
const [logViews, setLogViews] = useState<LogView[]>([]);
const createLocalTerminal = useCallback(() => {
const createLocalTerminal = useCallback((options?: {
shellType?: TerminalSession['shellType'];
}) => {
const sessionId = crypto.randomUUID();
const localHostId = `local-${sessionId}`;
const newSession: TerminalSession = {
@@ -48,6 +50,8 @@ export const useSessionState = () => {
hostname: 'localhost',
username: 'local',
status: 'connecting',
protocol: 'local',
shellType: options?.shellType,
};
setSessions(prev => [...prev, newSession]);
setActiveTabId(sessionId);
@@ -414,11 +418,17 @@ export const useSessionState = () => {
// direction: 'horizontal' = split top/bottom, 'vertical' = split left/right
const splitSession = useCallback((
sessionId: string,
direction: SplitDirection
direction: SplitDirection,
options?: {
localShellType?: TerminalSession['shellType'];
},
) => {
setSessions(prevSessions => {
const session = prevSessions.find(s => s.id === sessionId);
if (!session) return prevSessions;
const nextShellType = session.protocol === 'local'
? options?.localShellType
: session.shellType;
// If session is already in a workspace, split within that workspace
if (session.workspaceId) {
@@ -434,6 +444,7 @@ export const useSessionState = () => {
protocol: session.protocol,
port: session.port,
moshEnabled: session.moshEnabled,
shellType: nextShellType,
};
// Add pane to existing workspace
@@ -464,6 +475,7 @@ export const useSessionState = () => {
protocol: session.protocol,
port: session.port,
moshEnabled: session.moshEnabled,
shellType: nextShellType,
};
const hint: SplitHint = {
@@ -615,10 +627,15 @@ export const useSessionState = () => {
}, [setActiveTabId]);
// Copy a session - creates a new session with the same host connection
const copySession = useCallback((sessionId: string) => {
const copySession = useCallback((sessionId: string, options?: {
localShellType?: TerminalSession['shellType'];
}) => {
setSessions(prevSessions => {
const session = prevSessions.find(s => s.id === sessionId);
if (!session) return prevSessions;
const nextShellType = session.protocol === 'local'
? options?.localShellType
: session.shellType;
// Create a new session with the same connection info
const newSession: TerminalSession = {
@@ -631,6 +648,7 @@ export const useSessionState = () => {
protocol: session.protocol,
port: session.port,
moshEnabled: session.moshEnabled,
shellType: nextShellType,
serialConfig: session.serialConfig,
};

View File

@@ -21,6 +21,7 @@ import {
STORAGE_KEY_SFTP_AUTO_SYNC,
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_EDITOR_WORD_WRAP,
STORAGE_KEY_SESSION_LOGS_ENABLED,
STORAGE_KEY_SESSION_LOGS_DIR,
@@ -29,6 +30,7 @@ import {
STORAGE_KEY_CLOSE_TO_TRAY,
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
STORAGE_KEY_AUTO_UPDATE_ENABLED,
STORAGE_KEY_IMMERSIVE_MODE,
} from '../../infrastructure/config/storageKeys';
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
@@ -37,7 +39,6 @@ import { DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
import { DARK_UI_THEMES, LIGHT_UI_THEMES, UiThemeTokens, getUiThemeById } from '../../infrastructure/config/uiThemes';
import { UI_FONTS, DEFAULT_UI_FONT_ID } from '../../infrastructure/config/uiFonts';
import { uiFontStore, useUIFontsLoaded } from './uiFontStore';
import { useAvailableFonts } from './fontStore';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
@@ -63,6 +64,7 @@ const DEFAULT_SFTP_DOUBLE_CLICK_BEHAVIOR: 'open' | 'transfer' = 'open';
const DEFAULT_SFTP_AUTO_SYNC = false;
const DEFAULT_SFTP_SHOW_HIDDEN_FILES = false;
const DEFAULT_SFTP_USE_COMPRESSED_UPLOAD = true;
const DEFAULT_SFTP_AUTO_OPEN_SIDEBAR = false;
// Editor defaults
const DEFAULT_EDITOR_WORD_WRAP = false;
@@ -119,8 +121,11 @@ const applyThemeTokens = (
accentOverride: string,
) => {
const root = window.document.documentElement;
root.classList.remove('light', 'dark');
root.classList.add(resolvedTheme);
// If immersive mode 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.style.setProperty('--background', tokens.background);
root.style.setProperty('--foreground', tokens.foreground);
root.style.setProperty('--card', tokens.card);
@@ -153,7 +158,6 @@ const applyThemeTokens = (
};
export const useSettingsState = () => {
const availableFonts = useAvailableFonts();
const uiFontsLoaded = useUIFontsLoaded();
const [theme, setTheme] = useState<'dark' | 'light' | 'system'>(() => {
const stored = readStoredString(STORAGE_KEY_THEME);
@@ -231,6 +235,10 @@ export const useSettingsState = () => {
if (stored === 'false' || stored === 'disabled') return false;
return DEFAULT_SFTP_USE_COMPRESSED_UPLOAD;
});
const [sftpAutoOpenSidebar, setSftpAutoOpenSidebar] = useState<boolean>(() => {
const stored = readStoredString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
return stored === 'true' ? true : DEFAULT_SFTP_AUTO_OPEN_SIDEBAR;
});
// Editor Settings
const [editorWordWrap, setEditorWordWrapState] = useState<boolean>(() => {
@@ -281,6 +289,10 @@ export const useSettingsState = () => {
const localTerminalSettingsVersionRef = useRef(0);
const broadcastedLocalTerminalSettingsVersionRef = useRef(0);
// Fix 1: Mount guard — skip redundant IPC broadcasts & localStorage writes on initial mount.
// Set to true by the LAST useEffect declaration; all persist effects see false on first render.
const persistMountedRef = useRef(false);
const setTerminalSettings = useCallback((nextValue: SetStateAction<TerminalSettings>) => {
setTerminalSettingsState((prev) => {
const candidate = typeof nextValue === 'function'
@@ -316,6 +328,21 @@ export const useSettingsState = () => {
}
}, []);
const [immersiveMode, setImmersiveModeState] = useState<boolean>(() => {
const stored = localStorageAdapter.readString(STORAGE_KEY_IMMERSIVE_MODE);
if (stored === null || stored === '') {
// Persist default so collectSyncableSettings() can include it
localStorageAdapter.writeString(STORAGE_KEY_IMMERSIVE_MODE, 'true');
return true;
}
return stored === 'true';
});
const setImmersiveMode = useCallback((enabled: boolean) => {
setImmersiveModeState(enabled);
localStorageAdapter.writeString(STORAGE_KEY_IMMERSIVE_MODE, String(enabled));
notifySettingsChanged(STORAGE_KEY_IMMERSIVE_MODE, enabled);
}, [notifySettingsChanged]);
const syncAppearanceFromStorage = useCallback(() => {
const storedTheme = readStoredString(STORAGE_KEY_THEME);
const nextTheme = storedTheme && isValidTheme(storedTheme) ? storedTheme : theme;
@@ -328,6 +355,17 @@ export const useSettingsState = () => {
const storedAccent = readStoredString(STORAGE_KEY_COLOR);
const nextAccent = storedAccent && isValidHslToken(storedAccent) ? storedAccent.trim() : customAccent;
// Fix 2: Skip expensive DOM operations if nothing actually changed
if (
nextTheme === theme &&
nextLightId === lightUiThemeId &&
nextDarkId === darkUiThemeId &&
nextAccentMode === accentMode &&
nextAccent === customAccent
) {
return;
}
setTheme(nextTheme);
setLightUiThemeId(nextLightId);
setDarkUiThemeId(nextDarkId);
@@ -393,10 +431,20 @@ export const useSettingsState = () => {
if (storedHidden === 'true' || storedHidden === 'false') setSftpShowHiddenFiles(storedHidden === 'true');
const storedCompress = readStoredString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD);
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');
// Immersive mode
const storedImmersive = readStoredString(STORAGE_KEY_IMMERSIVE_MODE);
if (storedImmersive === 'true' || storedImmersive === 'false') {
const val = storedImmersive === 'true';
setImmersiveModeState(val);
notifySettingsChanged(STORAGE_KEY_IMMERSIVE_MODE, val);
}
// Custom terminal themes
customThemeStore.loadFromStorage();
}, [syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings]);
}, [syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings, notifySettingsChanged]);
useLayoutEffect(() => {
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
@@ -406,12 +454,11 @@ export const useSettingsState = () => {
localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId);
localStorageAdapter.writeString(STORAGE_KEY_ACCENT_MODE, accentMode);
localStorageAdapter.writeString(STORAGE_KEY_COLOR, customAccent);
// Notify other windows
// Fix 1: Skip IPC broadcast on initial mount (values already match localStorage)
if (!persistMountedRef.current) return;
// Fix 3: Send a single IPC instead of 5 — the receiver calls syncAppearanceFromStorage()
// which re-reads ALL appearance values from localStorage.
notifySettingsChanged(STORAGE_KEY_THEME, theme);
notifySettingsChanged(STORAGE_KEY_UI_THEME_LIGHT, lightUiThemeId);
notifySettingsChanged(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId);
notifySettingsChanged(STORAGE_KEY_ACCENT_MODE, accentMode);
notifySettingsChanged(STORAGE_KEY_COLOR, customAccent);
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, notifySettingsChanged]);
// Listen for OS color scheme changes to keep systemPreference in sync
@@ -429,7 +476,10 @@ export const useSettingsState = () => {
localStorageAdapter.writeString(STORAGE_KEY_UI_LANGUAGE, uiLanguage);
document.documentElement.lang = uiLanguage;
netcattyBridge.get()?.setLanguage?.(uiLanguage);
notifySettingsChanged(STORAGE_KEY_UI_LANGUAGE, uiLanguage);
// Fix 1: Skip IPC broadcast on initial mount
if (persistMountedRef.current) {
notifySettingsChanged(STORAGE_KEY_UI_LANGUAGE, uiLanguage);
}
}, [uiLanguage, notifySettingsChanged]);
// Apply and persist UI font family
@@ -438,7 +488,10 @@ export const useSettingsState = () => {
const font = uiFontStore.getFontById(uiFontFamilyId);
document.documentElement.style.setProperty('--font-sans', font.family);
localStorageAdapter.writeString(STORAGE_KEY_UI_FONT_FAMILY, uiFontFamilyId);
notifySettingsChanged(STORAGE_KEY_UI_FONT_FAMILY, uiFontFamilyId);
// Fix 1: Skip IPC broadcast on initial mount
if (persistMountedRef.current) {
notifySettingsChanged(STORAGE_KEY_UI_FONT_FAMILY, uiFontFamilyId);
}
}, [uiFontFamilyId, uiFontsLoaded, notifySettingsChanged]);
// Listen for settings changes from other windows via IPC
@@ -529,6 +582,12 @@ export const useSettingsState = () => {
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_IMMERSIVE_MODE && typeof value === 'boolean') {
setImmersiveModeState((prev) => (prev === value ? prev : value));
}
});
return () => {
try {
@@ -556,53 +615,76 @@ export const useSettingsState = () => {
};
}, []);
// Fix 4: Keep a ref snapshot of current settings so the storage event handler
// can compare without capturing 25+ state variables in its closure / dep array.
// This avoids constant listener detach/reattach on every state change.
const settingsSnapshotRef = useRef({
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
globalHotkeyEnabled, autoUpdateEnabled, immersiveMode,
});
settingsSnapshotRef.current = {
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
globalHotkeyEnabled, autoUpdateEnabled, immersiveMode,
};
// Listen for storage changes from other windows (cross-window sync)
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
const s = settingsSnapshotRef.current;
if (e.key === STORAGE_KEY_THEME && e.newValue) {
if (isValidTheme(e.newValue) && e.newValue !== theme) {
if (isValidTheme(e.newValue) && e.newValue !== s.theme) {
setTheme(e.newValue);
}
}
if (e.key === STORAGE_KEY_UI_THEME_LIGHT && e.newValue) {
if (isValidUiThemeId('light', e.newValue) && e.newValue !== lightUiThemeId) {
if (isValidUiThemeId('light', e.newValue) && e.newValue !== s.lightUiThemeId) {
setLightUiThemeId(e.newValue);
}
}
if (e.key === STORAGE_KEY_UI_THEME_DARK && e.newValue) {
if (isValidUiThemeId('dark', e.newValue) && e.newValue !== darkUiThemeId) {
if (isValidUiThemeId('dark', e.newValue) && e.newValue !== s.darkUiThemeId) {
setDarkUiThemeId(e.newValue);
}
}
if (e.key === STORAGE_KEY_ACCENT_MODE && e.newValue) {
if ((e.newValue === 'theme' || e.newValue === 'custom') && e.newValue !== accentMode) {
if ((e.newValue === 'theme' || e.newValue === 'custom') && e.newValue !== s.accentMode) {
setAccentMode(e.newValue);
}
}
if (e.key === STORAGE_KEY_COLOR && e.newValue) {
if (isValidHslToken(e.newValue) && e.newValue !== customAccent) {
if (isValidHslToken(e.newValue) && e.newValue !== s.customAccent) {
setCustomAccent(e.newValue.trim());
}
}
if (e.key === STORAGE_KEY_CUSTOM_CSS && e.newValue !== null) {
if (e.newValue !== customCSS) {
if (e.newValue !== s.customCSS) {
setCustomCSS(e.newValue);
}
}
if (e.key === STORAGE_KEY_UI_FONT_FAMILY && e.newValue) {
if (isValidUiFontId(e.newValue) && e.newValue !== uiFontFamilyId) {
if (isValidUiFontId(e.newValue) && e.newValue !== s.uiFontFamilyId) {
setUiFontFamilyId(e.newValue);
}
}
if (e.key === STORAGE_KEY_HOTKEY_SCHEME && e.newValue) {
const newScheme = e.newValue as HotkeyScheme;
if (newScheme !== hotkeyScheme) {
if (newScheme !== s.hotkeyScheme) {
setHotkeyScheme(newScheme);
}
}
if (e.key === STORAGE_KEY_UI_LANGUAGE && e.newValue) {
const next = resolveSupportedLocale(e.newValue);
if (next !== uiLanguage) {
if (next !== s.uiLanguage) {
setUiLanguage(next as UILanguage);
}
}
@@ -625,64 +707,64 @@ export const useSettingsState = () => {
}
// Sync terminal theme from other windows
if (e.key === STORAGE_KEY_TERM_THEME && e.newValue) {
if (e.newValue !== terminalThemeId) {
if (e.newValue !== s.terminalThemeId) {
setTerminalThemeId(e.newValue);
}
}
// Sync terminal font family from other windows
if (e.key === STORAGE_KEY_TERM_FONT_FAMILY && e.newValue) {
if (e.newValue !== terminalFontFamilyId) {
if (e.newValue !== s.terminalFontFamilyId) {
setTerminalFontFamilyId(e.newValue);
}
}
// Sync terminal font size from other windows
if (e.key === STORAGE_KEY_TERM_FONT_SIZE && e.newValue) {
const newSize = parseInt(e.newValue, 10);
if (!isNaN(newSize) && newSize !== terminalFontSize) {
if (!isNaN(newSize) && newSize !== s.terminalFontSize) {
setTerminalFontSize(newSize);
}
}
// Sync SFTP double-click behavior from other windows
if (e.key === STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR && e.newValue) {
if ((e.newValue === 'open' || e.newValue === 'transfer') && e.newValue !== sftpDoubleClickBehavior) {
if ((e.newValue === 'open' || e.newValue === 'transfer') && e.newValue !== s.sftpDoubleClickBehavior) {
setSftpDoubleClickBehavior(e.newValue);
}
}
// Sync SFTP auto-sync setting from other windows
if (e.key === STORAGE_KEY_SFTP_AUTO_SYNC && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== sftpAutoSync) {
if (newValue !== s.sftpAutoSync) {
setSftpAutoSync(newValue);
}
}
// Sync SFTP show hidden files setting from other windows
if (e.key === STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== sftpShowHiddenFiles) {
if (newValue !== s.sftpShowHiddenFiles) {
setSftpShowHiddenFiles(newValue);
}
}
if (e.key === STORAGE_KEY_EDITOR_WORD_WRAP && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== editorWordWrap) {
if (newValue !== s.editorWordWrap) {
setEditorWordWrapState(newValue);
}
}
if (e.key === STORAGE_KEY_SESSION_LOGS_ENABLED && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== sessionLogsEnabled) {
if (newValue !== s.sessionLogsEnabled) {
setSessionLogsEnabled(newValue);
}
}
if (e.key === STORAGE_KEY_SESSION_LOGS_DIR && e.newValue !== null) {
if (e.newValue !== sessionLogsDir) {
if (e.newValue !== s.sessionLogsDir) {
setSessionLogsDir(e.newValue);
}
}
if (e.key === STORAGE_KEY_SESSION_LOGS_FORMAT && e.newValue) {
if (
(e.newValue === 'txt' || e.newValue === 'raw' || e.newValue === 'html') &&
e.newValue !== sessionLogsFormat
e.newValue !== s.sessionLogsFormat
) {
setSessionLogsFormat(e.newValue);
}
@@ -690,47 +772,65 @@ export const useSettingsState = () => {
// Sync SFTP compressed upload setting from other windows
if (e.key === STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD && e.newValue !== null) {
const newValue = e.newValue === 'true' || e.newValue === 'enabled';
if (newValue !== sftpUseCompressedUpload) {
if (newValue !== s.sftpUseCompressedUpload) {
setSftpUseCompressedUpload(newValue);
}
}
// Sync SFTP auto-open sidebar setting from other windows
if (e.key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.sftpAutoOpenSidebar) {
setSftpAutoOpenSidebar(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';
if (newValue !== globalHotkeyEnabled) {
if (newValue !== s.globalHotkeyEnabled) {
setGlobalHotkeyEnabled(newValue);
}
}
// Sync auto-update enabled setting from other windows
if (e.key === STORAGE_KEY_AUTO_UPDATE_ENABLED && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== autoUpdateEnabled) {
if (newValue !== s.autoUpdateEnabled) {
setAutoUpdateEnabled(newValue);
}
}
// Sync immersive mode from other windows
if (e.key === STORAGE_KEY_IMMERSIVE_MODE && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.immersiveMode) {
setImmersiveModeState(newValue);
}
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, globalHotkeyEnabled, autoUpdateEnabled, mergeIncomingTerminalSettings]);
}, [mergeIncomingTerminalSettings]); // Fix 4: stable deps only — state comparisons use settingsSnapshotRef
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_TERM_THEME, terminalThemeId);
}, [terminalThemeId, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, terminalFontFamilyId);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_TERM_FONT_FAMILY, terminalFontFamilyId);
}, [terminalFontFamilyId, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeNumber(STORAGE_KEY_TERM_FONT_SIZE, terminalFontSize);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_TERM_FONT_SIZE, terminalFontSize);
}, [terminalFontSize, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.write(STORAGE_KEY_TERM_SETTINGS, terminalSettings);
if (!persistMountedRef.current) return;
const currentSignature = serializeTerminalSettings(terminalSettings);
const hasPendingUnbroadcastLocalChanges =
localTerminalSettingsVersionRef.current !== broadcastedLocalTerminalSettingsVersionRef.current;
@@ -745,11 +845,13 @@ export const useSettingsState = () => {
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_HOTKEY_SCHEME, hotkeyScheme);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_HOTKEY_SCHEME, hotkeyScheme);
}, [hotkeyScheme, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.write(STORAGE_KEY_CUSTOM_KEY_BINDINGS, customKeyBindings);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_CUSTOM_KEY_BINDINGS, customKeyBindings);
}, [customKeyBindings, notifySettingsChanged]);
@@ -760,10 +862,7 @@ export const useSettingsState = () => {
// Apply and persist custom CSS
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_CSS, customCSS);
notifySettingsChanged(STORAGE_KEY_CUSTOM_CSS, customCSS);
// Apply custom CSS to document
// Always apply CSS to document (needed on mount)
let styleEl = document.getElementById('netcatty-custom-css') as HTMLStyleElement | null;
if (!styleEl) {
styleEl = document.createElement('style');
@@ -771,53 +870,69 @@ export const useSettingsState = () => {
document.head.appendChild(styleEl);
}
styleEl.textContent = customCSS;
localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_CSS, customCSS);
// Skip IPC on initial mount
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_CUSTOM_CSS, customCSS);
}, [customCSS, notifySettingsChanged]);
// Persist SFTP double-click behavior
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR, sftpDoubleClickBehavior);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR, sftpDoubleClickBehavior);
}, [sftpDoubleClickBehavior, notifySettingsChanged]);
// Persist SFTP auto-sync setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_SYNC, sftpAutoSync ? 'true' : 'false');
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SFTP_AUTO_SYNC, sftpAutoSync);
}, [sftpAutoSync, notifySettingsChanged]);
// Persist SFTP show hidden files setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, sftpShowHiddenFiles ? 'true' : 'false');
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, sftpShowHiddenFiles);
}, [sftpShowHiddenFiles, notifySettingsChanged]);
// Persist SFTP compressed upload setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, sftpUseCompressedUpload ? 'true' : 'false');
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, sftpUseCompressedUpload);
}, [sftpUseCompressedUpload, notifySettingsChanged]);
// Persist SFTP auto-open sidebar setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, sftpAutoOpenSidebar ? 'true' : 'false');
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, sftpAutoOpenSidebar);
}, [sftpAutoOpenSidebar, notifySettingsChanged]);
// Persist Session Logs settings
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_ENABLED, sessionLogsEnabled ? 'true' : 'false');
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_ENABLED, sessionLogsEnabled);
}, [sessionLogsEnabled, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_DIR, sessionLogsDir);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_DIR, sessionLogsDir);
}, [sessionLogsDir, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_FORMAT, sessionLogsFormat);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_FORMAT, sessionLogsFormat);
}, [sessionLogsFormat, notifySettingsChanged]);
// Persist and sync toggle window hotkey setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
notifySettingsChanged(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
// Register/unregister the global hotkey in main process
// Register/unregister the global hotkey in main process (needed on mount)
const bridge = netcattyBridge.get();
if (bridge?.registerGlobalHotkey) {
if (toggleWindowHotkey && globalHotkeyEnabled) {
@@ -841,25 +956,32 @@ export const useSettingsState = () => {
});
}
}
localStorageAdapter.writeString(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
// Skip IPC on initial mount
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
}, [toggleWindowHotkey, globalHotkeyEnabled, notifySettingsChanged]);
// Persist global hotkey enabled setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, globalHotkeyEnabled ? 'true' : 'false');
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, globalHotkeyEnabled);
}, [globalHotkeyEnabled, notifySettingsChanged]);
// Persist and sync close to tray setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray ? 'true' : 'false');
notifySettingsChanged(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray);
// Update main process tray behavior
// Update main process tray behavior (needed on mount)
const bridge = netcattyBridge.get();
if (bridge?.setCloseToTray) {
bridge.setCloseToTray(closeToTray).catch((err) => {
console.warn('[SystemTray] Failed to set close-to-tray:', err);
});
}
localStorageAdapter.writeString(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray ? 'true' : 'false');
// Skip IPC on initial mount
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray);
}, [closeToTray, notifySettingsChanged]);
// Hydrate auto-update state from the main-process preference file on mount.
@@ -880,16 +1002,11 @@ export const useSettingsState = () => {
}, []);
// Persist auto-update enabled setting.
// Skip IPC on initial mount to avoid overwriting the main-process preference
// file when localStorage has been cleared (where the default is true).
const autoUpdateMountedRef = useRef(false);
// Initial mount still writes localStorage, but skips cross-window/main-process IPC.
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_AUTO_UPDATE_ENABLED, autoUpdateEnabled ? 'true' : 'false');
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_AUTO_UPDATE_ENABLED, autoUpdateEnabled);
if (!autoUpdateMountedRef.current) {
autoUpdateMountedRef.current = true;
return; // Skip IPC on initial mount
}
// Notify main process on user-initiated changes
const bridge = netcattyBridge.get();
bridge?.setAutoUpdate?.(autoUpdateEnabled).catch((err: unknown) => {
@@ -897,6 +1014,13 @@ export const useSettingsState = () => {
});
}, [autoUpdateEnabled, notifySettingsChanged]);
// Fix 1: Mark all persist effects as mounted.
// This MUST be declared AFTER all persist useEffects so that React runs it last
// during the initial mount cycle (effects fire in declaration order).
useEffect(() => {
persistMountedRef.current = true;
}, []);
// Get merged key bindings (defaults + custom overrides)
const keyBindings = useMemo((): KeyBinding[] => {
return DEFAULT_KEY_BINDINGS.map(binding => {
@@ -959,11 +1083,6 @@ export const useSettingsState = () => {
[terminalThemeId, customThemes]
);
const currentTerminalFont = useMemo(
() => availableFonts.find(f => f.id === terminalFontFamilyId) || availableFonts[0],
[terminalFontFamilyId, availableFonts]
);
const updateTerminalSetting = useCallback(<K extends keyof TerminalSettings>(
key: K,
value: TerminalSettings[K]
@@ -971,6 +1090,12 @@ 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 tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
return {
theme,
setTheme,
@@ -994,7 +1119,6 @@ export const useSettingsState = () => {
currentTerminalTheme,
terminalFontFamilyId,
setTerminalFontFamilyId,
currentTerminalFont,
terminalFontSize,
setTerminalFontSize,
terminalSettings,
@@ -1019,6 +1143,8 @@ export const useSettingsState = () => {
setSftpShowHiddenFiles,
sftpUseCompressedUpload,
setSftpUseCompressedUpload,
sftpAutoOpenSidebar,
setSftpAutoOpenSidebar,
// Editor Settings
editorWordWrap,
setEditorWordWrap: useCallback((enabled: boolean) => {
@@ -1026,7 +1152,6 @@ export const useSettingsState = () => {
localStorageAdapter.writeString(STORAGE_KEY_EDITOR_WORD_WRAP, String(enabled));
notifySettingsChanged(STORAGE_KEY_EDITOR_WORD_WRAP, enabled);
}, [notifySettingsChanged]),
availableFonts,
// Session Logs
sessionLogsEnabled,
setSessionLogsEnabled,
@@ -1045,6 +1170,9 @@ export const useSettingsState = () => {
globalHotkeyEnabled,
setGlobalHotkeyEnabled,
rehydrateAllFromStorage,
reapplyCurrentTheme,
immersiveMode,
setImmersiveMode,
// Opaque version that changes when any synced setting changes, used by useAutoSync.
// eslint-disable-next-line react-hooks/exhaustive-deps
settingsVersion: useMemo(() => Math.random(), [
@@ -1052,8 +1180,8 @@ export const useSettingsState = () => {
uiFontFamilyId, uiLanguage, customCSS,
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
customKeyBindings, editorWordWrap,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload,
customThemes,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar,
customThemes, immersiveMode,
]),
};
};

View File

@@ -197,6 +197,12 @@ export const useSftpBackend = () => {
return bridge.showSaveDialog(defaultPath, filters);
}, []);
const selectDirectory = async (title?: string, defaultPath?: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.selectDirectory) return null;
return bridge.selectDirectory(title, defaultPath);
};
const downloadSftpToTempAndOpen = useCallback(async (
sftpId: string,
remotePath: string,
@@ -210,9 +216,7 @@ export const useSftpBackend = () => {
}
// Download the file to temp
console.log("[SFTPBackend] Downloading file to temp", { sftpId, remotePath, fileName });
const tempPath = await bridge.downloadSftpToTemp(sftpId, remotePath, fileName, options?.encoding);
console.log("[SFTPBackend] File downloaded to temp", { tempPath });
// Register temp file for cleanup when SFTP session closes (regardless of auto-sync setting)
if (bridge.registerTempFile) {
@@ -224,25 +228,18 @@ export const useSftpBackend = () => {
}
// Open with the selected application
console.log("[SFTPBackend] Opening with application", { tempPath, appPath });
await bridge.openWithApplication(tempPath, appPath);
console.log("[SFTPBackend] Application launched");
// Start file watching if enabled
let watchId: string | undefined;
console.log("[SFTPBackend] Auto-sync enabled check", { enableWatch: options?.enableWatch, hasStartFileWatch: !!bridge.startFileWatch });
if (options?.enableWatch && bridge.startFileWatch) {
try {
console.log("[SFTPBackend] Starting file watch", { tempPath, remotePath, sftpId });
const result = await bridge.startFileWatch(tempPath, remotePath, sftpId, options?.encoding);
watchId = result.watchId;
console.log("[SFTPBackend] File watch started successfully", { watchId, tempPath, remotePath });
} catch (err) {
console.warn("[SFTPBackend] Failed to start file watch:", err);
// Don't fail the operation if watching fails
}
} else {
console.log("[SFTPBackend] File watching not enabled or not available");
}
return { localTempPath: tempPath, watchId };
@@ -278,6 +275,7 @@ export const useSftpBackend = () => {
onTransferProgress,
selectApplication,
showSaveDialog,
selectDirectory,
downloadSftpToTempAndOpen,
};
};

View File

@@ -25,7 +25,6 @@ let snapshotRef: { associations: FileAssociationsMap } = { associations: {} };
function loadFromStorage(): FileAssociationsMap {
const stored = localStorageAdapter.read<FileAssociationsMap>(STORAGE_KEY_SFTP_FILE_ASSOCIATIONS);
console.log('[SftpFileAssociations] Loading from storage:', stored);
if (stored) {
const migrated: FileAssociationsMap = {};
for (const [ext, value] of Object.entries(stored)) {
@@ -35,7 +34,6 @@ function loadFromStorage(): FileAssociationsMap {
migrated[ext] = value as FileAssociationEntry;
}
}
console.log('[SftpFileAssociations] Migrated associations:', migrated);
return migrated;
}
return {};
@@ -45,19 +43,13 @@ function loadFromStorage(): FileAssociationsMap {
snapshotRef = { associations: loadFromStorage() };
function saveToStorage(associations: FileAssociationsMap) {
console.log('[SftpFileAssociations] saveToStorage called with:', associations);
localStorageAdapter.write(STORAGE_KEY_SFTP_FILE_ASSOCIATIONS, associations);
// Verify it was saved
const verify = localStorageAdapter.read(STORAGE_KEY_SFTP_FILE_ASSOCIATIONS);
console.log('[SftpFileAssociations] Verification read from storage:', verify);
}
function updateAssociations(newAssociations: FileAssociationsMap) {
console.log('[SftpFileAssociations] Updating associations:', newAssociations);
// Create new reference so useSyncExternalStore detects change
snapshotRef = { associations: newAssociations };
saveToStorage(newAssociations);
console.log('[SftpFileAssociations] Notifying', subscribers.size, 'subscribers');
subscribers.forEach(callback => callback());
}
@@ -101,8 +93,6 @@ export function useSftpFileAssociations() {
openerType: FileOpenerType,
systemApp?: SystemAppInfo
) => {
console.log('[SftpFileAssociations] setOpenerForExtension called with:', { extension, openerType, systemApp });
console.log('[SftpFileAssociations] Current associations before update:', snapshotRef.associations);
updateAssociations({
...snapshotRef.associations,
[extension.toLowerCase()]: { openerType, systemApp },
@@ -122,13 +112,11 @@ export function useSftpFileAssociations() {
* Get all associations as an array
*/
const getAllAssociations = useCallback((): FileAssociation[] => {
const result = Object.entries(associations).map(([extension, entry]: [string, FileAssociationEntry]) => ({
return Object.entries(associations).map(([extension, entry]: [string, FileAssociationEntry]) => ({
extension,
openerType: entry.openerType,
systemApp: entry.systemApp,
}));
console.log('[SftpFileAssociations] getAllAssociations called, returning', result.length, 'items:', result);
return result;
}, [associations]);
/**

View File

@@ -96,7 +96,7 @@ export const useTerminalBackend = () => {
return bridge.onSessionExit(sessionId, cb);
}, []);
const onChainProgress = useCallback((cb: (hop: number, total: number, label: string, status: string) => void) => {
const onChainProgress = useCallback((cb: (sessionId: string, hop: number, total: number, label: string, status: string, error?: string) => void) => {
const bridge = netcattyBridge.get();
return bridge?.onChainProgress?.(cb);
}, []);

View File

@@ -16,12 +16,8 @@ const STARTUP_CHECK_DELAY_MS = 8000;
const IS_UPDATE_DEMO_MODE = typeof window !== 'undefined' &&
window.localStorage?.getItem('debug.updateDemo') === '1';
// Debug logging for update checks
const debugLog = (...args: unknown[]) => {
if (IS_UPDATE_DEMO_MODE || (typeof window !== 'undefined' && window.localStorage?.getItem('debug.updateCheck') === '1')) {
console.log('[UpdateCheck]', ...args);
}
};
// Debug logging for update checks (no-op in production)
const debugLog = (..._args: unknown[]) => {};
export type AutoDownloadStatus = 'idle' | 'downloading' | 'ready' | 'error';

View File

@@ -6,7 +6,6 @@
*
* Core logic is decomposed into focused hooks:
* - useAIChatStreaming: stream processing, abort management, agent sub-flows
* - useToolApproval: tool approval workflow, timeouts, resume logic
* - useConversationExport: export formats & object URL lifecycle
*/
@@ -20,7 +19,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { cn } from '../lib/utils';
import { useI18n } from '../application/i18n/I18nProvider';
import { useWindowControls } from '../application/state/useWindowControls';
import { useImageUpload } from '../application/state/useImageUpload';
import { useFileUpload } from '../application/state/useFileUpload';
import type {
AIPermissionMode,
AISession,
@@ -40,7 +39,7 @@ import ChatInput from './ai/ChatInput';
import ChatMessageList from './ai/ChatMessageList';
import ConversationExport from './ai/ConversationExport';
import { useAIChatStreaming, getNetcattyBridge } from './ai/hooks/useAIChatStreaming';
import { useToolApproval } from './ai/hooks/useToolApproval';
import { clearAllPendingApprovals } from '../infrastructure/ai/shared/approvalGate';
import { useConversationExport } from './ai/hooks/useConversationExport';
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
@@ -102,6 +101,8 @@ interface AIChatSidePanelProps {
label: string;
os?: string;
username?: string;
protocol?: string;
shellType?: string;
connected: boolean;
}>;
resolveExecutorContext?: (scope: {
@@ -202,7 +203,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
const [showHistory, setShowHistory] = useState(false);
const [currentAgentId, setCurrentAgentId] = useState(defaultAgentId);
const { images, addImages, removeImage, clearImages } = useImageUpload();
const { files, addFiles, removeFile, clearFiles } = useFileUpload();
const { openSettingsWindow } = useWindowControls();
const terminalSessionsRef = useRef(terminalSessions);
terminalSessionsRef.current = terminalSessions;
@@ -214,7 +215,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
streamingSessionIds,
setStreamingForScope,
abortControllersRef,
processCattyStream,
sendToCattyAgent,
sendToExternalAgent,
reportStreamError,
@@ -225,20 +225,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
updateMessageById,
});
// ── Tool approval hook ──
const {
pendingApprovalContextRef,
setPendingApproval,
handleApprovalResponse,
} = useToolApproval({
addMessageToSession,
updateLastMessage,
updateMessageById,
setStreamingForScope,
abortControllersRef,
processCattyStream,
t,
});
// Per-scope active session ID
const activeSessionId = activeSessionIdMap[scopeKey] ?? null;
@@ -260,7 +246,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
// Proactively sync terminal session metadata to main process whenever scope or sessions change
useEffect(() => {
const bridge = getNetcattyBridge();
if (bridge?.aiMcpUpdateSessions && terminalSessions.length > 0) {
if (bridge?.aiMcpUpdateSessions) {
void bridge.aiMcpUpdateSessions(terminalSessions, activeSessionId ?? undefined);
}
}, [terminalSessions, scopeKey, activeSessionId]);
@@ -407,8 +393,8 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
/** Refs to avoid re-creating handleSend on every keystroke / image change. */
const inputValueRef = useRef(inputValue);
inputValueRef.current = inputValue;
const imagesRef = useRef(images);
imagesRef.current = images;
const filesRef = useRef(files);
filesRef.current = files;
/** Auto-title a session from the first user message if untitled. */
const autoTitleSession = useCallback((sessionId: string, text: string) => {
@@ -434,7 +420,9 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
/** Ensure a session exists for the current scope and return its ID. */
const ensureSession = useCallback((): string => {
if (activeSessionId) return activeSessionId;
if (activeSessionId && sessionsRef.current.some((session) => session.id === activeSessionId)) {
return activeSessionId;
}
const scope: AISessionScope = { type: scopeType, targetId: scopeTargetId, hostIds: scopeHostIds };
const session = createSession(scope, currentAgentId);
setActiveSessionId(session.id);
@@ -465,16 +453,16 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
const sessionId = ensureSession();
// Capture images before clearing
const attachedImages = imagesRef.current.map(img => ({ base64Data: img.base64Data, mediaType: img.mediaType, filename: img.filename }));
const attachments = filesRef.current.map(f => ({ base64Data: f.base64Data, mediaType: f.mediaType, filename: f.filename, filePath: f.filePath }));
// Add user message
addMessageToSession(sessionId, {
id: generateId(), role: 'user', content: trimmed,
...(attachedImages.length > 0 ? { images: attachedImages } : {}),
...(attachments.length > 0 ? { attachments } : {}),
timestamp: Date.now(),
});
setInputValue('');
clearImages();
clearFiles();
setStreamingForScope(sessionId, true);
// Create assistant message placeholder with a tracked ID
@@ -497,7 +485,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
return;
}
try {
await sendToExternalAgent(sessionId, trimmed, agentConfig, abortController, attachedImages, {
await sendToExternalAgent(sessionId, trimmed, agentConfig, abortController, attachments, {
existingSessionId: currentSession?.externalSessionId,
updateExternalSessionId: updateSessionExternalSessionId,
historyMessages: buildAcpHistoryMessages(currentSession?.messages ?? []),
@@ -530,18 +518,17 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
terminalSessions,
webSearchConfig,
getExecutorContext: () => buildExecutorContextForScope(toolScope),
setPendingApproval,
autoTitleSession,
});
}, attachments.length > 0 ? attachments : undefined);
}
}, [
isStreaming, activeProvider, scopeKey, currentAgentId,
activeModelId, externalAgents,
ensureSession, addMessageToSession, updateMessageById, updateLastMessage,
setStreamingForScope, setInputValue, clearImages,
setStreamingForScope, setInputValue, clearFiles,
sendToExternalAgent, sendToCattyAgent, reportStreamError, autoTitleSession, t,
abortControllersRef, terminalSessions, providers, selectedAgentModel, updateSessionExternalSessionId,
scopeType, scopeTargetId, scopeLabel, globalPermissionMode, commandBlocklist, webSearchConfig, buildExecutorContextForScope, setPendingApproval,
scopeType, scopeTargetId, scopeLabel, globalPermissionMode, commandBlocklist, webSearchConfig, buildExecutorContextForScope,
]);
const handleStop = useCallback(() => {
@@ -556,11 +543,13 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
statusText: '',
executionStatus: msg.executionStatus === 'running' ? 'cancelled' : msg.executionStatus,
}));
// Also clear any pending approval (clears timeout too via setPendingApproval)
if (pendingApprovalContextRef.current?.sessionId === activeSessionId) {
setPendingApproval(null);
}
}, [activeSessionId, setStreamingForScope, updateLastMessage, setPendingApproval, abortControllersRef, pendingApprovalContextRef]);
// Clear pending approvals for this session (so tool execute functions don't hang)
clearAllPendingApprovals(activeSessionId);
// Cancel in-flight command executions (Catty Agent + ACP Agent)
const bridge = getNetcattyBridge();
bridge?.aiCattyCancelExec?.(activeSessionId);
bridge?.aiAcpCancel?.('', activeSessionId);
}, [activeSessionId, setStreamingForScope, updateLastMessage, abortControllersRef]);
const handleSelectSession = useCallback(
(sessionId: string) => {
@@ -653,16 +642,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
<ChatMessageList
messages={messages}
isStreaming={isStreaming}
onApprove={(messageId) => void handleApprovalResponse(messageId, true, {
globalPermissionMode,
commandBlocklist,
webSearchConfig,
})}
onReject={(messageId) => void handleApprovalResponse(messageId, false, {
globalPermissionMode,
commandBlocklist,
webSearchConfig,
})}
activeSessionId={activeSessionId}
/>
{/* Recent sessions (Zed-style, shown when no messages) */}
@@ -707,9 +687,9 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
modelPresets={agentModelPresets}
selectedModelId={selectedAgentModel}
onModelSelect={handleAgentModelSelect}
images={images}
onAddImages={addImages}
onRemoveImage={removeImage}
files={files}
onAddFiles={addFiles}
onRemoveFile={removeFile}
hosts={terminalSessions.map(s => ({ sessionId: s.sessionId, hostname: s.hostname, label: s.label, connected: s.connected }))}
permissionMode={globalPermissionMode}
onPermissionModeChange={setGlobalPermissionMode}

View File

@@ -411,7 +411,7 @@ const ProviderCard: React.FC<ProviderCardProps> = ({
) : (
<Button
size="sm"
onClick={() => { console.log('[ProviderCard] Connect clicked'); onConnect(); }}
onClick={() => { onConnect(); }}
className="gap-1"
disabled={disabled || isConnecting}
>
@@ -689,15 +689,6 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
}
};
// Debug: log provider states
console.log('[SyncDashboard] Provider states:', {
github: sync.providers.github.status,
google: sync.providers.google.status,
onedrive: sync.providers.onedrive.status,
webdav: sync.providers.webdav.status,
s3: sync.providers.s3.status,
});
// GitHub Device Flow state
const [showGitHubModal, setShowGitHubModal] = useState(false);
const [gitHubUserCode, setGitHubUserCode] = useState('');
@@ -789,12 +780,9 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
// Connect GitHub (disconnect others first - single provider only)
const handleConnectGitHub = async () => {
console.log('[CloudSync] handleConnectGitHub called');
try {
await disconnectOtherProviders('github');
console.log('[CloudSync] Calling sync.connectGitHub()...');
const deviceFlow = await sync.connectGitHub();
console.log('[CloudSync] Device flow received:', deviceFlow.userCode);
setGitHubUserCode(deviceFlow.userCode);
setGitHubVerificationUri(deviceFlow.verificationUri);
setShowGitHubModal(true);

View File

@@ -1,6 +1,6 @@
import { Server, Usb } from "lucide-react";
import React, { memo } from "react";
import { normalizeDistroId } from "../domain/host";
import { getEffectiveHostDistro } from "../domain/host";
import { cn } from "../lib/utils";
import { Host } from "../types";
@@ -58,8 +58,7 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
className,
size = "md",
}) => {
const distro =
normalizeDistroId(host.distro) || (host.distro || "").toLowerCase();
const distro = getEffectiveHostDistro(host);
const logo = DISTRO_LOGOS[distro];
const [errored, setErrored] = React.useState(false);
const bg = DISTRO_COLORS[distro] || DISTRO_COLORS.default;
@@ -106,7 +105,7 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
>
<img
src={logo}
alt={host.distro || host.os}
alt={distro || host.os}
className={cn("object-contain invert brightness-0", iconSize)}
onError={() => setErrored(true)}
/>

View File

@@ -45,7 +45,6 @@ export const FileOpenerDialog: React.FC<FileOpenerDialogProps> = ({
try {
const result = await onSelectSystemApp();
if (result) {
console.log('[FileOpenerDialog] Calling onSelect with rememberChoice:', rememberChoice, 'result:', result);
onSelect('system-app', rememberChoice, result);
onClose();
}

View File

@@ -20,6 +20,9 @@ import {
Tag,
TerminalSquare,
User,
FileKey,
FolderOpen,
Trash2,
Variable,
Wifi,
X,
@@ -28,10 +31,20 @@ import React, { useEffect, useMemo, useState, useCallback } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { useApplicationBackend } from "../application/state/useApplicationBackend";
import { useSettingsState } from "../application/state/useSettingsState";
import { getEffectiveHostDistro, LINUX_DISTRO_OPTIONS } from "../domain/host";
import { customThemeStore } from "../application/state/customThemeStore";
import {
clearHostFontSizeOverride,
clearHostThemeOverride,
hasHostFontSizeOverride,
hasHostThemeOverride,
resolveHostTerminalFontSize,
resolveHostTerminalThemeId,
} from "../domain/terminalAppearance";
import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "../infrastructure/config/fonts";
import { cn } from "../lib/utils";
import { EnvVar, Host, Identity, ManagedSource, ProxyConfig, SSHKey } from "../types";
import { DISTRO_COLORS, DISTRO_LOGOS } from "./DistroAvatar";
import { DistroAvatar } from "./DistroAvatar";
import ThemeSelectPanel from "./ThemeSelectPanel";
import {
@@ -59,7 +72,7 @@ import {
ProxyPanel,
} from "./host-details";
type CredentialType = "sshid" | "key" | "certificate" | null;
type CredentialType = "sshid" | "key" | "certificate" | "localKeyFile" | null;
type SubPanel =
| "none"
| "create-group"
@@ -69,6 +82,8 @@ type SubPanel =
| "theme-select"
| "telnet-theme-select";
const LINUX_DISTRO_OPTION_IDS = [...LINUX_DISTRO_OPTIONS];
interface HostDetailsPanelProps {
initialData?: Host | null;
availableKeys: SSHKey[];
@@ -115,8 +130,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
os: "linux",
authMethod: "password",
charset: "UTF-8",
theme: terminalThemeId,
fontSize: terminalFontSize,
distroMode: "auto",
createdAt: Date.now(),
group: defaultGroup || undefined, // Pre-fill with current navigation group
} as Host),
@@ -136,6 +150,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
// Password visibility state
const [showPassword, setShowPassword] = useState(false);
// Local key file path input state
const [newKeyFilePath, setNewKeyFilePath] = useState("");
// New group creation state
const [newGroupName, setNewGroupName] = useState("");
const [newGroupParent, setNewGroupParent] = useState("");
@@ -179,6 +196,56 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
setForm((prev) => ({ ...prev, [key]: value }));
};
const effectiveThemeId = useMemo(
() => resolveHostTerminalThemeId(form, terminalThemeId),
[form, terminalThemeId],
);
const effectiveFontSize = useMemo(
() => resolveHostTerminalFontSize(form, terminalFontSize),
[form, terminalFontSize],
);
const hasEffectiveThemeOverride = useMemo(
() => hasHostThemeOverride(form),
[form],
);
const hasEffectiveFontSizeOverride = useMemo(
() => hasHostFontSizeOverride(form),
[form],
);
const effectiveTelnetThemeId =
form.protocols?.find((p) => p.protocol === "telnet")?.theme || effectiveThemeId;
const distroOptions = useMemo(
() =>
LINUX_DISTRO_OPTION_IDS.map((value) => ({
value,
label: t(`hostDetails.distro.option.${value}`),
icon: DISTRO_LOGOS[value],
bgClass: DISTRO_COLORS[value] || DISTRO_COLORS.default,
})),
[t],
);
const getDistroOptionLabel = useCallback(
(value?: string) =>
distroOptions.find((option) => option.value === value)?.label ||
value ||
t("hostDetails.distro.pending"),
[distroOptions, t],
);
const effectiveFormDistro = getEffectiveHostDistro(form);
const handleDistroModeChange = useCallback((mode: "auto" | "manual") => {
setForm((prev) => ({
...prev,
distroMode: mode,
manualDistro:
mode === "manual"
? prev.manualDistro || getEffectiveHostDistro(prev) || "linux"
: prev.manualDistro,
}));
}, []);
const updateProxyConfig = useCallback(
(field: keyof ProxyConfig, value: string | number) => {
setForm((prev) => ({
@@ -298,6 +365,27 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
password: form.savePassword === false ? undefined : form.password,
managedSourceId: finalManagedSourceId,
};
const preserveLegacyTheme = initialData?.theme != null && cleaned.themeOverride !== false;
const preserveLegacyFontFamily = initialData?.fontFamily != null && cleaned.fontFamilyOverride !== false;
const preserveLegacyFontSize = initialData?.fontSize != null && cleaned.fontSizeOverride !== false;
if (cleaned.themeOverride === false) {
delete cleaned.theme;
} else if (preserveLegacyTheme && cleaned.theme == null) {
cleaned.theme = initialData?.theme;
}
if (cleaned.fontFamilyOverride === false) {
delete cleaned.fontFamily;
} else if (preserveLegacyFontFamily && cleaned.fontFamily == null) {
cleaned.fontFamily = initialData?.fontFamily;
}
if (cleaned.fontSizeOverride === false) {
delete cleaned.fontSize;
} else if (preserveLegacyFontSize && cleaned.fontSize == null) {
cleaned.fontSize = initialData?.fontSize;
}
onSave(cleaned);
};
@@ -387,6 +475,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
authMethod: identity.authMethod,
password: undefined,
identityFileId: undefined,
identityFilePaths: undefined,
}));
setSelectedCredentialType(null);
setCredentialPopoverOpen(false);
@@ -478,9 +567,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
return (
<ThemeSelectPanel
open={true}
selectedThemeId={form.theme || "flexoki-dark"}
selectedThemeId={effectiveThemeId}
onSelect={(themeId) => {
update("theme", themeId);
setForm((prev) => ({ ...prev, theme: themeId, themeOverride: true }));
setActiveSubPanel("none");
}}
onClose={onCancel}
@@ -495,11 +584,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
return (
<ThemeSelectPanel
open={true}
selectedThemeId={
form.protocols?.find((p) => p.protocol === "telnet")?.theme ||
form.theme ||
"flexoki-dark"
}
selectedThemeId={effectiveTelnetThemeId}
onSelect={(themeId) => {
// Update telnet protocol theme
const telnetConfig = form.protocols?.find(
@@ -891,6 +976,31 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</div>
)}
{/* Local key file paths display */}
{!selectedIdentity && !form.identityFileId && form.identityFilePaths && form.identityFilePaths.length > 0 && (
<div className="space-y-1.5">
{form.identityFilePaths.map((keyPath, idx) => (
<div key={idx} className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60">
<FileKey size={14} className="text-primary shrink-0" />
<span className="text-xs flex-1 truncate font-mono" title={keyPath}>
{keyPath}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={() => {
const paths = form.identityFilePaths?.filter((_, i) => i !== idx) || [];
update("identityFilePaths", paths.length > 0 ? paths : undefined);
}}
>
<Trash2 size={12} />
</Button>
</div>
))}
</div>
)}
{/* Selected credential display */}
{!selectedIdentity && form.identityFileId && (
<div className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60">
@@ -968,6 +1078,20 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
{t("hostDetails.credential.certificate")}
</span>
</button>
<button
type="button"
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-secondary/80 transition-colors text-left"
onClick={() => {
setSelectedCredentialType("localKeyFile");
setCredentialPopoverOpen(false);
}}
>
<FileKey size={16} className="text-muted-foreground" />
<span className="text-sm font-medium">
{t("hostDetails.credential.localKeyFile")}
</span>
</button>
</div>
</PopoverContent>
</Popover>
@@ -989,6 +1113,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onValueChange={(val) => {
update("identityFileId", val);
update("authMethod", "key");
update("identityFilePaths", undefined);
setSelectedCredentialType(null);
}}
placeholder={t("hostDetails.keys.search")}
@@ -1024,6 +1149,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
onValueChange={(val) => {
update("identityFileId", val);
update("authMethod", "certificate");
update("identityFilePaths", undefined);
setSelectedCredentialType(null);
}}
placeholder={t("hostDetails.certs.search")}
@@ -1043,6 +1169,67 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</Button>
</div>
)}
{/* Local key file path input - appears after selecting "Local Key File" type */}
{!selectedIdentity &&
selectedCredentialType === "localKeyFile" &&
!form.identityFileId && (
<div className="space-y-1.5">
<div className="flex items-center gap-1">
<input
type="text"
className="flex-1 h-8 px-2 text-xs font-mono bg-background border border-border/60 rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
placeholder={t("hostDetails.credential.localKeyFilePlaceholder")}
value={newKeyFilePath}
onChange={(e) => setNewKeyFilePath(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && newKeyFilePath.trim()) {
e.preventDefault();
const paths = [...(form.identityFilePaths || []), newKeyFilePath.trim()];
update("identityFilePaths", paths);
update("identityFileId", undefined);
update("authMethod", "key");
setNewKeyFilePath("");
}
}}
/>
<Button
variant="secondary"
size="icon"
className="h-8 w-8 shrink-0"
title={t("hostDetails.credential.browseKeyFile")}
onClick={async () => {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
if (!bridge?.selectFile) return;
const filePath = await bridge.selectFile(
"Select SSH Private Key",
undefined,
[{ name: "All Files", extensions: ["*"] }]
);
if (filePath) {
const paths = [...(form.identityFilePaths || []), filePath];
update("identityFilePaths", paths);
update("identityFileId", undefined);
update("authMethod", "key");
}
}}
>
<FolderOpen size={14} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => {
setSelectedCredentialType(null);
setNewKeyFilePath("");
}}
>
<X size={14} />
</Button>
</div>
</div>
)}
</div>
</Card>
@@ -1103,6 +1290,113 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</p>
</div>
{form.os === "linux" && (
<div className="space-y-2 rounded-lg border border-border/70 bg-secondary/30 p-3">
<div className="flex items-start gap-2">
<Globe size={14} className="mt-0.5 text-muted-foreground" />
<div className="space-y-0.5">
<p className="text-xs font-semibold">{t("hostDetails.distro.title")}</p>
<p className="text-xs text-muted-foreground">{t("hostDetails.distro.desc")}</p>
</div>
</div>
<div className="grid gap-2 md:grid-cols-2">
<div className="space-y-1">
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.mode")}</span>
<Select
value={form.distroMode || "auto"}
onValueChange={(val) => handleDistroModeChange(val as "auto" | "manual")}
>
<SelectTrigger className="h-8" aria-label={t("hostDetails.distro.mode")}>
<span className="truncate whitespace-nowrap pr-2 text-left">
{form.distroMode === "manual"
? t("hostDetails.distro.mode.manual")
: t("hostDetails.distro.mode.auto")}
</span>
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{t("hostDetails.distro.mode.auto")}</SelectItem>
<SelectItem value="manual">{t("hostDetails.distro.mode.manual")}</SelectItem>
</SelectContent>
</Select>
</div>
{form.distroMode === "manual" ? (
<div className="space-y-1">
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.manualLabel")}</span>
<Select
value={form.manualDistro}
onValueChange={(val) => update("manualDistro", val)}
>
<SelectTrigger className="h-8" aria-label={t("hostDetails.distro.manualLabel")}>
{(() => {
const selectedOption = distroOptions.find((option) => option.value === form.manualDistro);
return selectedOption ? (
<div className="flex min-w-0 items-center gap-2 pr-2">
<div
className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px]",
selectedOption.bgClass,
)}
>
{selectedOption.icon ? (
<img
src={selectedOption.icon}
alt={selectedOption.label}
className="h-3 w-3 object-contain invert brightness-0"
/>
) : (
<div className="h-2 w-2 rounded-full bg-white/70" />
)}
</div>
<span className="truncate whitespace-nowrap">{selectedOption.label}</span>
</div>
) : (
<SelectValue placeholder={t("hostDetails.distro.unknown")} />
);
})()}
</SelectTrigger>
<SelectContent className="min-w-[14rem]">
{distroOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className="flex items-center gap-2">
<div
className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px]",
option.bgClass,
)}
>
{option.icon ? (
<img
src={option.icon}
alt={option.label}
className="h-3 w-3 object-contain invert brightness-0"
/>
) : (
<div className="h-2 w-2 rounded-full bg-white/70" />
)}
</div>
<span className="whitespace-nowrap">{option.label}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : (
<div className="space-y-1">
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.detectedLabel")}</span>
<div className="flex h-8 items-center rounded-md border border-border/60 bg-background/50 px-3 text-sm">
{effectiveFormDistro
? getDistroOptionLabel(effectiveFormDistro)
: t("hostDetails.distro.unknown")}
</div>
</div>
)}
</div>
</div>
)}
{/* SSH Theme Selection */}
<button
type="button"
@@ -1113,15 +1407,15 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
className="w-12 h-8 rounded-md border border-border/60 flex items-center justify-center text-[6px] font-mono overflow-hidden"
style={{
backgroundColor:
customThemeStore.getThemeById(form.theme || "flexoki-dark")?.colors.background || "#100F0F",
customThemeStore.getThemeById(effectiveThemeId)?.colors.background || "#100F0F",
color:
customThemeStore.getThemeById(form.theme || "flexoki-dark")?.colors.foreground || "#CECDC3",
customThemeStore.getThemeById(effectiveThemeId)?.colors.foreground || "#CECDC3",
}}
>
<div className="p-0.5">
<div
style={{
color: customThemeStore.getThemeById(form.theme || "flexoki-dark")?.colors.green,
color: customThemeStore.getThemeById(effectiveThemeId)?.colors.green,
}}
>
$
@@ -1129,9 +1423,19 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</div>
</div>
<span className="text-sm flex-1">
{customThemeStore.getThemeById(form.theme || "flexoki-dark")?.name || "Flexoki Dark"}
{customThemeStore.getThemeById(effectiveThemeId)?.name || "Flexoki Dark"}
</span>
</button>
{hasEffectiveThemeOverride && (
<Button
variant="ghost"
size="sm"
className="w-full justify-start text-primary"
onClick={() => setForm((prev) => clearHostThemeOverride(prev))}
>
{t("common.useGlobal")}
</Button>
)}
{/* Font Size */}
<div className="flex items-center gap-2">
@@ -1140,11 +1444,15 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
variant="outline"
size="sm"
onClick={() => {
if ((form.fontSize || 14) > MIN_FONT_SIZE) {
update("fontSize", (form.fontSize || 14) - 1);
if (effectiveFontSize > MIN_FONT_SIZE) {
setForm((prev) => ({
...prev,
fontSize: effectiveFontSize - 1,
fontSizeOverride: true,
}));
}
}}
disabled={(form.fontSize || 14) <= MIN_FONT_SIZE}
disabled={effectiveFontSize <= MIN_FONT_SIZE}
className="px-2 h-8"
>
-
@@ -1153,25 +1461,43 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
type="number"
min={MIN_FONT_SIZE}
max={MAX_FONT_SIZE}
value={form.fontSize || 14}
value={effectiveFontSize}
onChange={(e) => {
const val = parseInt(e.target.value);
if (val >= MIN_FONT_SIZE && val <= MAX_FONT_SIZE) {
update("fontSize", val);
setForm((prev) => ({
...prev,
fontSize: val,
fontSizeOverride: true,
}));
}
}}
className="w-16 text-center h-8"
/>
<span className="text-sm text-muted-foreground">pt</span>
{hasEffectiveFontSizeOverride && (
<Button
variant="ghost"
size="sm"
className="ml-auto h-8 text-primary"
onClick={() => setForm((prev) => clearHostFontSizeOverride(prev))}
>
{t("common.useGlobal")}
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => {
if ((form.fontSize || 14) < MAX_FONT_SIZE) {
update("fontSize", (form.fontSize || 14) + 1);
if (effectiveFontSize < MAX_FONT_SIZE) {
setForm((prev) => ({
...prev,
fontSize: effectiveFontSize + 1,
fontSizeOverride: true,
}));
}
}}
disabled={(form.fontSize || 14) >= MAX_FONT_SIZE}
disabled={effectiveFontSize >= MAX_FONT_SIZE}
className="px-2 h-8"
>
+
@@ -1494,21 +1820,15 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
className="w-12 h-8 rounded-md border border-border/60 flex items-center justify-center text-[6px] font-mono overflow-hidden"
style={{
backgroundColor:
customThemeStore.getThemeById(
form.protocols?.find((p) => p.protocol === "telnet")?.theme || form.theme || "flexoki-dark"
)?.colors.background || "#100F0F",
customThemeStore.getThemeById(effectiveTelnetThemeId)?.colors.background || "#100F0F",
color:
customThemeStore.getThemeById(
form.protocols?.find((p) => p.protocol === "telnet")?.theme || form.theme || "flexoki-dark"
)?.colors.foreground || "#CECDC3",
customThemeStore.getThemeById(effectiveTelnetThemeId)?.colors.foreground || "#CECDC3",
}}
>
<div className="p-0.5">
<div
style={{
color: customThemeStore.getThemeById(
form.protocols?.find((p) => p.protocol === "telnet")?.theme || form.theme || "flexoki-dark"
)?.colors.green,
color: customThemeStore.getThemeById(effectiveTelnetThemeId)?.colors.green,
}}
>
$
@@ -1516,9 +1836,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</div>
</div>
<span className="text-sm flex-1">
{customThemeStore.getThemeById(
form.protocols?.find((p) => p.protocol === "telnet")?.theme || form.theme || "flexoki-dark"
)?.name || "Flexoki Dark"}
{customThemeStore.getThemeById(effectiveTelnetThemeId)?.name || "Flexoki Dark"}
</span>
</button>
</Card>

View File

@@ -254,25 +254,25 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
const RENDER_LIMIT = 100; // Limit rendered items for performance
// Define handleScanSystem before useEffect that depends on it
const handleScanSystem = useCallback(async () => {
const handleScanSystem = useCallback(async (silent = false) => {
setIsScanning(true);
try {
const content = await readKnownHosts();
if (content === undefined) {
toast.error(
if (!silent) toast.error(
t("knownHosts.toast.scanUnavailable"),
t("vault.nav.knownHosts"),
);
return;
}
if (!content) {
toast.info(t("knownHosts.toast.scanNoFile"), t("vault.nav.knownHosts"));
if (!silent) toast.info(t("knownHosts.toast.scanNoFile"), t("vault.nav.knownHosts"));
return;
}
const parsed = parseKnownHostsFile(content);
if (parsed.length === 0) {
toast.info(
if (!silent) toast.info(
t("knownHosts.toast.scanNoEntries"),
t("vault.nav.knownHosts"),
);
@@ -288,16 +288,16 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
if (newHosts.length > 0) {
onImportFromFile(newHosts);
toast.success(
if (!silent) toast.success(
t("knownHosts.toast.scanImported", { count: newHosts.length }),
t("vault.nav.knownHosts"),
);
} else {
toast.info(t("knownHosts.toast.scanNoNew"), t("vault.nav.knownHosts"));
if (!silent) toast.info(t("knownHosts.toast.scanNoNew"), t("vault.nav.knownHosts"));
}
} catch (err) {
logger.error("Failed to scan system known_hosts:", err);
toast.error(
if (!silent) toast.error(
err instanceof Error ? err.message : t("knownHosts.toast.scanFailed"),
t("vault.nav.knownHosts"),
);
@@ -307,13 +307,12 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
}
}, [knownHosts, onRefresh, onImportFromFile, readKnownHosts, t]);
// Auto-scan on first mount
// Auto-scan on first mount (silent — don't show toasts for missing known_hosts)
useEffect(() => {
if (!hasScannedRef.current) {
hasScannedRef.current = true;
// Delay scan slightly to not block initial render
const timer = setTimeout(() => {
handleScanSystem();
handleScanSystem(true);
}, 100);
return () => clearTimeout(timer);
}
@@ -515,7 +514,7 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
variant="ghost"
size="sm"
className="h-9 px-3 text-xs"
onClick={handleScanSystem}
onClick={() => handleScanSystem()}
disabled={isScanning}
>
<RefreshCw
@@ -572,7 +571,7 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
<div className="flex gap-2">
<Button
variant="secondary"
onClick={handleScanSystem}
onClick={() => handleScanSystem()}
disabled={isScanning}
>
<RefreshCw

View File

@@ -130,7 +130,9 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
const result = await startTunnel(
rule,
_host,
keys.map((k) => ({ id: k.id, privateKey: k.privateKey, passphrase: k.passphrase })),
hosts,
keys,
identities,
(status, error) => {
// Show toast on error (only once)
if (status === "error" && error && !errorShown) {
@@ -159,7 +161,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
});
}
},
[hosts, keys, setRuleStatus, startTunnel, t],
[hosts, identities, keys, setRuleStatus, startTunnel, t],
);
// Stop a port forwarding tunnel

View File

@@ -13,6 +13,7 @@ import {
import React, { useMemo, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import type { QuickConnectTarget } from "../domain/quickConnect";
import { formatHostPort } from "../domain/host";
import { cn } from "../lib/utils";
import { Host, SSHKey } from "../types";
import { Button } from "./ui/button";
@@ -531,11 +532,11 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
case "protocol":
return target.hostname;
case "username":
return `${protocol.toUpperCase()} ${target.hostname}:${port}`;
return `${protocol.toUpperCase()} ${formatHostPort(target.hostname, port)}`;
case "knownhost":
return `${protocol.toUpperCase()} ${effectiveUsername}@${target.hostname}:${port}`;
return `${protocol.toUpperCase()} ${effectiveUsername}@${formatHostPort(target.hostname, port)}`;
case "auth":
return `${protocol.toUpperCase()} ${target.hostname}:${port}`;
return `${protocol.toUpperCase()} ${formatHostPort(target.hostname, port)}`;
}
};

View File

@@ -1,817 +0,0 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { useSftpBackend } from "../application/state/useSftpBackend";
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
import { useSettingsState } from "../application/state/useSettingsState";
import { useSftpModalTransfers } from "./sftp-modal/hooks/useSftpModalTransfers";
import { Host, RemoteFile, SftpFilenameEncoding } from "../types";
import { filterHiddenFiles } from "./sftp";
import { DropEntry } from "../lib/sftpFileUtils";
import FileOpenerDialog from "./FileOpenerDialog";
import TextEditorModal from "./TextEditorModal";
import { SftpModalFileList } from "./sftp-modal/SftpModalFileList";
import { SftpModalDialogs } from "./sftp-modal/SftpModalDialogs";
import { SftpModalFooter } from "./sftp-modal/SftpModalFooter";
import { SftpModalHeader } from "./sftp-modal/SftpModalHeader";
import { SftpModalUploadTasks } from "./sftp-modal/SftpModalUploadTasks";
import { formatBytes, formatDate } from "./sftp-modal/utils";
import { useSftpModalSorting } from "./sftp-modal/hooks/useSftpModalSorting";
import { useSftpModalVirtualList } from "./sftp-modal/hooks/useSftpModalVirtualList";
import { useSftpModalPath } from "./sftp-modal/hooks/useSftpModalPath";
import { useSftpModalSelection } from "./sftp-modal/hooks/useSftpModalSelection";
import { useSftpModalSession } from "./sftp-modal/hooks/useSftpModalSession";
import { useSftpModalFileActions } from "./sftp-modal/hooks/useSftpModalFileActions";
import { useSftpModalKeyboardShortcuts } from "./sftp-modal/hooks/useSftpModalKeyboardShortcuts";
import { joinPath, isRootPath, getParentPath } from "./sftp-modal/pathUtils";
import { toast } from "./ui/toast";
interface SFTPModalProps {
host: Host;
credentials: {
username?: string;
hostname: string;
port?: number;
password?: string;
privateKey?: string;
certificate?: string;
passphrase?: string;
publicKey?: string;
keyId?: string;
keySource?: 'generated' | 'imported';
proxy?: NetcattyProxyConfig;
jumpHosts?: NetcattyJumpHost[];
sftpSudo?: boolean;
legacyAlgorithms?: boolean;
};
open: boolean;
onClose: () => void;
/** Initial path to open in SFTP. If not accessible, falls back to home directory. */
initialPath?: string;
/** Initial entries to upload when SFTP modal opens. Used for drag-and-drop to terminal. */
initialEntriesToUpload?: DropEntry[];
/** Callback to update the host (e.g. for bookmark persistence). */
onUpdateHost?: (host: Host) => void;
}
const SFTPModal: React.FC<SFTPModalProps> = ({
host,
credentials,
open,
onClose,
initialPath,
initialEntriesToUpload,
onUpdateHost,
}) => {
const {
openSftp,
closeSftp: closeSftpBackend,
listSftp,
readSftp,
writeSftpBinaryWithProgress,
writeSftpBinary,
writeSftp,
deleteSftp,
mkdirSftp,
renameSftp,
chmodSftp,
statSftp,
listLocalDir,
readLocalFile,
writeLocalFile,
deleteLocalFile,
mkdirLocal,
getHomeDir,
selectApplication,
downloadSftpToTempAndOpen,
cancelSftpUpload,
startStreamTransfer,
cancelTransfer,
showSaveDialog,
} = useSftpBackend();
const { t } = useI18n();
const {
sftpAutoSync,
sftpShowHiddenFiles,
setSftpShowHiddenFiles,
sftpUseCompressedUpload,
hotkeyScheme,
keyBindings,
editorWordWrap,
setEditorWordWrap,
} = useSettingsState();
const isLocalSession = host.protocol === "local";
const [filenameEncoding, setFilenameEncoding] = useState<SftpFilenameEncoding>(
host.sftpEncoding ?? "auto"
);
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const inputRef = useRef<HTMLInputElement>(null);
const folderInputRef = useRef<HTMLInputElement>(null);
const navigatingRef = useRef(false);
const clearSelection = useCallback(() => setSelectedFiles(new Set()), []);
// Update filenameEncoding when host changes
useEffect(() => {
setFilenameEncoding(host.sftpEncoding ?? "auto");
}, [host.id, host.sftpEncoding]);
const listSftpWithEncoding = useCallback(
(sftpId: string, path: string) => listSftp(sftpId, path, filenameEncoding),
[listSftp, filenameEncoding],
);
const readSftpWithEncoding = useCallback(
(sftpId: string, path: string) => readSftp(sftpId, path, filenameEncoding),
[readSftp, filenameEncoding],
);
const writeSftpWithEncoding = useCallback(
(sftpId: string, path: string, data: string) =>
writeSftp(sftpId, path, data, filenameEncoding),
[writeSftp, filenameEncoding],
);
const writeSftpBinaryWithEncoding = useCallback(
(sftpId: string, path: string, data: ArrayBuffer) =>
writeSftpBinary(sftpId, path, data, filenameEncoding),
[writeSftpBinary, filenameEncoding],
);
const writeSftpBinaryWithProgressWithEncoding = useCallback(
(
sftpId: string,
path: string,
data: ArrayBuffer,
transferId: string,
onProgress?: (transferred: number, total: number, speed: number) => void,
onComplete?: () => void,
onError?: (error: string) => void,
) =>
writeSftpBinaryWithProgress(
sftpId,
path,
data,
transferId,
filenameEncoding,
onProgress,
onComplete,
onError,
),
[writeSftpBinaryWithProgress, filenameEncoding],
);
const deleteSftpWithEncoding = useCallback(
(sftpId: string, path: string) => deleteSftp(sftpId, path, filenameEncoding),
[deleteSftp, filenameEncoding],
);
const mkdirSftpWithEncoding = useCallback(
(sftpId: string, path: string) => mkdirSftp(sftpId, path, filenameEncoding),
[mkdirSftp, filenameEncoding],
);
const renameSftpWithEncoding = useCallback(
(sftpId: string, oldPath: string, newPath: string) =>
renameSftp(sftpId, oldPath, newPath, filenameEncoding),
[renameSftp, filenameEncoding],
);
const chmodSftpWithEncoding = useCallback(
(sftpId: string, path: string, mode: string) =>
chmodSftp(sftpId, path, mode, filenameEncoding),
[chmodSftp, filenameEncoding],
);
const statSftpWithEncoding = useCallback(
(sftpId: string, path: string) => statSftp(sftpId, path, filenameEncoding),
[statSftp, filenameEncoding],
);
const downloadSftpToTempAndOpenWithEncoding = useCallback(
(
sftpId: string,
remotePath: string,
fileName: string,
appPath: string,
options?: { enableWatch?: boolean },
) =>
downloadSftpToTempAndOpen(sftpId, remotePath, fileName, appPath, {
...options,
encoding: filenameEncoding,
}),
[downloadSftpToTempAndOpen, filenameEncoding],
);
const {
currentPath,
setCurrentPath,
currentPathRef,
files,
loading,
setLoading,
reconnecting,
sessionVersion,
ensureSftp,
loadFiles,
closeSftpSession,
localHomeRef,
} = useSftpModalSession({
open,
host,
credentials,
initialPath,
isLocalSession,
t,
openSftp,
closeSftp: closeSftpBackend,
listSftp: listSftpWithEncoding,
listLocalDir,
getHomeDir,
onClearSelection: clearSelection,
});
// Track previous encoding to detect changes
const prevEncodingRef = useRef(filenameEncoding);
// Force reload only when filenameEncoding changes (not on every path change)
useEffect(() => {
if (!open || isLocalSession) return;
// Only force reload if encoding actually changed
if (prevEncodingRef.current !== filenameEncoding) {
prevEncodingRef.current = filenameEncoding;
loadFiles(currentPath, { force: true });
}
}, [currentPath, filenameEncoding, isLocalSession, loadFiles, open]);
const { getOpenerForFile, setOpenerForExtension } = useSftpFileAssociations();
const { sortField, sortOrder, columnWidths, handleSort, handleResizeStart } =
useSftpModalSorting();
const joinPathForSession = useCallback(
(base: string, name: string) => joinPath(base, name, isLocalSession),
[isLocalSession],
);
const isRootPathForSession = useCallback(
(path: string) => isRootPath(path, isLocalSession),
[isLocalSession],
);
const getParentPathForSession = useCallback(
(path: string) => getParentPath(path, isLocalSession),
[isLocalSession],
);
const handleNavigate = useCallback((path: string) => {
// Prevent double navigation (e.g., from double-click race condition)
if (navigatingRef.current) return;
navigatingRef.current = true;
setCurrentPath(path);
// Reset lock after a short delay
setTimeout(() => {
navigatingRef.current = false;
}, 300);
}, [navigatingRef, setCurrentPath]);
const handleUp = () => {
if (isRootPathForSession(currentPath)) return;
setCurrentPath(getParentPathForSession(currentPath));
};
const {
isEditingPath,
editingPathValue,
setEditingPathValue,
pathInputRef,
handlePathDoubleClick,
handlePathSubmit,
handlePathKeyDown,
breadcrumbs,
visibleBreadcrumbs,
hiddenBreadcrumbs,
needsBreadcrumbTruncation,
breadcrumbPathAtForIndex,
rootLabel,
rootPath,
} = useSftpModalPath({
currentPath,
isLocalSession,
localHomePath: localHomeRef.current,
onNavigate: handleNavigate,
});
const {
handleDelete,
handleCreateFolder,
handleCreateFile,
showCreateDialog,
setShowCreateDialog,
createType,
createName,
setCreateName,
isCreating,
handleCreateSubmit,
showRenameDialog,
setShowRenameDialog,
renameTarget,
renameName,
setRenameName,
isRenaming,
openRenameDialog,
handleRename,
showPermissionsDialog,
setShowPermissionsDialog,
permissionsTarget,
permissions,
isChangingPermissions,
openPermissionsDialog,
togglePermission,
getOctalPermissions,
getSymbolicPermissions,
handleSavePermissions,
showFileOpenerDialog,
setShowFileOpenerDialog,
fileOpenerTarget,
setFileOpenerTarget,
openFileOpenerDialog,
handleFileOpenerSelect,
handleSelectSystemApp,
showTextEditor,
setShowTextEditor,
textEditorTarget,
setTextEditorTarget,
textEditorContent,
setTextEditorContent,
loadingTextContent,
handleEditFile,
handleSaveTextFile,
handleOpenFile,
} = useSftpModalFileActions({
currentPath,
isLocalSession,
joinPath: joinPathForSession,
ensureSftp,
loadFiles,
readLocalFile,
readSftp: readSftpWithEncoding,
writeLocalFile,
writeSftp: writeSftpWithEncoding,
writeSftpBinary: writeSftpBinaryWithEncoding,
deleteLocalFile,
deleteSftp: deleteSftpWithEncoding,
mkdirLocal,
mkdirSftp: mkdirSftpWithEncoding,
renameSftp: renameSftpWithEncoding,
chmodSftp: chmodSftpWithEncoding,
statSftp: statSftpWithEncoding,
t,
sftpAutoSync,
getOpenerForFile,
setOpenerForExtension,
downloadSftpToTempAndOpen: downloadSftpToTempAndOpenWithEncoding,
selectApplication,
});
const {
uploading,
uploadTasks,
dragActive,
handleDownload,
handleUploadEntries,
handleFileSelect,
handleFolderSelect,
handleDrag,
handleDrop,
cancelUpload,
cancelTask,
dismissTask,
} = useSftpModalTransfers({
currentPath,
currentPathRef,
isLocalSession,
joinPath: joinPathForSession,
ensureSftp,
loadFiles,
readLocalFile,
readSftp: readSftpWithEncoding,
writeLocalFile,
writeSftpBinaryWithProgress: writeSftpBinaryWithProgressWithEncoding,
writeSftpBinary: writeSftpBinaryWithEncoding,
writeSftp: writeSftpWithEncoding,
mkdirLocal,
mkdirSftp: mkdirSftpWithEncoding,
cancelSftpUpload,
startStreamTransfer,
cancelTransfer,
showSaveDialog,
setLoading,
t,
useCompressedUpload: sftpUseCompressedUpload,
listSftp: listSftpWithEncoding,
deleteLocalFile,
});
const hasEverOpenedRef = useRef(false);
const hasActiveTransferTasks = useMemo(
() =>
uploadTasks.some(
(task) =>
task.status === "pending" ||
task.status === "uploading" ||
task.status === "downloading",
),
[uploadTasks],
);
useEffect(() => {
if (open) {
hasEverOpenedRef.current = true;
return;
}
if (!hasEverOpenedRef.current) return;
if (uploading || hasActiveTransferTasks) return;
void closeSftpSession();
}, [closeSftpSession, hasActiveTransferTasks, open, sessionVersion, uploading]);
const handleClose = async () => {
if (uploading || hasActiveTransferTasks) {
onClose();
return;
}
await closeSftpSession();
onClose();
};
// Handle initial entries to upload (from drag-and-drop to terminal)
const initialUploadTriggeredRef = useRef(false);
const prevLoadingRef = useRef(loading);
const prevEntriesRef = useRef<DropEntry[] | undefined>(undefined);
useEffect(() => {
// Detect when loading transitions from true to false (initial load complete)
const wasLoading = prevLoadingRef.current;
prevLoadingRef.current = loading;
const justFinishedLoading = wasLoading && !loading;
// Reset the flag when initialEntriesToUpload is cleared
if (!initialEntriesToUpload || initialEntriesToUpload.length === 0) {
initialUploadTriggeredRef.current = false;
prevEntriesRef.current = undefined;
return;
}
// Reset the flag when new entries arrive (different reference = new drop)
if (initialEntriesToUpload !== prevEntriesRef.current) {
initialUploadTriggeredRef.current = false;
prevEntriesRef.current = initialEntriesToUpload;
}
// Prevent duplicate uploads
if (initialUploadTriggeredRef.current) return;
// Wait for SFTP connection to be established
// Trigger when: modal is open AND loading just finished (works for empty directories too)
if (!open || loading) return;
if (!justFinishedLoading) return;
initialUploadTriggeredRef.current = true;
// Trigger upload with full DropEntry data (preserves directory structure)
void handleUploadEntries(initialEntriesToUpload);
}, [handleUploadEntries, initialEntriesToUpload, loading, open]);
// Display files with parent entry (like SftpView)
const displayFiles = useMemo(() => {
// Filter hidden files using utility function
const visibleFiles = filterHiddenFiles(files, sftpShowHiddenFiles);
// Check if we're at root
const atRoot = isRootPathForSession(currentPath);
if (atRoot) return visibleFiles;
// Add ".." parent directory entry at the top (only if not at root)
const parentEntry: RemoteFile = {
name: "..",
type: "directory",
size: "--",
lastModified: undefined,
};
return [parentEntry, ...visibleFiles.filter((f) => f.name !== "..")];
}, [files, currentPath, isRootPathForSession, sftpShowHiddenFiles]);
// Sorted files
const sortedFiles = useMemo(() => {
if (!displayFiles.length) return displayFiles;
// Keep ".." at the top, sort the rest
const parentEntry = displayFiles.find((f) => f.name === "..");
const otherFiles = displayFiles.filter((f) => f.name !== "..");
const sorted = [...otherFiles].sort((a, b) => {
// Directories and symlinks pointing to directories come first
const aIsDir = a.type === "directory" || (a.type === "symlink" && a.linkTarget === "directory");
const bIsDir = b.type === "directory" || (b.type === "symlink" && b.linkTarget === "directory");
if (aIsDir && !bIsDir) return -1;
if (!aIsDir && bIsDir) return 1;
let cmp = 0;
switch (sortField) {
case "name":
cmp = a.name.localeCompare(b.name);
break;
case "size": {
const sizeA =
typeof a.size === "number"
? a.size
: parseInt(String(a.size), 10) || 0;
const sizeB =
typeof b.size === "number"
? b.size
: parseInt(String(b.size), 10) || 0;
cmp = sizeA - sizeB;
break;
}
case "modified": {
const dateA = new Date(a.lastModified || 0).getTime();
const dateB = new Date(b.lastModified || 0).getTime();
cmp = dateA - dateB;
break;
}
}
return sortOrder === "asc" ? cmp : -cmp;
});
return parentEntry ? [parentEntry, ...sorted] : sorted;
}, [displayFiles, sortField, sortOrder]);
const hasFiles = files.length > 0;
const hasDisplayFiles = sortedFiles.length > 0;
const {
fileListRef,
handleFileListScroll,
shouldVirtualize,
totalHeight,
visibleRows,
} = useSftpModalVirtualList({ open, sortedFiles });
const { handleFileClick, handleFileDoubleClick } = useSftpModalSelection({
files,
setSelectedFiles,
currentPath,
joinPath: joinPathForSession,
onNavigate: handleNavigate,
onOpenFile: handleOpenFile,
onNavigateUp: handleUp,
});
// Keyboard shortcuts for modal
const handleKeyboardRename = useCallback((file: RemoteFile) => {
openRenameDialog(file);
}, [openRenameDialog]);
const handleKeyboardDelete = useCallback((fileNames: string[]) => {
// Find the files to pass to confirm dialog
if (fileNames.length === 0) return;
if (!confirm(t("sftp.deleteConfirm.title", { count: fileNames.length }))) return;
// Delete files
(async () => {
try {
for (const fileName of fileNames) {
const fullPath = joinPathForSession(currentPath, fileName);
if (isLocalSession) {
await deleteLocalFile(fullPath);
} else {
await deleteSftpWithEncoding(await ensureSftp(), fullPath);
}
}
await loadFiles(currentPath, { force: true });
setSelectedFiles(new Set());
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.deleteFailed"),
"SFTP",
);
}
})();
}, [currentPath, isLocalSession, deleteLocalFile, deleteSftpWithEncoding, ensureSftp, loadFiles, setSelectedFiles, t, joinPathForSession]);
const handleKeyboardNewFolder = useCallback(() => {
handleCreateFolder();
}, [handleCreateFolder]);
useSftpModalKeyboardShortcuts({
keyBindings,
hotkeyScheme,
open,
files,
visibleFiles: displayFiles,
selectedFiles,
setSelectedFiles,
onRefresh: () => loadFiles(currentPath, { force: true }),
onRename: handleKeyboardRename,
onDelete: handleKeyboardDelete,
onNewFolder: handleKeyboardNewFolder,
});
const handleDeleteSelected = async () => {
if (selectedFiles.size === 0) return;
const fileNames = Array.from(selectedFiles);
if (!confirm(t("sftp.deleteConfirm.title", { count: fileNames.length }))) return;
try {
for (const fileName of fileNames) {
const fullPath = joinPathForSession(currentPath, fileName);
if (isLocalSession) {
await deleteLocalFile(fullPath);
} else {
await deleteSftpWithEncoding(await ensureSftp(), fullPath);
}
}
await loadFiles(currentPath, { force: true });
setSelectedFiles(new Set());
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.deleteFailed"),
"SFTP",
);
}
};
const handleDownloadSelected = async () => {
if (selectedFiles.size === 0) return;
for (const fileName of selectedFiles) {
const file = files.find((f) => f.name === fileName);
if (file && file.type === "file") {
await handleDownload(file);
}
}
};
if (!open) return null;
return (
<>
<div className="h-full flex flex-col bg-background border-r border-border/60 overflow-hidden">
<SftpModalHeader
onClose={handleClose}
t={t}
host={host}
credentials={credentials}
showEncoding={!isLocalSession}
filenameEncoding={filenameEncoding}
onFilenameEncodingChange={setFilenameEncoding}
currentPath={currentPath}
isEditingPath={isEditingPath}
editingPathValue={editingPathValue}
setEditingPathValue={setEditingPathValue}
handlePathSubmit={handlePathSubmit}
handlePathKeyDown={handlePathKeyDown}
handlePathDoubleClick={handlePathDoubleClick}
isAtRoot={isRootPathForSession(currentPath)}
rootLabel={rootLabel}
isRefreshing={loading || reconnecting}
onUp={handleUp}
onHome={() =>
setCurrentPath((isLocalSession && localHomeRef.current) || rootPath)
}
onRefresh={() => loadFiles(currentPath, { force: true })}
visibleBreadcrumbs={visibleBreadcrumbs}
hiddenBreadcrumbs={hiddenBreadcrumbs}
needsBreadcrumbTruncation={needsBreadcrumbTruncation}
breadcrumbs={breadcrumbs}
onBreadcrumbSelect={(index) => setCurrentPath(breadcrumbPathAtForIndex(index))}
onRootSelect={() => setCurrentPath(rootPath)}
inputRef={inputRef}
folderInputRef={folderInputRef}
pathInputRef={pathInputRef}
uploading={uploading}
onTriggerUpload={() => inputRef.current?.click()}
onTriggerFolderUpload={() => folderInputRef.current?.click()}
onCreateFolder={handleCreateFolder}
onCreateFile={handleCreateFile}
onFileSelect={handleFileSelect}
onFolderSelect={handleFolderSelect}
showHiddenFiles={sftpShowHiddenFiles}
onToggleShowHiddenFiles={() =>
setSftpShowHiddenFiles(!sftpShowHiddenFiles)
}
onUpdateHost={onUpdateHost}
onNavigateToBookmark={(path) => setCurrentPath(path)}
/>
<SftpModalFileList
t={t}
currentPath={currentPath}
isLocalSession={isLocalSession}
hasFiles={hasFiles}
hasDisplayFiles={hasDisplayFiles}
selectedFiles={selectedFiles}
dragActive={dragActive}
loading={loading}
loadingTextContent={loadingTextContent}
reconnecting={reconnecting}
columnWidths={columnWidths}
sortField={sortField}
sortOrder={sortOrder}
shouldVirtualize={shouldVirtualize}
totalHeight={totalHeight}
visibleRows={visibleRows}
fileListRef={fileListRef}
inputRef={inputRef}
folderInputRef={folderInputRef}
handleSort={handleSort}
handleResizeStart={handleResizeStart}
handleFileListScroll={handleFileListScroll}
handleDrag={handleDrag}
handleDrop={handleDrop}
handleFileClick={handleFileClick}
handleFileDoubleClick={handleFileDoubleClick}
handleDownload={handleDownload}
handleDelete={handleDelete}
handleOpenFile={handleOpenFile}
openFileOpenerDialog={openFileOpenerDialog}
handleEditFile={handleEditFile}
openRenameDialog={openRenameDialog}
openPermissionsDialog={openPermissionsDialog}
handleNavigate={handleNavigate}
handleCreateFolder={handleCreateFolder}
handleCreateFile={handleCreateFile}
handleDownloadSelected={handleDownloadSelected}
handleDeleteSelected={handleDeleteSelected}
loadFiles={loadFiles}
formatBytes={formatBytes}
formatDate={formatDate}
/>
<SftpModalUploadTasks tasks={uploadTasks} t={t} onCancel={cancelUpload} onCancelTask={cancelTask} onDismiss={dismissTask} />
<SftpModalFooter
t={t}
files={files}
selectedFiles={selectedFiles}
loading={loading}
uploading={uploading}
onDownloadSelected={handleDownloadSelected}
onDeleteSelected={handleDeleteSelected}
/>
</div>
<SftpModalDialogs
t={t}
showRenameDialog={showRenameDialog}
setShowRenameDialog={setShowRenameDialog}
renameTarget={renameTarget}
renameName={renameName}
setRenameName={setRenameName}
handleRename={handleRename}
isRenaming={isRenaming}
showPermissionsDialog={showPermissionsDialog}
setShowPermissionsDialog={setShowPermissionsDialog}
permissionsTarget={permissionsTarget}
permissions={permissions}
togglePermission={togglePermission}
getOctalPermissions={getOctalPermissions}
getSymbolicPermissions={getSymbolicPermissions}
handleSavePermissions={handleSavePermissions}
isChangingPermissions={isChangingPermissions}
showCreateDialog={showCreateDialog}
setShowCreateDialog={setShowCreateDialog}
createType={createType}
createName={createName}
setCreateName={setCreateName}
isCreating={isCreating}
handleCreateSubmit={handleCreateSubmit}
/>
{/* File Opener Dialog */}
<FileOpenerDialog
open={showFileOpenerDialog}
onClose={() => {
setShowFileOpenerDialog(false);
setFileOpenerTarget(null);
}}
fileName={fileOpenerTarget?.name || ""}
onSelect={handleFileOpenerSelect}
onSelectSystemApp={handleSelectSystemApp}
/>
{/* Text Editor Modal */}
<TextEditorModal
open={showTextEditor}
onClose={() => {
setShowTextEditor(false);
setTextEditorTarget(null);
setTextEditorContent("");
}}
fileName={textEditorTarget?.name || ""}
initialContent={textEditorContent}
onSave={handleSaveTextFile}
editorWordWrap={editorWordWrap}
onToggleWordWrap={() => setEditorWordWrap(!editorWordWrap)}
/>
</>
);
};
export default SFTPModal;

View File

@@ -5,6 +5,7 @@
import { AppWindow, Cloud, FileType, HardDrive, Keyboard, Palette, Sparkles, TerminalSquare, X } from "lucide-react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useSettingsState } from "../application/state/useSettingsState";
import { useAvailableFonts } from "../application/state/fontStore";
import { usePortForwardingState } from "../application/state/usePortForwardingState";
import { useVaultState } from "../application/state/useVaultState";
import { useWindowControls } from "../application/state/useWindowControls";
@@ -19,7 +20,6 @@ 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 type { TerminalFont } from "../infrastructure/config/fonts";
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
@@ -45,12 +45,63 @@ class AITabErrorBoundary extends React.Component<
}
}
type SettingsState = ReturnType<typeof useSettingsState> & {
availableFonts: TerminalFont[];
};
type SettingsState = ReturnType<typeof useSettingsState>;
const SettingsSyncTab = React.lazy(() => import("./settings/tabs/SettingsSyncTab"));
const SettingsTerminalTabContainer: React.FC<{ settings: SettingsState }> = ({ settings }) => {
const availableFonts = useAvailableFonts();
return (
<SettingsTerminalTab
terminalThemeId={settings.terminalThemeId}
setTerminalThemeId={settings.setTerminalThemeId}
terminalFontFamilyId={settings.terminalFontFamilyId}
setTerminalFontFamilyId={settings.setTerminalFontFamilyId}
terminalFontSize={settings.terminalFontSize}
setTerminalFontSize={settings.setTerminalFontSize}
terminalSettings={settings.terminalSettings}
updateTerminalSetting={settings.updateTerminalSetting}
availableFonts={availableFonts}
/>
);
};
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}
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>
</AITabErrorBoundary>
);
};
const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = ({ onSettingsApplied }) => {
const {
hosts,
@@ -99,9 +150,12 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
const { t } = useI18n();
const { notifyRendererReady, closeSettingsWindow } = useWindowControls();
const { updateState, checkNow, installUpdate, openReleasePage } = useUpdateCheck({ autoUpdateEnabled: settings.autoUpdateEnabled });
const aiState = useAIState();
const [activeTab, setActiveTab] = useState("application");
const [mountedTabs, setMountedTabs] = useState(() => new Set(["application"]));
const isImmersive = settings.immersiveMode;
const toggleImmersive = useCallback(() => {
settings.setImmersiveMode(!isImmersive);
}, [settings, isImmersive]);
useEffect(() => {
notifyRendererReady();
@@ -227,21 +281,13 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
setUiLanguage={settings.setUiLanguage}
customCSS={settings.customCSS}
setCustomCSS={settings.setCustomCSS}
isImmersive={isImmersive}
onToggleImmersive={toggleImmersive}
/>
)}
{mountedTabs.has("terminal") && (
<SettingsTerminalTab
terminalThemeId={settings.terminalThemeId}
setTerminalThemeId={settings.setTerminalThemeId}
terminalFontFamilyId={settings.terminalFontFamilyId}
setTerminalFontFamilyId={settings.setTerminalFontFamilyId}
terminalFontSize={settings.terminalFontSize}
setTerminalFontSize={settings.setTerminalFontSize}
terminalSettings={settings.terminalSettings}
updateTerminalSetting={settings.updateTerminalSetting}
availableFonts={settings.availableFonts}
/>
<SettingsTerminalTabContainer settings={settings} />
)}
{mountedTabs.has("shortcuts") && (
@@ -261,34 +307,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
)}
{mountedTabs.has("ai") && (
<AITabErrorBoundary>
<React.Suspense fallback={null}>
<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}
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>
</AITabErrorBoundary>
<SettingsAITabContainer />
)}
{mountedTabs.has("sync") && (

View File

@@ -11,6 +11,7 @@
*/
import React, { memo, useCallback, useEffect, useMemo, useRef } from "react";
import { formatHostPort } from "../domain/host";
import { useI18n } from "../application/i18n/I18nProvider";
import { useSftpState } from "../application/state/useSftpState";
import { useSftpBackend } from "../application/state/useSftpBackend";
@@ -101,7 +102,14 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles]);
const sftp = useSftpState(hosts, keys, identities, sftpOptions);
const { showSaveDialog, startStreamTransfer } = useSftpBackend();
const {
showSaveDialog,
selectDirectory,
startStreamTransfer,
listSftp,
mkdirLocal,
deleteLocalFile,
} = useSftpBackend();
const sftpRef = useRef(sftp);
sftpRef.current = sftp;
@@ -153,7 +161,11 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
getOpenerForFileRef,
setOpenerForExtension,
t,
listSftp,
mkdirLocal,
deleteLocalFile,
showSaveDialog,
selectDirectory,
startStreamTransfer,
getSftpIdForConnection: sftp.getSftpIdForConnection,
});
@@ -183,17 +195,12 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
// Maps tab IDs to the connectionKey used to create them, so we can
// correctly identify tabs when the same host ID has different overrides.
const tabConnectionKeyMapRef = useRef<Map<string, string>>(new Map());
const pendingConnectionKeyRef = useRef<string | null>(null);
const prevIsVisibleRef = useRef(isVisible);
// Reset location guard when the panel is reopened so the terminal cwd
// is re-applied even if it matches the previous session's path.
useEffect(() => {
if (isVisible && !prevIsVisibleRef.current) {
lastAppliedInitialLocationKeyRef.current = null;
}
prevIsVisibleRef.current = isVisible;
}, [isVisible]);
// NOTE: We intentionally do NOT reset lastAppliedInitialLocationKeyRef on
// visibility changes. When the user switches terminal tabs, the panel
// toggles isVisible but should preserve its navigation state (the user may
// have navigated away from initialLocation). When the panel is truly
// closed, the component unmounts and all refs are naturally reset.
// Navigate SFTP to the terminal's current working directory
const handleGoToTerminalCwd = useCallback(async () => {
@@ -206,14 +213,12 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
// Track whether there's active work that should block connection switching.
// Computed outside the effect so it can be in the dependency array.
const hasActiveTransfers = useMemo(
() => sftp.transfers.some((t) => t.status === "pending" || t.status === "transferring"),
[sftp.transfers],
);
// Block host-following while any connection-sensitive UI or operation
// is active: text editor, permissions dialog, file-opener dialog, or
// Block host-following while any connection-sensitive interactive UI is
// active: text editor, permissions dialog, file-opener dialog, or
// auto-synced external file watches.
const hasActiveWork = hasActiveTransfers || showTextEditor || !!permissionsState || showFileOpenerDialog
// Note: transfers are NOT included here — they run on their own sftpId
// independent of the active tab, and forceNewTab preserves old connections.
const hasActiveWork = showTextEditor || !!permissionsState || showFileOpenerDialog
|| (sftp.activeFileWatchCountRef?.current ?? 0) > 0;
useEffect(() => {
@@ -298,28 +303,24 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
return;
}
// Create a new tab when there's already an active connection to a different
// host, so the previous tab is preserved for instant switching on focus change.
// Create a new tab when there's already an active connection, so the
// previous tab is preserved for instant switching on focus change.
// This covers both different hosts AND same host with different
// session-time overrides (port/protocol), preventing the old SFTP
// session from being closed while it may have in-flight transfers.
const currentConn = s.leftPane.connection;
const needsNewTab = !!(currentConn && currentConn.status === "connected" && currentConn.hostId !== activeHost.id);
const needsNewTab = !!(currentConn && currentConn.status === "connected");
connectedKeyRef.current = connectionKey;
connectedHostObjRef.current = activeHost;
// Store the pending key so the effect below can map it once the tab is created
pendingConnectionKeyRef.current = connectionKey;
s.connect("left", activeHost, needsNewTab ? { forceNewTab: true } : undefined);
s.connect("left", activeHost, {
...(needsNewTab ? { forceNewTab: true } : undefined),
onTabCreated: (tabId) => {
tabConnectionKeyMapRef.current.set(tabId, connectionKey);
},
});
}, [activeHost, hasActiveWork]); // Re-evaluate when work finishes so deferred switch can proceed
// Track the active tab's connectionKey after connect() creates or reuses it.
// Watches both activeTabId (new tab) and connection status (reused tab reconnecting).
useEffect(() => {
const activeTabId = sftp.leftTabs.activeTabId;
if (activeTabId && pendingConnectionKeyRef.current) {
tabConnectionKeyMapRef.current.set(activeTabId, pendingConnectionKeyRef.current);
pendingConnectionKeyRef.current = null;
}
}, [sftp.leftTabs.activeTabId, sftp.leftPane.connection?.status]);
// Clear the remembered connection key when the pane disconnects or the
// session is lost, so re-opening SFTP for the same terminal reconnects.
// Also reset the file-watch counter — watches are bound to the SFTP session,
@@ -425,10 +426,19 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
]);
const MAX_VISIBLE_TRANSFERS = 5;
const visibleTransfers = useMemo(
() => [...sftp.transfers].reverse().slice(0, MAX_VISIBLE_TRANSFERS),
[sftp.transfers],
);
const visibleTransfers = useMemo(() => {
const connection = sftp.leftPane.connection;
if (!connection) return [];
// Filter transfers to those relevant to the active connection's host,
// so workspace focus switches don't show transfers from other hosts.
const filtered = sftp.transfers.filter((t) => {
if (connection.isLocal) {
return t.sourceConnectionId === connection.id || t.targetConnectionId === connection.id;
}
return t.targetHostId === connection.hostId || t.sourceConnectionId === connection.id || t.targetConnectionId === connection.id;
});
return [...filtered].reverse().slice(0, MAX_VISIBLE_TRANSFERS);
}, [sftp.transfers, sftp.leftPane.connection]);
const handleRevealTransferTarget = useCallback(
async (task: TransferTask) => {
@@ -509,7 +519,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
/>
<div
className="min-w-0 flex-1 max-w-[calc(100%-1.75rem)] text-[11px] leading-5 truncate"
title={`${displayHost.label} · ${(displayHost.username || "root")}@${displayHost.hostname}:${displayHost.port || 22}`}
title={`${displayHost.label} · ${(displayHost.username || "root")}@${formatHostPort(displayHost.hostname, displayHost.port || 22)}`}
>
<span className="font-medium">
{displayHost.label}

View File

@@ -86,8 +86,15 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
const sftp = useSftpState(hosts, keys, identities, sftpOptions);
// Get stream transfer functions for optimized downloads
const { showSaveDialog, startStreamTransfer } = useSftpBackend();
// Get backend helpers for file downloads and local filesystem writes.
const {
showSaveDialog,
selectDirectory,
startStreamTransfer,
listSftp,
mkdirLocal,
deleteLocalFile,
} = useSftpBackend();
// Store sftp in a ref so callbacks can access the latest instance
// without needing to re-create when sftp changes
@@ -176,7 +183,11 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
getOpenerForFileRef,
setOpenerForExtension,
t,
listSftp,
mkdirLocal,
deleteLocalFile,
showSaveDialog,
selectDirectory,
startStreamTransfer,
getSftpIdForConnection: sftp.getSftpIdForConnection,
});

View File

@@ -439,8 +439,8 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
const name = newPackageName.trim();
if (!name) return;
// Allow leading slash and validate the rest - allow hyphens anywhere in package names
if (!/^\/?([\w-]+(\/[\w-]+)*)\/?$/.test(name)) {
// Allow leading slash and validate the rest - allow hyphens and Unicode letters/numbers
if (!/^\/?([\w\p{L}\p{N}-]+(\/[\w\p{L}\p{N}-]+)*)\/?$/u.test(name)) {
// Could add toast notification here for invalid characters
return;
}
@@ -550,9 +550,9 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
return;
}
// Validate: same rules as createPackage - only allow letters, numbers, hyphens, underscores
// Validate: same rules as createPackage - allow Unicode letters, numbers, hyphens, underscores
// Since we're renaming a single segment (no slashes allowed), use the segment-level pattern
if (!/^[\w-]+$/.test(newName)) {
if (!/^[\w\p{L}\p{N}-]+$/u.test(newName)) {
setRenameError(t('snippets.renameDialog.error.invalidChars'));
return;
}
@@ -1203,7 +1203,6 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
value={newPackageName}
onChange={(e) => setNewPackageName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && createPackage()}
pattern="^/?([\w-]+(/[\w-]+)*)?/?$"
title="Package names can contain letters, numbers, hyphens, underscores, and forward slashes. Can optionally start with /"
/>
<p className="text-[11px] text-muted-foreground">{t('snippets.packageDialog.hint')}</p>

View File

@@ -8,7 +8,7 @@ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "
// flushSync removed - no longer needed
import { useI18n } from "../application/i18n/I18nProvider";
import { logger } from "../lib/logger";
import { cn } from "../lib/utils";
import { cn, normalizeLineEndings, wrapBracketedPaste } from "../lib/utils";
import {
Host,
Identity,
@@ -25,6 +25,11 @@ import {
shouldEnableNativeUserInputAutoScroll,
shouldScrollOnTerminalInput,
} from "../domain/terminalScroll";
import {
resolveHostTerminalFontFamilyId,
resolveHostTerminalFontSize,
resolveHostTerminalThemeId,
} from "../domain/terminalAppearance";
import { resolveHostAuth } from "../domain/sshAuth";
import { useTerminalBackend } from "../application/state/useTerminalBackend";
import KnownHostConfirmDialog, { HostKeyInfo } from "./KnownHostConfirmDialog";
@@ -152,6 +157,12 @@ interface TerminalProps {
onToggleComposeBar?: () => void;
isWorkspaceComposeBarOpen?: boolean;
onBroadcastInput?: (data: string, sourceSessionId: string) => void;
onSnippetExecutorChange?: (
sessionId: string,
executor: ((command: string, noAutoRun?: boolean) => void) | null,
) => void;
// Session log configuration for real-time streaming
sessionLog?: { enabled: boolean; directory: string; format: string };
}
// Helper function to format network speed (bytes/sec) to human-readable format
@@ -209,6 +220,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onToggleComposeBar,
isWorkspaceComposeBarOpen,
onBroadcastInput,
onSnippetExecutorChange,
sessionLog,
}) => {
// Timeout for connection - increased to 120s to allow time for keyboard-interactive (2FA) authentication
const CONNECTION_TIMEOUT = 120000;
@@ -299,6 +312,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const [showSFTP, setShowSFTP] = useState(false);
const [progressValue, setProgressValue] = useState(15);
const [hasSelection, setHasSelection] = useState(false);
const [isDisconnectedDialogDismissed, setIsDisconnectedDialogDismissed] = useState(false);
const statusRef = useRef<TerminalSession["status"]>(status);
statusRef.current = status;
@@ -337,12 +351,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const isLocalConnection = host.protocol === "local";
const isSerialConnection = host.protocol === "serial";
// Server stats (CPU, Memory, Disk) for Linux servers
// Server stats (CPU, Memory, Disk) — only for Linux/macOS
const { stats: serverStats } = useServerStats({
sessionId,
enabled: terminalSettings?.showServerStats ?? true,
refreshInterval: terminalSettings?.serverStatsRefreshInterval ?? 5,
isLinux: host.os === 'linux',
isSupportedOs: host.os === 'linux' || host.os === 'macos',
isConnected: status === 'connected',
});
@@ -399,13 +413,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const customThemes = useCustomThemes();
const effectiveTheme = useMemo(() => {
if (host.theme) {
const hostTheme = TERMINAL_THEMES.find((t) => t.id === host.theme)
|| customThemes.find((t) => t.id === host.theme);
const themeId = resolveHostTerminalThemeId(host, terminalTheme.id);
if (themeId) {
const hostTheme = TERMINAL_THEMES.find((t) => t.id === themeId)
|| customThemes.find((t) => t.id === themeId);
if (hostTheme) return hostTheme;
}
return terminalTheme;
}, [host.theme, terminalTheme, customThemes]);
}, [host, terminalTheme, customThemes]);
const resolvedChainHosts =
(host.hostChain?.hostIds
@@ -487,6 +502,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onTerminalDataCapture,
onOsDetected,
onCommandExecuted,
sessionLog,
});
sessionStartersRef.current = sessionStarters;
@@ -498,6 +514,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
setProgressLogs([]);
setShowLogs(false);
setIsCancelling(false);
setIsDisconnectedDialogDismissed(false);
const boot = async () => {
try {
@@ -623,28 +640,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// Local terminal and serial connections don't need timeout/progress UI
if (isLocalConnection || isSerialConnection) return;
// Only show SSH-specific scripted logs for SSH connections
const isSSH = host.protocol !== "telnet";
let stepTimer: ReturnType<typeof setInterval> | undefined;
if (isSSH) {
const scripted = [
"Resolving host and keys...",
"Negotiating ciphers...",
"Exchanging keys...",
"Authenticating user...",
"Waiting for server greeting...",
];
let idx = 0;
stepTimer = setInterval(() => {
setProgressLogs((prev) => {
if (idx >= scripted.length) return prev;
const next = scripted[idx++];
return prev.includes(next) ? prev : [...prev, next];
});
}, 900);
}
setTimeLeft(CONNECTION_TIMEOUT / 1000);
const countdown = setInterval(() => {
setTimeLeft((prev) => (prev > 0 ? prev - 1 : 0));
@@ -667,7 +662,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}, 200);
return () => {
if (stepTimer) clearInterval(stepTimer);
clearInterval(countdown);
clearTimeout(timeout);
clearInterval(prog);
@@ -675,6 +669,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps -- updateStatus is a stable internal helper
}, [status, auth.needsAuth, host.protocol, host.hostname]);
useEffect(() => {
if (status === "connecting") {
setIsDisconnectedDialogDismissed(false);
}
}, [status]);
const safeFit = (options?: { force?: boolean; requireVisible?: boolean }) => {
const fitAddon = fitAddonRef.current;
if (!fitAddon) return;
@@ -721,7 +721,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
useEffect(() => {
if (termRef.current) {
const effectiveFontSize = host.fontSize || fontSize;
const effectiveFontSize = resolveHostTerminalFontSize(host, fontSize);
termRef.current.options.fontSize = effectiveFontSize;
termRef.current.options.theme = {
@@ -769,6 +769,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
terminalSettings.drawBoldInBrightColors;
termRef.current.options.minimumContrastRatio =
terminalSettings.minimumContrastRatio;
termRef.current.options.smoothScrollDuration =
terminalSettings.smoothScrolling
? XTERM_PERFORMANCE_CONFIG.rendering.smoothScrollDuration
: 0;
termRef.current.options.scrollOnUserInput =
shouldEnableNativeUserInputAutoScroll(terminalSettings);
termRef.current.options.altClickMovesCursor = !terminalSettings.altAsMeta;
@@ -778,14 +782,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
setTimeout(() => safeFit({ force: true }), 50);
}
}, [fontSize, effectiveTheme, terminalSettings, host.fontSize]);
}, [fontSize, effectiveTheme, terminalSettings, host]);
useEffect(() => {
if (termRef.current) {
const effectiveFontSize = host.fontSize || fontSize;
const effectiveFontSize = resolveHostTerminalFontSize(host, fontSize);
termRef.current.options.fontSize = effectiveFontSize;
const hostFontId = host.fontFamily || fontFamilyId || "menlo";
const hostFontId = resolveHostTerminalFontFamilyId(host, fontFamilyId) || "menlo";
const fontObj = availableFonts.find((f) => f.id === hostFontId) || availableFonts[0];
termRef.current.options.fontFamily = fontObj.family;
@@ -796,7 +800,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
setTimeout(() => safeFit({ force: true }), 50);
}
}, [host.fontSize, host.fontFamily, host.theme, fontFamilyId, fontSize, effectiveTheme, availableFonts]);
}, [host, fontFamilyId, fontSize, effectiveTheme, availableFonts]);
useEffect(() => {
if (!isVisible) return;
@@ -844,7 +848,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (terminalSettings && termRef.current) {
const fontFamily = termRef.current.options?.fontFamily || "";
const effectiveFontSize = host.fontSize || fontSize;
const effectiveFontSize = resolveHostTerminalFontSize(host, fontSize);
if (typeof document !== "undefined" && document.fonts?.check) {
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${fontFamily}`;
const resolvedBold = document.fonts.check(weightSpec)
@@ -880,7 +884,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return () => {
cancelled = true;
};
}, [host.id, host.fontFamily, host.fontSize, fontFamilyId, fontSize, resizeSession, sessionId, terminalSettings]);
}, [host, fontFamilyId, fontSize, resizeSession, sessionId, terminalSettings]);
useEffect(() => {
if (!isVisible || !containerRef.current || !fitAddonRef.current) return;
@@ -1043,11 +1047,43 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const scrollOnPasteRef = useRef(terminalSettings?.scrollOnPaste ?? true);
scrollOnPasteRef.current = terminalSettings?.scrollOnPaste ?? true;
const scrollToBottomAfterProgrammaticInput = (data: string) => {
const scrollToBottomAfterProgrammaticInput = useCallback((data: string) => {
if (termRef.current && shouldScrollOnTerminalInput(terminalSettingsRef.current, data)) {
termRef.current.scrollToBottom();
}
};
}, []);
const executeSnippetCommand = useCallback((command: string, noAutoRun?: boolean) => {
const term = termRef.current;
const id = sessionRef.current;
if (!term || !id) return;
let data = normalizeLineEndings(command);
const isMultiLine = data.includes('\n');
// Wrap in bracketed paste BEFORE appending \r so the Enter is sent
// outside the paste markers — otherwise shells treat it as pasted text
// instead of a submit action.
if (isMultiLine && term.modes.bracketedPasteMode && !disableBracketedPasteRef.current) {
data = wrapBracketedPaste(data);
}
if (!noAutoRun) data = `${data}\r`;
terminalBackend.writeToSession(id, data);
scrollToBottomAfterProgrammaticInput(data);
term.focus();
}, [scrollToBottomAfterProgrammaticInput, terminalBackend]);
// Only register the snippet executor once the terminal session is ready.
// Before that, TerminalLayer falls back to raw writeToSession which is the
// correct path for sessions that are still connecting.
useEffect(() => {
if (status !== "connected") {
onSnippetExecutorChange?.(sessionId, null);
return;
}
onSnippetExecutorChange?.(sessionId, executeSnippetCommand);
return () => onSnippetExecutorChange?.(sessionId, null);
}, [executeSnippetCommand, onSnippetExecutorChange, sessionId, status]);
const terminalContextActions = useTerminalContextActions({
termRef,
@@ -1106,6 +1142,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onCloseSession?.(sessionId);
};
const handleDismissDisconnectedDialog = () => {
setIsDisconnectedDialogDismissed(true);
};
const handleCloseDisconnectedSession = () => {
onCloseSession?.(sessionId);
};
const handleHostKeyClose = () => {
setNeedsHostKeyVerification(false);
setPendingHostKeyInfo(null);
@@ -1146,17 +1190,29 @@ const TerminalComponent: React.FC<TerminalProps> = ({
cleanupSession();
auth.resetForRetry();
hasRunStartupCommandRef.current = false;
setIsDisconnectedDialogDismissed(false);
setStatus("connecting");
setError(null);
setProgressLogs(["Retrying secure channel..."]);
setShowLogs(true);
if (host.protocol === "local" || host.hostname === "localhost") {
if (host.protocol === "serial") {
sessionStarters.startSerial(termRef.current);
} else if (host.protocol === "local" || host.hostname === "localhost") {
sessionStarters.startLocal(termRef.current);
} else if (host.protocol === "telnet") {
sessionStarters.startTelnet(termRef.current);
} else if (host.moshEnabled) {
sessionStarters.startMosh(termRef.current);
} else {
sessionStarters.startSSH(termRef.current);
}
};
const shouldShowConnectionDialog = status !== "connected"
&& !needsHostKeyVerification
&& !((isLocalConnection || isSerialConnection) && status === "connecting")
&& !(status === "disconnected" && isDisconnectedDialogDismissed);
// Drag and drop handlers
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
@@ -1337,8 +1393,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
)}
/>
</div>
{/* Server Stats Display - Linux only */}
{host.os === 'linux' && terminalSettings?.showServerStats && status === 'connected' && serverStats.lastUpdated && (
{/* Server Stats Display */}
{terminalSettings?.showServerStats && status === 'connected' && serverStats.lastUpdated && (
<div className="flex items-center gap-2.5 ml-2 text-[10px] opacity-80 flex-nowrap overflow-hidden min-w-0">
{/* CPU with HoverCard for per-core details */}
<HoverCard openDelay={200} closeDelay={100}>
@@ -1385,6 +1441,24 @@ const TerminalComponent: React.FC<TerminalProps> = ({
</div>
))}
</div>
) : serverStats.cpu !== null ? (
<div className="flex flex-col gap-1.5 min-w-[160px]">
<div className="w-full h-2 bg-muted rounded-full overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all",
serverStats.cpu >= 90 ? "bg-red-500" : serverStats.cpu >= 70 ? "bg-amber-500" : "bg-emerald-500"
)}
style={{ width: `${serverStats.cpu}%` }}
/>
</div>
<div className={cn(
"text-center text-[11px] font-medium",
serverStats.cpu >= 90 ? "text-red-400" : serverStats.cpu >= 70 ? "text-amber-400" : "text-emerald-400"
)}>
{serverStats.cpu}% · {serverStats.cpuCores ?? '?'} cores
</div>
</div>
) : (
<div className="text-muted-foreground">{t("terminal.serverStats.noData")}</div>
)}
@@ -1730,9 +1804,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
)}
{/* Connection dialog: skip for local/serial during connecting phase, but show on error */}
{status !== "connected" && !needsHostKeyVerification && !(
(isLocalConnection || isSerialConnection) && status === "connecting"
) && (
{shouldShowConnectionDialog && (
<TerminalConnectionDialog
host={host}
status={status}
@@ -1743,6 +1815,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
showLogs={showLogs}
_setShowLogs={setShowLogs}
keys={keys}
onDismissDisconnected={handleDismissDisconnectedDialog}
authProps={{
authMethod: auth.authMethod,
setAuthMethod: auth.setAuthMethod,
@@ -1768,7 +1841,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
timeLeft,
isCancelling,
progressLogs,
onCancel: handleCancelConnect,
onCancelConnect: handleCancelConnect,
onCloseSession: handleCloseDisconnectedSession,
onRetry: handleRetry,
}}
/>

View File

@@ -1,11 +1,23 @@
import { Circle, FolderTree, LayoutGrid, MessageSquare, PanelLeft, PanelRight, Palette, Server, X, Zap } from 'lucide-react';
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { useActiveTabId } from '../application/state/activeTabStore';
import { useTerminalBackend } from '../application/state/useTerminalBackend';
import { collectSessionIds } from '../domain/workspace';
import { SplitDirection } from '../domain/workspace';
import { KeyBinding, TerminalSettings } from '../domain/models';
import { cn } from '../lib/utils';
import {
clearHostFontFamilyOverride,
clearHostFontSizeOverride,
clearHostThemeOverride,
hasHostFontFamilyOverride,
hasHostFontSizeOverride,
hasHostThemeOverride,
resolveHostTerminalFontFamilyId,
resolveHostTerminalFontSize,
resolveHostTerminalThemeId,
} from '../domain/terminalAppearance';
import { cn, normalizeLineEndings } from '../lib/utils';
import { detectLocalOs } from '../lib/localShell';
import { useStoredString } from '../application/state/useStoredString';
import { buildCacheKey } from '../application/state/sftp/sharedRemoteHostCache';
import type { DropEntry } from '../lib/sftpFileUtils';
@@ -16,12 +28,13 @@ import { SftpSidePanel } from './SftpSidePanel';
import { ScriptsSidePanel } from './ScriptsSidePanel';
import { ThemeSidePanel } from './terminal/ThemeSidePanel';
import { AIChatSidePanel } from './AIChatSidePanel';
import { useAIState } from '../application/state/useAIState';
import { cleanupOrphanedAISessions, useAIState } from '../application/state/useAIState';
import { TerminalComposeBar } from './terminal/TerminalComposeBar';
import { TERMINAL_THEMES } from '../infrastructure/config/terminalThemes';
import { useCustomThemes } from '../application/state/customThemeStore';
import { Button } from './ui/button';
import { ScrollArea } from './ui/scroll-area';
import { setupMcpApprovalBridge } from '../infrastructure/ai/shared/approvalGate';
type SidePanelTab = 'sftp' | 'scripts' | 'theme' | 'ai';
@@ -52,6 +65,8 @@ type PendingSftpUpload = {
entries: DropEntry[];
};
type SnippetExecutor = (command: string, noAutoRun?: boolean) => void;
const filterTabsMap = <T,>(source: Map<string, T>, validIds: Set<string>): Map<string, T> => {
let changed = false;
const next = new Map<string, T>();
@@ -65,6 +80,142 @@ const filterTabsMap = <T,>(source: Map<string, T>, validIds: Set<string>): Map<s
return changed ? next : source;
};
type AITerminalSessionInfo = {
sessionId: string;
hostId: string;
hostname: string;
label: string;
os?: string;
username?: string;
protocol?: string;
shellType?: string;
connected: boolean;
};
type AIPanelContext = {
scopeType: 'terminal' | 'workspace';
scopeTargetId?: string;
scopeHostIds: string[];
scopeLabel: string;
terminalSessions: AITerminalSessionInfo[];
};
type AIStateValue = ReturnType<typeof useAIState>;
const AIStateContext = createContext<AIStateValue | null>(null);
const buildAITerminalSessionInfo = (
session: TerminalSession | undefined,
host: Host | undefined,
localOs: 'linux' | 'macos' | 'windows',
): AITerminalSessionInfo => {
const protocol = session?.protocol || host?.protocol;
const isLocalSession = protocol === 'local' || session?.hostId?.startsWith('local-');
return {
sessionId: session?.id || '',
hostId: session?.hostId || '',
hostname: host?.hostname || session?.hostname || '',
label: host?.label || session?.hostLabel || '',
os: host?.os || (isLocalSession ? localOs : undefined),
username: host?.username || session?.username,
protocol,
shellType: session?.shellType && session.shellType !== 'unknown' ? session.shellType : undefined,
connected: session?.status === 'connected',
};
};
interface AIChatPanelsHostProps {
mountedTabIds: string[];
activeTabId: string | null;
activeSidePanelTab: SidePanelTab | null;
contextsByTabId: Map<string, AIPanelContext>;
resolveExecutorContext: (scope: {
type: 'terminal' | 'workspace';
targetId?: string;
label?: string;
}) => ExecutorContext;
}
const AIStateProviderInner: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const aiState = useAIState();
return (
<AIStateContext.Provider value={aiState}>
{children}
</AIStateContext.Provider>
);
};
const AIStateProvider = memo(AIStateProviderInner);
AIStateProvider.displayName = 'AIStateProvider';
const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
mountedTabIds,
activeTabId,
activeSidePanelTab,
contextsByTabId,
resolveExecutorContext,
}) => {
const aiState = useContext(AIStateContext);
if (!aiState) {
throw new Error('AIChatPanelsHost must be rendered inside AIStateProvider');
}
return (
<>
{mountedTabIds.map((tabId) => {
const context = contextsByTabId.get(tabId);
if (!context) return null;
const isVisible = activeTabId === tabId && activeSidePanelTab === 'ai';
return (
<div
key={tabId}
className={cn("absolute inset-0 z-10", !isVisible && "hidden")}
>
<AIChatSidePanel
sessions={aiState.sessions}
activeSessionIdMap={aiState.activeSessionIdMap}
setActiveSessionId={aiState.setActiveSessionId}
createSession={aiState.createSession}
deleteSession={aiState.deleteSession}
updateSessionTitle={aiState.updateSessionTitle}
updateSessionExternalSessionId={aiState.updateSessionExternalSessionId}
addMessageToSession={aiState.addMessageToSession}
updateLastMessage={aiState.updateLastMessage}
updateMessageById={aiState.updateMessageById}
providers={aiState.providers}
activeProviderId={aiState.activeProviderId}
activeModelId={aiState.activeModelId}
defaultAgentId={aiState.defaultAgentId}
externalAgents={aiState.externalAgents}
setExternalAgents={aiState.setExternalAgents}
agentModelMap={aiState.agentModelMap}
setAgentModel={aiState.setAgentModel}
globalPermissionMode={aiState.globalPermissionMode}
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
commandBlocklist={aiState.commandBlocklist}
maxIterations={aiState.maxIterations}
webSearchConfig={aiState.webSearchConfig}
scopeType={context.scopeType}
scopeTargetId={context.scopeTargetId}
scopeHostIds={context.scopeHostIds}
scopeLabel={context.scopeLabel}
terminalSessions={context.terminalSessions}
resolveExecutorContext={resolveExecutorContext}
isVisible={isVisible}
/>
</div>
);
})}
</>
);
};
const AIChatPanelsHost = memo(AIChatPanelsHostInner);
AIChatPanelsHost.displayName = 'AIChatPanelsHost';
interface TerminalLayerProps {
hosts: Host[];
keys: SSHKey[];
@@ -108,8 +259,13 @@ interface TerminalLayerProps {
sftpAutoSync: boolean;
sftpShowHiddenFiles: boolean;
sftpUseCompressedUpload: boolean;
sftpAutoOpenSidebar: boolean;
editorWordWrap: boolean;
setEditorWordWrap: (value: boolean) => void;
// Session log settings for real-time streaming
sessionLogsEnabled?: boolean;
sessionLogsDir?: string;
sessionLogsFormat?: string;
}
const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
@@ -153,8 +309,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
sftpAutoSync,
sftpShowHiddenFiles,
sftpUseCompressedUpload,
sftpAutoOpenSidebar,
editorWordWrap,
setEditorWordWrap,
sessionLogsEnabled,
sessionLogsDir,
sessionLogsFormat,
}) => {
// Subscribe to activeTabId from external store
const activeTabId = useActiveTabId();
@@ -167,8 +327,56 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onCloseSession(sessionId);
}, [onCloseSession]);
const sftpAutoOpenSidebarRef = useRef(sftpAutoOpenSidebar);
sftpAutoOpenSidebarRef.current = sftpAutoOpenSidebar;
const handleStatusChange = useCallback((sessionId: string, status: TerminalSession['status']) => {
onUpdateSessionStatus(sessionId, status);
// Auto-open SFTP sidebar when a remote host connects (if setting enabled)
if (status === 'connected' && sftpAutoOpenSidebarRef.current) {
const session = sessionsRef.current.find(s => s.id === sessionId);
if (!session) return;
// Only auto-open for SSH/Mosh (SFTP requires SSH); skip local/unset protocol
const proto = session.protocol;
if (proto !== 'ssh' && proto !== 'mosh') return;
const host = hostsRef.current.find(h => h.id === session.hostId);
// Determine the tab ID (workspace or solo session)
const tabId = session.workspaceId || sessionId;
// Only open if the sidebar is not already open for this tab
if (sidePanelOpenTabsRef.current.has(tabId)) return;
const hostWithOverrides: Host = host
? {
...host,
protocol: session.protocol ?? host.protocol,
port: session.port ?? host.port,
moshEnabled: session.moshEnabled ?? host.moshEnabled,
}
: {
// Quick Connect / temporary session — build minimal host from session data
id: session.hostId || sessionId,
hostname: session.hostname,
username: session.username,
port: session.port ?? 22,
protocol: proto,
label: session.label || session.hostname,
} as Host;
setSidePanelOpenTabs(prev => {
const next = new Map(prev);
next.set(tabId, 'sftp');
return next;
});
setSftpHostForTab(prev => {
const next = new Map(prev);
next.set(tabId, hostWithOverrides);
return next;
});
}
}, [onUpdateSessionStatus]);
const handleSessionExit = useCallback((sessionId: string, evt: { exitCode?: number; signal?: number; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => {
@@ -204,6 +412,15 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
// Terminal backend for broadcast writes
const terminalBackend = useTerminalBackend();
const snippetExecutorsRef = useRef<Map<string, SnippetExecutor>>(new Map());
const handleSnippetExecutorChange = useCallback((sessionId: string, executor: SnippetExecutor | null) => {
if (executor) {
snippetExecutorsRef.current.set(sessionId, executor);
return;
}
snippetExecutorsRef.current.delete(sessionId);
}, []);
const [workspaceArea, setWorkspaceArea] = useState<{ width: number; height: number }>({ width: 0, height: 0 });
const workspaceOuterRef = useRef<HTMLDivElement>(null);
@@ -531,6 +748,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
setSftpPendingUploadsForTab(prev => filterTabsMap(prev, validTerminalTabIds));
}, [validTerminalTabIds]);
useEffect(() => {
cleanupOrphanedAISessions(validTerminalTabIds);
}, [validTerminalTabIds]);
const computeWorkspaceRects = useCallback((workspace?: Workspace, size?: { width: number; height: number }): Record<string, WorkspaceRect> => {
if (!workspace) return {} as Record<string, WorkspaceRect>;
const wTotal = size?.width || 1;
@@ -774,6 +995,13 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
() => Array.from(sftpHostForTab.keys()),
[sftpHostForTab],
);
const mountedAiTabIds = useMemo(
() =>
Array.from(sidePanelOpenTabs.entries())
.filter(([, panel]) => panel === 'ai')
.map(([tabId]) => tabId),
[sidePanelOpenTabs],
);
// Get the focused terminal's current working directory
const getTerminalCwd = useCallback(async (): Promise<string | null> => {
@@ -880,8 +1108,15 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const handleSnippetClickForFocusedSession = useCallback((command: string, noAutoRun?: boolean) => {
const sessionId = activeWorkspace?.focusedSessionId ?? activeSession?.id;
if (!sessionId) return;
const payload = noAutoRun ? command : `${command}\r`;
terminalBackend.writeToSession(sessionId, payload);
const executor = snippetExecutorsRef.current.get(sessionId);
if (executor) {
executor(command, noAutoRun);
return;
}
let data = normalizeLineEndings(command);
if (!noAutoRun) data = `${data}\r`;
terminalBackend.writeToSession(sessionId, data);
// Re-focus the terminal so the user can interact immediately
const pane = document.querySelector(`[data-session-id="${sessionId}"]`);
const textarea = pane?.querySelector('textarea.xterm-helper-textarea') as HTMLTextAreaElement | null;
@@ -909,67 +1144,113 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
return;
}
if (focusedHost) {
onUpdateHost({ ...focusedHost, theme: themeId });
onUpdateHost({ ...focusedHost, theme: themeId, themeOverride: true });
}
}, [focusedHost, isFocusedHostLocal, onUpdateTerminalThemeId, onUpdateHost]);
const handleThemeResetForFocusedSession = useCallback(() => {
if (!focusedHost || isFocusedHostLocal) return;
onUpdateHost(clearHostThemeOverride(focusedHost));
}, [focusedHost, isFocusedHostLocal, onUpdateHost]);
const handleFontFamilyChangeForFocusedSession = useCallback((fontFamilyId: string) => {
if (isFocusedHostLocal) {
onUpdateTerminalFontFamilyId?.(fontFamilyId);
return;
}
if (focusedHost) {
onUpdateHost({ ...focusedHost, fontFamily: fontFamilyId });
onUpdateHost({ ...focusedHost, fontFamily: fontFamilyId, fontFamilyOverride: true });
}
}, [focusedHost, isFocusedHostLocal, onUpdateTerminalFontFamilyId, onUpdateHost]);
const handleFontFamilyResetForFocusedSession = useCallback(() => {
if (!focusedHost || isFocusedHostLocal) return;
onUpdateHost(clearHostFontFamilyOverride(focusedHost));
}, [focusedHost, isFocusedHostLocal, onUpdateHost]);
const handleFontSizeChangeForFocusedSession = useCallback((newFontSize: number) => {
if (isFocusedHostLocal) {
onUpdateTerminalFontSize?.(newFontSize);
return;
}
if (focusedHost) {
onUpdateHost({ ...focusedHost, fontSize: newFontSize });
onUpdateHost({ ...focusedHost, fontSize: newFontSize, fontSizeOverride: true });
}
}, [focusedHost, isFocusedHostLocal, onUpdateTerminalFontSize, onUpdateHost]);
const handleFontSizeResetForFocusedSession = useCallback(() => {
if (!focusedHost || isFocusedHostLocal) return;
onUpdateHost(clearHostFontSizeOverride(focusedHost));
}, [focusedHost, isFocusedHostLocal, onUpdateHost]);
// Current theme/font/size for the focused session (for ThemeSidePanel)
const focusedThemeId = focusedHost?.theme ?? terminalTheme.id;
const focusedFontFamilyId = focusedHost?.fontFamily ?? terminalFontFamilyId;
const focusedFontSize = focusedHost?.fontSize ?? fontSize;
// AI Chat state
const aiState = useAIState();
const { cleanupOrphanedSessions } = aiState;
const focusedThemeId = resolveHostTerminalThemeId(focusedHost, terminalTheme.id);
const focusedFontFamilyId = resolveHostTerminalFontFamilyId(focusedHost, terminalFontFamilyId);
const focusedFontSize = resolveHostTerminalFontSize(focusedHost, fontSize);
const focusedThemeOverridden = hasHostThemeOverride(focusedHost);
const focusedFontFamilyOverridden = hasHostFontFamilyOverride(focusedHost);
const focusedFontSizeOverridden = hasHostFontSizeOverride(focusedHost);
// Keep MCP/ACP approval IPC listener alive for the entire terminal lifecycle.
// Must live here (TerminalLayer), not inside the AI panel subtree, so closing
// or hiding the panel never tears down approval handling mid-execution.
useEffect(() => {
const activeIds = new Set<string>();
for (const s of sessions) activeIds.add(s.id);
for (const w of workspaces) activeIds.add(w.id);
cleanupOrphanedSessions(activeIds);
}, [sessions, workspaces, cleanupOrphanedSessions]);
return setupMcpApprovalBridge();
}, []);
// Build terminal session context for the AI chat panel
const aiTerminalSessions = useMemo(() => {
const sessionIds = activeWorkspace?.root
? collectSessionIds(activeWorkspace.root)
: activeSession ? [activeSession.id] : [];
// Build per-tab AI contexts so hidden panels can stay mounted without
// recomputing scope resolution from scratch on every tab switch.
const aiContextsByTabId = useMemo(() => {
const localOs = detectLocalOs(navigator.userAgent || navigator.platform);
const sessionById = new Map(sessions.map((session) => [session.id, session]));
const workspaceById = new Map(workspaces.map((workspace) => [workspace.id, workspace]));
const tabIds = new Set<string>(mountedAiTabIds);
if (activeTabId) tabIds.add(activeTabId);
const result = sessionIds.map(sid => {
const s = sessions.find(s => s.id === sid);
const host = s?.hostId ? hosts.find(h => h.id === s.hostId) : undefined;
return {
sessionId: sid,
hostId: s?.hostId || '',
hostname: host?.hostname || '',
label: host?.label || s?.hostLabel || '',
os: host?.os,
username: host?.username,
connected: s?.status === 'connected',
};
});
return result;
}, [sessions, hosts, activeWorkspace, activeSession]);
const contexts = new Map<string, AIPanelContext>();
for (const tabId of tabIds) {
const workspace = workspaceById.get(tabId);
if (workspace) {
const sessionIds = collectSessionIds(workspace.root);
contexts.set(tabId, {
scopeType: 'workspace',
scopeTargetId: workspace.id,
scopeHostIds: sessionIds
.map((sessionId) => sessionById.get(sessionId)?.hostId)
.filter((hostId): hostId is string => !!hostId),
scopeLabel: workspace.title,
terminalSessions: sessionIds.map((sessionId) =>
buildAITerminalSessionInfo(
sessionById.get(sessionId),
sessionHostsMap.get(sessionId),
localOs,
),
),
});
continue;
}
const session = sessionById.get(tabId);
if (!session) continue;
contexts.set(tabId, {
scopeType: 'terminal',
scopeTargetId: session.id,
scopeHostIds: session.hostId ? [session.hostId] : [],
scopeLabel: session.hostLabel ?? '',
terminalSessions: [
buildAITerminalSessionInfo(
session,
sessionHostsMap.get(session.id),
localOs,
),
],
});
}
return contexts;
}, [sessions, workspaces, mountedAiTabIds, activeTabId, sessionHostsMap]);
const resolveAIExecutorContext = useCallback((scope: {
type: 'terminal' | 'workspace';
@@ -979,6 +1260,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const latestWorkspaces = workspacesRef.current;
const latestSessions = sessionsRef.current;
const latestHosts = hostsRef.current;
const localOs = detectLocalOs(navigator.userAgent || navigator.platform);
const sessionIds = scope.type === 'workspace'
? (() => {
const workspace = scope.targetId ? latestWorkspaces.find((w) => w.id === scope.targetId) : undefined;
@@ -994,15 +1276,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
sessions: sessionIds.map((sid) => {
const session = latestSessions.find((s) => s.id === sid);
const host = session?.hostId ? latestHosts.find((h) => h.id === session.hostId) : undefined;
return {
sessionId: sid,
hostId: session?.hostId || '',
hostname: host?.hostname || '',
label: host?.label || session?.hostLabel || '',
os: host?.os,
username: host?.username,
connected: session?.status === 'connected',
};
return buildAITerminalSessionInfo(session, host, localOs);
}),
workspaceId: scope.type === 'workspace' ? scope.targetId : undefined,
workspaceName,
@@ -1189,14 +1463,15 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
};
return (
<div
ref={workspaceOuterRef}
className="absolute inset-0 bg-background flex flex-col"
style={{ display: isTerminalLayerVisible ? 'flex' : 'none', zIndex: isTerminalLayerVisible ? 10 : 0 }}
>
<div className={cn("flex-1 flex min-h-0 relative", sidePanelPosition === 'right' && "flex-row-reverse")}>
<AIStateProvider>
<div
ref={workspaceOuterRef}
className="absolute inset-0 bg-background flex flex-col"
style={{ display: isTerminalLayerVisible ? 'flex' : 'none', zIndex: isTerminalLayerVisible ? 10 : 0 }}
>
<div className={cn("flex-1 flex min-h-0 relative", sidePanelPosition === 'right' && "flex-row-reverse")}>
{/* Side panel with tab header + content (SFTP / Scripts / Theme) */}
{(isSidePanelOpenForCurrentTab || mountedSftpTabIds.length > 0) && (
{(isSidePanelOpenForCurrentTab || mountedSftpTabIds.length > 0 || mountedAiTabIds.length > 0) && (
<>
<div
style={{ width: isSidePanelOpenForCurrentTab ? sidePanelWidth : 0 }}
@@ -1357,57 +1632,31 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
<div className="absolute inset-0 z-10">
<ThemeSidePanel
currentThemeId={focusedThemeId}
globalThemeId={terminalTheme.id}
currentFontFamilyId={focusedFontFamilyId}
globalFontFamilyId={terminalFontFamilyId}
currentFontSize={focusedFontSize}
canResetTheme={focusedThemeOverridden}
canResetFontFamily={focusedFontFamilyOverridden}
canResetFontSize={focusedFontSizeOverridden}
onThemeChange={handleThemeChangeForFocusedSession}
onThemeReset={handleThemeResetForFocusedSession}
onFontFamilyChange={handleFontFamilyChangeForFocusedSession}
onFontFamilyReset={handleFontFamilyResetForFocusedSession}
onFontSizeChange={handleFontSizeChangeForFocusedSession}
onFontSizeReset={handleFontSizeResetForFocusedSession}
/>
</div>
)}
{/* AI Chat sub-panel */}
{activeSidePanelTab === 'ai' && (
<div className="absolute inset-0 z-10">
<AIChatSidePanel
sessions={aiState.sessions}
activeSessionIdMap={aiState.activeSessionIdMap}
setActiveSessionId={aiState.setActiveSessionId}
createSession={aiState.createSession}
deleteSession={aiState.deleteSession}
updateSessionTitle={aiState.updateSessionTitle}
updateSessionExternalSessionId={aiState.updateSessionExternalSessionId}
addMessageToSession={aiState.addMessageToSession}
updateLastMessage={aiState.updateLastMessage}
updateMessageById={aiState.updateMessageById}
providers={aiState.providers}
activeProviderId={aiState.activeProviderId}
activeModelId={aiState.activeModelId}
defaultAgentId={aiState.defaultAgentId}
externalAgents={aiState.externalAgents}
setExternalAgents={aiState.setExternalAgents}
agentModelMap={aiState.agentModelMap}
setAgentModel={aiState.setAgentModel}
globalPermissionMode={aiState.globalPermissionMode}
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
commandBlocklist={aiState.commandBlocklist}
maxIterations={aiState.maxIterations}
webSearchConfig={aiState.webSearchConfig}
scopeType={activeWorkspace ? 'workspace' : 'terminal'}
scopeTargetId={activeWorkspace?.id ?? activeSession?.id}
scopeHostIds={activeWorkspace?.root
? collectSessionIds(activeWorkspace.root).map(sid => {
const s = sessions.find(s => s.id === sid);
return s?.hostId;
}).filter((id): id is string => !!id)
: activeSession?.hostId ? [activeSession.hostId] : []
}
scopeLabel={activeWorkspace?.title ?? activeSession?.hostLabel ?? ''}
terminalSessions={aiTerminalSessions}
resolveExecutorContext={resolveAIExecutorContext}
/>
</div>
)}
<AIChatPanelsHost
mountedTabIds={mountedAiTabIds}
activeTabId={activeTabId}
activeSidePanelTab={activeSidePanelTab}
contextsByTabId={aiContextsByTabId}
resolveExecutorContext={resolveAIExecutorContext}
/>
</div>
</div>
</div>
@@ -1557,6 +1806,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onToggleComposeBar={inActiveWorkspace ? handleToggleWorkspaceComposeBar : undefined}
isWorkspaceComposeBarOpen={inActiveWorkspace ? isComposeBarOpen : undefined}
onBroadcastInput={inActiveWorkspace && activeWorkspace && isBroadcastEnabled?.(activeWorkspace.id) ? handleBroadcastInput : undefined}
onSnippetExecutorChange={handleSnippetExecutorChange}
sessionLog={sessionLogsEnabled && sessionLogsDir ? { enabled: true, directory: sessionLogsDir, format: sessionLogsFormat || 'txt' } : undefined}
/>
</div>
);
@@ -1617,25 +1868,26 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
</div>
{/* Global compose bar for workspace mode */}
{activeWorkspace && isComposeBarOpen && (
<TerminalComposeBar
onSend={handleComposeSend}
onClose={() => {
setIsComposeBarOpen(false);
// Refocus the terminal pane (matching solo-session behavior)
if (focusedSessionId) {
requestAnimationFrame(() => {
const pane = document.querySelector(`[data-session-id="${focusedSessionId}"]`);
const textarea = pane?.querySelector('textarea.xterm-helper-textarea') as HTMLTextAreaElement | null;
textarea?.focus();
});
}
}}
isBroadcastEnabled={isBroadcastEnabled?.(activeWorkspace.id)}
themeColors={composeBarThemeColors}
/>
)}
</div>
{activeWorkspace && isComposeBarOpen && (
<TerminalComposeBar
onSend={handleComposeSend}
onClose={() => {
setIsComposeBarOpen(false);
// Refocus the terminal pane (matching solo-session behavior)
if (focusedSessionId) {
requestAnimationFrame(() => {
const pane = document.querySelector(`[data-session-id="${focusedSessionId}"]`);
const textarea = pane?.querySelector('textarea.xterm-helper-textarea') as HTMLTextAreaElement | null;
textarea?.focus();
});
}
}}
isBroadcastEnabled={isBroadcastEnabled?.(activeWorkspace.id)}
themeColors={composeBarThemeColors}
/>
)}
</div>
</AIStateProvider>
);
};
@@ -1658,6 +1910,7 @@ const terminalLayerAreEqual = (prev: TerminalLayerProps, next: TerminalLayerProp
prev.sftpAutoSync === next.sftpAutoSync &&
prev.sftpShowHiddenFiles === next.sftpShowHiddenFiles &&
prev.sftpUseCompressedUpload === next.sftpUseCompressedUpload &&
prev.sftpAutoOpenSidebar === next.sftpAutoOpenSidebar &&
prev.editorWordWrap === next.editorWordWrap &&
prev.setEditorWordWrap === next.setEditorWordWrap &&
prev.onHotkeyAction === next.onHotkeyAction &&

View File

@@ -151,6 +151,7 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
// Ref to store the latest save function to avoid stale closure in keyboard shortcut
const handleSaveRef = useRef<() => Promise<void>>(() => Promise.resolve());
const handlePasteRef = useRef<() => Promise<void>>(() => Promise.resolve());
const readClipboardTextRef = useRef<() => Promise<string | null>>(() => Promise.resolve(null));
// Track theme from document.documentElement class (syncs with app theme)
const [isDarkTheme, setIsDarkTheme] = useState(() =>
@@ -254,6 +255,10 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
}
}, [readClipboardTextFromBridge]);
useEffect(() => {
readClipboardTextRef.current = readClipboardText;
}, [readClipboardText]);
const handlePaste = useCallback(async () => {
const editor = editorRef.current;
if (!editor) return;
@@ -316,7 +321,30 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
});
// Fallback paste path for Electron environments where Monaco paste can fail.
// Skip custom paste when focus is inside the find/replace widget so that
// its input fields receive the pasted text via default browser behavior.
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyV, () => {
const active = document.activeElement;
if (active?.closest('.find-widget')) {
// Read clipboard and insert into the find/replace input field.
void (async () => {
try {
const text = await readClipboardTextRef.current();
if (!text) return;
// Monaco find widget inputs are <textarea> elements inside .monaco-inputbox
if (active instanceof HTMLTextAreaElement || active instanceof HTMLInputElement) {
const start = active.selectionStart ?? active.value.length;
const end = active.selectionEnd ?? active.value.length;
active.focus();
active.setSelectionRange(start, end);
document.execCommand('insertText', false, text);
}
} catch {
// Ignore paste simply won't work
}
})();
return;
}
void handlePasteRef.current();
});
}, []);

View File

@@ -4,7 +4,7 @@ import { activeTabStore, useActiveTabId } from '../application/state/activeTabSt
import { LogView } from '../application/state/useSessionState';
import { useWindowControls } from '../application/state/useWindowControls';
import { useI18n } from '../application/i18n/I18nProvider';
import { normalizeDistroId } from '../domain/host';
import { getEffectiveHostDistro } from '../domain/host';
import { cn } from '../lib/utils';
import { Host, TerminalSession, Workspace } from '../types';
import { DISTRO_LOGOS, DISTRO_COLORS } from './DistroAvatar';
@@ -36,6 +36,7 @@ interface TopTabsProps {
onToggleTheme: () => void;
onOpenSettings: () => void;
onSyncNow?: () => Promise<void>;
isImmersiveActive?: boolean;
onStartSessionDrag: (sessionId: string) => void;
onEndSessionDrag: () => void;
onReorderTabs: (draggedId: string, targetId: string, position: 'before' | 'after') => void;
@@ -89,7 +90,7 @@ const SessionTabIcon: React.FC<{ host: Host | undefined; isActive: boolean; prot
// Try distro logo with brand background color
if (host) {
const distro = normalizeDistroId(host.distro) || (host.distro || '').toLowerCase();
const distro = getEffectiveHostDistro(host);
const logo = DISTRO_LOGOS[distro];
if (logo) {
const bg = DISTRO_COLORS[distro] || DISTRO_COLORS.default;
@@ -97,7 +98,7 @@ const SessionTabIcon: React.FC<{ host: Host | undefined; isActive: boolean; prot
<div className={cn(boxBase, bg)}>
<img
src={logo}
alt={host.distro || host.os}
alt={distro || host.os}
className={cn(iconSize, "object-contain invert brightness-0")}
/>
</div>
@@ -217,6 +218,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onToggleTheme,
onOpenSettings,
onSyncNow,
isImmersiveActive,
onStartSessionDrag,
onEndSessionDrag,
onReorderTabs,
@@ -765,6 +767,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-foreground app-no-drag"
onClick={onToggleTheme}
disabled={isImmersiveActive}
title="Toggle theme"
>
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
@@ -788,10 +791,12 @@ const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean => {
prev.orphanSessions === next.orphanSessions &&
prev.workspaces === next.workspaces &&
prev.orderedTabs === next.orderedTabs &&
prev.logViews === next.logViews &&
prev.draggingSessionId === next.draggingSessionId &&
prev.isMacClient === next.isMacClient &&
prev.onOpenSettings === next.onOpenSettings &&
prev.onSyncNow === next.onSyncNow
prev.onSyncNow === next.onSyncNow &&
prev.isImmersiveActive === next.isImmersiveActive
);
};

View File

@@ -116,7 +116,7 @@ const TrayPanelContent: React.FC = () => {
onTrayPanelMenuData,
} = useTrayPanelBackend();
const { hosts, keys } = useVaultState();
const { hosts, keys, identities } = useVaultState();
useSessionState();
const { rules: portForwardingRules, startTunnel, stopTunnel } = usePortForwardingState();
const activeTabId = useActiveTabId();
@@ -151,11 +151,6 @@ const TrayPanelContent: React.FC = () => {
return () => unsubscribe?.();
}, [onTrayPanelRefresh]);
const keysForPf = useMemo(
() => keys.map((k) => ({ id: k.id, privateKey: k.privateKey, passphrase: k.passphrase })),
[keys],
);
const handleClose = useCallback(() => {
void hideTrayPanel();
}, [hideTrayPanel]);
@@ -339,7 +334,7 @@ const TrayPanelContent: React.FC = () => {
if (isActive) {
void stopTunnel(rule.id);
} else {
void startTunnel(rule, host, keysForPf, (status, error) => {
void startTunnel(rule, host, hosts, keys, identities, (status, error) => {
if (status === "error" && error) toast.error(error);
}, rule.autoStart);
}

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,7 @@ export type ConversationProps = ComponentProps<typeof StickToBottom>;
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn('relative flex-1 overflow-y-hidden', className)}
initial="smooth"
initial="instant"
resize="smooth"
role="log"
{...props}

View File

@@ -26,7 +26,7 @@ export const MessageContent = ({ children, className, ...props }: MessageContent
<div
className={cn(
'flex w-fit min-w-0 max-w-full flex-col gap-1.5 overflow-hidden text-[13px] leading-relaxed',
'group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:border group-[.is-user]:border-border/50 group-[.is-user]:bg-muted/50 group-[.is-user]:px-4 group-[.is-user]:py-2.5',
'group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:border group-[.is-user]:border-border/50 group-[.is-user]:bg-muted/50 group-[.is-user]:px-2.5 group-[.is-user]:py-2',
'group-[.is-assistant]:w-full group-[.is-assistant]:text-foreground/90',
className,
)}

View File

@@ -1,7 +1,9 @@
import { cn } from '../../lib/utils';
import { ChevronDown, ChevronRight, CheckCircle2, Loader2, XCircle, Slash } from 'lucide-react';
import type { HTMLAttributes } from 'react';
import { useState } from 'react';
import { Check, ChevronDown, ChevronRight, CheckCircle2, Loader2, ShieldAlert, X, XCircle, Slash } from 'lucide-react';
import React, { useCallback, useEffect, useRef, useState, type HTMLAttributes } from 'react';
import { Button } from '../ui/button';
import { Badge } from '../ui/badge';
import { useI18n } from '../../application/i18n/I18nProvider';
export interface ToolCallProps extends HTMLAttributes<HTMLDivElement> {
name: string;
@@ -10,23 +12,91 @@ export interface ToolCallProps extends HTMLAttributes<HTMLDivElement> {
isError?: boolean;
isLoading?: boolean;
isInterrupted?: boolean;
/** Approval state for this tool call (from the approval gate). */
approvalStatus?: 'pending' | 'approved' | 'denied';
/** Called when user approves this tool call. */
onApprove?: () => void;
/** Called when user rejects this tool call. */
onReject?: () => void;
}
export const ToolCall = ({ name, args, result, isError, isLoading, isInterrupted, className, ...props }: ToolCallProps) => {
export const ToolCall = ({
name, args, result, isError, isLoading, isInterrupted,
approvalStatus, onApprove, onReject,
className, ...props
}: ToolCallProps) => {
const { t } = useI18n();
const [expanded, setExpanded] = useState(false);
const cardRef = useRef<HTMLDivElement>(null);
const approveBtnRef = useRef<HTMLButtonElement>(null);
const [responded, setResponded] = useState(false);
const statusIcon = isLoading ? (
<Loader2 size={12} className="animate-spin text-blue-400/70" />
const isPendingApproval = approvalStatus === 'pending' && !responded;
const handleApprove = useCallback(() => {
if (!isPendingApproval) return;
setResponded(true);
onApprove?.();
}, [isPendingApproval, onApprove]);
const handleReject = useCallback(() => {
if (!isPendingApproval) return;
setResponded(true);
onReject?.();
}, [isPendingApproval, onReject]);
// Keyboard: Enter = approve, Escape = reject (when pending)
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (!isPendingApproval) return;
if (e.key === 'Enter') { e.preventDefault(); handleApprove(); }
else if (e.key === 'Escape') { e.preventDefault(); handleReject(); }
}, [isPendingApproval, handleApprove, handleReject]);
// Auto-focus and auto-scroll when approval is pending
useEffect(() => {
if (isPendingApproval && cardRef.current) {
cardRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' });
// Small delay to let the UI render, then expand and focus
setExpanded(true);
setTimeout(() => approveBtnRef.current?.focus(), 100);
}
}, [isPendingApproval]);
// Reset responded state when approvalStatus changes (e.g. new approval)
useEffect(() => {
if (approvalStatus === 'pending') setResponded(false);
}, [approvalStatus]);
// Border/bg color based on approval status
const borderClass = approvalStatus === 'pending'
? 'border-yellow-500/30 bg-yellow-500/[0.04]'
: approvalStatus === 'approved'
? 'border-green-500/20 bg-green-500/[0.03]'
: approvalStatus === 'denied'
? 'border-red-500/20 bg-red-500/[0.03]'
: 'border-border/25 bg-muted/10';
const statusIconClass = 'shrink-0';
const statusIcon = approvalStatus === 'pending' ? (
<ShieldAlert size={12} className={cn('text-yellow-500/70', statusIconClass)} />
) : isLoading ? (
<Loader2 size={12} className={cn('animate-spin text-blue-400/70', statusIconClass)} />
) : isInterrupted ? (
<Slash size={12} className="text-muted-foreground/55" />
<Slash size={12} className={cn('text-muted-foreground/55', statusIconClass)} />
) : isError ? (
<XCircle size={12} className="text-red-400/70" />
<XCircle size={12} className={cn('text-red-400/70', statusIconClass)} />
) : result !== undefined ? (
<CheckCircle2 size={12} className="text-green-400/70" />
<CheckCircle2 size={12} className={cn('text-green-400/70', statusIconClass)} />
) : null;
return (
<div className={cn('rounded-md border border-border/25 bg-muted/10 overflow-hidden text-[12px]', className)} {...props}>
<div
ref={cardRef}
tabIndex={isPendingApproval ? 0 : undefined}
onKeyDown={isPendingApproval ? handleKeyDown : undefined}
className={cn('rounded-md border overflow-hidden text-[12px] outline-none', borderClass, className)}
{...props}
>
<button
type="button"
onClick={() => setExpanded(e => !e)}
@@ -36,10 +106,28 @@ export const ToolCall = ({ name, args, result, isError, isLoading, isInterrupted
? <ChevronDown size={12} className="text-muted-foreground/40 shrink-0" />
: <ChevronRight size={12} className="text-muted-foreground/40 shrink-0" />
}
<span className="font-mono text-muted-foreground/70 truncate">{name}</span>
{name === 'terminal_execute' && args?.command ? (
<span className="font-mono text-muted-foreground/70 truncate" title={String(args.command)}>
<span className="text-muted-foreground/40">$ </span>{String(args.command)}
</span>
) : (
<span className="font-mono text-muted-foreground/70 truncate">{name}</span>
)}
<span className="flex-1" />
{/* Approval badge for resolved approvals */}
{approvalStatus === 'approved' && (
<Badge className="text-[10px] px-1.5 py-0 bg-green-600/20 text-green-400 border-green-600/30">
{t('ai.chat.toolApproved')}
</Badge>
)}
{approvalStatus === 'denied' && (
<Badge className="text-[10px] px-1.5 py-0 bg-red-600/20 text-red-400 border-red-600/30">
{t('ai.chat.toolDenied')}
</Badge>
)}
{statusIcon}
</button>
{expanded && (
<div className="border-t border-border/20">
{args && Object.keys(args).length > 0 && (
@@ -50,6 +138,38 @@ export const ToolCall = ({ name, args, result, isError, isLoading, isInterrupted
</pre>
</div>
)}
{/* Inline approval buttons */}
{isPendingApproval && (
<div className="px-3 py-2 border-t border-border/20">
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground/30">
{t('ai.chat.toolApprovalHint')}
</span>
<div className="flex items-center gap-1.5">
<Button
variant="outline"
size="sm"
className="h-6 px-2 text-[11px] border-red-500/20 text-red-400/80 hover:bg-red-500/10 hover:text-red-400"
onClick={handleReject}
>
<X size={11} className="mr-0.5" />
{t('ai.chat.reject')}
</Button>
<Button
ref={approveBtnRef}
size="sm"
className="h-6 px-2.5 text-[11px] bg-green-600/80 hover:bg-green-600 text-white"
onClick={handleApprove}
>
<Check size={11} className="mr-0.5" />
{t('ai.chat.approve')}
</Button>
</div>
</div>
</div>
)}
{result !== undefined && (
<div className="px-3 py-2 border-t border-border/20">
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/30 mb-1">Result</div>

View File

@@ -187,18 +187,27 @@ export const AgentIconBadge: React.FC<{
if (variant === 'plain') {
return (
<img
src={visual.src}
alt=""
<div
aria-hidden="true"
draggable={false}
className={cn('shrink-0', imageSize, visual.imageClassName, className)}
className={cn('shrink-0', imageSize, className)}
style={{
maskImage: `url(${visual.src})`,
WebkitMaskImage: `url(${visual.src})`,
maskSize: 'contain',
WebkitMaskSize: 'contain',
maskRepeat: 'no-repeat',
WebkitMaskRepeat: 'no-repeat',
maskPosition: 'center',
WebkitMaskPosition: 'center',
backgroundColor: 'currentColor',
}}
/>
);
}
return (
<div
data-agent-badge=""
className={cn(
'flex shrink-0 items-center justify-center overflow-hidden border',
badgeSize,

View File

@@ -208,7 +208,7 @@ const AgentSelector: React.FC<AgentSelectorProps> = ({
<DropdownContent
align="start"
sideOffset={6}
className="w-[288px] rounded-2xl border border-border/50 bg-popover p-0 text-foreground shadow-lg supports-[backdrop-filter]:backdrop-blur-xl"
className="w-[288px] overflow-hidden rounded-2xl border border-border/50 bg-popover p-0 text-foreground shadow-lg supports-[backdrop-filter]:backdrop-blur-xl"
>
{BUILTIN_AGENTS.map((agent) => (
<AgentMenuRow

View File

@@ -11,7 +11,7 @@ import React, { useCallback, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { createPortal } from 'react-dom';
import type { FormEvent } from 'react';
import type { UploadedImage } from '../../application/state/useImageUpload';
import type { UploadedFile } from '../../application/state/useFileUpload';
import {
PromptInput,
PromptInputFooter,
@@ -40,12 +40,12 @@ interface ChatInputProps {
selectedModelId?: string;
/** Callback when user selects a model */
onModelSelect?: (modelId: string) => void;
/** Attached images */
images?: UploadedImage[];
/** Callback to add images (paste/drop) */
onAddImages?: (files: File[]) => void;
/** Callback to remove an image */
onRemoveImage?: (id: string) => void;
/** Attached files (images, PDFs, etc.) */
files?: UploadedFile[];
/** Callback to add files (paste/drop) */
onAddFiles?: (files: File[]) => void;
/** Callback to remove a file */
onRemoveFile?: (id: string) => void;
/** Available hosts for @ mention */
hosts?: Array<{ sessionId: string; hostname: string; label: string; connected: boolean }>;
/** Permission mode (only shown for Catty Agent) */
@@ -68,9 +68,9 @@ const ChatInput: React.FC<ChatInputProps> = ({
modelPresets = [],
selectedModelId,
onModelSelect,
images = [],
onAddImages,
onRemoveImage,
files = [],
onAddFiles,
onRemoveFile,
hosts = [],
permissionMode,
onPermissionModeChange,
@@ -134,23 +134,22 @@ const ChatInput: React.FC<ChatInputProps> = ({
}, [value, onChange, closeAllMenus]);
const handlePaste = useCallback((e: React.ClipboardEvent) => {
const files = Array.from(e.clipboardData.items)
.filter((item) => item.type.startsWith('image/'))
const pastedFiles = Array.from(e.clipboardData.items)
.map((item) => item.getAsFile())
.filter(Boolean) as File[];
if (files.length > 0) {
if (pastedFiles.length > 0) {
e.preventDefault();
onAddImages?.(files);
onAddFiles?.(pastedFiles);
}
}, [onAddImages]);
}, [onAddFiles]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith('image/'));
if (files.length > 0) {
onAddImages?.(files);
const droppedFiles = Array.from(e.dataTransfer.files);
if (droppedFiles.length > 0) {
onAddFiles?.(droppedFiles);
}
}, [onAddImages]);
}, [onAddFiles]);
const defaultPlaceholder = agentName
? t('ai.chat.placeholder').replace('{agent}', agentName)
@@ -183,19 +182,23 @@ const ChatInput: React.FC<ChatInputProps> = ({
return (
<div className="shrink-0 px-4 pb-4">
<PromptInput onSubmit={handleSubmit}>
{/* Image attachment chips */}
{images.length > 0 && (
{/* File attachment chips */}
{files.length > 0 && (
<div className="flex gap-1.5 px-3 pt-2 pb-0.5 flex-wrap">
{images.map((img) => (
{files.map((file) => (
<div
key={img.id}
key={file.id}
className="inline-flex items-center gap-1 h-6 pl-1.5 pr-1 rounded-md bg-muted/30 border border-border/30 text-[11px] text-foreground/70 group"
>
<ImageIcon size={11} className="text-muted-foreground/60 shrink-0" />
<span className="truncate max-w-[80px]">{img.filename}</span>
{file.mediaType.startsWith('image/') ? (
<ImageIcon size={11} className="text-muted-foreground/60 shrink-0" />
) : (
<FileText size={11} className="text-muted-foreground/60 shrink-0" />
)}
<span className="truncate max-w-[80px]">{file.filename}</span>
<button
type="button"
onClick={() => onRemoveImage?.(img.id)}
onClick={() => onRemoveFile?.(file.id)}
className="h-3.5 w-3.5 rounded-sm flex items-center justify-center opacity-50 hover:opacity-100 hover:bg-muted/50 transition-opacity cursor-pointer"
>
<X size={8} />
@@ -213,7 +216,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
className="hidden"
onChange={(e) => {
if (e.target.files?.length) {
onAddImages?.(Array.from(e.target.files));
onAddFiles?.(Array.from(e.target.files));
e.target.value = '';
}
}}

View File

@@ -6,10 +6,11 @@
* No avatars. Thinking blocks are collapsible.
*/
import { AlertCircle } from 'lucide-react';
import React from 'react';
import { AlertCircle, FileText, RotateCcw, X, ZoomIn, ZoomOut } from 'lucide-react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import type { ChatMessage } from '../../infrastructure/ai/types';
import { Dialog, DialogContent, DialogTitle } from '../ui/dialog';
import {
Conversation,
ConversationContent,
@@ -17,17 +18,124 @@ import {
} from '../ai-elements/conversation';
import { Message, MessageContent, MessageResponse } from '../ai-elements/message';
import { ToolCall } from '../ai-elements/tool-call';
import { InlineApprovalCard } from './InlineApprovalCard';
import ThinkingBlock from './ThinkingBlock';
import {
onApprovalRequest,
onApprovalCleared,
replayPendingApprovals,
resolveApproval,
type ApprovalRequest,
} from '../../infrastructure/ai/shared/approvalGate';
interface ChatMessageListProps {
messages: ChatMessage[];
isStreaming?: boolean;
onApprove?: (messageId: string) => void;
onReject?: (messageId: string) => void;
/** Active chat session ID — used to filter standalone MCP approval blocks */
activeSessionId?: string | null;
}
const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming, onApprove, onReject }) => {
const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming, activeSessionId }) => {
// Track pending approvals from the approval gate
const [pendingApprovals, setPendingApprovals] = useState<Map<string, ApprovalRequest>>(new Map());
const [resolvedApprovals, setResolvedApprovals] = useState<Map<string, boolean>>(new Map());
// Subscribe to approval gate events (SDK + MCP tool calls)
useEffect(() => {
const handler = (request: ApprovalRequest) => {
setPendingApprovals(prev => new Map(prev).set(request.toolCallId, request));
};
const unsub = onApprovalRequest(handler);
// Replay any approvals that fired while this component was unmounted
replayPendingApprovals(handler);
return unsub;
}, []);
// Subscribe to approval cleared/removed events (fired on session stop or timeout)
useEffect(() => {
return onApprovalCleared((clearedIds) => {
setPendingApprovals(prev => {
const m = new Map(prev);
for (const id of clearedIds) m.delete(id);
return m;
});
});
}, []);
const handleApprove = useCallback((toolCallId: string) => {
resolveApproval(toolCallId, true);
setPendingApprovals(prev => { const m = new Map(prev); m.delete(toolCallId); return m; });
setResolvedApprovals(prev => new Map(prev).set(toolCallId, true));
}, []);
const handleReject = useCallback((toolCallId: string) => {
resolveApproval(toolCallId, false);
setPendingApprovals(prev => { const m = new Map(prev); m.delete(toolCallId); return m; });
setResolvedApprovals(prev => new Map(prev).set(toolCallId, false));
}, []);
const [preview, setPreview] = useState<{ src: string; name: string } | null>(null);
const [zoom, setZoom] = useState(100);
const [dragged, setDragged] = useState(false);
const imgRef = useRef<HTMLImageElement>(null);
const dragPos = useRef({ x: 0, y: 0 });
const dragStart = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
const applyTransform = useCallback((z: number, x: number, y: number, animate: boolean) => {
if (!imgRef.current) return;
imgRef.current.style.transition = animate ? 'transform 0.25s ease' : 'none';
imgRef.current.style.transform = `scale(${z / 100}) translate(${x / (z / 100)}px, ${y / (z / 100)}px)`;
}, []);
const zoomRef = useRef(100);
const setZoomAndRef = useCallback((fn: (z: number) => number) => {
setZoom(z => { const nz = fn(z); zoomRef.current = nz; return nz; });
}, []);
const zoomIn = useCallback(() => setZoomAndRef(z => { const nz = Math.min(z + 25, 200); applyTransform(nz, dragPos.current.x, dragPos.current.y, true); return nz; }), [applyTransform, setZoomAndRef]);
const zoomOut = useCallback(() => setZoomAndRef(z => { const nz = Math.max(z - 25, 25); applyTransform(nz, dragPos.current.x, dragPos.current.y, true); return nz; }), [applyTransform, setZoomAndRef]);
const onWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -10 : 10;
setZoomAndRef(z => {
const nz = Math.max(25, Math.min(200, z + delta));
applyTransform(nz, dragPos.current.x, dragPos.current.y, false);
return nz;
});
}, [applyTransform, setZoomAndRef]);
const openPreview = useCallback((src: string, name: string) => {
setZoom(100); zoomRef.current = 100;
setDragged(false);
dragPos.current = { x: 0, y: 0 };
setPreview({ src, name });
}, []);
const resetPreview = useCallback(() => {
setZoom(100); zoomRef.current = 100;
setDragged(false);
dragPos.current = { x: 0, y: 0 };
applyTransform(100, 0, 0, true);
}, [applyTransform]);
const onPointerDown = useCallback((e: React.PointerEvent) => {
e.preventDefault();
(e.target as HTMLElement).setPointerCapture(e.pointerId);
dragStart.current = { startX: e.clientX, startY: e.clientY, origX: dragPos.current.x, origY: dragPos.current.y };
}, []);
const onPointerMove = useCallback((e: React.PointerEvent) => {
if (!dragStart.current) return;
if ((e.buttons & 1) === 0) { dragStart.current = null; return; }
const x = dragStart.current.origX + (e.clientX - dragStart.current.startX);
const y = dragStart.current.origY + (e.clientY - dragStart.current.startY);
dragPos.current = { x, y };
applyTransform(zoomRef.current, x, y, false);
}, [applyTransform]);
const endDrag = useCallback(() => {
if (dragStart.current && (dragPos.current.x !== 0 || dragPos.current.y !== 0)) {
setDragged(true);
}
dragStart.current = null;
}, []);
const { t } = useI18n();
const visibleMessages = messages.filter(m => m.role !== 'system');
const resolvedToolCallIds = new Set(
@@ -36,6 +144,18 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
.flatMap((m) => m.toolResults?.map((tr) => tr.toolCallId) ?? []),
);
// Build maps from toolCallId → toolName / toolArgs for display
const toolCallNames = new Map<string, string>();
const toolCallArgs = new Map<string, Record<string, unknown>>();
for (const m of visibleMessages) {
if (m.role === 'assistant' && m.toolCalls) {
for (const tc of m.toolCalls) {
toolCallNames.set(tc.id, tc.name);
if (tc.arguments) toolCallArgs.set(tc.id, tc.arguments);
}
}
}
if (visibleMessages.length === 0 && !isStreaming) {
return (
<div className="flex-1 flex items-center justify-center px-6">
@@ -49,6 +169,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
const lastAssistantMessage = visibleMessages.findLast(m => m.role === 'assistant');
return (
<>
<Conversation className="flex-1">
<ConversationContent className="gap-1.5 px-4 py-2">
{visibleMessages.map((message) => {
@@ -58,7 +179,8 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
{message.toolResults?.map((tr) => (
<ToolCall
key={tr.toolCallId}
name={tr.toolCallId}
name={toolCallNames.get(tr.toolCallId) || tr.toolCallId}
args={toolCallArgs.get(tr.toolCallId)}
result={tr.content}
isError={tr.isError}
/>
@@ -83,16 +205,27 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
/>
)}
{/* User images */}
{isUser && message.images && message.images.length > 0 && (
{/* User attachments (images, files) — fallback to legacy `images` field */}
{isUser && (message.attachments ?? message.images)?.length && (
<div className="flex gap-1.5 flex-wrap mb-1">
{message.images.map((img, i) => (
<img
key={img.filename ? `${img.filename}-${i}` : `img-${message.id}-${i}`}
src={`data:${img.mediaType};base64,${img.base64Data}`}
alt={img.filename || 'image'}
className="max-h-[120px] max-w-[200px] rounded-md object-contain border border-border/20"
/>
{(message.attachments ?? message.images)!.map((att, i) => (
att.mediaType.startsWith('image/') ? (
<img
key={att.filename ? `${att.filename}-${i}` : `att-${message.id}-${i}`}
src={`data:${att.mediaType};base64,${att.base64Data}`}
alt={att.filename || 'image'}
className="max-h-[120px] max-w-[200px] rounded-md object-contain border border-border/20 cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => openPreview(`data:${att.mediaType};base64,${att.base64Data}`, att.filename || 'image')}
/>
) : (
<div
key={att.filename ? `${att.filename}-${i}` : `att-${message.id}-${i}`}
className="inline-flex items-center gap-1.5 h-7 px-2 rounded-md bg-muted/20 border border-border/20 text-[11px] text-foreground/70"
>
<FileText size={12} className="text-muted-foreground/60 shrink-0" />
<span className="truncate max-w-[120px]">{att.filename || 'file'}</span>
</div>
)
))}
</div>
)}
@@ -106,26 +239,30 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
)}
{/* Tool calls */}
{message.toolCalls?.map((tc) => (
<ToolCall
key={tc.id}
name={tc.name}
args={tc.arguments}
isLoading={isThisStreaming && message.executionStatus === 'running'}
isInterrupted={message.executionStatus === 'cancelled' && !resolvedToolCallIds.has(tc.id)}
/>
))}
{message.toolCalls?.map((tc) => {
const isPending = pendingApprovals.has(tc.id);
const resolved = resolvedApprovals.get(tc.id);
const approvalStatus = isPending
? 'pending' as const
: resolved === true
? 'approved' as const
: resolved === false
? 'denied' as const
: undefined;
{/* Inline approval card */}
{message.pendingApproval && (
<InlineApprovalCard
toolName={message.pendingApproval.toolName}
toolArgs={message.pendingApproval.toolArgs}
status={message.pendingApproval.status}
onApprove={() => onApprove?.(message.id)}
onReject={() => onReject?.(message.id)}
/>
)}
return (
<ToolCall
key={tc.id}
name={tc.name}
args={tc.arguments}
isLoading={isThisStreaming && message.executionStatus === 'running' && !isPending}
isInterrupted={message.executionStatus === 'cancelled' && !resolvedToolCallIds.has(tc.id)}
approvalStatus={approvalStatus}
onApprove={() => handleApprove(tc.id)}
onReject={() => handleReject(tc.id)}
/>
);
})}
{/* Status text with shimmer */}
{message.statusText && (
@@ -153,6 +290,24 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
);
})}
{/* Standalone MCP/ACP approval requests (not tied to SDK tool calls) */}
{Array.from(pendingApprovals.entries())
.filter((entry) => entry[0].startsWith('mcp_approval_') && (!activeSessionId || entry[1].chatSessionId === activeSessionId))
.map((entry) => {
const [id, req] = entry;
return (
<ToolCall
key={id}
name={req.toolName}
args={req.args}
isLoading={false}
isInterrupted={false}
approvalStatus={'pending'}
onApprove={() => handleApprove(id)}
onReject={() => handleReject(id)}
/>
);
})}
{/* Streaming indicator — only when no content and no thinking yet */}
{isStreaming && !lastAssistantMessage?.content && !lastAssistantMessage?.thinking && (
<div className="flex items-center gap-1 py-2">
@@ -164,13 +319,89 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
</ConversationContent>
<ConversationScrollButton />
</Conversation>
{/* Image preview lightbox */}
<Dialog open={!!preview} onOpenChange={(open) => { if (!open) setPreview(null); }}>
<DialogContent
hideCloseButton
className="max-w-[min(90vw,800px)] max-h-[min(90vh,700px)] min-w-[280px] min-h-[200px] w-fit p-0 gap-0 focus:outline-none shadow-2xl"
>
{/* Title bar: filename | zoom controls | close — all in one flex row */}
<div className="flex items-center h-10 px-3 border-b border-border/40 gap-2 shrink-0">
<DialogTitle className="text-sm font-medium truncate flex-1">{preview?.name}</DialogTitle>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={resetPreview}
disabled={zoom === 100 && !dragged}
className="p-1 rounded hover:bg-muted disabled:opacity-30 transition-colors text-muted-foreground"
aria-label={t('common.reset')}
>
<RotateCcw size={14} />
</button>
<div className="w-px h-3.5 bg-border/40 mx-0.5" />
<button
onClick={zoomOut}
disabled={zoom <= 25}
className="p-1 rounded hover:bg-muted disabled:opacity-30 transition-colors text-muted-foreground"
aria-label={t('common.zoomOut')}
>
<ZoomOut size={14} />
</button>
<span className="text-xs text-muted-foreground tabular-nums w-9 text-center select-none">{zoom}%</span>
<button
onClick={zoomIn}
disabled={zoom >= 200}
className="p-1 rounded hover:bg-muted disabled:opacity-30 transition-colors text-muted-foreground"
aria-label={t('common.zoomIn')}
>
<ZoomIn size={14} />
</button>
</div>
<button
onClick={() => setPreview(null)}
className="p-1 rounded hover:bg-muted transition-colors text-muted-foreground shrink-0"
aria-label={t('common.close')}
>
<X size={14} />
</button>
</div>
{/* Image area with drag support */}
{preview && (
<div
className="overflow-hidden flex items-center justify-center"
style={{
height: 'calc(min(90vh, 700px) - 40px)',
cursor: 'grab',
// Clamp aspect ratio: if image is extremely tall/wide, the container
// constrains it; object-contain handles the rest.
aspectRatio: 'auto',
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onWheel={onWheel}
onLostPointerCapture={endDrag}
>
<img
ref={imgRef}
src={preview.src}
alt={preview.name}
draggable={false}
className="select-none max-w-full max-h-full object-contain"
style={{ transition: 'transform 0.25s ease' }}
/>
</div>
)}
</DialogContent>
</Dialog>
</>
);
};
function areMessagesEqual(prev: ChatMessageListProps, next: ChatMessageListProps): boolean {
if (prev.isStreaming !== next.isStreaming) return false;
if (prev.onApprove !== next.onApprove) return false;
if (prev.onReject !== next.onReject) return false;
if (prev.activeSessionId !== next.activeSessionId) return false;
if (prev.messages.length !== next.messages.length) return false;
if (prev.messages === next.messages) return true;
@@ -188,7 +419,6 @@ function areMessagesEqual(prev: ChatMessageListProps, next: ChatMessageListProps
p.role !== n.role ||
p.statusText !== n.statusText ||
p.executionStatus !== n.executionStatus ||
p.pendingApproval !== n.pendingApproval ||
p.errorInfo !== n.errorInfo ||
p.toolCalls !== n.toolCalls ||
p.toolResults !== n.toolResults

View File

@@ -1,193 +0,0 @@
/**
* InlineApprovalCard - Inline tool approval card rendered within chat messages.
*
* Replaces the modal PermissionDialog. Shows tool name, arguments, and
* approve/reject buttons. Keyboard shortcuts: Enter to approve, Escape to reject.
*/
import { Check, ShieldAlert, X } from 'lucide-react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
interface InlineApprovalCardProps {
toolName: string;
toolArgs: Record<string, unknown>;
status: 'pending' | 'approved' | 'denied';
onApprove: () => void;
onReject: () => void;
}
const InlineApprovalCard: React.FC<InlineApprovalCardProps> = ({
toolName,
toolArgs,
status,
onApprove,
onReject,
}) => {
const { t } = useI18n();
const cardRef = useRef<HTMLDivElement>(null);
const approveBtnRef = useRef<HTMLButtonElement>(null);
const isPending = status === 'pending';
const [responded, setResponded] = useState(false);
// Use refs to always access the latest callbacks without re-registering the listener
const onApproveRef = useRef(onApprove);
const onRejectRef = useRef(onReject);
onApproveRef.current = onApprove;
onRejectRef.current = onReject;
const isDisabled = !isPending || responded;
const handleApprove = useCallback(() => {
if (isDisabled) return;
setResponded(true);
onApproveRef.current();
}, [isDisabled]);
const handleReject = useCallback(() => {
if (isDisabled) return;
setResponded(true);
onRejectRef.current();
}, [isDisabled]);
// Keyboard shortcuts: handled via local onKeyDown on the focusable card element
// to avoid conflicts when multiple InlineApprovalCard instances exist simultaneously.
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (isDisabled) return;
if (e.key === 'Enter') {
e.preventDefault();
handleApprove();
} else if (e.key === 'Escape') {
e.preventDefault();
handleReject();
}
}, [isDisabled, handleApprove, handleReject]);
// Auto-focus approve button and auto-scroll into view when mounted as pending
useEffect(() => {
if (isPending && cardRef.current) {
cardRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' });
approveBtnRef.current?.focus();
}
}, [isPending]);
let formattedArgs: string;
try {
formattedArgs = JSON.stringify(toolArgs, null, 2);
} catch {
formattedArgs = String(toolArgs);
}
// Extract target session info if present
const sessionId = toolArgs?.sessionId as string | undefined;
return (
<div
ref={cardRef}
tabIndex={0}
role="alertdialog"
aria-label="Tool execution approval required"
onKeyDown={handleKeyDown}
className={`rounded-md border overflow-hidden text-[12px] mt-1.5 outline-none ${
isPending
? 'border-yellow-500/30 bg-yellow-500/[0.04]'
: status === 'approved'
? 'border-green-500/20 bg-green-500/[0.03]'
: 'border-red-500/20 bg-red-500/[0.03]'
}`}
>
{/* Header */}
<div className="flex items-center gap-2 px-3 py-1.5">
<ShieldAlert
size={13}
className={
isPending
? 'text-yellow-500/70 shrink-0'
: status === 'approved'
? 'text-green-400/70 shrink-0'
: 'text-red-400/70 shrink-0'
}
/>
<span className="text-[11px] font-medium text-foreground/70">
{t('ai.chat.toolApprovalTitle')}
</span>
{!isPending && (
<Badge
className={`ml-auto text-[10px] px-1.5 py-0 ${
status === 'approved'
? 'bg-green-600/20 text-green-400 border-green-600/30'
: 'bg-red-600/20 text-red-400 border-red-600/30'
}`}
>
{status === 'approved' ? t('ai.chat.toolApproved') : t('ai.chat.toolDenied')}
</Badge>
)}
</div>
{/* Tool info */}
<div className="px-3 pb-2 space-y-1.5">
<div className="flex items-center gap-2">
<span className="text-[10px] text-muted-foreground/40 uppercase tracking-wider">{t('ai.chat.toolLabel')}</span>
<code className="text-[11px] font-mono text-muted-foreground/70 bg-muted/30 px-1.5 py-0.5 rounded">
{toolName}
</code>
</div>
{sessionId && (
<div className="flex items-center gap-2">
<span className="text-[10px] text-muted-foreground/40 uppercase tracking-wider">{t('ai.chat.targetLabel')}</span>
<code className="text-[11px] font-mono text-muted-foreground/50 bg-muted/30 px-1.5 py-0.5 rounded">
{sessionId}
</code>
</div>
)}
{/* Arguments */}
<div className="rounded border border-border/20 bg-muted/10 p-2 max-h-32 overflow-auto">
<pre className="text-[11px] font-mono whitespace-pre-wrap break-all text-muted-foreground/50">
{formattedArgs}
</pre>
</div>
{/* Actions or hint */}
{isPending && (
<div className="flex items-center justify-between pt-0.5">
<span className="text-[10px] text-muted-foreground/30">
{t('ai.chat.toolApprovalHint')}
</span>
<div className="flex items-center gap-1.5">
<Button
variant="outline"
size="sm"
disabled={responded}
className={`h-6 px-2 text-[11px] border-red-500/20 text-red-400/80 hover:bg-red-500/10 hover:text-red-400 ${responded ? 'opacity-50 cursor-not-allowed' : ''}`}
onClick={handleReject}
>
<X size={11} className="mr-0.5" />
{t('ai.chat.reject')}
</Button>
<Button
ref={approveBtnRef}
size="sm"
disabled={responded}
className={`h-6 px-2.5 text-[11px] bg-green-600/80 hover:bg-green-600 text-white ${responded ? 'opacity-50 cursor-not-allowed' : ''}`}
onClick={handleApprove}
>
<Check size={11} className="mr-0.5" />
{t('ai.chat.approve')}
</Button>
</div>
</div>
)}
</div>
</div>
);
};
InlineApprovalCard.displayName = 'InlineApprovalCard';
export default InlineApprovalCard;
export { InlineApprovalCard };
export type { InlineApprovalCardProps };

View File

@@ -16,6 +16,7 @@ import type {
AIPermissionMode,
AISession,
ChatMessage,
ChatMessageAttachment,
ExternalAgentConfig,
ProviderConfig,
WebSearchConfig,
@@ -63,16 +64,30 @@ interface ToolResultChunk {
result?: unknown;
}
/** Shape of a tool-approval-request chunk from the Vercel AI SDK fullStream. */
interface ToolApprovalRequestChunk {
type: 'tool-approval-request';
approvalId: string;
toolCall: {
toolCallId: string;
toolName: string;
args?: Record<string, unknown>;
input?: Record<string, unknown>;
};
/** Detect tool results that represent errors/denials (e.g. `{ error: "..." }` or `{ ok: false }`) */
function isToolResultError(output: unknown): boolean {
if (output == null) return false;
if (typeof output === 'object') {
const obj = output as Record<string, unknown>;
// Check for explicit error objects
if ('error' in obj && typeof obj.error === 'string') return true;
if ('ok' in obj && obj.ok === false) return true;
}
// Check stringified JSON (common for tool result wrapping)
if (typeof output === 'string') {
try {
const parsed = JSON.parse(output);
if (parsed && typeof parsed === 'object') {
const parsedObj = parsed as Record<string, unknown>;
if ('error' in parsedObj && typeof parsedObj.error === 'string') return true;
if ('ok' in parsedObj && parsedObj.ok === false) return true;
}
} catch { /* not JSON, not an error */ }
}
return false;
}
/** Shape of an error chunk from the Vercel AI SDK fullStream. */
@@ -87,9 +102,8 @@ type StreamChunk =
| ReasoningChunk
| ToolCallChunk
| ToolResultChunk
| ToolApprovalRequestChunk
| ErrorChunk
| { type: 'reasoning-end' | 'text-start' | 'text-end' | 'start' | 'finish' | 'start-step' | 'finish-step' };
| { type: 'reasoning-end' | 'text-start' | 'text-end' | 'start' | 'finish' | 'start-step' | 'finish-step' | 'tool-approval-request' };
/** Shape of the netcatty bridge exposed on `window` (panel-specific subset). */
export interface PanelBridge extends NetcattyBridge {
@@ -109,6 +123,8 @@ export interface TerminalSessionInfo {
label: string;
os?: string;
username?: string;
protocol?: string;
shellType?: string;
connected: boolean;
}
@@ -118,27 +134,8 @@ export function getNetcattyBridge(): PanelBridge | undefined {
return (window as any).netcatty as PanelBridge | undefined;
}
/** Approval info returned by processCattyStream when a tool-approval-request is received. */
export interface ApprovalInfo {
approvalId: string;
toolCallId: string;
toolName: string;
toolArgs: Record<string, unknown>;
}
/** Pending approval context stored between approval request and user response. */
export interface PendingApprovalContext {
sessionId: string;
scopeKey: string;
sdkMessages: Array<ModelMessage>;
approvalInfo: ApprovalInfo;
model: ReturnType<typeof createModelFromConfig>;
systemPrompt: string;
tools: ReturnType<typeof createCattyTools>;
scopeType: 'terminal' | 'workspace';
scopeLabel?: string;
getExecutorContext: () => ExecutorContext;
}
// ApprovalInfo and PendingApprovalContext removed — approval is now handled
// inside the tool's execute function via the approvalGate module.
function generateId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
@@ -180,7 +177,7 @@ export interface UseAIChatStreamingReturn {
setStreamingForScope: (key: string, val: boolean) => void;
/** Ref to per-session abort controllers. */
abortControllersRef: React.MutableRefObject<Map<string, AbortController>>;
/** Process a Catty agent stream, returning approval info if one is requested. */
/** Process a Catty agent stream. */
processCattyStream: (
streamSessionId: string,
model: ReturnType<typeof createModelFromConfig>,
@@ -189,7 +186,7 @@ export interface UseAIChatStreamingReturn {
sdkMessages: Array<ModelMessage>,
signal: AbortSignal,
currentAssistantMsgId: string,
) => Promise<ApprovalInfo | null>;
) => Promise<void>;
/** Send a message to the Catty agent (built-in). */
sendToCattyAgent: (
sessionId: string,
@@ -199,6 +196,7 @@ export interface UseAIChatStreamingReturn {
currentSession: AISession | undefined,
assistantMsgId: string,
context: SendToCattyContext,
attachments?: ChatMessageAttachment[],
) => Promise<void>;
/** Send a message to an external agent (ACP or raw process). */
sendToExternalAgent: (
@@ -225,7 +223,6 @@ export interface SendToCattyContext {
terminalSessions: TerminalSessionInfo[];
webSearchConfig?: WebSearchConfig | null;
getExecutorContext?: () => ExecutorContext;
setPendingApproval: (ctx: PendingApprovalContext | null) => void;
autoTitleSession: (sessionId: string, text: string) => void;
}
@@ -323,7 +320,7 @@ export function useAIChatStreaming({
sdkMessages: Array<ModelMessage>,
signal: AbortSignal,
currentAssistantMsgId: string,
): Promise<ApprovalInfo | null> => {
): Promise<void> => {
const result = streamText({
model,
messages: sdkMessages,
@@ -337,7 +334,6 @@ export function useAIChatStreaming({
let activeMsgId = currentAssistantMsgId;
let lastAddedRole: 'assistant' | 'tool' = 'assistant';
const reader = result.fullStream.getReader();
let pendingApprovalInfo: ApprovalInfo | null = null;
// -- Text-delta batching: accumulate deltas and flush periodically --
let pendingText = '';
@@ -455,6 +451,7 @@ export function useAIChatStreaming({
? { ...msg, executionStatus: 'completed', statusText: undefined } : msg,
);
const toolOutput = typedChunk.output ?? typedChunk.result;
const toolError = isToolResultError(toolOutput);
addMessageToSession(streamSessionId, {
id: generateId(),
role: 'tool',
@@ -464,7 +461,7 @@ export function useAIChatStreaming({
content: typeof toolOutput === 'string'
? toolOutput
: JSON.stringify(toolOutput),
isError: false,
isError: toolError,
}],
timestamp: Date.now(),
executionStatus: 'completed',
@@ -472,25 +469,9 @@ export function useAIChatStreaming({
lastAddedRole = 'tool';
break;
}
case 'tool-approval-request': {
cancelPendingFlush();
flushText();
const typedChunk = chunk as ToolApprovalRequestChunk;
pendingApprovalInfo = {
approvalId: typedChunk.approvalId,
toolCallId: typedChunk.toolCall.toolCallId,
toolName: typedChunk.toolCall.toolName,
toolArgs: typedChunk.toolCall.args ?? typedChunk.toolCall.input ?? {},
};
updateMessageById(streamSessionId, activeMsgId, msg => ({
...msg,
pendingApproval: {
...pendingApprovalInfo!,
status: 'pending' as const,
},
}));
break;
}
// tool-approval-request is no longer handled here — approval is now
// inside the tool's execute function via the approvalGate module.
// The SDK may still emit this chunk type but we simply ignore it.
case 'error': {
cancelPendingFlush();
flushText();
@@ -522,7 +503,7 @@ export function useAIChatStreaming({
flushText();
reader.releaseLock();
}
return pendingApprovalInfo;
return;
}, [maxIterations, addMessageToSession, updateMessageById]);
// -------------------------------------------------------------------
@@ -592,23 +573,29 @@ export function useAIChatStreaming({
...msg, thinkingDurationMs: msg.thinkingDurationMs || (Date.now() - msg.timestamp),
}));
},
onToolCall: (toolName: string, args: Record<string, unknown>) => {
onToolCall: (toolName: string, args: Record<string, unknown>, toolCallId?: string) => {
maybeCreateAssistantMsg();
updateLastMessage(sessionId, msg => ({
...msg,
toolCalls: [...(msg.toolCalls || []), { id: `tc_${Date.now()}`, name: toolName, arguments: args }],
toolCalls: [...(msg.toolCalls || []), { id: toolCallId || `tc_${Date.now()}`, name: toolName, arguments: args }],
executionStatus: 'running',
statusText: undefined,
}));
},
onToolResult: (toolCallId: string, result: string) => {
updateLastMessage(sessionId, msg =>
msg.role === 'assistant' && msg.executionStatus === 'running'
? { ...msg, executionStatus: 'completed', statusText: undefined } : msg,
);
onToolResult: (toolCallId: string, result: string, toolName?: string) => {
updateLastMessage(sessionId, msg => {
if (msg.role !== 'assistant' || msg.executionStatus !== 'running') return msg;
// Only patch tool call name if the existing name is missing/generic
// (don't overwrite a good name from onToolCall with a wrapper name from tool-result)
const updatedToolCalls = toolName && !toolName.includes('acp_provider_agent_dynamic_tool') && msg.toolCalls
? msg.toolCalls.map(tc => tc.id === toolCallId && !tc.name ? { ...tc, name: toolName } : tc)
: msg.toolCalls;
return { ...msg, toolCalls: updatedToolCalls, executionStatus: 'completed', statusText: undefined };
});
const toolError = isToolResultError(result);
addMessageToSession(sessionId, {
id: generateId(), role: 'tool', content: '',
toolResults: [{ toolCallId, content: result, isError: false }],
toolResults: [{ toolCallId, content: result, isError: toolError }],
timestamp: Date.now(), executionStatus: 'completed',
});
needsNewAssistantMsg = true;
@@ -668,6 +655,7 @@ export function useAIChatStreaming({
currentSession: AISession | undefined,
assistantMsgId: string,
context: SendToCattyContext,
attachments?: ChatMessageAttachment[],
) => {
const bridge = getNetcattyBridge();
const getExecutorContext = context.getExecutorContext ?? (() => ({
@@ -681,13 +669,18 @@ export function useAIChatStreaming({
context.commandBlocklist,
context.globalPermissionMode,
context.webSearchConfig ?? undefined,
sessionId,
);
const systemPrompt = buildSystemPrompt({
scopeType: context.scopeType, scopeLabel: context.scopeLabel,
hosts: context.terminalSessions.map(s => ({
sessionId: s.sessionId, hostname: s.hostname, label: s.label,
os: s.os, username: s.username, connected: s.connected,
os: s.os,
username: s.username,
protocol: s.protocol,
shellType: s.shellType,
connected: s.connected,
})),
permissionMode: context.globalPermissionMode,
webSearchEnabled: isWebSearchReady(context.webSearchConfig),
@@ -741,7 +734,22 @@ export function useAIChatStreaming({
const sdkMessages: Array<ModelMessage> = [];
for (const m of allMessages) {
if (m.role === 'user') {
sdkMessages.push({ role: 'user', content: m.content });
// Build multimodal content when attachments are present (fallback to legacy `images` field)
const messageAttachments = m.attachments ?? m.images;
if (messageAttachments?.length) {
const parts: Array<{ type: 'text'; text: string } | { type: 'image'; image: string; mediaType?: string } | { type: 'file'; data: string; mediaType: string; filename?: string }> = [];
parts.push({ type: 'text', text: m.content });
for (const att of messageAttachments) {
if (att.mediaType.startsWith('image/')) {
parts.push({ type: 'image', image: att.base64Data, mediaType: att.mediaType });
} else {
parts.push({ type: 'file', data: att.base64Data, mediaType: att.mediaType, filename: att.filename });
}
}
sdkMessages.push({ role: 'user', content: parts });
} else {
sdkMessages.push({ role: 'user', content: m.content });
}
} else if (m.role === 'assistant') {
if (m.toolCalls?.length) {
// Only include tool calls that have matching results
@@ -780,25 +788,23 @@ export function useAIChatStreaming({
});
}
}
sdkMessages.push({ role: 'user', content: trimmed });
const approvalInfo = await processCattyStream(sessionId, model, systemPrompt, tools, sdkMessages, abortController.signal, assistantMsgId);
if (approvalInfo) {
context.setPendingApproval({
sessionId,
scopeKey: sendScopeKey,
sdkMessages,
approvalInfo,
model,
systemPrompt,
tools,
scopeType: context.scopeType,
scopeLabel: context.scopeLabel,
getExecutorContext,
});
return; // Keep streaming flag — waiting for user approval
// Build the current user message — include attachments as multimodal content
if (attachments?.length) {
const parts: Array<{ type: 'text'; text: string } | { type: 'image'; image: string; mediaType?: string } | { type: 'file'; data: string; mediaType: string; filename?: string }> = [];
parts.push({ type: 'text', text: trimmed });
for (const att of attachments) {
if (att.mediaType.startsWith('image/')) {
parts.push({ type: 'image', image: att.base64Data, mediaType: att.mediaType });
} else {
parts.push({ type: 'file', data: att.base64Data, mediaType: att.mediaType, filename: att.filename });
}
}
sdkMessages.push({ role: 'user', content: parts });
} else {
sdkMessages.push({ role: 'user', content: trimmed });
}
await processCattyStream(sessionId, model, systemPrompt, tools, sdkMessages, abortController.signal, assistantMsgId);
} catch (err) {
console.error('[Catty] streamText error:', err);
reportStreamError(sessionId, abortController.signal, err);

View File

@@ -1,298 +0,0 @@
/**
* useToolApproval — Encapsulates the tool approval workflow for the AI chat panel.
*
* Handles:
* - Pending approval context management
* - Approval timeout (auto-clear after 5 minutes)
* - handleApprovalResponse (approve/reject from InlineApprovalCard)
* - Resuming the Catty stream after approval
*/
import React, { useCallback, useRef } from 'react';
import type { ModelMessage } from 'ai';
import type {
AIPermissionMode,
ChatMessage,
WebSearchConfig,
} from '../../../infrastructure/ai/types';
import { isWebSearchReady } from '../../../infrastructure/ai/types';
import { buildSystemPrompt } from '../../../infrastructure/ai/cattyAgent/systemPrompt';
import { createCattyTools } from '../../../infrastructure/ai/sdk/tools';
import { classifyError } from '../../../infrastructure/ai/errorClassifier';
import type {
ApprovalInfo,
PendingApprovalContext,
} from './useAIChatStreaming';
import { getNetcattyBridge } from './useAIChatStreaming';
import type { createModelFromConfig } from '../../../infrastructure/ai/sdk/providers';
function generateId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
let sharedPendingApprovalContext: PendingApprovalContext | null = null;
let sharedPendingApprovalTimeout: ReturnType<typeof setTimeout> | null = null;
// -------------------------------------------------------------------
// Hook parameters
// -------------------------------------------------------------------
export interface UseToolApprovalParams {
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
updateLastMessage: (sessionId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
updateMessageById: (sessionId: string, messageId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
setStreamingForScope: (key: string, val: boolean) => void;
abortControllersRef: React.MutableRefObject<Map<string, AbortController>>;
processCattyStream: (
streamSessionId: string,
model: ReturnType<typeof createModelFromConfig>,
systemPrompt: string,
tools: ReturnType<typeof createCattyTools>,
sdkMessages: Array<ModelMessage>,
signal: AbortSignal,
currentAssistantMsgId: string,
) => Promise<ApprovalInfo | null>;
t: (key: string) => string;
}
// -------------------------------------------------------------------
// Hook return type
// -------------------------------------------------------------------
export interface UseToolApprovalReturn {
/** Ref to the current pending approval context (null when none). */
pendingApprovalContextRef: React.MutableRefObject<PendingApprovalContext | null>;
/** Set or clear the pending approval context (manages timeout). */
setPendingApproval: (ctx: PendingApprovalContext | null) => void;
/** Handle a user's approve/reject response from InlineApprovalCard. */
handleApprovalResponse: (
messageId: string,
approved: boolean,
approvalContext: ToolApprovalContext,
) => Promise<void>;
}
/** Context values needed by handleApprovalResponse that change frequently. */
export interface ToolApprovalContext {
globalPermissionMode: AIPermissionMode;
commandBlocklist?: string[];
webSearchConfig?: WebSearchConfig | null;
}
// -------------------------------------------------------------------
// Hook implementation
// -------------------------------------------------------------------
export function useToolApproval({
addMessageToSession,
updateLastMessage,
updateMessageById,
setStreamingForScope,
abortControllersRef,
processCattyStream,
t,
}: UseToolApprovalParams): UseToolApprovalReturn {
// Pending approval context — stores SDK state needed to resume after user approves/rejects
const pendingApprovalContextRef = useRef<PendingApprovalContext | null>(sharedPendingApprovalContext);
pendingApprovalContextRef.current = sharedPendingApprovalContext;
/** Set pending approval context with a 5-minute auto-clear timeout. */
const setPendingApproval = useCallback((ctx: PendingApprovalContext | null) => {
// Clear any existing timeout
if (sharedPendingApprovalTimeout) {
clearTimeout(sharedPendingApprovalTimeout);
sharedPendingApprovalTimeout = null;
}
sharedPendingApprovalContext = ctx;
pendingApprovalContextRef.current = ctx;
if (ctx) {
sharedPendingApprovalTimeout = setTimeout(() => {
// Auto-clear after 5 minutes if user never responds
if (sharedPendingApprovalContext?.sessionId === ctx.sessionId) {
sharedPendingApprovalContext = null;
pendingApprovalContextRef.current = null;
setStreamingForScope(ctx.sessionId, false);
abortControllersRef.current.get(ctx.sessionId)?.abort();
abortControllersRef.current.delete(ctx.sessionId);
// Notify the user that the approval timed out
updateLastMessage(ctx.sessionId, msg => ({
...msg,
statusText: '',
executionStatus: msg.executionStatus === 'running' ? 'failed' : msg.executionStatus,
}));
addMessageToSession(ctx.sessionId, {
id: generateId(),
role: 'assistant',
content: t('ai.chat.approvalTimeout'),
timestamp: Date.now(),
});
}
sharedPendingApprovalTimeout = null;
}, 5 * 60 * 1000); // 5 minutes
}
}, [setStreamingForScope, abortControllersRef, updateLastMessage, addMessageToSession, t]);
// Handle inline approval response (approve/reject from InlineApprovalCard)
const handleApprovalResponse = useCallback(async (
messageId: string,
approved: boolean,
approvalContext: ToolApprovalContext,
) => {
const ctx = pendingApprovalContextRef.current;
if (!ctx) return;
// Destructure all needed values BEFORE clearing the ref to avoid race conditions
const {
sessionId: sid,
scopeKey: sk,
sdkMessages,
approvalInfo,
model: ctxModel,
scopeType,
scopeLabel,
getExecutorContext,
} = ctx;
// Clear pending approval (and its timeout) via setPendingApproval
setPendingApproval(null);
// Update the message's pendingApproval status using message ID
updateMessageById(sid, messageId, msg => ({
...msg,
pendingApproval: msg.pendingApproval
? { ...msg.pendingApproval, status: approved ? 'approved' as const : 'denied' as const }
: undefined,
}));
if (!approved) {
// User rejected — add denial text and stop
updateMessageById(sid, messageId, msg => ({
...msg,
content: msg.content + (msg.content ? '\n\n' : '') + t('ai.chat.toolDenied'),
statusText: '',
executionStatus: 'completed',
}));
setStreamingForScope(sid, false);
abortControllersRef.current.delete(sid);
return;
}
// User approved — construct SDK messages with approval response and resume
const resumeMessages: Array<Record<string, unknown>> = [
...sdkMessages,
// The assistant message that contained the tool call + approval request
{
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: approvalInfo.toolCallId,
toolName: approvalInfo.toolName,
input: approvalInfo.toolArgs,
},
{
type: 'tool-approval-request',
approvalId: approvalInfo.approvalId,
toolCallId: approvalInfo.toolCallId,
},
],
},
// The user's approval response
{
role: 'tool',
content: [
{
type: 'tool-approval-response',
approvalId: approvalInfo.approvalId,
approved: true,
},
],
},
];
// Create a new assistant message placeholder for the continuation
const newAssistantMsgId = generateId();
addMessageToSession(sid, {
id: newAssistantMsgId,
role: 'assistant',
content: '',
timestamp: Date.now(),
});
const abortController = new AbortController();
abortControllersRef.current.set(sid, abortController);
try {
// Rebuild tools and system prompt with the latest permission mode to prevent
// stale settings, while keeping the original AI scope pinned to its workspace/session.
const bridge = getNetcattyBridge();
const freshExecutorContext = getExecutorContext();
const freshTools = createCattyTools(
bridge,
getExecutorContext,
approvalContext.commandBlocklist,
approvalContext.globalPermissionMode,
approvalContext.webSearchConfig ?? undefined,
);
const freshSystemPrompt = buildSystemPrompt({
scopeType,
scopeLabel,
hosts: freshExecutorContext.sessions.map(s => ({
sessionId: s.sessionId, hostname: s.hostname, label: s.label,
os: s.os, username: s.username, connected: s.connected,
})),
permissionMode: approvalContext.globalPermissionMode,
webSearchEnabled: isWebSearchReady(approvalContext.webSearchConfig),
});
const newApprovalInfo = await processCattyStream(sid, ctxModel, freshSystemPrompt, freshTools, resumeMessages as unknown as ModelMessage[], abortController.signal, newAssistantMsgId);
if (newApprovalInfo) {
// Another approval needed — save context for the next round (with timeout)
setPendingApproval({
sessionId: sid,
scopeKey: sk,
sdkMessages: resumeMessages,
approvalInfo: newApprovalInfo,
model: ctxModel,
systemPrompt: freshSystemPrompt,
tools: freshTools,
scopeType,
scopeLabel,
getExecutorContext,
});
return;
}
} catch (err) {
console.error('[Catty resume] streamText error:', err);
if (!abortController.signal.aborted) {
const errorStr = err instanceof Error ? err.message : String(err);
updateMessageById(sid, newAssistantMsgId, msg => ({
...msg,
statusText: '',
executionStatus: msg.executionStatus === 'running' ? 'failed' : msg.executionStatus,
}));
addMessageToSession(sid, {
id: generateId(),
role: 'assistant',
content: '',
errorInfo: classifyError(errorStr),
timestamp: Date.now(),
});
}
} finally {
if (!pendingApprovalContextRef.current || pendingApprovalContextRef.current.sessionId !== sid) {
// Clear any lingering statusText when the resumed stream finishes
updateLastMessage(sid, msg => msg.statusText ? { ...msg, statusText: '' } : msg);
setStreamingForScope(sid, false);
abortControllersRef.current.delete(sid);
}
}
}, [
processCattyStream, addMessageToSession, updateMessageById, updateLastMessage,
setStreamingForScope, abortControllersRef, t, setPendingApproval,
]);
return {
pendingApprovalContextRef,
setPendingApproval,
handleApprovalResponse,
};
}

View File

@@ -25,6 +25,8 @@ export default function SettingsAppearanceTab(props: {
setUiLanguage: (language: string) => void;
customCSS: string;
setCustomCSS: (css: string) => void;
isImmersive?: boolean;
onToggleImmersive?: () => void;
}) {
const { t } = useI18n();
const availableUIFonts = useAvailableUIFonts();
@@ -45,6 +47,8 @@ export default function SettingsAppearanceTab(props: {
setUiLanguage,
customCSS,
setCustomCSS,
isImmersive,
onToggleImmersive,
} = props;
const getHslStyle = useCallback((hsl: string) => ({ backgroundColor: `hsl(${hsl})` }), []);
@@ -254,6 +258,19 @@ export default function SettingsAppearanceTab(props: {
</SettingRow>
</div>
<SectionHeader title={t("settings.appearance.immersiveMode")} />
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
<SettingRow
label={t("settings.appearance.immersiveMode")}
description={t("settings.appearance.immersiveMode.desc")}
>
<Toggle
checked={!!isImmersive}
onChange={() => onToggleImmersive?.()}
/>
</SettingRow>
</div>
<SectionHeader title={t("settings.appearance.customCss")} />
<div className="space-y-2">
<p className="text-xs text-muted-foreground">

View File

@@ -29,13 +29,10 @@ const getOpenerLabel = (
export default function SettingsFileAssociationsTab() {
const { t } = useI18n();
const { getAllAssociations, removeAssociation, setOpenerForExtension } = useSftpFileAssociations();
const { sftpDoubleClickBehavior, setSftpDoubleClickBehavior, sftpAutoSync, setSftpAutoSync, sftpShowHiddenFiles, setSftpShowHiddenFiles, sftpUseCompressedUpload, setSftpUseCompressedUpload } = useSettingsState();
const { sftpDoubleClickBehavior, setSftpDoubleClickBehavior, sftpAutoSync, setSftpAutoSync, sftpShowHiddenFiles, setSftpShowHiddenFiles, sftpUseCompressedUpload, setSftpUseCompressedUpload, sftpAutoOpenSidebar, setSftpAutoOpenSidebar } = useSettingsState();
const associations = getAllAssociations();
const [editingExtension, setEditingExtension] = useState<string | null>(null);
// Debug log for Settings page
console.log('[SettingsFileAssociationsTab] Rendering with', associations.length, 'associations:', associations);
const handleRemove = useCallback((extension: string) => {
if (confirm(t('settings.sftpFileAssociations.removeConfirm', { ext: extension === 'file' ? t('sftp.opener.noExtension') : extension }))) {
removeAssociation(extension);
@@ -253,6 +250,46 @@ export default function SettingsFileAssociationsTab() {
</button>
</div>
{/* Auto-open sidebar section */}
<div className="space-y-4">
<SectionHeader title={t('settings.sftp.autoOpenSidebar')} />
<p className="text-sm text-muted-foreground">
{t('settings.sftp.autoOpenSidebar.desc')}
</p>
<button
onClick={() => setSftpAutoOpenSidebar(!sftpAutoOpenSidebar)}
className={cn(
"w-full text-left p-4 rounded-lg border-2 transition-colors",
sftpAutoOpenSidebar
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-secondary/50"
)}
>
<div className="flex items-start gap-3">
<div className={cn(
"h-5 w-5 rounded border-2 flex items-center justify-center mt-0.5 shrink-0",
sftpAutoOpenSidebar
? "border-primary bg-primary"
: "border-muted-foreground/30"
)}>
{sftpAutoOpenSidebar && (
<svg className="h-3 w-3 text-primary-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</div>
<div className="space-y-1">
<Label className="font-medium cursor-pointer">
{t('settings.sftp.autoOpenSidebar.enable')}
</Label>
<p className="text-sm text-muted-foreground">
{t('settings.sftp.autoOpenSidebar.enableDesc')}
</p>
</div>
</div>
</button>
</div>
{/* File associations section */}
<div className="space-y-4">
<SectionHeader title={t('settings.sftpFileAssociations.title')} />

View File

@@ -1,7 +1,7 @@
/**
* Settings System Tab - System information, temp file management, session logs, and global hotkey
*/
import { Download, ExternalLink, FileText, FolderOpen, HardDrive, Keyboard, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
import { AlertTriangle, ChevronDown, ChevronRight, Download, ExternalLink, FileText, FolderOpen, HardDrive, Keyboard, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
import React, { useCallback, useEffect, useState } from "react";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { getCredentialProtectionAvailability } from "../../../infrastructure/services/credentialProtection";
@@ -13,6 +13,31 @@ import { Button } from "../../ui/button";
import { Toggle, Select, SettingRow } from "../settings-ui";
import { cn } from "../../../lib/utils";
interface CrashLogFile {
fileName: string;
date: string;
size: number;
entryCount: number;
}
interface CrashLogEntry {
timestamp: string;
source: string;
message: string;
stack?: string;
errorMeta?: Record<string, unknown>;
extra?: Record<string, unknown>;
pid?: number;
platform?: string;
arch?: string;
version?: string;
electronVersion?: string;
osVersion?: string;
memoryMB?: { rss: number; heapUsed: number; heapTotal: number };
activeSessionCount?: number;
uptimeSeconds?: number;
}
interface TempDirInfo {
path: string;
fileCount: number;
@@ -98,6 +123,12 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
const [hotkeyError, setHotkeyError] = useState<string | null>(null);
const [credentialsAvailable, setCredentialsAvailable] = useState<boolean | null>(null);
const [isCheckingCredentials, setIsCheckingCredentials] = useState(false);
const [crashLogs, setCrashLogs] = useState<CrashLogFile[]>([]);
const [isLoadingCrashLogs, setIsLoadingCrashLogs] = useState(false);
const [expandedLog, setExpandedLog] = useState<string | null>(null);
const [logEntries, setLogEntries] = useState<CrashLogEntry[]>([]);
const [isClearingCrashLogs, setIsClearingCrashLogs] = useState(false);
const [crashLogClearResult, setCrashLogClearResult] = useState<{ deletedCount: number } | null>(null);
const [appVersion, setAppVersion] = useState('');
@@ -144,6 +175,73 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
void loadCredentialProtectionStatus();
}, [loadCredentialProtectionStatus]);
const loadCrashLogs = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.getCrashLogs) return;
setIsLoadingCrashLogs(true);
try {
const logs = await bridge.getCrashLogs();
setCrashLogs(logs);
} catch (err) {
console.error("[SettingsSystemTab] Failed to load crash logs:", err);
} finally {
setIsLoadingCrashLogs(false);
}
}, []);
useEffect(() => {
void loadCrashLogs();
}, [loadCrashLogs]);
const expandRequestRef = React.useRef(0);
const handleExpandCrashLog = useCallback(async (fileName: string) => {
if (expandedLog === fileName) {
setExpandedLog(null);
setLogEntries([]);
return;
}
const bridge = netcattyBridge.get();
if (!bridge?.readCrashLog) return;
const requestId = ++expandRequestRef.current;
// Optimistically show expanded state while loading
setExpandedLog(fileName);
setLogEntries([]);
try {
const entries = await bridge.readCrashLog(fileName);
// Discard if user clicked a different file while awaiting
if (expandRequestRef.current !== requestId) return;
setLogEntries(entries);
} catch (err) {
if (expandRequestRef.current !== requestId) return;
console.error("[SettingsSystemTab] Failed to read crash log:", err);
}
}, [expandedLog]);
const handleClearCrashLogs = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.clearCrashLogs) return;
setIsClearingCrashLogs(true);
setCrashLogClearResult(null);
try {
const result = await bridge.clearCrashLogs();
setCrashLogClearResult(result);
setExpandedLog(null);
setLogEntries([]);
// Reload the list so partial failures still show remaining files
await loadCrashLogs();
} catch (err) {
console.error("[SettingsSystemTab] Failed to clear crash logs:", err);
} finally {
setIsClearingCrashLogs(false);
}
}, [loadCrashLogs]);
const handleOpenCrashLogsDir = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.openCrashLogsDir) return;
await bridge.openCrashLogsDir();
}, []);
const handleClearTempFiles = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.clearTempDir) return;
@@ -449,6 +547,148 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
</div>
</div>
{/* Crash Logs Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<AlertTriangle size={18} className="text-muted-foreground" />
<h3 className="text-base font-medium">{t("settings.system.crashLogs.title")}</h3>
</div>
<div className="bg-muted/30 rounded-lg p-4 space-y-3">
<p className="text-sm text-muted-foreground">
{t("settings.system.crashLogs.description")}
</p>
{crashLogs.length === 0 && !isLoadingCrashLogs && (
<p className="text-sm text-muted-foreground italic">
{t("settings.system.crashLogs.noLogs")}
</p>
)}
{crashLogs.length > 0 && (
<div className="space-y-2">
{crashLogs.map((log) => (
<div key={log.fileName} className="border border-border/60 rounded-md overflow-hidden">
<button
onClick={() => handleExpandCrashLog(log.fileName)}
className="w-full flex items-center justify-between px-3 py-2 text-sm hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-2">
{expandedLog === log.fileName ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<span className="font-mono">{log.date}</span>
<span className="text-muted-foreground">
({t("settings.system.crashLogs.entries").replace("{count}", String(log.entryCount))})
</span>
</div>
<span className="text-xs text-muted-foreground">{formatBytes(log.size)}</span>
</button>
{expandedLog === log.fileName && logEntries.length > 0 && (
<div className="border-t border-border/60 max-h-64 overflow-y-auto">
{logEntries.map((entry, idx) => (
<div key={idx} className="px-3 py-2 text-xs border-b border-border/30 last:border-b-0 space-y-1">
<div className="flex items-center gap-3 flex-wrap">
<span className="font-mono text-muted-foreground">
{new Date(entry.timestamp).toLocaleTimeString()}
</span>
<span className="px-1.5 py-0.5 rounded bg-destructive/10 text-destructive font-medium">
{entry.source}
</span>
</div>
<p className="font-mono break-all">{entry.message}</p>
{entry.errorMeta && Object.keys(entry.errorMeta).length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
{Object.entries(entry.errorMeta).map(([k, v]) => (
<span key={k} className="px-1.5 py-0.5 rounded bg-muted font-mono">
{k}={String(v)}
</span>
))}
</div>
)}
{entry.extra && Object.keys(entry.extra).length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
{Object.entries(entry.extra).map(([k, v]) => (
<span key={k} className="px-1.5 py-0.5 rounded bg-muted font-mono">
{k}={String(v)}
</span>
))}
</div>
)}
{(() => {
const parts: string[] = [];
if (entry.version) parts.push(`v${entry.version}`);
if (entry.electronVersion) parts.push(`Electron ${entry.electronVersion}`);
if (entry.platform) parts.push(`${entry.platform}/${entry.arch}`);
if (entry.osVersion) parts.push(`OS ${entry.osVersion}`);
if (entry.pid) parts.push(`PID ${entry.pid}`);
if (entry.activeSessionCount != null && entry.activeSessionCount >= 0) parts.push(`Sessions: ${entry.activeSessionCount}`);
if (entry.memoryMB) parts.push(`RAM: ${entry.memoryMB.rss}MB`);
if (entry.uptimeSeconds != null) parts.push(`Uptime: ${entry.uptimeSeconds}s`);
const text = parts.join(' ');
return text ? (
<div className="text-muted-foreground truncate" title={text}>
{text}
</div>
) : null;
})()}
{entry.stack && (
<pre className="mt-1 p-2 bg-muted rounded text-[11px] leading-relaxed overflow-x-auto whitespace-pre-wrap break-all text-muted-foreground">
{entry.stack}
</pre>
)}
</div>
))}
</div>
)}
</div>
))}
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2 pt-2">
<Button
variant="outline"
size="sm"
onClick={loadCrashLogs}
disabled={isLoadingCrashLogs}
className="gap-1.5"
>
<RefreshCw size={14} className={isLoadingCrashLogs ? "animate-spin" : ""} />
{t("settings.system.refresh")}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleClearCrashLogs}
disabled={isClearingCrashLogs || crashLogs.length === 0}
className="gap-1.5 text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 size={14} />
{t("settings.system.crashLogs.clear")}
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleOpenCrashLogsDir}
title={t("settings.system.openFolder")}
>
<FolderOpen size={16} />
</Button>
</div>
{crashLogClearResult && (
<p className="text-sm text-muted-foreground">
{t("settings.system.crashLogs.cleared").replace("{count}", String(crashLogClearResult.deletedCount))}
</p>
)}
</div>
<p className="text-xs text-muted-foreground">
{t("settings.system.crashLogs.hint")}
</p>
</div>
{/* Temp Directory Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">

View File

@@ -119,18 +119,14 @@ export default function SettingsTerminalTab(props: {
const handleImportItermcolors = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) {
console.log('[Settings] No file selected');
return;
}
console.log('[Settings] File selected:', file.name, 'size:', file.size);
const name = file.name.replace(/\.(itermcolors|xml)$/i, '');
const reader = new FileReader();
reader.onload = () => {
const xml = reader.result as string;
console.log('[Settings] File read successfully, length:', xml.length);
const parsed = parseItermcolors(xml, name);
if (parsed) {
console.log('[Settings] Theme parsed successfully:', parsed.id, parsed.name);
customThemeStore.addTheme(parsed);
setTerminalThemeId(parsed.id);
} else {
@@ -620,6 +616,13 @@ export default function SettingsTerminalTab(props: {
<Toggle checked={terminalSettings.scrollOnPaste} onChange={(v) => updateTerminalSetting("scrollOnPaste", v)} />
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.smoothScrolling")}
description={t("settings.terminal.behavior.smoothScrolling.desc")}
>
<Toggle checked={terminalSettings.smoothScrolling} onChange={(v) => updateTerminalSetting("smoothScrolling", v)} />
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.linkModifier")}
description={t("settings.terminal.behavior.linkModifier.desc")}

View File

@@ -77,7 +77,7 @@ export const AGENT_DEFAULTS: Record<string, Omit<ExternalAgentConfig, "id" | "co
name: "Claude Code",
args: ["-p", "--output-format", "text", "{prompt}"],
icon: "claude",
acpCommand: "claude-code-acp",
acpCommand: "claude-agent-acp",
acpArgs: [],
},
};

View File

@@ -1,186 +0,0 @@
import React from "react";
import { Loader2 } from "lucide-react";
import { Button } from "../ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
import { Input } from "../ui/input";
import type { RemoteFile } from "../../types";
interface PermissionsState {
owner: { read: boolean; write: boolean; execute: boolean };
group: { read: boolean; write: boolean; execute: boolean };
others: { read: boolean; write: boolean; execute: boolean };
}
interface SftpModalDialogsProps {
t: (key: string, params?: Record<string, unknown>) => string;
showRenameDialog: boolean;
setShowRenameDialog: (open: boolean) => void;
renameTarget: RemoteFile | null;
renameName: string;
setRenameName: (value: string) => void;
handleRename: () => void;
isRenaming: boolean;
showPermissionsDialog: boolean;
setShowPermissionsDialog: (open: boolean) => void;
permissionsTarget: RemoteFile | null;
permissions: PermissionsState;
togglePermission: (role: "owner" | "group" | "others", perm: "read" | "write" | "execute") => void;
getOctalPermissions: () => string;
getSymbolicPermissions: () => string;
handleSavePermissions: () => void;
isChangingPermissions: boolean;
showCreateDialog: boolean;
setShowCreateDialog: (open: boolean) => void;
createType: "file" | "folder";
createName: string;
setCreateName: (value: string) => void;
isCreating: boolean;
handleCreateSubmit: () => void;
}
export const SftpModalDialogs: React.FC<SftpModalDialogsProps> = ({
t,
showRenameDialog,
setShowRenameDialog,
renameTarget,
renameName,
setRenameName,
handleRename,
isRenaming,
showPermissionsDialog,
setShowPermissionsDialog,
permissionsTarget,
permissions,
togglePermission,
getOctalPermissions,
getSymbolicPermissions,
handleSavePermissions,
isChangingPermissions,
showCreateDialog,
setShowCreateDialog,
createType,
createName,
setCreateName,
isCreating,
handleCreateSubmit,
}) => (
<>
<Dialog open={showRenameDialog} onOpenChange={setShowRenameDialog}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>{t("sftp.rename.title")}</DialogTitle>
<DialogDescription className="truncate">
{renameTarget?.name}
</DialogDescription>
</DialogHeader>
<div className="py-4">
<Input
value={renameName}
onChange={(e) => setRenameName(e.target.value)}
placeholder={t("sftp.rename.placeholder")}
onKeyDown={(e) => {
if (e.key === "Enter") handleRename();
}}
autoFocus
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowRenameDialog(false)}>
{t("common.cancel")}
</Button>
<Button onClick={handleRename} disabled={isRenaming || !renameName.trim()}>
{isRenaming ? <Loader2 size={14} className="mr-2 animate-spin" /> : null}
{t("common.apply")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showPermissionsDialog} onOpenChange={setShowPermissionsDialog}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>{t("sftp.permissions.title")}</DialogTitle>
<DialogDescription className="truncate">
{permissionsTarget?.name}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-3">
{(["owner", "group", "others"] as const).map((role) => (
<div key={role} className="flex items-center gap-4">
<div className="w-16 text-sm font-medium">
{t(`sftp.permissions.${role}`)}
</div>
<div className="flex gap-3">
{(["read", "write", "execute"] as const).map((perm) => (
<label key={perm} className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={permissions[role][perm]}
onChange={() => togglePermission(role, perm)}
className="rounded border-border"
/>
<span className="text-xs">
{perm === "read" ? "R" : perm === "write" ? "W" : "X"}
</span>
</label>
))}
</div>
</div>
))}
</div>
<div className="flex items-center justify-between pt-2 border-t border-border/60">
<div className="text-xs text-muted-foreground">
{t("sftp.permissions.octal")}: <span className="font-mono text-foreground">{getOctalPermissions()}</span>
</div>
<div className="text-xs text-muted-foreground">
{t("sftp.permissions.symbolic")}: <span className="font-mono text-foreground">{getSymbolicPermissions()}</span>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowPermissionsDialog(false)}>
{t("common.cancel")}
</Button>
<Button onClick={handleSavePermissions} disabled={isChangingPermissions}>
{isChangingPermissions ? <Loader2 size={14} className="mr-2 animate-spin" /> : null}
{t("common.apply")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>
{t(createType === "folder" ? "sftp.newFolder" : "sftp.newFile")}
</DialogTitle>
<DialogDescription>
{t(createType === "folder" ? "sftp.prompt.newFolderName" : "sftp.fileName.placeholder")}
</DialogDescription>
</DialogHeader>
<div className="py-4">
<Input
value={createName}
onChange={(e) => setCreateName(e.target.value)}
placeholder={t(createType === "folder" ? "sftp.prompt.newFolderName" : "sftp.fileName.placeholder")}
onKeyDown={(e) => {
if (e.key === "Enter") handleCreateSubmit();
}}
autoFocus
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCreateDialog(false)}>
{t("common.cancel")}
</Button>
<Button onClick={handleCreateSubmit} disabled={isCreating || !createName.trim()}>
{isCreating ? <Loader2 size={14} className="mr-2 animate-spin" /> : null}
{t("common.apply")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);

View File

@@ -1,438 +0,0 @@
import React from "react";
import { Download, Edit2, Folder, FolderOpen, FolderUp, Link, Loader2, MoreHorizontal, Plus, RefreshCw, Shield, Trash2, Upload } from "lucide-react";
import { cn } from "../../lib/utils";
import type { RemoteFile } from "../../types";
import { isKnownBinaryFile } from "../../lib/sftpFileUtils";
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from "../ui/context-menu";
import { Button } from "../ui/button";
import { getFileIcon } from "./fileIcons";
interface VisibleRow {
file: RemoteFile;
index: number;
top: number;
}
interface SftpModalFileListProps {
t: (key: string, params?: Record<string, unknown>) => string;
currentPath: string;
isLocalSession: boolean;
hasFiles: boolean;
hasDisplayFiles: boolean;
selectedFiles: Set<string>;
dragActive: boolean;
loading: boolean;
loadingTextContent: boolean;
reconnecting: boolean;
columnWidths: { name: number; size: number; modified: number; actions: number };
sortField: "name" | "size" | "modified";
sortOrder: "asc" | "desc";
shouldVirtualize: boolean;
totalHeight: number;
visibleRows: VisibleRow[];
fileListRef: React.RefObject<HTMLDivElement>;
inputRef: React.RefObject<HTMLInputElement>;
folderInputRef: React.RefObject<HTMLInputElement>;
handleSort: (field: "name" | "size" | "modified") => void;
handleResizeStart: (field: string, e: React.MouseEvent) => void;
handleFileListScroll: (e: React.UIEvent<HTMLDivElement>) => void;
handleDrag: (e: React.DragEvent) => void;
handleDrop: (e: React.DragEvent) => void;
handleFileClick: (file: RemoteFile, index: number, e: React.MouseEvent) => void;
handleFileDoubleClick: (file: RemoteFile) => void;
handleDownload: (file: RemoteFile) => void;
handleDelete: (file: RemoteFile) => void;
handleOpenFile: (file: RemoteFile) => void;
openFileOpenerDialog: (file: RemoteFile) => void;
handleEditFile: (file: RemoteFile) => void;
openRenameDialog: (file: RemoteFile) => void;
openPermissionsDialog: (file: RemoteFile) => void;
handleNavigate: (path: string) => void;
handleCreateFolder: () => void;
handleCreateFile: () => void;
handleDownloadSelected: () => void;
handleDeleteSelected: () => void;
loadFiles: (path: string, options?: { force?: boolean }) => void;
formatBytes: (bytes: number | string) => string;
formatDate: (dateStr: string | number | undefined) => string;
}
export const SftpModalFileList: React.FC<SftpModalFileListProps> = ({
t,
currentPath,
isLocalSession,
hasFiles,
hasDisplayFiles,
selectedFiles,
dragActive,
loading,
loadingTextContent,
reconnecting,
columnWidths,
sortField,
sortOrder,
shouldVirtualize,
totalHeight,
visibleRows,
fileListRef,
inputRef,
folderInputRef,
handleSort,
handleResizeStart,
handleFileListScroll,
handleDrag,
handleDrop,
handleFileClick,
handleFileDoubleClick,
handleDownload,
handleDelete,
handleOpenFile,
openFileOpenerDialog,
handleEditFile,
openRenameDialog,
openPermissionsDialog,
handleNavigate,
handleCreateFolder,
handleCreateFile,
handleDownloadSelected,
handleDeleteSelected,
loadFiles,
formatBytes,
formatDate,
}) => (
<>
<div
className="shrink-0 bg-muted/80 backdrop-blur-sm border-b border-border/60 px-4 py-2 flex items-center text-xs font-medium text-muted-foreground select-none"
style={{
display: "grid",
gridTemplateColumns: `${columnWidths.name}% ${columnWidths.size}% ${columnWidths.modified}% ${columnWidths.actions}%`,
}}
>
<div
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2"
onClick={() => handleSort("name")}
>
<span>{t("sftp.columns.name")}</span>
{sortField === "name" && (
<span className="text-primary">{sortOrder === "asc" ? "^" : "v"}</span>
)}
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
onMouseDown={(e) => handleResizeStart("name", e)}
/>
</div>
<div
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2"
onClick={() => handleSort("size")}
>
<span>{t("sftp.columns.size")}</span>
{sortField === "size" && (
<span className="text-primary">{sortOrder === "asc" ? "^" : "v"}</span>
)}
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
onMouseDown={(e) => handleResizeStart("size", e)}
/>
</div>
<div
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2"
onClick={() => handleSort("modified")}
>
<span>{t("sftp.columns.modified")}</span>
{sortField === "modified" && (
<span className="text-primary">{sortOrder === "asc" ? "^" : "v"}</span>
)}
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
onMouseDown={(e) => handleResizeStart("modified", e)}
/>
</div>
<div className="text-right">{t("sftp.columns.actions")}</div>
</div>
<div
ref={fileListRef}
className={cn(
"flex-1 min-h-0 overflow-y-auto relative",
dragActive && "bg-primary/5 ring-2 ring-inset ring-primary",
)}
onScroll={handleFileListScroll}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
>
{dragActive && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
<div className="bg-background/95 p-6 rounded-xl shadow-lg border-2 border-dashed border-primary text-primary font-medium flex flex-col items-center gap-2">
<Upload size={32} />
<span>{t("sftp.dropFilesHere")}</span>
</div>
</div>
)}
{loading && !hasFiles && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)}
{loadingTextContent && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-20">
<div className="flex flex-col items-center gap-2">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">
{t("sftp.status.loading")}
</span>
</div>
</div>
)}
{reconnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-20">
<div className="flex flex-col items-center gap-3 p-6 rounded-xl bg-secondary/90 border border-border/60 shadow-lg">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<div className="text-center">
<div className="text-sm font-medium">{t("sftp.reconnecting.title")}</div>
<div className="text-xs text-muted-foreground mt-1">
{t("sftp.reconnecting.desc")}
</div>
</div>
</div>
</div>
)}
{!hasDisplayFiles && !loading && (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<Folder size={48} className="mb-3 opacity-50" />
<div className="text-sm font-medium">{t("sftp.emptyDirectory")}</div>
<div className="text-xs mt-1">{t("sftp.dragDropToUpload")}</div>
</div>
)}
<ContextMenu>
<ContextMenuTrigger asChild>
<div
className={shouldVirtualize ? "relative" : "divide-y divide-border/30"}
style={shouldVirtualize ? { height: totalHeight } : undefined}
>
{visibleRows.map(({ file, index: idx, top }) => {
const isNavigableDirectory =
file.type === "directory" ||
(file.type === "symlink" && file.linkTarget === "directory");
const isDownloadableFile =
file.type === "file" ||
(file.type === "symlink" && file.linkTarget === "file");
const isParentEntry = file.name === "..";
return (
<ContextMenu key={file.name}>
<ContextMenuTrigger>
<div
data-sftp-modal-row="true"
className={cn(
"px-4 py-2.5 items-center hover:bg-muted/50 cursor-pointer transition-colors text-sm",
selectedFiles.has(file.name) && !isParentEntry && "bg-primary/10",
shouldVirtualize ? "absolute left-0 right-0 border-b border-border/30" : "",
)}
style={
shouldVirtualize
? {
top,
display: "grid",
gridTemplateColumns: `${columnWidths.name}% ${columnWidths.size}% ${columnWidths.modified}% ${columnWidths.actions}%`,
}
: {
display: "grid",
gridTemplateColumns: `${columnWidths.name}% ${columnWidths.size}% ${columnWidths.modified}% ${columnWidths.actions}%`,
}
}
onClick={(e) => handleFileClick(file, idx, e)}
onDoubleClick={() => handleFileDoubleClick(file)}
>
<div className="flex items-center gap-3 min-w-0">
<div className="relative shrink-0 h-7 w-7 flex items-center justify-center">
{getFileIcon(
file.name,
isNavigableDirectory,
file.type === "symlink" && !isNavigableDirectory,
)}
{file.type === "symlink" && (
<Link
size={10}
className="absolute -bottom-0.5 -right-0.5 text-muted-foreground"
aria-hidden="true"
/>
)}
</div>
<span
className={cn(
"truncate font-medium",
file.type === "symlink" && "italic pr-1",
)}
>
{file.name}
{file.type === "symlink" && (
<span className="sr-only"> (symbolic link)</span>
)}
</span>
</div>
<div className="text-xs text-muted-foreground">
{isNavigableDirectory ? "--" : formatBytes(file.size)}
</div>
<div className="text-xs text-muted-foreground truncate">
{formatDate(file.lastModified)}
</div>
<div className="flex items-center justify-end gap-1">
{isDownloadableFile && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={(e) => {
e.stopPropagation();
handleDownload(file);
}}
title={t("sftp.context.download")}
>
<Download size={14} />
</Button>
)}
{!isParentEntry && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleDelete(file);
}}
title={t("sftp.context.delete")}
>
<Trash2 size={14} />
</Button>
)}
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
{isParentEntry ? (
<ContextMenuItem
onClick={() => {
const segments = currentPath.split("/").filter(Boolean);
segments.pop();
const parentPath =
segments.length === 0 ? "/" : `/${segments.join("/")}`;
handleNavigate(parentPath);
}}
>
{t("sftp.context.open")}
</ContextMenuItem>
) : (
<>
{isNavigableDirectory && (
<>
<ContextMenuItem
onClick={() =>
handleNavigate(
currentPath === "/"
? `/${file.name}`
: `${currentPath}/${file.name}`,
)
}
>
<FolderOpen size={14} className="mr-2" />
{t("sftp.context.open")}
</ContextMenuItem>
{!isLocalSession && (
<ContextMenuItem onClick={() => handleDownload(file)}>
<Download size={14} className="mr-2" />
{t("sftp.context.download")}
</ContextMenuItem>
)}
</>
)}
{isDownloadableFile && (
<>
<ContextMenuItem onClick={() => handleOpenFile(file)}>
<FolderOpen size={14} className="mr-2" />
{t("sftp.context.open")}
</ContextMenuItem>
<ContextMenuItem onClick={() => openFileOpenerDialog(file)}>
<MoreHorizontal size={14} className="mr-2" />
{t("sftp.context.openWith")}
</ContextMenuItem>
{!isKnownBinaryFile(file.name) && (
<ContextMenuItem onClick={() => handleEditFile(file)}>
<Edit2 size={14} className="mr-2" />
{t("sftp.context.edit")}
</ContextMenuItem>
)}
<ContextMenuSeparator />
<ContextMenuItem onClick={() => handleDownload(file)}>
<Download size={14} className="mr-2" />
{t("sftp.context.download")}
</ContextMenuItem>
</>
)}
<ContextMenuItem onClick={() => openRenameDialog(file)}>
<Edit2 size={14} className="mr-2" />
{t("sftp.context.rename")}
</ContextMenuItem>
{!isLocalSession && (
<ContextMenuItem onClick={() => openPermissionsDialog(file)}>
<Shield size={14} className="mr-2" />
{t("sftp.context.permissions")}
</ContextMenuItem>
)}
<ContextMenuItem
className="text-destructive"
onClick={() => handleDelete(file)}
>
<Trash2 size={14} className="mr-2" />
{t("sftp.context.delete")}
</ContextMenuItem>
</>
)}
</ContextMenuContent>
</ContextMenu>
);
})}
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={handleCreateFolder}>
<Plus className="h-4 w-4 mr-2" /> {t("sftp.newFolder")}
</ContextMenuItem>
<ContextMenuItem onClick={handleCreateFile}>
<Plus className="h-4 w-4 mr-2" /> {t("sftp.newFile")}
</ContextMenuItem>
<ContextMenuItem onClick={() => inputRef.current?.click()}>
<Upload className="h-4 w-4 mr-2" /> {t("sftp.uploadFiles")}
</ContextMenuItem>
<ContextMenuItem onClick={() => folderInputRef.current?.click()}>
<FolderUp className="h-4 w-4 mr-2" /> {t("sftp.uploadFolder")}
</ContextMenuItem>
<ContextMenuItem onClick={() => loadFiles(currentPath, { force: true })}>
<RefreshCw className="h-4 w-4 mr-2" /> {t("sftp.context.refresh")}
</ContextMenuItem>
{selectedFiles.size > 0 && (
<>
<ContextMenuItem onClick={handleDownloadSelected}>
<Download className="h-4 w-4 mr-2" />
{t("sftp.context.downloadSelected", { count: selectedFiles.size })}
</ContextMenuItem>
<ContextMenuItem
className="text-destructive"
onClick={handleDeleteSelected}
>
<Trash2 className="h-4 w-4 mr-2" />
{t("sftp.context.deleteSelected", { count: selectedFiles.size })}
</ContextMenuItem>
</>
)}
</ContextMenuContent>
</ContextMenu>
</div>
</>
);

View File

@@ -1,61 +0,0 @@
import React from "react";
import { Download, Trash2 } from "lucide-react";
import { Button } from "../ui/button";
import type { RemoteFile } from "../../types";
interface SftpModalFooterProps {
t: (key: string, params?: Record<string, unknown>) => string;
files: RemoteFile[];
selectedFiles: Set<string>;
loading: boolean;
uploading: boolean;
onDownloadSelected: () => void;
onDeleteSelected: () => void;
}
export const SftpModalFooter: React.FC<SftpModalFooterProps> = ({
t,
files,
selectedFiles,
loading,
uploading,
onDownloadSelected,
onDeleteSelected,
}) => (
<div className="px-4 py-2 border-t border-border/60 flex items-center justify-between text-xs text-muted-foreground bg-muted/30 flex-shrink-0">
<span>
{t("sftp.itemsCount", { count: files.length })}
{selectedFiles.size > 0 && (
<>
<span className="mx-2">|</span>
<span className="text-primary">
{t("sftp.selectedCount", { count: selectedFiles.size })}
</span>
<Button
variant="ghost"
size="sm"
className="h-5 px-2 ml-2 text-xs text-primary hover:text-primary"
onClick={onDownloadSelected}
>
<Download size={10} className="mr-1" /> {t("sftp.context.download")}
</Button>
<Button
variant="ghost"
size="sm"
className="h-5 px-2 text-xs text-destructive hover:text-destructive"
onClick={onDeleteSelected}
>
<Trash2 size={10} className="mr-1" /> {t("sftp.context.delete")}
</Button>
</>
)}
</span>
<span>
{loading
? t("sftp.status.loading")
: uploading
? t("sftp.status.uploading")
: t("sftp.status.ready")}
</span>
</div>
);

View File

@@ -1,480 +0,0 @@
import React, { useEffect, useState } from "react";
import { ArrowUp, Bookmark, Check, ChevronRight, Eye, EyeOff, FilePlus, FolderPlus, FolderUp, Home, Languages, MoreHorizontal, RefreshCw, Trash2, Upload, X } from "lucide-react";
import { cn } from "../../lib/utils";
import type { Host, SftpFilenameEncoding } from "../../types";
import { useSftpBookmarks } from "../sftp/hooks/useSftpBookmarks";
import { DistroAvatar } from "../DistroAvatar";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from "../ui/popover";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
interface BreadcrumbPart {
part: string;
originalIndex: number;
}
interface SftpModalHeaderProps {
t: (key: string, params?: Record<string, unknown>) => string;
host: Host;
credentials: { username?: string; hostname: string; port?: number };
showEncoding: boolean;
filenameEncoding: SftpFilenameEncoding;
onFilenameEncodingChange: (encoding: SftpFilenameEncoding) => void;
currentPath: string;
isEditingPath: boolean;
editingPathValue: string;
setEditingPathValue: (value: string) => void;
handlePathSubmit: () => void;
handlePathKeyDown: (e: React.KeyboardEvent) => void;
handlePathDoubleClick: () => void;
isAtRoot: boolean;
rootLabel: string;
isRefreshing: boolean;
onUp: () => void;
onHome: () => void;
onRefresh: () => void;
visibleBreadcrumbs: BreadcrumbPart[];
hiddenBreadcrumbs: BreadcrumbPart[];
needsBreadcrumbTruncation: boolean;
breadcrumbs: string[];
onBreadcrumbSelect: (index: number) => void;
onRootSelect: () => void;
inputRef: React.RefObject<HTMLInputElement>;
folderInputRef: React.RefObject<HTMLInputElement>;
pathInputRef: React.RefObject<HTMLInputElement>;
uploading: boolean;
onTriggerUpload: () => void;
onTriggerFolderUpload: () => void;
onCreateFolder: () => void;
onCreateFile: () => void;
onFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
onFolderSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
showHiddenFiles: boolean;
onToggleShowHiddenFiles: () => void;
onUpdateHost?: (host: Host) => void;
onNavigateToBookmark?: (path: string) => void;
onClose?: () => void;
}
export const SftpModalHeader: React.FC<SftpModalHeaderProps> = ({
t,
host,
credentials,
showEncoding,
filenameEncoding,
onFilenameEncodingChange,
currentPath,
isEditingPath,
editingPathValue,
setEditingPathValue,
handlePathSubmit,
handlePathKeyDown,
handlePathDoubleClick,
isAtRoot,
rootLabel,
isRefreshing,
onUp,
onHome,
onRefresh,
visibleBreadcrumbs,
hiddenBreadcrumbs,
needsBreadcrumbTruncation,
breadcrumbs,
onBreadcrumbSelect,
onRootSelect,
inputRef,
folderInputRef,
pathInputRef,
uploading,
onTriggerUpload,
onTriggerFolderUpload,
onCreateFolder,
onCreateFile,
onFileSelect,
onFolderSelect,
showHiddenFiles,
onToggleShowHiddenFiles,
onUpdateHost,
onNavigateToBookmark,
onClose,
}) => {
// Delay tooltip activation to prevent flickering when modal opens
const [tooltipsReady, setTooltipsReady] = useState(false);
const [openTooltip, setOpenTooltip] = useState<string | null>(null);
// Bookmarks
const {
bookmarks,
isCurrentPathBookmarked,
toggleBookmark,
deleteBookmark,
} = useSftpBookmarks({
host,
currentPath,
onUpdateHost,
});
useEffect(() => {
const timer = setTimeout(() => setTooltipsReady(true), 500);
return () => clearTimeout(timer);
}, []);
const handleTooltipOpenChange = (id: string) => (open: boolean) => {
if (!tooltipsReady) return;
setOpenTooltip(open ? id : null);
};
return (
<>
<div className="px-4 py-3 border-b border-border/60 flex-shrink-0">
<div className="flex items-center gap-3">
<DistroAvatar
host={host}
fallback={host.label.slice(0, 2).toUpperCase()}
className="h-8 w-8"
size="sm"
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold">
{host.label}
</div>
<div className="text-xs text-muted-foreground font-mono">
{credentials.username || "root"}@{credentials.hostname}:
{credentials.port || 22}
</div>
</div>
{onClose && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={onClose}
>
<X size={14} />
</Button>
)}
</div>
</div>
<TooltipProvider delayDuration={500} skipDelayDuration={800} disableHoverableContent>
<div className="px-4 py-2 border-b border-border/60 flex items-center gap-2 flex-shrink-0 bg-muted/30">
<Tooltip open={openTooltip === 'up'} onOpenChange={handleTooltipOpenChange('up')}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onUp}
disabled={isAtRoot}
>
<ArrowUp size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.nav.up")}</TooltipContent>
</Tooltip>
<Tooltip open={openTooltip === 'home'} onOpenChange={handleTooltipOpenChange('home')}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onHome}
>
<Home size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.nav.home")}</TooltipContent>
</Tooltip>
<Tooltip open={openTooltip === 'refresh'} onOpenChange={handleTooltipOpenChange('refresh')}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onRefresh}
>
<RefreshCw
size={14}
className={cn(isRefreshing && "animate-spin")}
/>
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.nav.refresh")}</TooltipContent>
</Tooltip>
{/* Bookmark button */}
{onUpdateHost && (
<Popover>
<Tooltip open={openTooltip === 'bookmark'} onOpenChange={handleTooltipOpenChange('bookmark')}>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
>
<Bookmark
size={14}
className={cn(
isCurrentPathBookmarked && "fill-yellow-500 text-yellow-500"
)}
/>
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>
{isCurrentPathBookmarked ? t("sftp.bookmark.remove") : t("sftp.bookmark.add")}
</TooltipContent>
</Tooltip>
<PopoverContent className="w-56 p-1" align="start">
{/* Toggle button */}
<button
className="w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors"
onClick={toggleBookmark}
>
<Bookmark
size={12}
className={cn(
"shrink-0",
isCurrentPathBookmarked && "fill-yellow-500 text-yellow-500"
)}
/>
{isCurrentPathBookmarked ? t("sftp.bookmark.remove") : t("sftp.bookmark.add")}
</button>
{/* Divider + list */}
{bookmarks.length > 0 && (
<>
<div className="my-1 border-t border-border/60" />
{bookmarks.map((bm) => (
<div
key={bm.id}
className="group flex items-center gap-1 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors cursor-pointer"
onClick={() => onNavigateToBookmark?.(bm.path)}
title={bm.path}
>
<Bookmark size={10} className="shrink-0 text-muted-foreground" />
<span className="flex-1 truncate">{bm.label}</span>
<span className="flex-1 truncate text-muted-foreground text-[10px]">{bm.path}</span>
<Button
variant="ghost"
size="icon"
className="h-4 w-4 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
onClick={(e) => {
e.stopPropagation();
deleteBookmark(bm.id);
}}
>
<Trash2 size={10} />
</Button>
</div>
))}
</>
)}
{bookmarks.length === 0 && (
<div className="p-2 text-xs text-muted-foreground text-center">
{t("sftp.bookmark.empty")}
</div>
)}
</PopoverContent>
</Popover>
)}
{showEncoding && (
<Popover>
<Tooltip open={openTooltip === 'encoding'} onOpenChange={handleTooltipOpenChange('encoding')}>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
>
<Languages size={14} />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>{t("sftp.encoding.label")}</TooltipContent>
</Tooltip>
<PopoverContent className="w-36 p-1" align="start">
{(["auto", "utf-8", "gb18030"] as const).map((encoding) => (
<PopoverClose asChild key={encoding}>
<button
className={cn(
"w-full flex items-center gap-2 px-2 py-1.5 text-sm rounded-sm hover:bg-secondary transition-colors",
filenameEncoding === encoding && "bg-secondary"
)}
onClick={() => onFilenameEncodingChange(encoding)}
>
<Check
size={14}
className={cn(
"shrink-0",
filenameEncoding === encoding ? "opacity-100" : "opacity-0"
)}
/>
{t(`sftp.encoding.${encoding === "utf-8" ? "utf8" : encoding}`)}
</button>
</PopoverClose>
))}
</PopoverContent>
</Popover>
)}
<Tooltip
open={openTooltip === 'showHiddenFiles'}
onOpenChange={handleTooltipOpenChange('showHiddenFiles')}
>
<TooltipTrigger asChild>
<Button
variant={showHiddenFiles ? "secondary" : "ghost"}
size="icon"
className={cn("h-7 w-7", showHiddenFiles && "text-primary")}
onClick={onToggleShowHiddenFiles}
>
{showHiddenFiles ? <EyeOff size={14} /> : <Eye size={14} />}
</Button>
</TooltipTrigger>
<TooltipContent>{t("settings.sftp.showHiddenFiles")}</TooltipContent>
</Tooltip>
<div className="flex items-center gap-1 text-sm flex-1 min-w-0 overflow-hidden">
{isEditingPath ? (
<Input
ref={pathInputRef}
value={editingPathValue}
onChange={(e) => setEditingPathValue(e.target.value)}
onBlur={handlePathSubmit}
onKeyDown={handlePathKeyDown}
className="h-7 text-sm bg-background"
autoFocus
/>
) : (
<div
className="flex items-center gap-1 flex-1 min-w-0 cursor-text hover:bg-secondary/50 rounded px-1 py-0.5 transition-colors"
onDoubleClick={handlePathDoubleClick}
title={currentPath}
>
<button
className="text-muted-foreground hover:text-foreground px-1 shrink-0"
onClick={onRootSelect}
>
{rootLabel}
</button>
{visibleBreadcrumbs.map(({ part, originalIndex }, displayIdx) => {
const isLast = originalIndex === breadcrumbs.length - 1;
const showEllipsisBefore =
needsBreadcrumbTruncation && displayIdx === 1;
return (
<React.Fragment key={originalIndex}>
{showEllipsisBefore && (
<>
<ChevronRight
size={12}
className="text-muted-foreground flex-shrink-0"
/>
<span
className="text-muted-foreground px-1 shrink-0 flex items-center cursor-default"
title={`${t("sftp.showHiddenPaths")}: ${hiddenBreadcrumbs
.map((h) => h.part)
.join(" > ")}`}
>
<MoreHorizontal size={14} />
</span>
</>
)}
<ChevronRight
size={12}
className="text-muted-foreground flex-shrink-0"
/>
<button
className={cn(
"text-muted-foreground hover:text-foreground truncate px-1 max-w-[100px]",
isLast && "text-foreground font-medium",
)}
onClick={() => onBreadcrumbSelect(originalIndex)}
title={part}
>
{part}
</button>
</React.Fragment>
);
})}
</div>
)}
</div>
<div className="flex items-center gap-1 ml-auto">
<Tooltip open={openTooltip === 'upload'} onOpenChange={handleTooltipOpenChange('upload')}>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-7 w-7"
onClick={onTriggerUpload}
disabled={uploading}
>
<Upload size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.upload")}</TooltipContent>
</Tooltip>
<Tooltip open={openTooltip === 'uploadFolder'} onOpenChange={handleTooltipOpenChange('uploadFolder')}>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-7 w-7"
onClick={onTriggerFolderUpload}
disabled={uploading}
>
<FolderUp size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.uploadFolder")}</TooltipContent>
</Tooltip>
<Tooltip open={openTooltip === 'newFolder'} onOpenChange={handleTooltipOpenChange('newFolder')}>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-7 w-7"
onClick={onCreateFolder}
>
<FolderPlus size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.newFolder")}</TooltipContent>
</Tooltip>
<Tooltip open={openTooltip === 'newFile'} onOpenChange={handleTooltipOpenChange('newFile')}>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-7 w-7"
onClick={onCreateFile}
>
<FilePlus size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.newFile")}</TooltipContent>
</Tooltip>
<input
type="file"
className="hidden"
ref={inputRef}
onChange={onFileSelect}
multiple
/>
<input
type="file"
className="hidden"
ref={folderInputRef}
onChange={onFolderSelect}
webkitdirectory=""
multiple
/>
</div>
</div>
</TooltipProvider>
</>
);
};

View File

@@ -1,228 +0,0 @@
import React from "react";
import { Download, Loader2, Upload, X, XCircle } from "lucide-react";
import { cn } from "../../lib/utils";
import { Button } from "../ui/button";
interface TransferTask {
id: string;
fileName: string;
totalBytes: number;
transferredBytes: number;
progress: number;
speed: number;
status: "pending" | "uploading" | "downloading" | "completed" | "failed" | "cancelled";
error?: string;
direction: "upload" | "download";
targetPath?: string;
}
interface SftpModalUploadTasksProps {
tasks: TransferTask[];
t: (key: string, params?: Record<string, unknown>) => string;
onCancel?: () => void;
onCancelTask?: (taskId: string) => void;
onDismiss?: (taskId: string) => void;
}
export const SftpModalUploadTasks: React.FC<SftpModalUploadTasksProps> = ({ tasks, t, onCancel, onCancelTask, onDismiss }) => {
if (tasks.length === 0) return null;
// Helper function to get localized display name for compressed uploads
const getDisplayName = (task: TransferTask) => {
// Check for explicit phase marker format: "folderName|phase"
// This is the format sent by uploadService.ts for compressed uploads
if (task.fileName.includes('|')) {
const pipeIndex = task.fileName.lastIndexOf('|');
const baseName = task.fileName.substring(0, pipeIndex);
const phase = task.fileName.substring(pipeIndex + 1);
if (phase === 'compressing' || phase === 'extracting' || phase === 'uploading' || phase === 'compressed') {
const phaseLabel = t(`sftp.upload.phase.${phase}`);
return `${baseName} (${phaseLabel})`;
}
}
// Check for exact matches of phase status strings (legacy support)
if (task.fileName === t('sftp.upload.compressing') || task.fileName === 'Compressing...' || task.fileName === 'Compressing') {
return t('sftp.upload.compressing');
}
if (task.fileName === t('sftp.upload.extracting') || task.fileName === 'Extracting...' || task.fileName === 'Extracting') {
return t('sftp.upload.extracting');
}
if (task.fileName === t('sftp.upload.scanning') || task.fileName === 'Scanning files...' || task.fileName === 'Scanning files') {
return t('sftp.upload.scanning');
}
// Check if this is a compressed upload task (legacy format)
if (task.fileName.includes('(compressed)')) {
const baseName = task.fileName.replace(' (compressed)', '');
return `${baseName} (${t('sftp.upload.compressed')})`;
}
return task.fileName;
};
return (
<div className="border-t border-border/60 bg-secondary/50 flex-shrink-0">
<div className="max-h-40 overflow-y-auto overflow-x-hidden">
{[...tasks].reverse().map((task) => {
const formatSpeed = (bytesPerSec: number) => {
if (bytesPerSec <= 0) return "";
if (bytesPerSec >= 1024 * 1024)
return `${(bytesPerSec / (1024 * 1024)).toFixed(1)} MB/s`;
if (bytesPerSec >= 1024)
return `${(bytesPerSec / 1024).toFixed(1)} KB/s`;
return `${Math.round(bytesPerSec)} B/s`;
};
const formatBytes = (bytes: number) => {
if (bytes === 0) return "0 B";
if (bytes >= 1024 * 1024)
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${bytes} B`;
};
const remainingBytes = task.totalBytes - task.transferredBytes;
const effectiveSpeed = task.speed > 0 ? task.speed : 0;
const remainingTime =
effectiveSpeed > 0 ? Math.ceil(remainingBytes / effectiveSpeed) : 0;
const remainingStr =
remainingTime > 60
? `~${Math.ceil(remainingTime / 60)}m left`
: remainingTime > 0
? `~${remainingTime}s left`
: "";
return (
<div
key={task.id}
className="px-4 py-2.5 flex items-center gap-3 border-b border-border/30 last:border-b-0"
>
<div className="shrink-0">
{(task.status === "uploading" || task.status === "downloading") && (
<Loader2 size={14} className="animate-spin text-primary" />
)}
{task.status === "pending" && (
task.direction === "download"
? <Download size={14} className="text-muted-foreground animate-pulse" />
: <Upload size={14} className="text-muted-foreground animate-pulse" />
)}
{task.status === "completed" && (
task.direction === "download"
? <Download size={14} className="text-green-500" />
: <Upload size={14} className="text-green-500" />
)}
{task.status === "failed" && (
<XCircle size={14} className="text-destructive" />
)}
{task.status === "cancelled" && (
<XCircle size={14} className="text-muted-foreground" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-medium truncate">
{getDisplayName(task)}
</span>
{(task.status === "uploading" || task.status === "downloading") && effectiveSpeed > 0 && (
<span className="text-[10px] text-primary font-mono shrink-0">
{formatSpeed(effectiveSpeed)}
</span>
)}
{(task.status === "uploading" || task.status === "downloading") && remainingStr && (
<span className="text-[10px] text-muted-foreground shrink-0">
{remainingStr}
</span>
)}
</div>
{(task.status === "uploading" || task.status === "downloading" || task.status === "pending") && (
<div className="mt-1.5 flex items-center gap-2">
<div className="flex-1 h-1.5 bg-secondary rounded-full overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all duration-150",
task.status === "pending"
? "bg-muted-foreground/50 animate-pulse w-full"
: "bg-primary",
)}
style={{
width:
task.status === "uploading" || task.status === "downloading"
? `${task.progress}%`
: undefined,
}}
/>
</div>
<span className="text-[10px] text-muted-foreground font-mono shrink-0 w-8 text-right">
{task.status === "uploading" || task.status === "downloading" ? `${Math.round(task.progress)}%` : "..."}
</span>
</div>
)}
{(task.status === "uploading" || task.status === "downloading") && task.totalBytes > 0 && (
<div className="text-[10px] text-muted-foreground mt-0.5 font-mono">
{formatBytes(task.transferredBytes)} / {formatBytes(task.totalBytes)}
</div>
)}
{task.status === "completed" && (
<div className="text-[10px] text-green-600 mt-0.5">
{t(task.direction === "download" ? "sftp.download.completed" : "sftp.upload.completed")} - {formatBytes(task.totalBytes)}
{task.targetPath && (
<span className="text-muted-foreground ml-1"> {task.targetPath}</span>
)}
</div>
)}
{task.status === "cancelled" && (
<div className="text-[10px] text-muted-foreground mt-0.5">
{t(task.direction === "download" ? "sftp.download.cancelled" : "sftp.upload.cancelled")}
</div>
)}
{task.status === "failed" && task.error && (
<div className="text-[10px] text-destructive truncate mt-0.5">
{task.error}
</div>
)}
</div>
<div className="shrink-0 flex items-center gap-1">
{task.status === "pending" && (
<span className="text-[10px] text-muted-foreground">
{t("sftp.task.waiting")}
</span>
)}
{(task.status === "uploading" || task.status === "downloading" || task.status === "pending") && (onCancelTask || onCancel) && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive hover:text-destructive"
onClick={() => {
// For download tasks or when onCancelTask is available, use task-specific cancel
if (onCancelTask) {
onCancelTask(task.id);
} else if (onCancel) {
onCancel();
}
}}
title={t("sftp.action.cancel")}
>
<X size={12} />
</Button>
)}
{(task.status === "completed" || task.status === "failed" || task.status === "cancelled") && onDismiss && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-foreground"
onClick={() => onDismiss(task.id)}
title={t("sftp.action.dismiss")}
>
<X size={12} />
</Button>
)}
</div>
</div>
);
})}
</div>
</div>
);
};

View File

@@ -1,149 +0,0 @@
import {
Database,
ExternalLink,
File,
FileArchive,
FileAudio,
FileCode,
FileImage,
FileSpreadsheet,
FileText,
FileType,
FileVideo,
Folder,
Globe,
Lock,
Settings,
Terminal,
} from "lucide-react";
import React from "react";
export const getFileIcon = (fileName: string, isDirectory: boolean, isSymlink?: boolean) => {
if (isDirectory)
return (
<Folder
size={18}
fill="currentColor"
fillOpacity={0.2}
className="text-blue-400"
/>
);
if (isSymlink) {
return <ExternalLink size={18} className="text-cyan-500" />;
}
const ext = fileName.split(".").pop()?.toLowerCase() || "";
if (["doc", "docx", "rtf", "odt"].includes(ext))
return <FileText size={18} className="text-blue-500" />;
if (["xls", "xlsx", "csv", "ods"].includes(ext))
return <FileSpreadsheet size={18} className="text-green-500" />;
if (["ppt", "pptx", "odp"].includes(ext))
return <FileType size={18} className="text-orange-500" />;
if (["pdf"].includes(ext))
return <FileText size={18} className="text-red-500" />;
if (["js", "jsx", "ts", "tsx", "mjs", "cjs"].includes(ext))
return <FileCode size={18} className="text-yellow-500" />;
if (["py", "pyc", "pyw"].includes(ext))
return <FileCode size={18} className="text-blue-400" />;
if (["sh", "bash", "zsh", "fish", "bat", "cmd", "ps1"].includes(ext))
return <Terminal size={18} className="text-green-400" />;
if (["c", "cpp", "h", "hpp", "cc", "cxx"].includes(ext))
return <FileCode size={18} className="text-blue-600" />;
if (["java", "class", "jar"].includes(ext))
return <FileCode size={18} className="text-orange-600" />;
if (["go"].includes(ext))
return <FileCode size={18} className="text-cyan-500" />;
if (["rs"].includes(ext))
return <FileCode size={18} className="text-orange-400" />;
if (["rb"].includes(ext))
return <FileCode size={18} className="text-red-400" />;
if (["php"].includes(ext))
return <FileCode size={18} className="text-purple-500" />;
if (["html", "htm", "xhtml"].includes(ext))
return <Globe size={18} className="text-orange-500" />;
if (["css", "scss", "sass", "less"].includes(ext))
return <FileCode size={18} className="text-blue-500" />;
if (["vue", "svelte"].includes(ext))
return <FileCode size={18} className="text-green-500" />;
if (["json", "json5"].includes(ext))
return <FileCode size={18} className="text-yellow-600" />;
if (["xml", "xsl", "xslt"].includes(ext))
return <FileCode size={18} className="text-orange-400" />;
if (["yml", "yaml"].includes(ext))
return <Settings size={18} className="text-pink-400" />;
if (["toml", "ini", "conf", "cfg", "config"].includes(ext))
return <Settings size={18} className="text-gray-400" />;
if (["env"].includes(ext))
return <Lock size={18} className="text-yellow-500" />;
if (["sql", "sqlite", "db"].includes(ext))
return <Database size={18} className="text-blue-400" />;
if (
[
"jpg",
"jpeg",
"png",
"gif",
"bmp",
"webp",
"svg",
"ico",
"tiff",
"tif",
"heic",
"heif",
"avif",
].includes(ext)
)
return <FileImage size={18} className="text-purple-400" />;
if (
[
"mp4",
"mkv",
"avi",
"mov",
"wmv",
"flv",
"webm",
"m4v",
"3gp",
"mpeg",
"mpg",
].includes(ext)
)
return <FileVideo size={18} className="text-pink-500" />;
if (
["mp3", "wav", "flac", "aac", "ogg", "m4a", "wma", "opus", "aiff"].includes(
ext,
)
)
return <FileAudio size={18} className="text-green-400" />;
if (
[
"zip",
"rar",
"7z",
"tar",
"gz",
"bz2",
"xz",
"tgz",
"tbz2",
"lz",
"lzma",
"cab",
"iso",
"dmg",
].includes(ext)
)
return <FileArchive size={18} className="text-yellow-600" />;
return <File size={18} className="text-muted-foreground" />;
};

View File

@@ -1,140 +0,0 @@
import { useCallback, useState } from "react";
import type { RemoteFile } from "../../../types";
import { toast } from "../../ui/toast";
interface UseSftpModalCreateDeleteParams {
currentPath: string;
isLocalSession: boolean;
joinPath: (base: string, name: string) => string;
ensureSftp: () => Promise<string>;
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
deleteLocalFile: (path: string) => Promise<void>;
deleteSftp: (sftpId: string, path: string) => Promise<void>;
mkdirLocal: (path: string) => Promise<void>;
mkdirSftp: (sftpId: string, path: string) => Promise<void>;
writeLocalFile: (path: string, data: ArrayBuffer) => Promise<void>;
writeSftpBinary: (sftpId: string, path: string, data: ArrayBuffer) => Promise<void>;
writeSftp: (sftpId: string, path: string, data: string) => Promise<void>;
t: (key: string, params?: Record<string, unknown>) => string;
}
interface UseSftpModalCreateDeleteResult {
handleDelete: (file: RemoteFile) => Promise<void>;
handleCreateFolder: () => void;
handleCreateFile: () => void;
// Create dialog state
showCreateDialog: boolean;
setShowCreateDialog: (open: boolean) => void;
createType: "file" | "folder";
createName: string;
setCreateName: (value: string) => void;
isCreating: boolean;
handleCreateSubmit: () => Promise<void>;
}
export const useSftpModalCreateDelete = ({
currentPath,
isLocalSession,
joinPath,
ensureSftp,
loadFiles,
deleteLocalFile,
deleteSftp,
mkdirLocal,
mkdirSftp,
writeLocalFile,
writeSftpBinary,
writeSftp,
t,
}: UseSftpModalCreateDeleteParams): UseSftpModalCreateDeleteResult => {
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [createType, setCreateType] = useState<"file" | "folder">("folder");
const [createName, setCreateName] = useState("");
const [isCreating, setIsCreating] = useState(false);
const handleDelete = useCallback(
async (file: RemoteFile) => {
if (file.name === "..") return;
if (!confirm(t("sftp.deleteConfirm.single", { name: file.name }))) return;
try {
const fullPath = joinPath(currentPath, file.name);
if (isLocalSession) {
await deleteLocalFile(fullPath);
} else {
await deleteSftp(await ensureSftp(), fullPath);
}
await loadFiles(currentPath, { force: true });
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.deleteFailed"),
"SFTP",
);
}
},
[currentPath, deleteLocalFile, deleteSftp, ensureSftp, isLocalSession, joinPath, loadFiles, t],
);
const handleCreateFolder = useCallback(() => {
setCreateType("folder");
setCreateName("");
setShowCreateDialog(true);
}, []);
const handleCreateFile = useCallback(() => {
setCreateType("file");
setCreateName("");
setShowCreateDialog(true);
}, []);
const handleCreateSubmit = useCallback(async () => {
const name = createName.trim();
if (!name || isCreating) return;
setIsCreating(true);
try {
const fullPath = joinPath(currentPath, name);
if (createType === "folder") {
if (isLocalSession) {
await mkdirLocal(fullPath);
} else {
await mkdirSftp(await ensureSftp(), fullPath);
}
} else {
if (isLocalSession) {
await writeLocalFile(fullPath, new ArrayBuffer(0));
} else {
try {
await writeSftpBinary(await ensureSftp(), fullPath, new ArrayBuffer(0));
} catch {
await writeSftp(await ensureSftp(), fullPath, "");
}
}
}
setShowCreateDialog(false);
setCreateName("");
await loadFiles(currentPath, { force: true });
} catch (e) {
toast.error(
e instanceof Error
? e.message
: t(createType === "folder" ? "sftp.error.createFolderFailed" : "sftp.error.createFileFailed"),
"SFTP",
);
} finally {
setIsCreating(false);
}
}, [createName, createType, currentPath, ensureSftp, isCreating, isLocalSession, joinPath, loadFiles, mkdirLocal, mkdirSftp, t, writeLocalFile, writeSftp, writeSftpBinary]);
return {
handleDelete,
handleCreateFolder,
handleCreateFile,
showCreateDialog,
setShowCreateDialog,
createType,
createName,
setCreateName,
isCreating,
handleCreateSubmit,
};
};

View File

@@ -1,277 +0,0 @@
import type { RemoteFile } from "../../../types";
import { useSftpModalCreateDelete } from "./useSftpModalCreateDelete";
import { useSftpModalRename } from "./useSftpModalRename";
import { useSftpModalPermissions } from "./useSftpModalPermissions";
import { useSftpModalTextEditor } from "./useSftpModalTextEditor";
import { useSftpModalFileOpener } from "./useSftpModalFileOpener";
import type { FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
interface UseSftpModalFileActionsParams {
currentPath: string;
isLocalSession: boolean;
joinPath: (base: string, name: string) => string;
ensureSftp: () => Promise<string>;
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
readLocalFile: (path: string) => Promise<ArrayBuffer>;
readSftp: (sftpId: string, path: string) => Promise<string>;
writeLocalFile: (path: string, data: ArrayBuffer) => Promise<void>;
writeSftp: (sftpId: string, path: string, data: string) => Promise<void>;
writeSftpBinary: (sftpId: string, path: string, data: ArrayBuffer) => Promise<void>;
deleteLocalFile: (path: string) => Promise<void>;
deleteSftp: (sftpId: string, path: string) => Promise<void>;
mkdirLocal: (path: string) => Promise<void>;
mkdirSftp: (sftpId: string, path: string) => Promise<void>;
renameSftp: (sftpId: string, oldPath: string, newPath: string) => Promise<void>;
chmodSftp: (sftpId: string, path: string, permissions: string) => Promise<void>;
statSftp: (sftpId: string, path: string) => Promise<{ permissions?: string }>;
t: (key: string, params?: Record<string, unknown>) => string;
sftpAutoSync: boolean;
getOpenerForFile: (name: string) => { openerType: FileOpenerType; systemApp?: SystemAppInfo } | null;
setOpenerForExtension: (ext: string, openerType: FileOpenerType, systemApp?: SystemAppInfo) => void;
downloadSftpToTempAndOpen: (sftpId: string, path: string, fileName: string, appPath: string, opts: { enableWatch: boolean }) => Promise<void>;
selectApplication: () => Promise<{ path: string; name: string } | null>;
}
interface UseSftpModalFileActionsResult {
handleDelete: (file: RemoteFile) => Promise<void>;
handleCreateFolder: () => void;
handleCreateFile: () => void;
showCreateDialog: boolean;
setShowCreateDialog: (open: boolean) => void;
createType: "file" | "folder";
createName: string;
setCreateName: (value: string) => void;
isCreating: boolean;
handleCreateSubmit: () => Promise<void>;
showRenameDialog: boolean;
setShowRenameDialog: (open: boolean) => void;
renameTarget: RemoteFile | null;
renameName: string;
setRenameName: (value: string) => void;
isRenaming: boolean;
openRenameDialog: (file: RemoteFile) => void;
handleRename: () => Promise<void>;
showPermissionsDialog: boolean;
setShowPermissionsDialog: (open: boolean) => void;
permissionsTarget: RemoteFile | null;
permissions: {
owner: { read: boolean; write: boolean; execute: boolean };
group: { read: boolean; write: boolean; execute: boolean };
others: { read: boolean; write: boolean; execute: boolean };
};
isChangingPermissions: boolean;
openPermissionsDialog: (file: RemoteFile) => Promise<void>;
togglePermission: (role: "owner" | "group" | "others", perm: "read" | "write" | "execute") => void;
getOctalPermissions: () => string;
getSymbolicPermissions: () => string;
handleSavePermissions: () => Promise<void>;
showFileOpenerDialog: boolean;
setShowFileOpenerDialog: (open: boolean) => void;
fileOpenerTarget: RemoteFile | null;
setFileOpenerTarget: (target: RemoteFile | null) => void;
openFileOpenerDialog: (file: RemoteFile) => void;
handleFileOpenerSelect: (
openerType: FileOpenerType,
setAsDefault: boolean,
systemApp?: SystemAppInfo,
) => Promise<void>;
handleSelectSystemApp: () => Promise<SystemAppInfo | null>;
showTextEditor: boolean;
setShowTextEditor: (open: boolean) => void;
textEditorTarget: RemoteFile | null;
setTextEditorTarget: (target: RemoteFile | null) => void;
textEditorContent: string;
setTextEditorContent: (value: string) => void;
loadingTextContent: boolean;
handleEditFile: (file: RemoteFile) => Promise<void>;
handleSaveTextFile: (content: string) => Promise<void>;
handleOpenFile: (file: RemoteFile) => Promise<void>;
}
export const useSftpModalFileActions = ({
currentPath,
isLocalSession,
joinPath,
ensureSftp,
loadFiles,
readLocalFile,
readSftp,
writeLocalFile,
writeSftp,
writeSftpBinary,
deleteLocalFile,
deleteSftp,
mkdirLocal,
mkdirSftp,
renameSftp,
chmodSftp,
statSftp,
t,
sftpAutoSync,
getOpenerForFile,
setOpenerForExtension,
downloadSftpToTempAndOpen,
selectApplication,
}: UseSftpModalFileActionsParams): UseSftpModalFileActionsResult => {
const {
handleDelete,
handleCreateFolder,
handleCreateFile,
showCreateDialog,
setShowCreateDialog,
createType,
createName,
setCreateName,
isCreating,
handleCreateSubmit,
} =
useSftpModalCreateDelete({
currentPath,
isLocalSession,
joinPath,
ensureSftp,
loadFiles,
deleteLocalFile,
deleteSftp,
mkdirLocal,
mkdirSftp,
writeLocalFile,
writeSftpBinary,
writeSftp,
t,
});
const {
showRenameDialog,
setShowRenameDialog,
renameTarget,
renameName,
setRenameName,
isRenaming,
openRenameDialog,
handleRename,
} = useSftpModalRename({
currentPath,
isLocalSession,
joinPath,
ensureSftp,
loadFiles,
renameSftp,
t,
});
const {
showPermissionsDialog,
setShowPermissionsDialog,
permissionsTarget,
permissions,
isChangingPermissions,
openPermissionsDialog,
togglePermission,
getOctalPermissions,
getSymbolicPermissions,
handleSavePermissions,
} = useSftpModalPermissions({
currentPath,
isLocalSession,
joinPath,
ensureSftp,
loadFiles,
chmodSftp,
statSftp,
t,
});
const {
showTextEditor,
setShowTextEditor,
textEditorTarget,
setTextEditorTarget,
textEditorContent,
setTextEditorContent,
loadingTextContent,
handleEditFile,
handleSaveTextFile,
} = useSftpModalTextEditor({
currentPath,
isLocalSession,
joinPath,
ensureSftp,
readLocalFile,
readSftp,
writeLocalFile,
writeSftp,
t,
});
const {
showFileOpenerDialog,
setShowFileOpenerDialog,
fileOpenerTarget,
setFileOpenerTarget,
openFileOpenerDialog,
handleOpenFile,
handleFileOpenerSelect,
handleSelectSystemApp,
} = useSftpModalFileOpener({
currentPath,
isLocalSession,
joinPath,
ensureSftp,
sftpAutoSync,
getOpenerForFile,
setOpenerForExtension,
downloadSftpToTempAndOpen,
selectApplication,
t,
handleEditFile,
});
return {
handleDelete,
handleCreateFolder,
handleCreateFile,
showCreateDialog,
setShowCreateDialog,
createType,
createName,
setCreateName,
isCreating,
handleCreateSubmit,
showRenameDialog,
setShowRenameDialog,
renameTarget,
renameName,
setRenameName,
isRenaming,
openRenameDialog,
handleRename,
showPermissionsDialog,
setShowPermissionsDialog,
permissionsTarget,
permissions,
isChangingPermissions,
openPermissionsDialog,
togglePermission,
getOctalPermissions,
getSymbolicPermissions,
handleSavePermissions,
showFileOpenerDialog,
setShowFileOpenerDialog,
fileOpenerTarget,
setFileOpenerTarget,
openFileOpenerDialog,
handleFileOpenerSelect,
handleSelectSystemApp,
showTextEditor,
setShowTextEditor,
textEditorTarget,
setTextEditorTarget,
textEditorContent,
setTextEditorContent,
loadingTextContent,
handleEditFile,
handleSaveTextFile,
handleOpenFile,
};
};

View File

@@ -1,154 +0,0 @@
import { useCallback, useState } from "react";
import type { RemoteFile } from "../../../types";
import { toast } from "../../ui/toast";
import { getFileExtension, FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
interface UseSftpModalFileOpenerParams {
currentPath: string;
isLocalSession: boolean;
joinPath: (base: string, name: string) => string;
ensureSftp: () => Promise<string>;
sftpAutoSync: boolean;
getOpenerForFile: (name: string) => { openerType: FileOpenerType; systemApp?: SystemAppInfo } | null;
setOpenerForExtension: (ext: string, openerType: FileOpenerType, systemApp?: SystemAppInfo) => void;
downloadSftpToTempAndOpen: (sftpId: string, path: string, fileName: string, appPath: string, opts: { enableWatch: boolean }) => Promise<void>;
selectApplication: () => Promise<{ path: string; name: string } | null>;
t: (key: string, params?: Record<string, unknown>) => string;
handleEditFile: (file: RemoteFile) => Promise<void>;
}
interface UseSftpModalFileOpenerResult {
showFileOpenerDialog: boolean;
setShowFileOpenerDialog: (open: boolean) => void;
fileOpenerTarget: RemoteFile | null;
setFileOpenerTarget: (target: RemoteFile | null) => void;
openFileOpenerDialog: (file: RemoteFile) => void;
handleOpenFile: (file: RemoteFile) => Promise<void>;
handleFileOpenerSelect: (
openerType: FileOpenerType,
setAsDefault: boolean,
systemApp?: SystemAppInfo,
) => Promise<void>;
handleSelectSystemApp: () => Promise<SystemAppInfo | null>;
}
export const useSftpModalFileOpener = ({
currentPath,
isLocalSession,
joinPath,
ensureSftp,
sftpAutoSync,
getOpenerForFile,
setOpenerForExtension,
downloadSftpToTempAndOpen,
selectApplication,
t,
handleEditFile,
}: UseSftpModalFileOpenerParams): UseSftpModalFileOpenerResult => {
const [showFileOpenerDialog, setShowFileOpenerDialog] = useState(false);
const [fileOpenerTarget, setFileOpenerTarget] = useState<RemoteFile | null>(null);
const openFileOpenerDialog = useCallback((file: RemoteFile) => {
setFileOpenerTarget(file);
setShowFileOpenerDialog(true);
}, []);
const handleOpenFile = useCallback(async (file: RemoteFile) => {
const savedOpener = getOpenerForFile(file.name);
if (savedOpener) {
if (savedOpener.openerType === "builtin-editor") {
await handleEditFile(file);
} else if (savedOpener.openerType === "system-app" && savedOpener.systemApp) {
try {
const fullPath = joinPath(currentPath, file.name);
if (isLocalSession) {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
if (bridge?.openWithApplication) {
await bridge.openWithApplication(fullPath, savedOpener.systemApp.path);
}
} else {
const sftpId = await ensureSftp();
await downloadSftpToTempAndOpen(
sftpId,
fullPath,
file.name,
savedOpener.systemApp.path,
{ enableWatch: sftpAutoSync },
);
}
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.openFailed"),
"SFTP",
);
}
}
} else {
openFileOpenerDialog(file);
}
}, [currentPath, downloadSftpToTempAndOpen, ensureSftp, getOpenerForFile, handleEditFile, isLocalSession, joinPath, openFileOpenerDialog, sftpAutoSync, t]);
const handleFileOpenerSelect = useCallback(
async (openerType: FileOpenerType, setAsDefault: boolean, systemApp?: SystemAppInfo) => {
if (!fileOpenerTarget) return;
if (setAsDefault) {
const ext = getFileExtension(fileOpenerTarget.name);
setOpenerForExtension(ext, openerType, systemApp);
}
setShowFileOpenerDialog(false);
if (openerType === "builtin-editor") {
await handleEditFile(fileOpenerTarget);
} else if (openerType === "system-app" && systemApp) {
try {
const fullPath = joinPath(currentPath, fileOpenerTarget.name);
if (isLocalSession) {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
if (bridge?.openWithApplication) {
await bridge.openWithApplication(fullPath, systemApp.path);
}
} else {
const sftpId = await ensureSftp();
await downloadSftpToTempAndOpen(
sftpId,
fullPath,
fileOpenerTarget.name,
systemApp.path,
{ enableWatch: sftpAutoSync },
);
}
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.openFailed"),
"SFTP",
);
}
}
setFileOpenerTarget(null);
},
[currentPath, downloadSftpToTempAndOpen, ensureSftp, fileOpenerTarget, handleEditFile, isLocalSession, joinPath, sftpAutoSync, setOpenerForExtension, t],
);
const handleSelectSystemApp = useCallback(async (): Promise<SystemAppInfo | null> => {
const result = await selectApplication();
if (result) {
return { path: result.path, name: result.name };
}
return null;
}, [selectApplication]);
return {
showFileOpenerDialog,
setShowFileOpenerDialog,
fileOpenerTarget,
setFileOpenerTarget,
openFileOpenerDialog,
handleOpenFile,
handleFileOpenerSelect,
handleSelectSystemApp,
};
};

View File

@@ -1,156 +0,0 @@
/**
* useSftpModalKeyboardShortcuts
*
* Hook that handles keyboard shortcuts for SFTPModal operations.
* Supports select all, rename, delete, refresh, and new folder.
* Note: Copy/Cut/Paste are not supported in the modal as it's a single-pane view.
*/
import { useCallback, useEffect } from "react";
import { KeyBinding, matchesKeyBinding } from "../../../domain/models";
import type { RemoteFile } from "../../../types";
// SFTP Modal action names that we handle (subset of main SFTP actions)
const SFTP_MODAL_ACTIONS = new Set([
"sftpSelectAll",
"sftpRename",
"sftpDelete",
"sftpRefresh",
"sftpNewFolder",
]);
interface UseSftpModalKeyboardShortcutsParams {
keyBindings: KeyBinding[];
hotkeyScheme: "disabled" | "mac" | "pc";
open: boolean;
files: RemoteFile[];
visibleFiles: RemoteFile[];
selectedFiles: Set<string>;
setSelectedFiles: (files: Set<string>) => void;
onRefresh: () => void;
onRename?: (file: RemoteFile) => void;
onDelete?: (fileNames: string[]) => void;
onNewFolder?: () => void;
}
/**
* Check if a keyboard event matches any SFTP action
*/
const matchSftpAction = (
e: KeyboardEvent,
keyBindings: KeyBinding[],
isMac: boolean
): { action: string; binding: KeyBinding } | null => {
for (const binding of keyBindings) {
if (binding.category !== "sftp") continue;
const keyStr = isMac ? binding.mac : binding.pc;
if (matchesKeyBinding(e, keyStr, isMac)) {
return { action: binding.action, binding };
}
}
return null;
};
export const useSftpModalKeyboardShortcuts = ({
keyBindings,
hotkeyScheme,
open,
files,
visibleFiles,
selectedFiles,
setSelectedFiles,
onRefresh,
onRename,
onDelete,
onNewFolder,
}: UseSftpModalKeyboardShortcutsParams) => {
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
// Skip if shortcuts are disabled or modal is not open
if (hotkeyScheme === "disabled" || !open) return;
// Skip if focus is on an input element
const target = e.target as HTMLElement;
const isEditableTarget =
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable ||
!!target.closest?.(".monaco-editor, .monaco-diff-editor, .monaco-inputbox");
if (isEditableTarget) {
return;
}
const isMac = hotkeyScheme === "mac";
const matched = matchSftpAction(e, keyBindings, isMac);
if (!matched) return;
const { action } = matched;
if (!SFTP_MODAL_ACTIONS.has(action)) return;
// Prevent default behavior
e.preventDefault();
e.stopPropagation();
switch (action) {
case "sftpSelectAll": {
// Select all files
const allFileNames = new Set(
visibleFiles.filter((f) => f.name !== "..").map((f) => f.name)
);
setSelectedFiles(allFileNames);
break;
}
case "sftpRename": {
// Trigger rename for the first selected file
const selectedArray = Array.from(selectedFiles);
if (selectedArray.length !== 1) return;
const file = files.find((f) => f.name === selectedArray[0]);
if (file && onRename) {
onRename(file);
}
break;
}
case "sftpDelete": {
// Delete selected files
const selectedArray = Array.from(selectedFiles);
if (selectedArray.length === 0) return;
onDelete?.(selectedArray);
break;
}
case "sftpRefresh": {
// Refresh file list
onRefresh();
break;
}
case "sftpNewFolder": {
// Create new folder
onNewFolder?.();
break;
}
}
},
[
hotkeyScheme,
open,
files,
visibleFiles,
selectedFiles,
setSelectedFiles,
onRefresh,
onRename,
onDelete,
onNewFolder,
keyBindings,
]
);
useEffect(() => {
// Use capture phase to intercept before other handlers
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
}, [handleKeyDown]);
};

View File

@@ -1,135 +0,0 @@
import React, { useCallback, useMemo, useRef, useState } from "react";
import { breadcrumbPathAt, getBreadcrumbs, getRootPath, getWindowsDrive, isWindowsPath } from "../pathUtils";
interface UseSftpModalPathParams {
currentPath: string;
isLocalSession: boolean;
localHomePath: string | null;
onNavigate: (path: string) => void;
maxVisibleBreadcrumbParts?: number;
}
interface UseSftpModalPathResult {
isEditingPath: boolean;
editingPathValue: string;
setEditingPathValue: (value: string) => void;
pathInputRef: React.RefObject<HTMLInputElement>;
handlePathDoubleClick: () => void;
handlePathSubmit: () => void;
handlePathKeyDown: (e: React.KeyboardEvent) => void;
breadcrumbs: string[];
visibleBreadcrumbs: { part: string; originalIndex: number }[];
hiddenBreadcrumbs: { part: string; originalIndex: number }[];
needsBreadcrumbTruncation: boolean;
breadcrumbPathAtForIndex: (index: number) => string;
rootLabel: string;
rootPath: string;
}
export const useSftpModalPath = ({
currentPath,
isLocalSession,
localHomePath,
onNavigate,
maxVisibleBreadcrumbParts = 4,
}: UseSftpModalPathParams): UseSftpModalPathResult => {
const [isEditingPath, setIsEditingPath] = useState(false);
const [editingPathValue, setEditingPathValue] = useState("");
const pathInputRef = useRef<HTMLInputElement>(null);
const handlePathDoubleClick = () => {
setEditingPathValue(currentPath);
setIsEditingPath(true);
setTimeout(() => pathInputRef.current?.select(), 0);
};
const handlePathSubmit = () => {
const fallbackPath = localHomePath || getRootPath(currentPath, isLocalSession);
const newPath = editingPathValue.trim() || fallbackPath;
setIsEditingPath(false);
if (newPath !== currentPath) {
if (isLocalSession) {
onNavigate(newPath);
} else {
onNavigate(newPath.startsWith("/") ? newPath : `/${newPath}`);
}
}
};
const handlePathKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
handlePathSubmit();
} else if (e.key === "Escape") {
setIsEditingPath(false);
}
};
const breadcrumbs = useMemo(
() => getBreadcrumbs(currentPath, isLocalSession),
[currentPath, isLocalSession],
);
const { visibleBreadcrumbs, hiddenBreadcrumbs, needsBreadcrumbTruncation } =
useMemo(() => {
if (breadcrumbs.length <= maxVisibleBreadcrumbParts) {
return {
visibleBreadcrumbs: breadcrumbs.map((part, idx) => ({ part, originalIndex: idx })),
hiddenBreadcrumbs: [] as { part: string; originalIndex: number }[],
needsBreadcrumbTruncation: false,
};
}
const firstPart = [{ part: breadcrumbs[0], originalIndex: 0 }];
const lastPartsCount = maxVisibleBreadcrumbParts - 1;
const lastParts = breadcrumbs.slice(-lastPartsCount).map((part, idx) => ({
part,
originalIndex: breadcrumbs.length - lastPartsCount + idx,
}));
const hidden = breadcrumbs.slice(1, -lastPartsCount).map((part, idx) => ({
part,
originalIndex: idx + 1,
}));
return {
visibleBreadcrumbs: [...firstPart, ...lastParts],
hiddenBreadcrumbs: hidden,
needsBreadcrumbTruncation: true,
};
}, [breadcrumbs, maxVisibleBreadcrumbParts]);
const breadcrumbPathAtForIndex = useCallback(
(index: number) =>
breadcrumbPathAt(breadcrumbs, index, currentPath, isLocalSession),
[breadcrumbs, currentPath, isLocalSession],
);
const rootLabel = useMemo(
() =>
isLocalSession && isWindowsPath(currentPath)
? getWindowsDrive(currentPath) ?? "C:"
: "/",
[currentPath, isLocalSession],
);
const rootPath = useMemo(
() => getRootPath(currentPath, isLocalSession),
[currentPath, isLocalSession],
);
return {
isEditingPath,
editingPathValue,
setEditingPathValue,
pathInputRef,
handlePathDoubleClick,
handlePathSubmit,
handlePathKeyDown,
breadcrumbs,
visibleBreadcrumbs,
hiddenBreadcrumbs,
needsBreadcrumbTruncation,
breadcrumbPathAtForIndex,
rootLabel,
rootPath,
};
};

View File

@@ -1,189 +0,0 @@
import { useCallback, useState } from "react";
import type { RemoteFile } from "../../../types";
import { toast } from "../../ui/toast";
interface UseSftpModalPermissionsParams {
currentPath: string;
isLocalSession: boolean;
joinPath: (base: string, name: string) => string;
ensureSftp: () => Promise<string>;
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
chmodSftp: (sftpId: string, path: string, permissions: string) => Promise<void>;
statSftp: (sftpId: string, path: string) => Promise<{ permissions?: string }>;
t: (key: string, params?: Record<string, unknown>) => string;
}
interface PermissionsState {
owner: { read: boolean; write: boolean; execute: boolean };
group: { read: boolean; write: boolean; execute: boolean };
others: { read: boolean; write: boolean; execute: boolean };
}
interface UseSftpModalPermissionsResult {
showPermissionsDialog: boolean;
setShowPermissionsDialog: (open: boolean) => void;
permissionsTarget: RemoteFile | null;
permissions: PermissionsState;
isChangingPermissions: boolean;
openPermissionsDialog: (file: RemoteFile) => Promise<void>;
togglePermission: (role: "owner" | "group" | "others", perm: "read" | "write" | "execute") => void;
getOctalPermissions: () => string;
getSymbolicPermissions: () => string;
handleSavePermissions: () => Promise<void>;
}
export const useSftpModalPermissions = ({
currentPath,
isLocalSession,
joinPath,
ensureSftp,
loadFiles,
chmodSftp,
statSftp,
t,
}: UseSftpModalPermissionsParams): UseSftpModalPermissionsResult => {
const [showPermissionsDialog, setShowPermissionsDialog] = useState(false);
const [permissionsTarget, setPermissionsTarget] = useState<RemoteFile | null>(null);
const [permissions, setPermissions] = useState<PermissionsState>({
owner: { read: false, write: false, execute: false },
group: { read: false, write: false, execute: false },
others: { read: false, write: false, execute: false },
});
const [isChangingPermissions, setIsChangingPermissions] = useState(false);
const parsePermissions = useCallback((perms: string | undefined) => {
const defaultPerms = {
owner: { read: false, write: false, execute: false },
group: { read: false, write: false, execute: false },
others: { read: false, write: false, execute: false },
};
if (!perms) return defaultPerms;
if (/^[0-7]{3,4}$/.test(perms)) {
const octal = perms.length === 4 ? perms.slice(1) : perms;
const ownerBits = parseInt(octal[0], 10);
const groupBits = parseInt(octal[1], 10);
const othersBits = parseInt(octal[2], 10);
return {
owner: {
read: (ownerBits & 4) !== 0,
write: (ownerBits & 2) !== 0,
execute: (ownerBits & 1) !== 0,
},
group: {
read: (groupBits & 4) !== 0,
write: (groupBits & 2) !== 0,
execute: (groupBits & 1) !== 0,
},
others: {
read: (othersBits & 4) !== 0,
write: (othersBits & 2) !== 0,
execute: (othersBits & 1) !== 0,
},
};
}
const pStr = perms.length === 10 ? perms.slice(1) : perms;
if (pStr.length >= 9) {
return {
owner: {
read: pStr[0] === "r",
write: pStr[1] === "w",
execute: pStr[2] === "x" || pStr[2] === "s",
},
group: {
read: pStr[3] === "r",
write: pStr[4] === "w",
execute: pStr[5] === "x" || pStr[5] === "s",
},
others: {
read: pStr[6] === "r",
write: pStr[7] === "w",
execute: pStr[8] === "x" || pStr[8] === "t",
},
};
}
return defaultPerms;
}, []);
const openPermissionsDialog = useCallback(async (file: RemoteFile) => {
if (isLocalSession) {
toast.error("Permissions not available for local files", "SFTP");
return;
}
setPermissionsTarget(file);
let permsStr = file.permissions;
try {
const fullPath = joinPath(currentPath, file.name);
const stat = await statSftp(await ensureSftp(), fullPath);
if (stat.permissions) {
permsStr = stat.permissions;
}
} catch (e) {
console.warn("Failed to fetch file permissions:", e);
}
setPermissions(parsePermissions(permsStr));
setShowPermissionsDialog(true);
}, [currentPath, ensureSftp, isLocalSession, joinPath, parsePermissions, statSftp]);
const togglePermission = useCallback(
(role: "owner" | "group" | "others", perm: "read" | "write" | "execute") => {
setPermissions((prev) => ({
...prev,
[role]: { ...prev[role], [perm]: !prev[role][perm] },
}));
},
[],
);
const getOctalPermissions = useCallback(() => {
const getNum = (p: { read: boolean; write: boolean; execute: boolean }) =>
(p.read ? 4 : 0) + (p.write ? 2 : 0) + (p.execute ? 1 : 0);
return `${getNum(permissions.owner)}${getNum(permissions.group)}${getNum(permissions.others)}`;
}, [permissions]);
const getSymbolicPermissions = useCallback(() => {
const getSym = (p: { read: boolean; write: boolean; execute: boolean }) =>
`${p.read ? "r" : "-"}${p.write ? "w" : "-"}${p.execute ? "x" : "-"}`;
return (
getSym(permissions.owner) +
getSym(permissions.group) +
getSym(permissions.others)
);
}, [permissions]);
const handleSavePermissions = useCallback(async () => {
if (!permissionsTarget || isChangingPermissions) return;
setIsChangingPermissions(true);
try {
const fullPath = joinPath(currentPath, permissionsTarget.name);
await chmodSftp(await ensureSftp(), fullPath, getOctalPermissions());
setShowPermissionsDialog(false);
setPermissionsTarget(null);
await loadFiles(currentPath, { force: true });
toast.success(t("sftp.permissions.success"), "SFTP");
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.permissions.failed"),
"SFTP",
);
} finally {
setIsChangingPermissions(false);
}
}, [chmodSftp, currentPath, ensureSftp, getOctalPermissions, isChangingPermissions, joinPath, loadFiles, permissionsTarget, t]);
return {
showPermissionsDialog,
setShowPermissionsDialog,
permissionsTarget,
permissions,
isChangingPermissions,
openPermissionsDialog,
togglePermission,
getOctalPermissions,
getSymbolicPermissions,
handleSavePermissions,
};
};

View File

@@ -1,85 +0,0 @@
import { useCallback, useState } from "react";
import type { RemoteFile } from "../../../types";
import { toast } from "../../ui/toast";
interface UseSftpModalRenameParams {
currentPath: string;
isLocalSession: boolean;
joinPath: (base: string, name: string) => string;
ensureSftp: () => Promise<string>;
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
renameSftp: (sftpId: string, oldPath: string, newPath: string) => Promise<void>;
t: (key: string, params?: Record<string, unknown>) => string;
}
interface UseSftpModalRenameResult {
showRenameDialog: boolean;
setShowRenameDialog: (open: boolean) => void;
renameTarget: RemoteFile | null;
renameName: string;
setRenameName: (value: string) => void;
isRenaming: boolean;
openRenameDialog: (file: RemoteFile) => void;
handleRename: () => Promise<void>;
}
export const useSftpModalRename = ({
currentPath,
isLocalSession,
joinPath,
ensureSftp,
loadFiles,
renameSftp,
t,
}: UseSftpModalRenameParams): UseSftpModalRenameResult => {
const [showRenameDialog, setShowRenameDialog] = useState(false);
const [renameTarget, setRenameTarget] = useState<RemoteFile | null>(null);
const [renameName, setRenameName] = useState("");
const [isRenaming, setIsRenaming] = useState(false);
const openRenameDialog = useCallback((file: RemoteFile) => {
setRenameTarget(file);
setRenameName(file.name);
setShowRenameDialog(true);
}, []);
const handleRename = useCallback(async () => {
if (!renameTarget || !renameName.trim() || isRenaming) return;
if (renameName.trim() === renameTarget.name) {
setShowRenameDialog(false);
return;
}
setIsRenaming(true);
try {
const oldPath = joinPath(currentPath, renameTarget.name);
const newPath = joinPath(currentPath, renameName.trim());
if (isLocalSession) {
toast.error("Local rename not implemented", "SFTP");
} else {
await renameSftp(await ensureSftp(), oldPath, newPath);
}
setShowRenameDialog(false);
setRenameTarget(null);
setRenameName("");
await loadFiles(currentPath, { force: true });
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.renameFailed"),
"SFTP",
);
} finally {
setIsRenaming(false);
}
}, [currentPath, ensureSftp, isLocalSession, joinPath, loadFiles, renameName, renameSftp, renameTarget, t, isRenaming]);
return {
showRenameDialog,
setShowRenameDialog,
renameTarget,
renameName,
setRenameName,
isRenaming,
openRenameDialog,
handleRename,
};
};

View File

@@ -1,99 +0,0 @@
import React, { useCallback, useRef } from "react";
import type { RemoteFile } from "../../../types";
interface UseSftpModalSelectionParams {
files: RemoteFile[];
setSelectedFiles: (value: Set<string> | ((prev: Set<string>) => Set<string>)) => void;
currentPath: string;
joinPath: (base: string, name: string) => string;
onNavigate: (path: string) => void;
onOpenFile: (file: RemoteFile) => void;
onNavigateUp: () => void;
}
interface UseSftpModalSelectionResult {
handleFileClick: (file: RemoteFile, index: number, e: React.MouseEvent) => void;
handleFileDoubleClick: (file: RemoteFile) => void;
}
export const useSftpModalSelection = ({
files,
setSelectedFiles,
currentPath,
joinPath,
onNavigate,
onOpenFile,
onNavigateUp,
}: UseSftpModalSelectionParams): UseSftpModalSelectionResult => {
const lastSelectedIndexRef = useRef<number | null>(null);
const handleFileClick = useCallback(
(file: RemoteFile, index: number, e: React.MouseEvent) => {
if (file.name === "..") return;
if (file.type === "directory") {
if (e.shiftKey && lastSelectedIndexRef.current !== null) {
const start = Math.min(lastSelectedIndexRef.current, index);
const end = Math.max(lastSelectedIndexRef.current, index);
const newSelection = new Set<string>();
for (let i = start; i <= end; i++) {
if (files[i] && files[i].type !== "directory") {
newSelection.add(files[i].name);
}
}
setSelectedFiles(newSelection);
} else if (e.ctrlKey || e.metaKey) {
setSelectedFiles((prev) => {
const next = new Set(prev);
return next;
});
}
return;
}
if (e.shiftKey && lastSelectedIndexRef.current !== null) {
const start = Math.min(lastSelectedIndexRef.current, index);
const end = Math.max(lastSelectedIndexRef.current, index);
const newSelection = new Set<string>();
for (let i = start; i <= end; i++) {
if (files[i] && files[i].type !== "directory") {
newSelection.add(files[i].name);
}
}
setSelectedFiles(newSelection);
} else if (e.ctrlKey || e.metaKey) {
setSelectedFiles((prev) => {
const next = new Set(prev);
if (next.has(file.name)) {
next.delete(file.name);
} else {
next.add(file.name);
}
return next;
});
lastSelectedIndexRef.current = index;
} else {
setSelectedFiles(new Set([file.name]));
lastSelectedIndexRef.current = index;
}
},
[files, setSelectedFiles],
);
const handleFileDoubleClick = useCallback(
(file: RemoteFile) => {
if (file.name === "..") {
onNavigateUp();
return;
}
if (file.type === "directory" || (file.type === "symlink" && file.linkTarget === "directory")) {
onNavigate(joinPath(currentPath, file.name));
} else {
onOpenFile(file);
}
},
[currentPath, joinPath, onNavigate, onNavigateUp, onOpenFile],
);
return { handleFileClick, handleFileDoubleClick };
};

View File

@@ -1,462 +0,0 @@
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import type { Host, RemoteFile } from "../../../types";
import { logger } from "../../../lib/logger";
import { isSessionError } from "../../../application/state/sftp/errors";
import { toast } from "../../ui/toast";
interface UseSftpModalSessionParams {
open: boolean;
host: Host;
credentials: {
username?: string;
hostname: string;
port?: number;
password?: string;
privateKey?: string;
certificate?: string;
passphrase?: string;
publicKey?: string;
keyId?: string;
keySource?: "generated" | "imported";
proxy?: NetcattyProxyConfig;
jumpHosts?: NetcattyJumpHost[];
sftpSudo?: boolean;
legacyAlgorithms?: boolean;
};
initialPath?: string;
isLocalSession: boolean;
t: (key: string, params?: Record<string, unknown>) => string;
openSftp: (params: {
sessionId: string;
hostname: string;
username: string;
port: number;
password?: string;
privateKey?: string;
certificate?: string;
passphrase?: string;
publicKey?: string;
keyId?: string;
keySource?: "generated" | "imported";
proxy?: NetcattyProxyConfig;
jumpHosts?: NetcattyJumpHost[];
sudo?: boolean;
legacyAlgorithms?: boolean;
}) => Promise<string>;
closeSftp: (sftpId: string) => Promise<void>;
listSftp: (sftpId: string, path: string) => Promise<RemoteFile[]>;
listLocalDir: (path: string) => Promise<RemoteFile[]>;
getHomeDir: () => Promise<string | null>;
onClearSelection: () => void;
}
interface UseSftpModalSessionResult {
currentPath: string;
setCurrentPath: (path: string) => void;
currentPathRef: React.MutableRefObject<string>;
files: RemoteFile[];
setFiles: (files: RemoteFile[]) => void;
loading: boolean;
setLoading: (loading: boolean) => void;
reconnecting: boolean;
sessionVersion: number;
ensureSftp: () => Promise<string>;
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
closeSftpSession: () => Promise<void>;
localHomeRef: React.MutableRefObject<string | null>;
}
export const useSftpModalSession = ({
open,
host,
credentials,
initialPath,
isLocalSession,
t,
openSftp,
closeSftp,
listSftp,
listLocalDir,
getHomeDir,
onClearSelection,
}: UseSftpModalSessionParams): UseSftpModalSessionResult => {
const [currentPath, setCurrentPathState] = useState("/");
const [files, setFiles] = useState<RemoteFile[]>([]);
const [loading, setLoading] = useState(false);
const [reconnecting, setReconnecting] = useState(false);
const [sessionVersion, setSessionVersion] = useState(0);
const currentPathRef = useRef(currentPath);
const sftpIdRef = useRef<string | null>(null);
const closingPromiseRef = useRef<Promise<void> | null>(null);
const initializedRef = useRef(false);
const initializingRef = useRef(false);
const lastInitialPathRef = useRef<string | undefined>(undefined);
const localHomeRef = useRef<string | null>(null);
const reconnectingRef = useRef(false);
const reconnectAttemptsRef = useRef(0);
const MAX_RECONNECT_ATTEMPTS = 3;
const DIR_CACHE_TTL_MS = 10_000;
const dirCacheRef = useRef<
Map<string, { files: RemoteFile[]; timestamp: number }>
>(new Map());
const loadSeqRef = useRef(0);
const setCurrentPath = useCallback((path: string) => {
currentPathRef.current = path;
setCurrentPathState(path);
}, []);
const bumpSessionVersion = useCallback(() => {
setSessionVersion((prev) => prev + 1);
}, []);
const ensureSftp = useCallback(async () => {
if (isLocalSession) throw new Error("Local session does not use SFTP");
if (closingPromiseRef.current) {
await closingPromiseRef.current;
}
if (sftpIdRef.current) return sftpIdRef.current;
const sftpId = await openSftp({
sessionId: `sftp-modal-${host.id}`,
hostname: credentials.hostname,
username: credentials.username || "root",
port: credentials.port || 22,
password: credentials.password,
privateKey: credentials.privateKey,
certificate: credentials.certificate,
passphrase: credentials.passphrase,
publicKey: credentials.publicKey,
keyId: credentials.keyId,
keySource: credentials.keySource,
proxy: credentials.proxy,
jumpHosts: credentials.jumpHosts,
sudo: credentials.sftpSudo,
legacyAlgorithms: credentials.legacyAlgorithms,
});
if (sftpIdRef.current !== sftpId) {
sftpIdRef.current = sftpId;
bumpSessionVersion();
}
return sftpId;
}, [
isLocalSession,
host.id,
credentials.hostname,
credentials.username,
credentials.port,
credentials.password,
credentials.privateKey,
credentials.certificate,
credentials.passphrase,
credentials.publicKey,
credentials.keyId,
credentials.keySource,
credentials.proxy,
credentials.jumpHosts,
credentials.sftpSudo,
credentials.legacyAlgorithms,
bumpSessionVersion,
openSftp,
]);
const closeSftpSession = useCallback(async () => {
if (isLocalSession) {
if (sftpIdRef.current !== null) {
sftpIdRef.current = null;
bumpSessionVersion();
}
return;
}
// Clear ref before awaiting backend close to avoid handing out a stale ID
// if the modal is reopened while close is still in flight.
const sftpIdToClose = sftpIdRef.current;
if (sftpIdToClose !== null) {
sftpIdRef.current = null;
bumpSessionVersion();
}
if (!sftpIdToClose) {
return;
}
const currentClosePromise = (async () => {
try {
await closeSftp(sftpIdToClose);
} catch {
// Silently ignore close errors - connection may already be closed
} finally {
if (closingPromiseRef.current === currentClosePromise) {
closingPromiseRef.current = null;
}
}
})();
closingPromiseRef.current = currentClosePromise;
await currentClosePromise;
}, [bumpSessionVersion, closeSftp, isLocalSession]);
// Use shared session-error classifier from errors.ts
const handleSessionError = useCallback(async () => {
if (reconnectingRef.current) return;
reconnectingRef.current = true;
setReconnecting(true);
reconnectAttemptsRef.current = 0;
while (reconnectAttemptsRef.current < MAX_RECONNECT_ATTEMPTS) {
try {
reconnectAttemptsRef.current += 1;
await closeSftpSession();
const newSftpId = await ensureSftp();
reconnectingRef.current = false;
setReconnecting(false);
// Auto-reload current directory after successful reconnect
try {
const reloadPath = currentPathRef.current;
const reloadRequestId = loadSeqRef.current;
const list = await listSftp(newSftpId, reloadPath);
if (
reloadRequestId !== loadSeqRef.current ||
currentPathRef.current !== reloadPath
) {
return;
}
onClearSelection();
setFiles(list);
dirCacheRef.current.set(`${host.id}::${reloadPath}`, {
files: list,
timestamp: Date.now(),
});
} catch {
// Reload failed — UI still shows old data, user can manually refresh
}
return;
} catch (err) {
logger.warn(
`[SFTP] Reconnect attempt ${reconnectAttemptsRef.current} failed`,
err,
);
if (reconnectAttemptsRef.current >= MAX_RECONNECT_ATTEMPTS) {
reconnectingRef.current = false;
setReconnecting(false);
toast.error(t("sftp.error.reconnectFailed"), "SFTP");
return;
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
}, [closeSftpSession, ensureSftp, listSftp, host.id, onClearSelection, t]);
const loadFiles = useCallback(
async (path: string, options?: { force?: boolean }) => {
const requestId = ++loadSeqRef.current;
setLoading(true);
onClearSelection();
try {
if (isLocalSession) {
const list = await listLocalDir(path);
if (requestId === loadSeqRef.current) {
setFiles(list);
}
return;
}
const cacheKey = `${host.id}::${path}`;
const cached = dirCacheRef.current.get(cacheKey);
const isFresh =
cached && Date.now() - cached.timestamp < DIR_CACHE_TTL_MS;
if (cached && isFresh && !options?.force) {
setFiles(cached.files);
return;
}
const sftpId = await ensureSftp();
const list = await listSftp(sftpId, path);
if (requestId !== loadSeqRef.current) return;
setFiles(list);
dirCacheRef.current.set(cacheKey, {
files: list,
timestamp: Date.now(),
});
} catch (e) {
if (!isLocalSession && isSessionError(e) && files.length > 0) {
logger.info("[SFTP] Session lost, attempting to reconnect...");
handleSessionError();
return;
}
logger.error("Failed to load files", e);
toast.error(
e instanceof Error ? e.message : t("sftp.error.loadFailed"),
"SFTP",
);
setFiles([]);
} finally {
if (loadSeqRef.current === requestId) {
setLoading(false);
}
}
},
[ensureSftp, host.id, isLocalSession, listLocalDir, listSftp, t, handleSessionError, files.length, onClearSelection],
);
useLayoutEffect(() => {
if (!open) return;
const cacheKey = `${host.id}::${currentPath}`;
const cached = dirCacheRef.current.get(cacheKey);
const isFresh = cached && Date.now() - cached.timestamp < DIR_CACHE_TTL_MS;
if (!isFresh) {
setFiles([]);
onClearSelection();
}
}, [currentPath, host.id, onClearSelection, open]);
useEffect(() => {
if (open) {
if (!initializedRef.current || lastInitialPathRef.current !== initialPath) {
initializedRef.current = true;
initializingRef.current = true;
lastInitialPathRef.current = initialPath;
onClearSelection();
setLoading(true);
if (isLocalSession) {
(async () => {
try {
const homePath = await getHomeDir();
localHomeRef.current = homePath ?? null;
const startPath = initialPath || homePath || "/";
try {
const list = await listLocalDir(startPath);
setCurrentPath(startPath);
setFiles(list);
dirCacheRef.current.set(`${host.id}::${startPath}`, {
files: list,
timestamp: Date.now(),
});
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.loadFailed"),
"SFTP",
);
} finally {
setLoading(false);
}
} finally {
initializingRef.current = false;
}
})();
return;
}
(async () => {
try {
const homePath = await getHomeDir();
localHomeRef.current = homePath ?? null;
if (initialPath) {
try {
const sftpId = await ensureSftp();
const list = await listSftp(sftpId, initialPath);
setCurrentPath(initialPath);
setFiles(list);
dirCacheRef.current.set(`${host.id}::${initialPath}`, {
files: list,
timestamp: Date.now(),
});
setLoading(false);
return;
} catch {
logger.warn(
`[SFTP] Initial path ${initialPath} not accessible, falling back to home`,
);
}
}
try {
const sftpId = await ensureSftp();
const list = await listSftp(sftpId, homePath || "/");
setCurrentPath(homePath || "/");
setFiles(list);
dirCacheRef.current.set(`${host.id}::${homePath || "/"}`, {
files: list,
timestamp: Date.now(),
});
setLoading(false);
} catch {
logger.warn(`[SFTP] Home ${homePath} not accessible, using /`);
try {
const sftpId = await ensureSftp();
const list = await listSftp(sftpId, "/");
setCurrentPath("/");
setFiles(list);
dirCacheRef.current.set(`${host.id}::/`, {
files: list,
timestamp: Date.now(),
});
} catch (e) {
logger.error("[SFTP] Failed to load root directory", e);
toast.error(t("sftp.error.loadFailed"), "SFTP");
} finally {
setLoading(false);
}
}
} finally {
initializingRef.current = false;
}
})();
return;
}
// Skip redundant loadFiles while async initialization is still in flight.
// Without this guard, dependency changes (e.g. loadFiles recreation from
// files.length change) can re-trigger this effect and call loadFiles with
// the stale currentPath before the initialization IIFE has resolved and
// updated currentPathRef — causing uploads to target the wrong directory.
if (!initializingRef.current) {
void loadFiles(currentPath);
}
} else {
loadSeqRef.current += 1;
initializedRef.current = false;
initializingRef.current = false;
}
}, [
closeSftpSession,
currentPath,
ensureSftp,
getHomeDir,
host.id,
initialPath,
isLocalSession,
listLocalDir,
listSftp,
loadFiles,
onClearSelection,
open,
setCurrentPath,
t,
]);
useEffect(() => {
return () => {
void closeSftpSession();
};
}, [closeSftpSession]);
return {
currentPath,
setCurrentPath,
currentPathRef,
files,
setFiles,
loading,
setLoading,
reconnecting,
sessionVersion,
ensureSftp,
loadFiles,
closeSftpSession,
localHomeRef,
};
};

View File

@@ -1,76 +0,0 @@
import React, { useCallback, useRef, useState } from "react";
export type SortField = "name" | "size" | "modified";
export type SortOrder = "asc" | "desc";
interface UseSftpModalSortingResult {
sortField: SortField;
sortOrder: SortOrder;
columnWidths: { name: number; size: number; modified: number; actions: number };
handleSort: (field: SortField) => void;
handleResizeStart: (field: string, e: React.MouseEvent) => void;
}
export const useSftpModalSorting = (): UseSftpModalSortingResult => {
const [sortField, setSortField] = useState<SortField>("name");
const [sortOrder, setSortOrder] = useState<SortOrder>("asc");
const [columnWidths, setColumnWidths] = useState({
name: 45,
size: 15,
modified: 25,
actions: 15,
});
const resizingRef = useRef<{
field: string;
startX: number;
startWidth: number;
} | null>(null);
const handleSort = (field: SortField) => {
if (sortField === field) {
setSortOrder((prev) => (prev === "asc" ? "desc" : "asc"));
} else {
setSortField(field);
setSortOrder("asc");
}
};
const handleResizeMove = useCallback((e: MouseEvent) => {
if (!resizingRef.current) return;
const diff = e.clientX - resizingRef.current.startX;
const newWidth = Math.max(
10,
Math.min(60, resizingRef.current.startWidth + diff / 5),
);
setColumnWidths((prev) => ({
...prev,
[resizingRef.current!.field]: newWidth,
}));
}, []);
const handleResizeEnd = useCallback(() => {
resizingRef.current = null;
document.removeEventListener("mousemove", handleResizeMove);
document.removeEventListener("mouseup", handleResizeEnd);
}, [handleResizeMove]);
const handleResizeStart = (field: string, e: React.MouseEvent) => {
e.preventDefault();
resizingRef.current = {
field,
startX: e.clientX,
startWidth: columnWidths[field as keyof typeof columnWidths],
};
document.addEventListener("mousemove", handleResizeMove);
document.addEventListener("mouseup", handleResizeEnd);
};
return {
sortField,
sortOrder,
columnWidths,
handleSort,
handleResizeStart,
};
};

View File

@@ -1,87 +0,0 @@
import { useCallback, useState } from "react";
import type { RemoteFile } from "../../../types";
import { toast } from "../../ui/toast";
interface UseSftpModalTextEditorParams {
currentPath: string;
isLocalSession: boolean;
joinPath: (base: string, name: string) => string;
ensureSftp: () => Promise<string>;
readLocalFile: (path: string) => Promise<ArrayBuffer>;
readSftp: (sftpId: string, path: string) => Promise<string>;
writeLocalFile: (path: string, data: ArrayBuffer) => Promise<void>;
writeSftp: (sftpId: string, path: string, data: string) => Promise<void>;
t: (key: string, params?: Record<string, unknown>) => string;
}
interface UseSftpModalTextEditorResult {
showTextEditor: boolean;
setShowTextEditor: (open: boolean) => void;
textEditorTarget: RemoteFile | null;
setTextEditorTarget: (target: RemoteFile | null) => void;
textEditorContent: string;
setTextEditorContent: (value: string) => void;
loadingTextContent: boolean;
handleEditFile: (file: RemoteFile) => Promise<void>;
handleSaveTextFile: (content: string) => Promise<void>;
}
export const useSftpModalTextEditor = ({
currentPath,
isLocalSession,
joinPath,
ensureSftp,
readLocalFile,
readSftp,
writeLocalFile,
writeSftp,
t,
}: UseSftpModalTextEditorParams): UseSftpModalTextEditorResult => {
const [showTextEditor, setShowTextEditor] = useState(false);
const [textEditorTarget, setTextEditorTarget] = useState<RemoteFile | null>(null);
const [textEditorContent, setTextEditorContent] = useState("");
const [loadingTextContent, setLoadingTextContent] = useState(false);
const handleEditFile = useCallback(async (file: RemoteFile) => {
try {
setLoadingTextContent(true);
setTextEditorTarget(file);
const fullPath = joinPath(currentPath, file.name);
const content = isLocalSession
? await readLocalFile(fullPath).then((buf) => new TextDecoder().decode(buf))
: await readSftp(await ensureSftp(), fullPath);
setTextEditorContent(content);
setShowTextEditor(true);
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.loadFailed"),
"SFTP",
);
} finally {
setLoadingTextContent(false);
}
}, [currentPath, ensureSftp, isLocalSession, joinPath, readLocalFile, readSftp, t]);
const handleSaveTextFile = useCallback(async (content: string) => {
if (!textEditorTarget) return;
const fullPath = joinPath(currentPath, textEditorTarget.name);
if (isLocalSession) {
const encoder = new TextEncoder();
await writeLocalFile(fullPath, encoder.encode(content).buffer);
} else {
await writeSftp(await ensureSftp(), fullPath, content);
}
}, [currentPath, ensureSftp, isLocalSession, joinPath, textEditorTarget, writeLocalFile, writeSftp]);
return {
showTextEditor,
setShowTextEditor,
textEditorTarget,
setTextEditorTarget,
textEditorContent,
setTextEditorContent,
loadingTextContent,
handleEditFile,
handleSaveTextFile,
};
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,123 +0,0 @@
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { RemoteFile } from "../../../types";
interface UseSftpModalVirtualListParams {
open: boolean;
sortedFiles: RemoteFile[];
}
interface UseSftpModalVirtualListResult {
fileListRef: React.RefObject<HTMLDivElement>;
rowHeight: number;
handleFileListScroll: (e: React.UIEvent<HTMLDivElement>) => void;
shouldVirtualize: boolean;
totalHeight: number;
visibleRows: { file: RemoteFile; index: number; top: number }[];
}
export const useSftpModalVirtualList = ({
open,
sortedFiles,
}: UseSftpModalVirtualListParams): UseSftpModalVirtualListResult => {
const fileListRef = useRef<HTMLDivElement>(null);
const scrollFrameRef = useRef<number | null>(null);
const [scrollTop, setScrollTop] = useState(0);
const [viewportHeight, setViewportHeight] = useState(0);
const [rowHeight, setRowHeight] = useState(40);
useLayoutEffect(() => {
const container = fileListRef.current;
if (!container || !open) return;
const update = () => setViewportHeight(container.clientHeight);
update();
const raf = window.requestAnimationFrame(update);
const resizeObserver = new ResizeObserver(update);
resizeObserver.observe(container);
return () => {
resizeObserver.disconnect();
window.cancelAnimationFrame(raf);
};
}, [open, sortedFiles.length]);
useLayoutEffect(() => {
const container = fileListRef.current;
if (!container || !open || sortedFiles.length === 0) return;
const raf = window.requestAnimationFrame(() => {
const rowElement = container.querySelector(
'[data-sftp-modal-row="true"]',
) as HTMLElement | null;
if (!rowElement) return;
const nextHeight = Math.round(rowElement.getBoundingClientRect().height);
if (nextHeight && Math.abs(nextHeight - rowHeight) > 1) {
setRowHeight(nextHeight);
}
});
return () => window.cancelAnimationFrame(raf);
}, [open, rowHeight, sortedFiles.length]);
useEffect(() => {
return () => {
if (scrollFrameRef.current !== null) {
window.cancelAnimationFrame(scrollFrameRef.current);
}
};
}, []);
const handleFileListScroll = useCallback(
(e: React.UIEvent<HTMLDivElement>) => {
const nextTop = e.currentTarget.scrollTop;
if (scrollFrameRef.current !== null) return;
scrollFrameRef.current = window.requestAnimationFrame(() => {
scrollFrameRef.current = null;
setScrollTop(nextTop);
});
},
[],
);
const { shouldVirtualize, totalHeight, visibleRows } = useMemo(() => {
const overscan = 6;
const canVirtualize = open && viewportHeight > 0 && rowHeight > 0;
const shouldVirtualizeLocal = canVirtualize && sortedFiles.length > 50;
const totalHeightLocal = shouldVirtualizeLocal
? sortedFiles.length * rowHeight
: 0;
const startIndex = shouldVirtualizeLocal
? Math.max(0, Math.floor(scrollTop / rowHeight) - overscan)
: 0;
const endIndex = shouldVirtualizeLocal
? Math.min(
sortedFiles.length - 1,
Math.ceil((scrollTop + viewportHeight) / rowHeight) + overscan,
)
: sortedFiles.length - 1;
const visibleRowsLocal = shouldVirtualizeLocal
? sortedFiles
.slice(startIndex, endIndex + 1)
.map((file, idx) => ({
file,
index: startIndex + idx,
top: (startIndex + idx) * rowHeight,
}))
: sortedFiles.map((file, index) => ({
file,
index,
top: 0,
}));
return {
shouldVirtualize: shouldVirtualizeLocal,
totalHeight: totalHeightLocal,
visibleRows: visibleRowsLocal,
};
}, [open, rowHeight, scrollTop, sortedFiles, viewportHeight]);
return {
fileListRef,
rowHeight,
handleFileListScroll,
shouldVirtualize,
totalHeight,
visibleRows,
};
};

View File

@@ -1,83 +0,0 @@
export const isWindowsPath = (path: string): boolean => /^[A-Za-z]:/.test(path);
export const normalizeWindowsRoot = (path: string): string => {
const normalized = path.replace(/\//g, "\\");
if (/^[A-Za-z]:\\$/.test(normalized)) return normalized;
if (/^[A-Za-z]:$/.test(normalized)) return `${normalized}\\`;
return normalized;
};
export const joinPath = (base: string, name: string, isLocalSession: boolean): string => {
if (isLocalSession && isWindowsPath(base)) {
const normalizedBase = normalizeWindowsRoot(base).replace(/[\\/]+$/, "");
return `${normalizedBase}\\${name}`;
}
if (base === "/") return `/${name}`;
return `${base}/${name}`;
};
export const isRootPath = (path: string, isLocalSession: boolean): boolean => {
if (isLocalSession && isWindowsPath(path)) {
return /^[A-Za-z]:\\?$/.test(path.replace(/\//g, "\\"));
}
return path === "/";
};
export const getParentPath = (path: string, isLocalSession: boolean): string => {
if (isLocalSession && isWindowsPath(path)) {
const normalized = normalizeWindowsRoot(path).replace(/[\\]+$/, "");
const drive = normalized.slice(0, 2);
if (/^[A-Za-z]:$/.test(normalized) || /^[A-Za-z]:\\$/.test(normalized)) {
return `${drive}\\`;
}
const rest = normalized.slice(2).replace(/^[\\]+/, "");
const parts = rest ? rest.split(/[\\]+/).filter(Boolean) : [];
if (parts.length <= 1) return `${drive}\\`;
parts.pop();
return `${drive}\\${parts.join("\\")}`;
}
if (path === "/") return "/";
const parts = path.split("/").filter(Boolean);
parts.pop();
return parts.length ? `/${parts.join("/")}` : "/";
};
export const getRootPath = (path: string, isLocalSession: boolean): string => {
if (isLocalSession && isWindowsPath(path)) {
const drive = path.replace(/\//g, "\\").slice(0, 2);
return `${drive}\\`;
}
return "/";
};
export const getWindowsDrive = (path: string): string | null => {
if (!isWindowsPath(path)) return null;
const normalized = path.replace(/\//g, "\\");
return /^[A-Za-z]:/.test(normalized) ? normalized.slice(0, 2) : null;
};
export const getBreadcrumbs = (path: string, isLocalSession: boolean): string[] => {
if (isLocalSession && isWindowsPath(path)) {
const normalized = normalizeWindowsRoot(path).replace(/[\\]+$/, "");
const rest = normalized.slice(2).replace(/^[\\]+/, "");
const parts = rest ? rest.split(/[\\]+/).filter(Boolean) : [];
return parts;
}
return path === "/" ? [] : path.split("/").filter(Boolean);
};
export const breadcrumbPathAt = (
breadcrumbs: string[],
idx: number,
currentPath: string,
isLocalSession: boolean,
): string => {
if (isLocalSession) {
const drive = getWindowsDrive(currentPath);
if (drive) {
const rest = breadcrumbs.slice(0, idx + 1).join("\\");
return rest ? `${drive}\\${rest}` : `${drive}\\`;
}
}
return "/" + breadcrumbs.slice(0, idx + 1).join("/");
};

View File

@@ -1,16 +0,0 @@
export const formatBytes = (bytes: number | string): string => {
const numBytes = typeof bytes === "string" ? parseInt(bytes, 10) : bytes;
if (isNaN(numBytes) || numBytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(numBytes) / Math.log(1024));
const size = numBytes / Math.pow(1024, i);
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
};
export const formatDate = (dateStr: string | number | undefined): string => {
if (!dateStr) return "--";
const date = typeof dateStr === "number" ? new Date(dateStr) : new Date(dateStr);
if (isNaN(date.getTime())) return String(dateStr);
const pad = (value: number) => value.toString().padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
};

View File

@@ -47,7 +47,6 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
onSelect(entry, index, e);
}, [entry, index, onSelect]);
const handleOpen = useCallback(() => {
console.log("[SftpFileRow] handleOpen called", { entryName: entry.name, entryType: entry.type });
onOpen(entry);
}, [entry, onOpen]);
const handleDragStart = useCallback((e: React.DragEvent) => {
@@ -89,7 +88,7 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
<Link size={8} className="absolute -bottom-0.5 -right-0.5 text-muted-foreground" aria-hidden="true" />
)}
</div>
<span className={cn("truncate", entry.type === 'symlink' && "italic pr-1")}>
<span className={cn("truncate", entry.type === 'symlink' && "italic pr-1")} title={entry.name}>
{entry.name}
{entry.type === 'symlink' && <span className="sr-only"> (symbolic link)</span>}
</span>

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useMemo } from "react";
import { AlertCircle, ArrowDown, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderPlus, Loader2, Pencil, RefreshCw, Shield, Trash2 } from "lucide-react";
import React, { useCallback, useMemo, useState } from "react";
import { AlertCircle, ArrowDown, ChevronDown, ClipboardCopy, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderPlus, Loader2, Pencil, RefreshCw, Shield, Trash2 } from "lucide-react";
import { Button } from "../ui/button";
import {
ContextMenu,
@@ -9,6 +9,7 @@ import {
ContextMenuTrigger,
} from "../ui/context-menu";
import { cn } from "../../lib/utils";
import { joinPath } from "../../application/state/sftp/utils";
import type { SftpFileEntry } from "../../types";
import type { SftpPane } from "../../application/state/sftp/types";
import type { ColumnWidths, SortField, SortOrder } from "./utils";
@@ -58,6 +59,46 @@ interface SftpPaneFileListProps {
visibleRows: { entry: SftpFileEntry; index: number; top: number }[];
}
const SftpErrorWithLogs: React.FC<{
error: string;
connectionLogs: string[];
onRetry: () => void;
t: (key: string) => string;
}> = ({ error, connectionLogs, onRetry, t }) => {
const [showLogs, setShowLogs] = useState(false);
return (
<div className="flex flex-col items-center justify-center h-full gap-2 text-destructive">
<AlertCircle size={24} />
<span className="text-sm text-center px-4">{t(error)}</span>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={onRetry}>
{t("sftp.retry")}
</Button>
{connectionLogs.length > 0 && (
<Button
variant="ghost"
size="sm"
className="text-muted-foreground"
onClick={() => setShowLogs(!showLogs)}
>
<ChevronDown size={14} className={`mr-1 transition-transform ${showLogs ? 'rotate-180' : ''}`} />
{showLogs ? "Hide logs" : "Show logs"}
</Button>
)}
</div>
{showLogs && connectionLogs.length > 0 && (
<div className="w-full max-w-sm mt-1 p-2 rounded-md bg-secondary/50 border border-border/60 space-y-0.5 max-h-40 overflow-y-auto">
{connectionLogs.map((log, i) => (
<div key={i} className="text-[11px] text-muted-foreground truncate font-mono">
{log}
</div>
))}
</div>
)}
</div>
);
};
export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
t,
pane,
@@ -151,7 +192,8 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
{t("sftp.context.edit")}
</ContextMenuItem>
)}
{!isNavigableDirectory(entry) && onDownloadFile && (
{onDownloadFile &&
(!isNavigableDirectory(entry) || !pane.connection?.isLocal) && (
<ContextMenuItem onClick={() => onDownloadFile(entry)}>
<Download size={14} className="mr-2" />{" "}
{t("sftp.context.download")}
@@ -177,6 +219,14 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
<Copy size={14} className="mr-2" />{" "}
{t("sftp.context.copyToOtherPane")}
</ContextMenuItem>
<ContextMenuItem
onClick={() => {
navigator.clipboard.writeText(joinPath(pane.connection.currentPath, entry.name));
}}
>
<ClipboardCopy size={14} className="mr-2" />{" "}
{t("sftp.context.copyPath")}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => openRenameDialog(entry.name)}>
<Pencil size={14} className="mr-2" /> {t("common.rename")}
@@ -339,17 +389,25 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
onScroll={handleFileListScroll}
>
{pane.loading && sortedDisplayFiles.length === 0 ? (
<div className="flex items-center justify-center h-full">
<div className="flex flex-col items-center justify-center h-full gap-2">
<Loader2 size={24} className="animate-spin text-muted-foreground" />
{pane.connectionLogs.length > 0 && (
<div className="w-full max-w-sm mt-2 space-y-0.5 px-4">
{pane.connectionLogs.map((log, i) => (
<div key={i} className="text-[11px] text-muted-foreground truncate">
{log}
</div>
))}
</div>
)}
</div>
) : pane.error && !pane.reconnecting ? (
<div className="flex flex-col items-center justify-center h-full gap-2 text-destructive">
<AlertCircle size={24} />
<span className="text-sm">{t(pane.error)}</span>
<Button variant="outline" size="sm" onClick={onRefresh}>
{t("sftp.retry")}
</Button>
</div>
<SftpErrorWithLogs
error={pane.error}
connectionLogs={pane.connectionLogs}
onRetry={onRefresh}
t={t}
/>
) : sortedDisplayFiles.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<Folder size={32} className="mb-2 opacity-50" />
@@ -409,10 +467,19 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
</span>
</div>
{/* Loading overlay - covers entire pane when navigating directories */}
{/* Loading overlay - covers entire pane when navigating or reconnecting */}
{pane.loading && sortedDisplayFiles.length > 0 && !pane.reconnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-background/40 backdrop-blur-[1px] z-10">
<div className="absolute inset-0 flex flex-col items-center justify-center bg-background/40 backdrop-blur-[1px] z-10">
<Loader2 size={24} className="animate-spin text-muted-foreground" />
{pane.connectionLogs.length > 0 && (
<div className="w-full max-w-sm mt-2 space-y-0.5 px-4">
{pane.connectionLogs.map((log, i) => (
<div key={i} className="text-[11px] text-muted-foreground truncate">
{log}
</div>
))}
</div>
)}
</div>
)}

View File

@@ -13,6 +13,7 @@ import {
} from 'lucide-react';
import React, { memo } from 'react';
import { getParentPath } from '../../application/state/sftp/utils';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
import { TransferTask } from '../../types';
import { Button } from '../ui/button';
@@ -35,14 +36,18 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
canRevealTarget = false,
onRevealTarget,
}) => {
const { t } = useI18n();
const hasKnownTotal = task.totalBytes > 0;
const progress = task.totalBytes > 0 ? Math.min((task.transferredBytes / task.totalBytes) * 100, 100) : 0;
// Show indeterminate state when transferring but no real progress received yet
const isIndeterminate = task.status === 'transferring' && hasKnownTotal && task.transferredBytes === 0;
// Calculate remaining time from backend-reported sliding-window speed
const remainingBytes = task.totalBytes - task.transferredBytes;
const effectiveSpeed = task.status === 'transferring'
? (Number.isFinite(task.speed) && task.speed > 0 ? task.speed : 0)
: 0;
const remainingTime = effectiveSpeed > 0
const remainingTime = hasKnownTotal && effectiveSpeed > 0
? Math.ceil(remainingBytes / effectiveSpeed)
: 0;
const remainingFormatted = remainingTime > 60
@@ -54,6 +59,8 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
// Format bytes transferred / total
const bytesDisplay = task.status === 'transferring' && task.totalBytes > 0
? `${formatTransferBytes(task.transferredBytes)} / ${formatTransferBytes(task.totalBytes)}`
: task.status === 'transferring'
? formatTransferBytes(task.transferredBytes)
: task.status === 'completed' && task.totalBytes > 0
? formatTransferBytes(task.totalBytes)
: '';
@@ -77,10 +84,10 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-[13px] leading-5 truncate font-medium">{task.fileName}</span>
{task.status === 'transferring' && speedFormatted && (
{task.status === 'transferring' && !isIndeterminate && speedFormatted && (
<span className="text-[10px] text-primary/80 font-mono transition-opacity duration-300">{speedFormatted}</span>
)}
{task.status === 'transferring' && remainingFormatted && (
{task.status === 'transferring' && !isIndeterminate && remainingFormatted && (
<span className="text-[10px] text-muted-foreground transition-opacity duration-300">{remainingFormatted}</span>
)}
</div>
@@ -99,12 +106,16 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
<div
className={cn(
"h-full rounded-full relative overflow-hidden",
task.status === 'pending'
task.status === 'pending' || (task.status === 'transferring' && !hasKnownTotal)
? "bg-muted-foreground/50 animate-pulse"
: "bg-gradient-to-r from-primary via-primary/90 to-primary"
: isIndeterminate
? "bg-primary/60 animate-pulse"
: "bg-gradient-to-r from-primary via-primary/90 to-primary"
)}
style={{
width: task.status === 'pending' ? '100%' : `${progress}%`,
width: task.status === 'pending' || (task.status === 'transferring' && !hasKnownTotal) || isIndeterminate
? '100%'
: `${progress}%`,
transition: 'width 150ms ease-out'
}}
>
@@ -121,7 +132,13 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
</div>
</div>
<span className="text-[10px] text-muted-foreground shrink-0 min-w-[34px] text-right font-mono">
{task.status === 'pending' ? 'waiting...' : `${Math.round(progress)}%`}
{task.status === 'pending'
? 'waiting...'
: isIndeterminate
? t('sftp.transfer.preparing')
: hasKnownTotal
? `${Math.round(progress)}%`
: '...'}
</span>
</div>
)}
@@ -130,6 +147,11 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
{bytesDisplay}
</div>
)}
{task.status === 'transferring' && !hasKnownTotal && (
<div className="text-[9px] text-muted-foreground mt-0.5">
{t('sftp.transfers.calculatingTotal')}
</div>
)}
{task.status === 'completed' && bytesDisplay && (
<div className="text-[9px] text-green-600 mt-0.5">
Completed - {bytesDisplay}
@@ -196,10 +218,13 @@ const arePropsEqual = (
// Always re-render on fileName change
if (prev.fileName !== next.fileName) return false;
if (prev.targetPath !== next.targetPath) return false;
if (prev.totalBytes !== next.totalBytes) return false;
if ((prevProps.canRevealTarget ?? false) !== (nextProps.canRevealTarget ?? false)) return false;
// For transferring status, allow frequent re-renders for smooth progress bar
if (next.status === 'transferring') {
if (next.totalBytes <= 0 && prev.transferredBytes !== next.transferredBytes) return false;
// Re-render on any meaningful progress change (0.1% for smooth bar animation)
const prevProgress = prev.totalBytes > 0 ? (prev.transferredBytes / prev.totalBytes) * 100 : 0;
const nextProgress = next.totalBytes > 0 ? (next.transferredBytes / next.totalBytes) * 100 : 0;

View File

@@ -7,14 +7,14 @@
import { useSyncExternalStore } from "react";
export type SftpClipboardOperation = "copy" | "cut";
type SftpClipboardOperation = "copy" | "cut";
export interface SftpClipboardFile {
name: string;
isDirectory: boolean;
}
export interface SftpClipboardState {
interface SftpClipboardState {
files: SftpClipboardFile[];
sourcePath: string;
sourceConnectionId: string;

View File

@@ -8,9 +8,9 @@
import { useSyncExternalStore, useEffect } from "react";
import { sftpFocusStore, SftpFocusedSide } from "./useSftpFocusedPane";
export type SftpDialogActionType = "rename" | "delete" | "newFolder" | "newFile" | null;
type SftpDialogActionType = "rename" | "delete" | "newFolder" | "newFile" | null;
export interface SftpDialogAction {
interface SftpDialogAction {
type: SftpDialogActionType;
targetSide: SftpFocusedSide;
targetFiles?: string[]; // For rename (single file) or delete (multiple files)

View File

@@ -1,6 +1,7 @@
import React, { useCallback, useState } from "react";
import type { MutableRefObject } from "react";
import type { SftpFileEntry } from "../../../types";
import type { RemoteFile, SftpFileEntry, SftpFilenameEncoding } from "../../../types";
import { joinPath as joinFsPath } from "../../../application/state/sftp/utils";
import type { SftpStateApi } from "../../../application/state/useSftpState";
import { logger } from "../../../lib/logger";
import { toast } from "../../ui/toast";
@@ -20,7 +21,11 @@ interface UseSftpViewFileOpsParams {
systemApp?: SystemAppInfo,
) => void;
t: (key: string, vars?: Record<string, string | number>) => string;
listSftp?: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<RemoteFile[]>;
mkdirLocal?: (path: string) => Promise<unknown>;
deleteLocalFile?: (path: string) => Promise<unknown>;
showSaveDialog?: (defaultPath: string, filters?: Array<{ name: string; extensions: string[] }>) => Promise<string | null>;
selectDirectory?: (title?: string, defaultPath?: string) => Promise<string | null>;
startStreamTransfer?: (
options: {
transferId: string;
@@ -31,6 +36,8 @@ interface UseSftpViewFileOpsParams {
sourceSftpId?: string;
targetSftpId?: string;
totalBytes?: number;
sourceEncoding?: SftpFilenameEncoding;
targetEncoding?: SftpFilenameEncoding;
},
onProgress?: (transferred: number, total: number, speed: number) => void,
onComplete?: () => void,
@@ -105,7 +112,11 @@ export const useSftpViewFileOps = ({
getOpenerForFileRef,
setOpenerForExtension,
t,
listSftp,
mkdirLocal,
deleteLocalFile,
showSaveDialog,
selectDirectory,
startStreamTransfer,
getSftpIdForConnection,
}: UseSftpViewFileOpsParams): UseSftpViewFileOpsResult => {
@@ -363,10 +374,16 @@ export const useSftpViewFileOps = ({
if (!pane.connection) return;
const fullPath = sftpRef.current.joinPath(pane.connection.currentPath, file.name);
const isDirectory = isNavigableDirectory(file);
try {
// For local files, use blob download
// For local files, use blob download.
if (pane.connection.isLocal) {
if (isDirectory) {
toast.error(t("sftp.error.downloadFailed"), "SFTP");
return;
}
const content = await sftpRef.current.readBinaryFile(side, fullPath);
const blob = new Blob([content], { type: "application/octet-stream" });
@@ -383,7 +400,7 @@ export const useSftpViewFileOps = ({
return;
}
// For remote SFTP files, use streaming download with save dialog
// For remote SFTP files/directories, use streaming download with save dialog.
if (!showSaveDialog || !startStreamTransfer || !getSftpIdForConnection) {
toast.error(t("sftp.error.downloadFailed"), "SFTP");
return;
@@ -394,6 +411,413 @@ export const useSftpViewFileOps = ({
throw new Error("SFTP session not found");
}
if (isDirectory) {
if (!listSftp || !mkdirLocal || !selectDirectory) {
toast.error(t("sftp.error.downloadFailed"), "SFTP");
return;
}
const selectedDirectory = await selectDirectory(t("sftp.context.download"));
if (!selectedDirectory) return;
const targetPath = joinFsPath(selectedDirectory, file.name);
const transferId = `download-dir-${Date.now()}-${Math.random().toString(36).slice(2)}`;
let completedBytes = 0;
const MAX_SYMLINK_DEPTH = 32;
const DIRECTORY_DOWNLOAD_MAX_CONCURRENCY = 10;
const activeChildTransferIds = new Set<string>();
const activeFileProgress = new Map<string, { transferred: number; speed: number }>();
const activeFileSizes = new Map<string, number>();
const visitedPaths = new Set<string>();
const directoryTaskQueue: Array<{
type: "directory";
remotePath: string;
localPath: string;
symlinkDepth: number;
}> = [];
const fileTaskQueue: Array<{
type: "file";
remotePath: string;
localPath: string;
size: number;
}> = [];
let pendingDirectoryTasks = 0;
let discoveredTotalBytes = 0;
let estimatedTotalBytes = 0;
let activeQueueTasks = 0;
const isTaskCancelled = () =>
sftpRef.current.transfers.some(
(task) => task.id === transferId && task.status === "cancelled",
);
const updateAggregateProgress = () => {
let activeTransferredBytes = 0;
let activeSpeed = 0;
for (const progress of activeFileProgress.values()) {
activeTransferredBytes += progress.transferred;
activeSpeed += progress.speed;
}
sftpRef.current.updateExternalUpload(transferId, {
fileName: pendingDirectoryTasks > 0 ? `${file.name} (${t("sftp.upload.scanning")})` : file.name,
transferredBytes: completedBytes + activeTransferredBytes,
totalBytes: estimatedTotalBytes > 0 ? estimatedTotalBytes : 0,
speed: activeSpeed,
});
};
const cancelActiveChildTransfers = async () => {
await Promise.all(
Array.from(activeChildTransferIds).map((childTransferId) =>
sftpRef.current.cancelTransfer(childTransferId).catch(() => undefined),
),
);
};
const maybeFinalizeDiscovery = () => {
if (pendingDirectoryTasks === 0) {
estimatedTotalBytes = discoveredTotalBytes;
updateAggregateProgress();
}
};
const getDynamicConcurrencyLimit = () => {
let largeFiles = 0;
let mediumFiles = 0;
for (const size of activeFileSizes.values()) {
if (size >= 32 * 1024 * 1024) largeFiles += 1;
else if (size >= 1 * 1024 * 1024) mediumFiles += 1;
}
if (largeFiles > 0) return 2;
if (mediumFiles >= 2) return 4;
if (mediumFiles === 1) return 5;
return DIRECTORY_DOWNLOAD_MAX_CONCURRENCY;
};
const enqueueDirectoryTask = (task: {
type: "directory";
remotePath: string;
localPath: string;
symlinkDepth: number;
}) => {
directoryTaskQueue.push(task);
};
const enqueueFileTask = (task: {
type: "file";
remotePath: string;
localPath: string;
size: number;
}) => {
const insertIndex = fileTaskQueue.findIndex((queuedTask) => queuedTask.size > task.size);
if (insertIndex === -1) {
fileTaskQueue.push(task);
} else {
fileTaskQueue.splice(insertIndex, 0, task);
}
};
const dequeueTask = () => {
if (pendingDirectoryTasks > 0 && directoryTaskQueue.length > 0) {
return directoryTaskQueue.shift() ?? null;
}
if (fileTaskQueue.length > 0) return fileTaskQueue.shift() ?? null;
if (directoryTaskQueue.length > 0) return directoryTaskQueue.shift() ?? null;
return null;
};
const processFileTask = async (task: {
type: "file";
remotePath: string;
localPath: string;
size: number;
}) => {
const childTransferId = `download-${Date.now()}-${Math.random().toString(36).slice(2)}`;
activeChildTransferIds.add(childTransferId);
activeFileSizes.set(childTransferId, task.size);
activeFileProgress.set(childTransferId, { transferred: 0, speed: 0 });
updateAggregateProgress();
try {
await new Promise<void>((resolve, reject) => {
startStreamTransfer(
{
transferId: childTransferId,
sourcePath: task.remotePath,
targetPath: task.localPath,
sourceType: "sftp",
targetType: "local",
sourceSftpId: sftpId,
totalBytes: task.size,
sourceEncoding: pane.filenameEncoding,
},
(transferred, _total, speed) => {
if (isTaskCancelled()) {
sftpRef.current.cancelTransfer(childTransferId).catch(() => undefined);
return;
}
activeFileProgress.set(childTransferId, {
transferred,
speed: Number.isFinite(speed) && speed > 0 ? speed : 0,
});
updateAggregateProgress();
},
() => {
completedBytes += task.size;
activeChildTransferIds.delete(childTransferId);
activeFileSizes.delete(childTransferId);
activeFileProgress.delete(childTransferId);
updateAggregateProgress();
resolve();
},
(error) => {
activeChildTransferIds.delete(childTransferId);
activeFileSizes.delete(childTransferId);
activeFileProgress.delete(childTransferId);
updateAggregateProgress();
reject(new Error(error));
},
)
.then((result) => {
if (result === undefined) {
activeChildTransferIds.delete(childTransferId);
activeFileSizes.delete(childTransferId);
activeFileProgress.delete(childTransferId);
updateAggregateProgress();
reject(new Error("Stream transfer unavailable"));
} else if (result.error) {
activeChildTransferIds.delete(childTransferId);
activeFileSizes.delete(childTransferId);
activeFileProgress.delete(childTransferId);
updateAggregateProgress();
reject(new Error(result.error));
}
})
.catch(reject);
});
} finally {
activeChildTransferIds.delete(childTransferId);
activeFileSizes.delete(childTransferId);
activeFileProgress.delete(childTransferId);
}
};
const processDirectoryTask = async (task: {
type: "directory";
remotePath: string;
localPath: string;
symlinkDepth: number;
}) => {
if (visitedPaths.has(task.remotePath)) {
pendingDirectoryTasks -= 1;
maybeFinalizeDiscovery();
return;
}
visitedPaths.add(task.remotePath);
if (isTaskCancelled()) {
throw new Error("Transfer cancelled");
}
const entries = await listSftp(sftpId, task.remotePath, pane.filenameEncoding);
for (const entry of entries) {
if (entry.name === ".." || entry.name === ".") continue;
if (isTaskCancelled()) {
await cancelActiveChildTransfers();
throw new Error("Transfer cancelled");
}
const remoteEntryPath = sftpRef.current.joinPath(task.remotePath, entry.name);
const localEntryPath = joinFsPath(task.localPath, entry.name);
const isRealDir = entry.type === "directory";
const isSymlinkDir =
entry.type === "symlink" && entry.linkTarget === "directory";
if (isRealDir || isSymlinkDir) {
if (isSymlinkDir && task.symlinkDepth >= MAX_SYMLINK_DEPTH) {
throw new Error(
"Maximum symlink directory depth exceeded (possible symlink cycle)",
);
}
try {
await mkdirLocal(localEntryPath);
} catch (mkdirErr: unknown) {
const isEEXIST =
mkdirErr instanceof Error && mkdirErr.message.includes("EEXIST");
if (!isEEXIST) throw mkdirErr;
}
pendingDirectoryTasks += 1;
enqueueDirectoryTask({
type: "directory",
remotePath: remoteEntryPath,
localPath: localEntryPath,
symlinkDepth: isSymlinkDir ? task.symlinkDepth + 1 : task.symlinkDepth,
});
continue;
}
const entrySize =
typeof entry.size === "string"
? parseInt(String(entry.size), 10) || 0
: entry.size || 0;
discoveredTotalBytes += entrySize;
enqueueFileTask({
type: "file",
remotePath: remoteEntryPath,
localPath: localEntryPath,
size: entrySize,
});
}
pendingDirectoryTasks -= 1;
maybeFinalizeDiscovery();
};
const runQueue = async () =>
new Promise<void>((resolve, reject) => {
let settled = false;
const pump = () => {
if (settled) return;
if (isTaskCancelled()) {
settled = true;
void cancelActiveChildTransfers().finally(() =>
reject(new Error("Transfer cancelled")),
);
return;
}
while (
activeQueueTasks < getDynamicConcurrencyLimit()
) {
const nextTask = dequeueTask();
if (!nextTask) break;
activeQueueTasks += 1;
Promise.resolve(
nextTask.type === "directory"
? processDirectoryTask(nextTask)
: processFileTask(nextTask),
)
.then(() => {
activeQueueTasks -= 1;
if (
!settled &&
fileTaskQueue.length === 0 &&
directoryTaskQueue.length === 0 &&
activeQueueTasks === 0 &&
pendingDirectoryTasks === 0
) {
settled = true;
resolve();
return;
}
pump();
})
.catch((error) => {
if (settled) return;
settled = true;
void cancelActiveChildTransfers().finally(() => reject(error));
});
}
if (
!settled &&
fileTaskQueue.length === 0 &&
directoryTaskQueue.length === 0 &&
activeQueueTasks === 0 &&
pendingDirectoryTasks === 0
) {
settled = true;
resolve();
}
};
pump();
});
sftpRef.current.addExternalUpload({
id: transferId,
fileName: `${file.name} (${t("sftp.upload.scanning")})`,
sourcePath: fullPath,
targetPath,
sourceConnectionId: pane.connection.id,
targetConnectionId: "local",
direction: "download",
status: "transferring",
totalBytes: 0,
transferredBytes: 0,
speed: 0,
startTime: Date.now(),
isDirectory: true,
retryable: false,
});
try {
try {
await mkdirLocal(targetPath);
} catch (mkdirErr: unknown) {
const isEEXIST =
mkdirErr instanceof Error && mkdirErr.message.includes("EEXIST");
if (isEEXIST && deleteLocalFile) {
await deleteLocalFile(targetPath);
await mkdirLocal(targetPath);
} else {
throw mkdirErr;
}
}
pendingDirectoryTasks = 1;
enqueueDirectoryTask({
type: "directory",
remotePath: fullPath,
localPath: targetPath,
symlinkDepth: 0,
});
await runQueue();
sftpRef.current.updateExternalUpload(transferId, {
status: "completed",
fileName: file.name,
transferredBytes: completedBytes,
totalBytes: estimatedTotalBytes > 0 ? estimatedTotalBytes : completedBytes,
speed: 0,
endTime: Date.now(),
});
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : t("sftp.error.downloadFailed");
const isCancelled =
errorMessage.includes("cancelled") || errorMessage.includes("canceled");
sftpRef.current.updateExternalUpload(transferId, {
status: isCancelled ? "cancelled" : "failed",
error: isCancelled ? undefined : errorMessage,
speed: 0,
endTime: Date.now(),
});
if (!isCancelled) {
toast.error(errorMessage, "SFTP");
}
}
return;
}
// Show save dialog to get target path
const targetPath = await showSaveDialog(file.name);
if (!targetPath) {
@@ -433,6 +857,7 @@ export const useSftpViewFileOps = ({
targetType: 'local',
sourceSftpId: sftpId,
totalBytes: fileSize,
sourceEncoding: pane.filenameEncoding,
},
(transferred, total, speed) => {
// Update transfer progress in the queue
@@ -497,7 +922,17 @@ export const useSftpViewFileOps = ({
);
}
},
[sftpRef, t, showSaveDialog, startStreamTransfer, getSftpIdForConnection],
[
sftpRef,
t,
listSftp,
mkdirLocal,
deleteLocalFile,
showSaveDialog,
selectDirectory,
startStreamTransfer,
getSftpIdForConnection,
],
);
const onDownloadFileLeft = useCallback(

View File

@@ -1,6 +1,7 @@
import { useMemo } from "react";
import type { MutableRefObject } from "react";
import type { SftpStateApi } from "../../../application/state/useSftpState";
import type { RemoteFile, SftpFilenameEncoding } from "../../../types";
import type { SftpPaneCallbacks } from "../SftpContext";
import { useSftpViewPaneActions } from "./useSftpViewPaneActions";
import { useSftpViewFileOps } from "./useSftpViewFileOps";
@@ -19,7 +20,11 @@ interface UseSftpViewPaneCallbacksParams {
systemApp?: SystemAppInfo,
) => void;
t: (key: string, vars?: Record<string, string | number>) => string;
listSftp?: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<RemoteFile[]>;
mkdirLocal?: (path: string) => Promise<unknown>;
deleteLocalFile?: (path: string) => Promise<unknown>;
showSaveDialog?: (defaultPath: string, filters?: Array<{ name: string; extensions: string[] }>) => Promise<string | null>;
selectDirectory?: (title?: string, defaultPath?: string) => Promise<string | null>;
startStreamTransfer?: (
options: {
transferId: string;
@@ -30,6 +35,8 @@ interface UseSftpViewPaneCallbacksParams {
sourceSftpId?: string;
targetSftpId?: string;
totalBytes?: number;
sourceEncoding?: SftpFilenameEncoding;
targetEncoding?: SftpFilenameEncoding;
},
onProgress?: (transferred: number, total: number, speed: number) => void,
onComplete?: () => void,
@@ -45,7 +52,11 @@ export const useSftpViewPaneCallbacks = ({
getOpenerForFileRef,
setOpenerForExtension,
t,
listSftp,
mkdirLocal,
deleteLocalFile,
showSaveDialog,
selectDirectory,
startStreamTransfer,
getSftpIdForConnection,
}: UseSftpViewPaneCallbacksParams) => {
@@ -57,7 +68,11 @@ export const useSftpViewPaneCallbacks = ({
getOpenerForFileRef,
setOpenerForExtension,
t,
listSftp,
mkdirLocal,
deleteLocalFile,
showSaveDialog,
selectDirectory,
startStreamTransfer,
getSftpIdForConnection,
});

View File

@@ -2,11 +2,12 @@
* Terminal Connection Dialog
* Full connection overlay with host info, progress indicator, and auth/progress content
*/
import { Loader2, TerminalSquare, User } from 'lucide-react';
import { Loader2, Plug, TerminalSquare, X } from 'lucide-react';
import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
import { Host, SSHKey } from '../../types';
import { formatHostPort } from '../../domain/host';
import { DistroAvatar } from '../DistroAvatar';
import { Button } from '../ui/button';
import { TerminalAuthDialog, TerminalAuthDialogProps } from './TerminalAuthDialog';
@@ -30,6 +31,7 @@ export interface TerminalConnectionDialogProps {
// Auth dialog props
authProps: Omit<TerminalAuthDialogProps, 'keys'>;
keys: SSHKey[];
onDismissDisconnected?: () => void;
// Progress props
progressProps: Omit<TerminalConnectionProgressProps, 'status' | 'error' | 'showLogs'>;
}
@@ -68,11 +70,13 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
_setShowLogs: setShowLogs, // Rename back to setShowLogs for internal use
authProps,
keys,
onDismissDisconnected,
progressProps,
}) => {
const { t } = useI18n();
const hasError = Boolean(error);
const isConnecting = status === 'connecting';
const canDismissDisconnected = status === 'disconnected' && !needsAuth && !!onDismissDisconnected;
const protocolInfo = getProtocolInfo(host);
return (
@@ -80,15 +84,14 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
"absolute inset-0 z-20 flex items-center justify-center",
needsAuth ? "bg-black" : "bg-black/30"
)}>
<div className="w-[560px] max-w-[90vw] bg-background/95 border border-border/60 rounded-2xl shadow-xl p-6 space-y-4">
<div className="w-[560px] max-w-[90vw] bg-background/95 border border-border/60 rounded-xl shadow-xl p-6 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} className="h-10 w-10" />
<div>
{/* Show chain progress if available */}
<div className="flex items-center gap-3 min-w-0 flex-1">
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} className="h-10 w-10 rounded-lg shrink-0" />
<div className="min-w-0">
{chainProgress ? (
<>
<div className="text-sm font-semibold">
<div className="text-sm font-semibold truncate">
<span className="text-muted-foreground">
{t('terminal.connection.chainOf', {
current: chainProgress.currentHop,
@@ -98,46 +101,70 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
</span>
<span>{chainProgress.currentHostLabel}</span>
</div>
<div className="text-[11px] text-muted-foreground font-mono">
{t(protocolInfo.i18nKey)} {protocolInfo.showPort ? `${host.hostname}:${protocolInfo.port}` : host.hostname}
<div className="text-[11px] text-muted-foreground font-mono truncate">
{t(protocolInfo.i18nKey)} {protocolInfo.showPort ? formatHostPort(host.hostname, protocolInfo.port) : host.hostname}
</div>
</>
) : (
<>
<div className="text-sm font-semibold">{host.label}</div>
<div className="text-[11px] text-muted-foreground font-mono">
{t(protocolInfo.i18nKey)} {protocolInfo.showPort ? `${host.hostname}:${protocolInfo.port}` : host.hostname}
<div className="text-lg font-semibold truncate">{host.label}</div>
<div className="text-[11px] text-muted-foreground font-mono truncate">
{t(protocolInfo.i18nKey)} {protocolInfo.showPort ? formatHostPort(host.hostname, protocolInfo.port) : host.hostname}
</div>
</>
)}
</div>
</div>
{!needsAuth && (
<Button
size="sm"
variant="outline"
className="h-8 text-xs"
onClick={() => setShowLogs(!showLogs)}
>
{showLogs ? t('terminal.connection.hideLogs') : t('terminal.connection.showLogs')}
</Button>
)}
<div className="flex items-center gap-2 shrink-0 ml-3">
{!needsAuth && (
<Button
size="sm"
variant="outline"
className="h-8 text-xs"
onClick={() => setShowLogs(!showLogs)}
>
{showLogs ? t('terminal.connection.hideLogs') : t('terminal.connection.showLogs')}
</Button>
)}
{status === 'connecting' && !needsAuth && (
<Button
size="sm"
variant="outline"
className="h-8 text-xs"
onClick={progressProps.onCancelConnect}
disabled={progressProps.isCancelling}
>
{progressProps.isCancelling ? t('terminal.progress.cancelling') : t('common.close')}
</Button>
)}
{canDismissDisconnected && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
aria-label={t('terminal.connection.dismissDisconnectedDialog')}
title={t('terminal.connection.dismissDisconnectedDialog')}
onClick={onDismissDisconnected}
>
<X size={14} />
</Button>
)}
</div>
</div>
{/* Progress indicator - icons with progress bar below */}
<div className="space-y-2">
<div className="flex items-center gap-3">
<div className={cn(
"h-8 w-8 rounded-full flex items-center justify-center flex-shrink-0",
"h-8 w-8 rounded-lg flex items-center justify-center flex-shrink-0",
needsAuth
? "bg-primary text-primary-foreground"
: hasError
? "bg-destructive/20 text-destructive"
: isConnecting
: isConnecting
? "bg-primary/15 text-primary"
: "bg-muted text-muted-foreground"
)}>
<User size={14} />
<Plug size={14} />
</div>
<div className="flex-1 h-1.5 rounded-full bg-border/60 overflow-hidden relative">
<div
@@ -151,7 +178,7 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
/>
</div>
<div className={cn(
"h-8 w-8 rounded-full flex items-center justify-center flex-shrink-0",
"h-8 w-8 rounded-lg flex items-center justify-center flex-shrink-0",
hasError ? "bg-destructive/20 text-destructive" : "bg-muted text-muted-foreground"
)}>
{isConnecting ? (

View File

@@ -2,7 +2,7 @@
* Terminal Connection Progress
* Displays connection progress with logs and timeout
*/
import { AlertCircle, Clock, Play, ShieldCheck } from 'lucide-react';
import { Loader2, Play } from 'lucide-react';
import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Button } from '../ui/button';
@@ -15,7 +15,8 @@ export interface TerminalConnectionProgressProps {
isCancelling: boolean;
showLogs: boolean;
progressLogs: string[];
onCancel: () => void;
onCancelConnect: () => void;
onCloseSession: () => void;
onRetry: () => void;
}
@@ -23,71 +24,70 @@ export const TerminalConnectionProgress: React.FC<TerminalConnectionProgressProp
status,
error,
timeLeft,
isCancelling,
isCancelling: _isCancelling,
showLogs,
progressLogs,
onCancel,
onCancelConnect: _onCancelConnect,
onCloseSession,
onRetry,
}) => {
const { t } = useI18n();
return (
<>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<Clock className="h-3 w-3" />
<span>
{status === 'connecting'
? t('terminal.progress.timeoutIn', { seconds: timeLeft })
: error || t('terminal.progress.disconnected')}
</span>
</div>
<div className="flex items-center gap-2">
<div className="flex items-start justify-between gap-3 text-xs text-muted-foreground">
<div className="flex min-w-0 items-start gap-2">
{status === 'connecting' ? (
<Button
variant="ghost"
size="sm"
className="h-8"
onClick={onCancel}
disabled={isCancelling}
>
{isCancelling ? t('terminal.progress.cancelling') : t('common.close')}
</Button>
<>
<Loader2 className="h-3 w-3 mt-0.5 flex-shrink-0 animate-spin" />
<span className="min-w-0 whitespace-pre-wrap break-words leading-5">
{t('terminal.progress.timeoutIn', { seconds: timeLeft })}
</span>
</>
) : (
<div className="flex gap-2">
<Button variant="ghost" size="sm" className="h-8" onClick={onCancel}>
{t('common.close')}
</Button>
<Button size="sm" className="h-8" onClick={onRetry}>
<Play className="h-3 w-3 mr-2" /> {t('terminal.progress.startOver')}
</Button>
</div>
<>
<div className="mt-[0.4rem] h-1.5 w-1.5 flex-shrink-0 rounded-full bg-destructive" />
<span className="min-w-0 whitespace-pre-wrap break-words leading-5 text-destructive">
{error || t('terminal.progress.disconnected')}
</span>
</>
)}
</div>
</div>
{showLogs && (
<div className="rounded-xl border border-border/60 bg-background/70 shadow-inner">
<div className="rounded-md border border-border/35 bg-background/40">
<ScrollArea className="max-h-52 p-3">
<div className="space-y-2 text-sm text-foreground/90">
<div className="space-y-1 text-sm text-foreground/90">
{progressLogs.map((line, idx) => (
<div key={idx} className="flex items-start gap-2">
<div className="mt-0.5">
<ShieldCheck className="h-3.5 w-3.5 text-primary" />
</div>
<div>{line}</div>
<div className="mt-[0.4rem] h-1.5 w-1.5 flex-shrink-0 rounded-full bg-emerald-500" />
<div className="min-w-0 break-words leading-5">{line}</div>
</div>
))}
{error && (
<div className="flex items-start gap-2 text-destructive">
<AlertCircle className="h-3.5 w-3.5 mt-0.5" />
<div>{error}</div>
<div className="mt-[0.4rem] h-1.5 w-1.5 flex-shrink-0 rounded-full bg-destructive" />
<div className="min-w-0 break-words leading-5">{error}</div>
</div>
)}
</div>
</ScrollArea>
</div>
)}
<div className="flex justify-end gap-2">
{status !== 'connecting' && (
<>
<Button variant="ghost" size="sm" className="h-8" onClick={onCloseSession}>
{t('terminal.toolbar.closeSession')}
</Button>
<Button size="sm" className="h-8" onClick={onRetry}>
<Play className="h-3 w-3 mr-2" /> {t('terminal.progress.startOver')}
</Button>
</>
)}
</div>
</>
);
};

View File

@@ -39,7 +39,7 @@ const ThemeItem = memo(({
onClick={() => onSelect(theme.id)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(theme.id); } }}
className={cn(
'w-full flex items-center gap-2.5 px-3 py-2 text-left transition-colors group cursor-pointer',
'w-full flex items-center gap-2.5 px-3 py-2 text-left group cursor-pointer',
isSelected
? 'bg-accent/50'
: 'hover:bg-accent/50'
@@ -118,21 +118,37 @@ FontItem.displayName = 'FontItem';
interface ThemeSidePanelProps {
currentThemeId: string;
globalThemeId: string;
currentFontFamilyId: string;
globalFontFamilyId: string;
currentFontSize: number;
canResetTheme?: boolean;
canResetFontFamily?: boolean;
canResetFontSize?: boolean;
onThemeChange: (themeId: string) => void;
onThemeReset?: () => void;
onFontFamilyChange: (fontFamilyId: string) => void;
onFontFamilyReset?: () => void;
onFontSizeChange: (fontSize: number) => void;
onFontSizeReset?: () => void;
isVisible?: boolean;
}
const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
currentThemeId,
globalThemeId,
currentFontFamilyId,
globalFontFamilyId,
currentFontSize,
canResetTheme = false,
canResetFontFamily = false,
canResetFontSize = false,
onThemeChange,
onThemeReset,
onFontFamilyChange,
onFontFamilyReset,
onFontSizeChange,
onFontSizeReset,
isVisible = true,
}) => {
const { t } = useI18n();
@@ -149,6 +165,14 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
() => [...TERMINAL_THEMES, ...customThemes],
[customThemes]
);
const globalTheme = useMemo(
() => allThemes.find((theme) => theme.id === globalThemeId) || TERMINAL_THEMES[0],
[allThemes, globalThemeId],
);
const globalFont = useMemo(
() => availableFonts.find((font) => font.id === globalFontFamilyId) || availableFonts[0],
[availableFonts, globalFontFamilyId],
);
const handleThemeSelect = useCallback((themeId: string) => {
setEditingTheme(null);
@@ -294,6 +318,18 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
))}
</>
)}
{canResetTheme && (
<>
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mt-2 mb-1 px-1 font-semibold">
{t('terminal.themeModal.globalTheme')}
</div>
<ThemeItem
theme={globalTheme}
isSelected={!canResetTheme}
onSelect={() => onThemeReset?.()}
/>
</>
)}
</div>
)}
{activeTab === 'font' && (
@@ -306,6 +342,18 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
onSelect={handleFontSelect}
/>
))}
{canResetFontFamily && (
<>
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mt-2 mb-1 px-1 font-semibold">
{t('terminal.themeModal.globalFont')}
</div>
<FontItem
font={globalFont}
isSelected={!canResetFontFamily}
onSelect={() => onFontFamilyReset?.()}
/>
</>
)}
</div>
)}
{activeTab === 'custom' && !editingTheme && (
@@ -365,8 +413,18 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
{/* Font Size Control (only in font tab) */}
{activeTab === 'font' && (
<div className="p-2.5 border-t border-border/50 shrink-0">
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mb-1.5 font-semibold">
{t('terminal.themeModal.fontSize')}
<div className="flex items-center justify-between gap-2 mb-1.5">
<div className="text-[9px] uppercase tracking-wider text-muted-foreground font-semibold">
{t('terminal.themeModal.fontSize')}
</div>
{canResetFontSize && (
<button
onClick={onFontSizeReset}
className="text-[10px] font-medium text-primary hover:opacity-80 transition-opacity"
>
{t('common.useGlobal')}
</button>
)}
</div>
<div className="flex items-center justify-between gap-2 bg-muted/30 rounded-lg p-1.5">
<button

View File

@@ -48,7 +48,7 @@ interface UseServerStatsOptions {
sessionId: string;
enabled: boolean; // Whether stats collection is enabled (from settings)
refreshInterval: number; // Refresh interval in seconds
isLinux: boolean; // Only collect stats for Linux servers
isSupportedOs: boolean; // Only collect stats for Linux/macOS servers
isConnected: boolean; // Only collect when connected
}
@@ -56,7 +56,7 @@ export function useServerStats({
sessionId,
enabled,
refreshInterval,
isLinux,
isSupportedOs,
isConnected,
}: UseServerStatsOptions) {
const [stats, setStats] = useState<ServerStats>({
@@ -86,7 +86,7 @@ export function useServerStats({
const isMountedRef = useRef(true);
const fetchStats = useCallback(async () => {
if (!enabled || !isLinux || !isConnected || !sessionId) {
if (!enabled || !isSupportedOs || !isConnected || !sessionId) {
return;
}
@@ -137,7 +137,7 @@ export function useServerStats({
setIsLoading(false);
}
}
}, [sessionId, enabled, isLinux, isConnected]);
}, [sessionId, enabled, isSupportedOs, isConnected]);
// Initial fetch and periodic refresh
useEffect(() => {
@@ -149,8 +149,7 @@ export function useServerStats({
intervalRef.current = null;
}
// Don't run if not enabled or not a Linux system
if (!enabled || !isLinux || !isConnected) {
if (!enabled || !isSupportedOs || !isConnected) {
// Reset stats when disabled or not connected
setStats({
cpu: null,
@@ -193,7 +192,7 @@ export function useServerStats({
intervalRef.current = null;
}
};
}, [enabled, isLinux, isConnected, refreshInterval, fetchStats]);
}, [enabled, isSupportedOs, isConnected, refreshInterval, fetchStats]);
// Manual refresh function
const refresh = useCallback(() => {

View File

@@ -10,6 +10,19 @@ interface CompiledRule {
color: string;
}
interface CachedDecorationRange {
x: number;
width: number;
color: string;
}
/** Shared empty array for non-matching lines to avoid per-call allocations. */
const EMPTY_RANGES: readonly CachedDecorationRange[] = Object.freeze([]);
/** ASCII-only test — when true, string indices equal cell columns. */
// eslint-disable-next-line no-control-regex
const RE_ASCII_ONLY = /^[\x00-\x7f]*$/;
/**
* Manages terminal decorations for keyword highlighting.
* Uses xterm.js Decoration API to overlay styles without modifying the data stream.
@@ -20,6 +33,9 @@ export class KeywordHighlighter implements IDisposable {
private compiledRules: CompiledRule[] = [];
private decorations: { decoration: IDecoration; marker: IMarker }[] = [];
private debounceTimer: NodeJS.Timeout | null = null;
private animationFrameId: number | null = null;
private lastRefreshTime: number = 0;
private matchCache = new Map<string, CachedDecorationRange[]>();
private enabled: boolean = false;
private disposables: IDisposable[] = [];
private lastViewportY: number = -1;
@@ -27,30 +43,26 @@ export class KeywordHighlighter implements IDisposable {
constructor(term: XTerm) {
this.term = term;
// Debug logging
console.log('[KeywordHighlighter] Initialized');
// Hook into terminal events to trigger highlighting
this.disposables.push(
// When user scrolls, refresh visible area
this.term.onScroll(() => {
// console.log('[KeywordHighlighter] onScroll');
this.triggerRefresh();
this.triggerRefresh("debounced");
}),
// When new data is written, refresh
// When new data is written, refresh on the next frame so highlights land
// with the freshly rendered content instead of trailing behind it.
this.term.onWriteParsed(() => {
// console.log('[KeywordHighlighter] onWriteParsed');
this.triggerRefresh();
this.triggerRefresh("immediate");
}),
// Also refresh on resize as viewport content changes
this.term.onResize(() => this.triggerRefresh()),
this.term.onResize(() => this.triggerRefresh("debounced")),
// onRender fires after each render cycle - catch scrolls that onScroll might miss
this.term.onRender(() => {
// Only trigger refresh if viewport position changed
const currentViewportY = this.term.buffer.active?.viewportY ?? 0;
if (currentViewportY !== this.lastViewportY) {
this.lastViewportY = currentViewportY;
this.triggerRefresh();
this.triggerRefresh("debounced");
}
})
);
@@ -58,6 +70,7 @@ export class KeywordHighlighter implements IDisposable {
public setRules(rules: KeywordHighlightRule[], enabled: boolean) {
this.enabled = enabled;
this.matchCache.clear();
// Pre-compile all patterns into regexes for better performance
// This avoids creating new RegExp objects on every viewport refresh
@@ -79,7 +92,7 @@ export class KeywordHighlighter implements IDisposable {
// Clear existing and force an immediate refresh if enabling
this.clearDecorations();
if (this.enabled && this.compiledRules.length > 0) {
this.triggerRefresh();
this.triggerRefresh("immediate");
}
}
@@ -90,9 +103,14 @@ export class KeywordHighlighter implements IDisposable {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
this.matchCache.clear();
}
private triggerRefresh() {
private triggerRefresh(mode: "immediate" | "debounced") {
if (!this.enabled || this.compiledRules.length === 0) return;
// Optimization: Disable highlighting in Alternate Buffer (e.g. Vim, Htop)
@@ -104,12 +122,72 @@ export class KeywordHighlighter implements IDisposable {
return;
}
if (mode === "immediate") {
// Throttle: skip if a rAF is already pending.
// Don't clear the debounce timer here — in a hidden tab rAF never
// fires, so the fallback timer is the only path that will run.
if (this.animationFrameId !== null) {
return;
}
const now = performance.now();
const minInterval = XTERM_PERFORMANCE_CONFIG.highlighting.immediateMinIntervalMs;
if (now - this.lastRefreshTime < minInterval) {
// Too soon — fall through to debounced path instead of dropping
this.triggerRefresh("debounced");
return;
}
this.animationFrameId = requestAnimationFrame(() => {
this.animationFrameId = null;
// rAF fired — cancel the fallback timer to avoid a redundant refresh
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
this.debounceTimer = null;
}
this.executeRefresh();
});
// Arm a debounced fallback: rAF does not fire in background/hidden
// tabs (Chromium throttles it), so the timer ensures highlights
// still update for ongoing output. If rAF fires first it cancels
// this timer (see above), preventing a double refresh.
if (!this.debounceTimer) {
this.debounceTimer = setTimeout(() => {
this.debounceTimer = null;
this.executeRefresh();
}, XTERM_PERFORMANCE_CONFIG.highlighting.debounceMs);
}
return;
}
if (this.animationFrameId !== null) {
return;
}
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
const delay = XTERM_PERFORMANCE_CONFIG.highlighting.debounceMs;
this.debounceTimer = setTimeout(() => this.refreshViewport(), delay);
this.debounceTimer = setTimeout(() => {
this.debounceTimer = null;
this.executeRefresh();
}, delay);
}
/** Shared refresh execution for both rAF and timer callbacks. */
private executeRefresh() {
// Cancel any stale rAF that will never fire (e.g. hidden tab)
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
// Re-check state: may have changed since the refresh was scheduled
if (!this.enabled || this.compiledRules.length === 0) return;
if (this.term.buffer.active.type === 'alternate') {
if (this.decorations.length > 0) this.clearDecorations();
return;
}
this.lastRefreshTime = performance.now();
this.refreshViewport();
}
private clearDecorations() {
@@ -143,8 +221,14 @@ export class KeywordHighlighter implements IDisposable {
// Skip continuation cells (width 0) - these are the 2nd cell of wide characters
if (width === 0) continue;
// Map each character in this cell to the current cell column
for (let i = 0; i < chars.length; i++) {
if (chars.length > 0) {
// Map each character in this cell to the current cell column
for (let i = 0; i < chars.length; i++) {
map.push(cellCol);
}
} else {
// Empty cell (codepoint 0) — translateToString() outputs a space
// for it, so we must push one entry to keep the map aligned.
map.push(cellCol);
}
@@ -180,49 +264,106 @@ export class KeywordHighlighter implements IDisposable {
const lineText = line.translateToString(true); // true = trim right whitespace
if (!lineText) continue;
// Build mapping from string index to cell column for wide char support
const cellMap = this.buildStringToCellMap(line);
const cachedRanges = this.getCachedRanges(line, lineText);
if (cachedRanges.length === 0) continue;
// Process each pre-compiled rule
for (const { regex, color } of this.compiledRules) {
// Reset regex state for reuse (global flag maintains lastIndex)
regex.lastIndex = 0;
let match;
// Calculate offset relative to the absolute cursor position
// offset = targetLineAbs - (baseY + cursorY)
const offset = lineY - cursorAbsoluteY;
while ((match = regex.exec(lineText)) !== null) {
const strStart = match.index;
const strEnd = strStart + match[0].length;
for (const range of cachedRanges) {
const marker = this.term.registerMarker(offset);
// Map string indices to cell columns
const cellStartCol = cellMap[strStart] ?? strStart;
const cellEndCol = cellMap[strEnd] ?? strEnd;
const cellWidth = cellEndCol - cellStartCol;
if (marker) {
const deco = this.term.registerDecoration({
marker,
x: range.x,
width: range.width,
foregroundColor: range.color,
});
// Skip if width is 0 or negative (shouldn't happen, but be safe)
if (cellWidth <= 0) continue;
// Calculate offset relative to the absolute cursor position
// offset = targetLineAbs - (baseY + cursorY)
const offset = lineY - cursorAbsoluteY;
const marker = this.term.registerMarker(offset);
if (marker) {
const deco = this.term.registerDecoration({
marker,
x: cellStartCol,
width: cellWidth,
foregroundColor: color,
});
if (deco) {
this.decorations.push({ decoration: deco, marker });
} else {
// If decoration failed, cleanup marker
marker.dispose();
}
if (deco) {
this.decorations.push({ decoration: deco, marker });
} else {
// If decoration failed, cleanup marker
marker.dispose();
}
}
}
}
}
private getCachedRanges(line: IBufferLine, lineText: string): CachedDecorationRange[] {
const cached = this.matchCache.get(lineText);
if (cached) {
// LRU: move to end
this.matchCache.delete(lineText);
this.matchCache.set(lineText, cached);
return cached;
}
const ranges = this.scanLine(line, lineText);
this.matchCache.set(lineText, ranges);
const maxEntries = XTERM_PERFORMANCE_CONFIG.highlighting.cacheEntries;
if (this.matchCache.size > maxEntries) {
const oldestKey = this.matchCache.keys().next().value;
if (oldestKey !== undefined) {
this.matchCache.delete(oldestKey);
}
}
return ranges;
}
private scanLine(line: IBufferLine, lineText: string): CachedDecorationRange[] {
// ASCII-only lines have a 1:1 string-index-to-cell-column mapping,
// so we can skip the expensive buildStringToCellMap call entirely.
const asciiOnly = RE_ASCII_ONLY.test(lineText);
let cellMap: number[] | null = null;
let ranges: CachedDecorationRange[] | null = null;
// Process each pre-compiled rule
for (const { regex, color } of this.compiledRules) {
// Reset regex state for reuse (global flag maintains lastIndex)
regex.lastIndex = 0;
let match;
while ((match = regex.exec(lineText)) !== null) {
const strStart = match.index;
const strEnd = strStart + match[0].length;
let cellStartCol: number;
let cellEndCol: number;
if (asciiOnly) {
cellStartCol = strStart;
cellEndCol = strEnd;
} else {
// Lazily build cellMap only when a match is found
if (cellMap === null) {
cellMap = this.buildStringToCellMap(line);
}
cellStartCol = cellMap[strStart] ?? strStart;
cellEndCol = cellMap[strEnd] ?? strEnd;
}
const cellWidth = cellEndCol - cellStartCol;
// Skip if width is 0 or negative (shouldn't happen, but be safe)
if (cellWidth <= 0) continue;
if (ranges === null) {
ranges = [];
}
ranges.push({
x: cellStartCol,
width: cellWidth,
color,
});
}
}
return ranges ?? (EMPTY_RANGES as CachedDecorationRange[]);
}
}

View File

@@ -44,7 +44,7 @@ type TerminalBackendApi = {
cb: (evt: { exitCode?: number; signal?: number; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => void,
) => () => void;
onChainProgress: (
cb: (hop: number, total: number, label: string, status: string) => void,
cb: (sessionId: string, hop: number, total: number, label: string, status: string, error?: string) => void,
) => (() => void) | undefined;
writeToSession: (sessionId: string, data: string) => void;
resizeSession: (sessionId: string, cols: number, rows: number) => void;
@@ -64,6 +64,12 @@ type ChainProgressState = {
currentHostLabel: string;
} | null;
export type SessionLogConfig = {
enabled: boolean;
directory: string;
format: string;
};
export type TerminalSessionStartersContext = {
host: Host;
keys: SSHKey[];
@@ -76,6 +82,7 @@ export type TerminalSessionStartersContext = {
terminalSettingsRef?: RefObject<TerminalSettings | undefined>;
terminalBackend: TerminalBackendApi;
serialConfig?: SerialConfig;
sessionLog?: SessionLogConfig;
isVisibleRef?: RefObject<boolean>;
pendingOutputScrollRef?: RefObject<boolean>;
@@ -316,7 +323,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
: undefined;
const jumpHostsWithUnavailableCredentials: string[] = [];
const jumpHosts = ctx.resolvedChainHosts.map<NetcattyJumpHost>((jumpHost) => {
const jumpHosts = ctx.resolvedChainHosts.map<NetcattyJumpHost>((jumpHost, index) => {
const jumpAuth = resolveHostAuth({
host: jumpHost,
keys: ctx.keys,
@@ -329,13 +336,20 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
const jumpPassword = sanitizeCredentialValue(rawJumpPassword);
const jumpPrivateKey = sanitizeCredentialValue(rawJumpPrivateKey);
const jumpPassphrase = sanitizeCredentialValue(rawJumpPassphrase);
const hasConfiguredJumpProxyEndpoint =
index === 0 &&
!!(jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port);
const hasEncryptedJumpProxyCredential =
hasConfiguredJumpProxyEndpoint &&
Boolean(jumpHost.proxyConfig?.username) &&
isEncryptedCredentialPlaceholder(jumpHost.proxyConfig?.password);
const hasEncryptedJumpCredential =
isEncryptedCredentialPlaceholder(rawJumpPassword) ||
isEncryptedCredentialPlaceholder(rawJumpPrivateKey) ||
isEncryptedCredentialPlaceholder(rawJumpPassphrase);
if (hasEncryptedJumpCredential && !jumpPassword && !jumpPrivateKey) {
if (hasEncryptedJumpProxyCredential || (hasEncryptedJumpCredential && !jumpPassword && !jumpPrivateKey)) {
jumpHostsWithUnavailableCredentials.push(jumpHost.label || jumpHost.hostname);
}
@@ -351,10 +365,21 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
keyId: jumpAuth.keyId,
keySource: jumpKey?.source,
label: jumpHost.label,
proxy: jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port
? {
type: jumpHost.proxyConfig.type,
host: jumpHost.proxyConfig.host,
port: jumpHost.proxyConfig.port,
username: jumpHost.proxyConfig.username,
password: sanitizeCredentialValue(jumpHost.proxyConfig.password),
}
: undefined,
identityFilePaths: jumpHost.identityFilePaths,
};
});
if (hasEncryptedProxyPassword && !proxyConfig?.password && proxyConfig?.username) {
const usesTargetProxyForFirstHop = !!proxyConfig && !jumpHosts[0]?.proxy;
if (usesTargetProxyForFirstHop && hasEncryptedProxyPassword && !proxyConfig?.password && proxyConfig?.username) {
const message = tr(
"terminal.auth.proxyCredentialsUnavailable",
"Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.",
@@ -396,21 +421,64 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
currentHostLabel:
jumpHosts[0]?.label || jumpHosts[0]?.hostname || ctx.host.hostname,
});
ctx.setProgressLogs((prev) => [
...prev,
`Starting chain connection (${totalHops} hops)...`,
]);
}
const unsub = ctx.terminalBackend.onChainProgress((hop, total, label, status) => {
ctx.setChainProgress({
currentHop: hop,
totalHops: total,
currentHostLabel: label,
});
ctx.setProgressLogs((prev) => [
...prev,
`Chain ${hop} of ${total}: ${label} - ${status}`,
]);
{
const unsub = ctx.terminalBackend.onChainProgress((sid, hop, total, label, status, error) => {
// P1: Only process events for this session
if (sid !== ctx.sessionId) return;
// P3: Only show chain progress UI for multi-hop connections
if (total > 1) {
ctx.setChainProgress({
currentHop: hop,
totalHops: total,
currentHostLabel: label,
});
}
// Build human-readable log line
let logLine: string;
const prefix = total > 1 ? `[${hop}/${total}] ` : '';
switch (status) {
case 'connecting':
logLine = `${prefix}${tr("terminal.progress.connecting", "Connecting to")} ${label}...`;
break;
case 'authenticating':
logLine = `${prefix}${label} - ${tr("terminal.progress.keyExchangeComplete", "Key exchange complete")}`;
break;
case 'auth-attempt':
if (error?.endsWith('rejected')) {
logLine = `${prefix}${label} - ✗ ${error}`;
} else if (error === 'all methods exhausted') {
logLine = `${prefix}${label} - ✗ All authentication methods exhausted`;
} else if (error === 'waiting for user input...' || error === 'user responded') {
logLine = `${prefix}${label} - ${error}`;
} else {
logLine = `${prefix}${label} - ${tr("terminal.progress.trying", "Trying")} ${error}...`;
}
break;
case 'authenticated':
logLine = `${prefix}${label} - ${tr("terminal.progress.authenticated", "Authenticated")}`;
break;
case 'connected':
logLine = `${prefix}${label} - ${tr("terminal.progress.connected", "Connected")}`;
break;
case 'forwarding':
logLine = `${prefix}${label} - ${tr("terminal.progress.forwarding", "Forwarding")}...`;
break;
case 'shell':
logLine = `${prefix}${tr("terminal.progress.openingShell", "Opening shell")}...`;
break;
case 'error':
logLine = `${prefix}${label} - ${tr("terminal.progress.error", "Error")}${error ? `: ${error}` : ''}`;
break;
default:
logLine = `${prefix}${label} - ${status}${error ? `: ${error}` : ''}`;
}
ctx.setProgressLogs((prev) => [...prev, logLine]);
const hopProgress = (hop / total) * 80 + 10;
ctx.setProgressValue(Math.min(95, hopProgress));
});
@@ -420,21 +488,13 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
try {
const termEnv = buildTermEnv(ctx.host, ctx.terminalSettings);
// DEBUG: Log key info for troubleshooting
console.log("[Terminal] Starting SSH session with key info:", {
keyId: key?.id,
keyLabel: key?.label,
keySource: key?.source,
hasPublicKey: !!key?.publicKey,
hasPrivateKey: !!key?.privateKey,
});
const startAttempt = async (attempt: {
password?: string;
key?: SSHKey;
}): Promise<string> => {
return ctx.terminalBackend.startSSHSession({
sessionId: ctx.sessionId,
hostLabel: ctx.host.label,
hostname: ctx.host.hostname,
username: effectiveUsername,
port: ctx.host.port || 22,
@@ -456,6 +516,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
proxy: proxyConfig,
jumpHosts: jumpHosts.length > 0 ? jumpHosts : undefined,
keepaliveInterval: ctx.terminalSettings?.keepaliveInterval,
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
// Only pass local key paths if no vault key is explicitly configured
identityFilePaths: attempt.key ? undefined : ctx.host.identityFilePaths,
});
};
@@ -547,6 +610,18 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
}
}, 600);
}
// Run OS detection only after successful connection
setTimeout(
() =>
void runDistroDetection(ctx, {
username: effectiveUsername,
password: usedPassword,
key: usedKey,
passphrase: effectivePassphrase,
}),
600,
);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const authError = isAuthError(err);
@@ -572,17 +647,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
ctx.setChainProgress(null);
if (unsubscribeChainProgress) unsubscribeChainProgress();
}
setTimeout(
() =>
void runDistroDetection(ctx, {
username: effectiveUsername,
password: usedPassword,
key: usedKey,
passphrase: effectivePassphrase,
}),
600,
);
};
const startTelnet = async (term: XTerm) => {
@@ -609,6 +673,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
rows: term.rows,
charset: ctx.host.charset,
env: telnetEnv,
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
});
attachSessionToTerminal(ctx, term, id, {
@@ -650,6 +715,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
rows: term.rows,
charset: ctx.host.charset,
env: moshEnv,
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
});
attachSessionToTerminal(ctx, term, id, {
@@ -708,6 +774,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
env: {
TERM: ctx.terminalSettings?.terminalEmulationType ?? "xterm-256color",
},
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
});
ctx.sessionRef.current = id;
@@ -787,6 +854,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
stopBits: ctx.serialConfig.stopBits,
parity: ctx.serialConfig.parity,
flowControl: ctx.serialConfig.flowControl,
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
});
// Serial connection is established immediately when session starts

View File

@@ -22,6 +22,10 @@ import {
shouldScrollOnTerminalInput,
shouldScrollOnTerminalPaste,
} from "../../../domain/terminalScroll";
import {
resolveHostTerminalFontFamilyId,
resolveHostTerminalFontSize,
} from "../../../domain/terminalAppearance";
import { logger } from "../../../lib/logger";
import { isMacPlatform, normalizeLineEndings, wrapBracketedPaste } from "../../../lib/utils";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
@@ -141,12 +145,12 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
rendererType,
});
const hostFontId = ctx.host.fontFamily || ctx.fontFamilyId || "menlo";
const hostFontId = resolveHostTerminalFontFamilyId(ctx.host, ctx.fontFamilyId) || "menlo";
// Use fontStore for font lookup - guarantees non-empty result
const fontObj = fontStore.getFontById(hostFontId);
const fontFamily = fontObj.family;
const effectiveFontSize = ctx.host.fontSize || ctx.fontSize;
const effectiveFontSize = resolveHostTerminalFontSize(ctx.host, ctx.fontSize);
const cursorStyle = settings?.cursorShape ?? "block";
const cursorBlink = settings?.cursorBlink ?? true;
@@ -157,6 +161,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
const lineHeight = 1 + (settings?.linePadding ?? 0) / 10;
const minimumContrastRatio = settings?.minimumContrastRatio ?? 1;
const scrollOnUserInput = shouldEnableNativeUserInputAutoScroll(settings);
const smoothScrollDuration = settings?.smoothScrolling
? performanceConfig.options.smoothScrollDuration
: 0;
const altIsMeta = settings?.altAsMeta ?? false;
const wordSeparator = settings?.wordSeparators ?? " ()[]{}'\"";
const keywordHighlightRules = settings?.keywordHighlightRules ?? [];
@@ -209,6 +216,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
allowProposedApi: true,
drawBoldTextInBrightColors,
minimumContrastRatio,
smoothScrollDuration,
scrollOnUserInput,
macOptionClickForcesSelection: true,
altClickMovesCursor: !altIsMeta,
@@ -387,13 +395,17 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
e.preventDefault();
e.stopPropagation();
// Send the snippet command to the terminal
const payload = snippet.noAutoRun
? normalizeLineEndings(snippet.command)
: `${normalizeLineEndings(snippet.command)}\r`;
ctx.terminalBackend.writeToSession(id, payload);
let snippetData = normalizeLineEndings(snippet.command);
if (!snippet.noAutoRun) snippetData = `${snippetData}\r`;
// Broadcast the normalized (un-wrapped) data so each target
// session can apply its own bracket paste state
if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
ctx.onBroadcastInputRef.current(payload, ctx.sessionId);
ctx.onBroadcastInputRef.current(snippetData, ctx.sessionId);
}
// Wrap for this terminal only, after broadcasting
const snippetIsMultiLine = snippetData.includes("\n");
if (snippetIsMultiLine && term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste) snippetData = wrapBracketedPaste(snippetData);
ctx.terminalBackend.writeToSession(id, snippetData);
if (!snippet.noAutoRun && ctx.onCommandExecuted) {
const cmd = snippet.command.trim();
if (cmd) ctx.onCommandExecuted(cmd, ctx.host.id, ctx.host.label, ctx.sessionId);
@@ -423,20 +435,6 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
if (terminalActions.has(action)) {
e.preventDefault();
e.stopPropagation();
const hotkeyDebug =
import.meta.env.DEV &&
typeof window !== "undefined" &&
window.localStorage?.getItem("debug.hotkeys") === "1";
if (hotkeyDebug) {
console.log('[Hotkeys] Xterm terminal-level', {
action,
key: e.key,
meta: e.metaKey,
ctrl: e.ctrlKey,
alt: e.altKey,
shift: e.shiftKey,
});
}
switch (action) {
case "copy": {
const selection = term.getSelection();

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