Compare commits

...

263 Commits

Author SHA1 Message Date
陈大猫
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
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
陈大猫
63558b5301 Remove HTTP localhost-only restriction for AI requests (#393)
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 the restriction that blocked non-localhost HTTP URLs for AI
provider requests. Users with HTTP-based AI services on internal
networks can now configure http:// provider base URLs.

Security measures:
- Only providers explicitly configured with http:// are allowed over HTTP
- HTTPS-configured providers cannot be silently downgraded
- Temporary HTTP permissions expire after 30s TTL
- Non-http/https schemes are explicitly rejected
- webSearchApiHost entries preserved from accidental expiry

Fixes #392
2026-03-18 19:57:47 +08:00
陈大猫
c2b4d43531 Merge pull request #391 from binaricat/fix/sftp-download-windows-drive-root 2026-03-18 16:11:10 +08:00
bincxz
4d5c0eed69 Fix SFTP download failing on Windows drive root paths
On Windows, `fs.promises.mkdir("E:\", { recursive: true })` throws
EPERM for drive root directories. When users save SFTP downloads to a
drive root (e.g. E:\file.txt), `path.dirname` returns "E:\" and the
subsequent mkdir fails. Fix by catching the error and verifying the
directory already exists before re-throwing.

Fixes #390

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 16:06:23 +08:00
bincxz
3ad710e5da Fix AI error message wrapping
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-18 13:38:30 +08:00
陈大猫
d2e5a26317 Merge pull request #374 from yuzifu/fix-host-count-in-tree-view
Fix host count in tree view
2026-03-18 13:30:42 +08:00
陈大猫
4f1eb4a8a9 Merge pull request #389 from binaricat/codex/show-raw-ai-errors
Show raw AI errors instead of inferred causes
2026-03-18 13:26:41 +08:00
bincxz
e35bb708a2 Show raw AI errors instead of inferred causes 2026-03-18 13:00:27 +08:00
陈大猫
cd2631428e Fix AI scope leaking across tab switches (#388)
* Fix AI scope leaking across tab switches

* Keep AI executor context live across resumes
2026-03-18 11:56:28 +08:00
yuzifu
09af399543 fix: import import certificate icon size too small (#387)
fix icon small when dropdown item text is too long

Co-authored-by: yuzifu <yuzifu@TB16PGen5.Info>
2026-03-18 10:07:07 +08:00
陈大猫
db9970d040 fix: surface streaming provider errors in chat (#386)
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: surface streaming provider errors in chat

* fix: sanitize streaming status text as ByteString
2026-03-18 03:44:59 +08:00
陈大猫
3d4fbf8763 fix: keep workspace MCP scope in sync (#385)
* fix: keep workspace MCP scope in sync

* fix: refresh catty workspace tool context

* fix: preserve AI stream state across tab switches

* fix: align ACP stop and resume with 1code semantics

* fix: harden ACP resume fallback for unsupported agents
2026-03-18 03:33:00 +08:00
陈大猫
9387590696 Fix ACP stop cleanup and cancel state (#384)
* Fix ACP stop cleanup and cancel state

* Block ACP tool writes after stop

* Kill ACP child processes on cleanup

* Cleanup ACP sessions when tabs disappear
2026-03-18 02:24:36 +08:00
陈大猫
74a04f1d8e feat: three-way merge for cloud sync (#381)
Implements automatic three-way merge for cloud sync, replacing the
binary USE_REMOTE/USE_LOCAL conflict resolution. Same principle as
Git's merge algorithm.

After every successful sync, a "base snapshot" is saved (encrypted
with AES-256-GCM using the derived master key). When a conflict is
detected, the system performs per-entity merge by ID:
- Items added on one side → included
- Items deleted on one side (unchanged on other) → removed
- Items modified on one side only → take that version
- Both sides modified same item → prefer local
- One side deleted + other modified → keep modification

Additional improvements:
- Per-provider sync base to prevent cross-provider contamination
- Deep merge for nested settings (terminalSettings, customKeyBindings)
- Entity merge for array-valued settings (customTerminalThemes)
- KnownHost deduplication by (hostname, port, keyType)
- Chunked base encoding to avoid stack overflow on large vaults
- Base cleared on provider disconnect/reconnect
- Correct version numbering after multi-provider merge

Closes #378

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 02:12:49 +08:00
陈大猫
3c258b0f19 feat: auto-close tab when user actively exits session (#380)
* feat: auto-close tab when user actively exits a session

When a user intentionally exits a session (e.g. typing `exit`, `logout`,
or Ctrl+D), the tab is now automatically closed instead of showing the
"Start Over" disconnected page. This matches the behavior of macOS
Terminal and other popular terminal emulators.

Network errors, timeouts, and server-initiated disconnects still show
the disconnected page with the Start Over option, so users can reconnect.

In workspace mode, only the individual terminal pane is closed, not the
entire workspace.

Implementation:
- Backend bridges now include a `reason` field in exit events to
  distinguish stream-level exits ("exited") from connection errors
  ("error"), timeouts ("timeout"), and connection closes ("closed")
- SSH bridge captures real exit code from stream "exit" event instead
  of hardcoding 0
- Frontend auto-closes session only when reason is "exited"

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

* fix: address review feedback for auto-close feature

1. Pass exit event to onSessionExit in local shell path (line 757)
   to prevent undefined access when checking evt.reason

2. Change Telnet socket close reason from "exited" to "closed" since
   a clean socket close can also be server-initiated (idle timeout,
   remote shutdown), not just user exit

3. Change Serial port close reason from "exited" to "closed" since
   port close can be from device disconnect, not user action

Only SSH stream close and local/mosh process exit (node-pty onExit)
now use reason "exited", which correctly represents user-initiated exits.

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

* fix: only mark SSH exit as "exited" when stream exit event fired

ssh2's stream "close" event fires whenever the channel closes, not
only on normal shell exit. If the network drops and the channel closes
without a preceding "exit" event, the reason was incorrectly set to
"exited", causing the tab to auto-close instead of showing the
disconnected/Start Over page.

Now tracks whether stream "exit" actually fired via a flag, and only
uses reason "exited" in that case. Otherwise falls back to "closed".

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

* fix: classify mosh non-zero exits as errors

Mosh process exiting with a non-zero code typically indicates a
connection or auth failure. Mark these as reason "error" so the
disconnected/Start Over UI is shown instead of auto-closing the tab.

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

* fix: treat SSH signal-terminated exits as disconnects

ssh2's stream "exit" event also fires for signal terminations (e.g.
SIGHUP from server idle timeout, SIGTERM from admin kill), where code
is null and signal is set. These are not user-initiated exits and
should show the disconnected/Start Over page.

Now only sets streamExited=true when there's a numeric exit code and
no signal present.

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

* fix: distinguish abnormal local PTY exits from user exits

Local shell terminated by signal or crashing on startup should show
the disconnected UI, not auto-close the tab. Now only marks as
reason "exited" when exitCode is 0 and no signal, matching the same
logic used for mosh.

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

* fix: use signal presence to distinguish local shell exit reason

For local shells, non-zero exit codes are common in user-initiated
exits (e.g. typing `exit` after a failed command returns that
command's exit code). Use signal presence instead: signal means the
process was killed externally (show disconnected UI), no signal
means normal process exit (auto-close tab).

Mosh keeps exitCode-based logic since non-zero there indicates
connection/auth failure, not user exit.

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-17 23:45:56 +08:00
陈大猫
6303eef3a2 fix: make global and host-level keyword highlight independent (#379) 2026-03-17 22:59:02 +08:00
yuzifu
a9a648039f Merge branch 'main' into fix-host-count-in-tree-view 2026-03-17 21:53:30 +08:00
陈大猫
ccfa2d4dd0 fix: non-zero exit code is not a failure, include output on real errors (#377)
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: treat non-zero exit code as success and include output on failure

- Non-zero exit codes (e.g. grep returning 1, ls on missing file) are
  valid command results, not execution failures. Changed execViaPty and
  execViaChannel to always return ok:true when the command actually ran.
- ok:false is now reserved for real failures: timeout, session gone,
  stream not writable, etc.
- When ok:false, include any partial stdout/stderr in the error message
  so the user and LLM can see what happened before the failure.

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

* fix: return stdout+exitCode for all completed commands, clean up dead code

- ptyExec: preserve original ok semantics (non-zero = ok:false) so MCP
  server bridge callers (handleMultiExec, stopOnError) still work
- execViaChannel: null exit code (SSH disconnect) returns ok:false
- toolExecutors: Catty Agent always returns stdout+exitCode to the LLM
  regardless of exit code, only treats real failures (timeout, disconnect)
  as errors — with partial output included
- Remove dead code: executeTerminalSendInput, executeSftp*, executeMultiHost
- Clean up unused imports, bridge interface, ExecutorContext

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-17 19:53:23 +08:00
陈大猫
7c5478b2a5 refactor: remove SFTP tools from AI agent (#376)
Remove sftp_list_directory, sftp_read_file, and sftp_write_file tools.
The AI can use terminal_execute with standard shell commands (ls, cat,
tee, etc.) which is more flexible, supports sudo/pipes/redirects, and
reduces tool choice complexity for the LLM.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 18:59:47 +08:00
陈大猫
338ba94d42 feat: add paste-only option for snippets (no auto-execute) (#375)
* feat: add "paste only" option for snippets (no auto-execute)

Add a noAutoRun flag to snippets that pastes the command into the
terminal without appending a carriage return, so users can review
and edit before manually pressing Enter.

Applies to all snippet execution paths: snippet runner (new session),
keyboard shortcut, and startup command.

Closes #371

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

* fix: use clearer wording "仅粘贴" instead of "仅上屏"

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

* fix: skip onCommandExecuted for paste-only shortcut snippets

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

* fix: persist noAutoRun on save and apply to Scripts panel clicks

- Include noAutoRun in handleSubmit serialization (was being lost)
- Pass noAutoRun through ScriptsSidePanel click handler to TerminalLayer
  so paste-only snippets work from the Scripts panel too

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-17 18:09:17 +08:00
yuzifu
1d4ec7afb9 Merge remote-tracking branch 'origin/fix-host-count-in-tree-view' into fix-host-count-in-tree-view 2026-03-17 17:25:00 +08:00
yuzifu
a1899951e0 fix: show hosts count(update)
Avoid recalculating the number of hosts during re-rendering
2026-03-17 17:24:16 +08:00
陈大猫
b7b2e91fab fix: show real error message instead of [object Object] (#373)
* fix: show real error message instead of [object Object]

When an error object (not a string or Error instance) reaches the
error display path, String(obj) produces "[object Object]". Now
extract .message from error-like objects, or JSON.stringify as fallback.

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

* fix: guard JSON.stringify fallback against undefined return

JSON.stringify(undefined) returns undefined (not a string), which would
crash classifyError().toLowerCase(). Add ?? 'Unknown error' fallback.

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

* fix: use non-throwing fallback for error serialization

JSON.stringify can throw on circular objects or BigInt values. Wrap in
try-catch to avoid losing the original error and leaving the stream
stuck in a streaming state.

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-17 17:23:05 +08:00
yuzifu
cd723000fc fix: show host count in tree view (#364)
* fix: show host count in tree view

* update show host count in tree view

* perf: memoize subtree host count to avoid repeated traversals

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

---------

Co-authored-by: yuzifu <yuzifu@TB16PGen5.Info>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 17:00:31 +08:00
bincxz
d84668aa0f perf: memoize subtree host count to avoid repeated traversals
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 16:52:41 +08:00
yuzifu
68d0f4574c update show hosts count in tree view 2026-03-17 16:40:45 +08:00
陈大猫
fff031eb25 fix: remove multi_host_execute and fix MissingToolResultsError (#372)
Remove multi_host_execute tool — the AI can call terminal_execute for
each host individually, which is simpler, more reliable, and avoids
the hang issue where parallel remote commands block the stream.

Fix AI_MissingToolResultsError that occurs after user stops a stream
mid-tool-execution: when building SDK messages, skip orphaned tool
calls that have no matching tool result instead of including them
(which causes the SDK to reject the next message).

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 16:29:57 +08:00
yuzifu
2f1fd399cf fix: avoid repeated sync (#370)
Co-authored-by: yuzifu <yuzifu@TB16PGen5.Info>
2026-03-17 16:17:04 +08:00
陈大猫
43c4d4c430 fix: open settings window on the same display as the main window (#367)
Use Electron's screen.getDisplayMatching() to find which display the
main window is on, then center the settings window on that display's
work area. Previously the settings window used Electron's default
placement which could open on the primary display even when the main
window was on an external monitor.

Ref #294

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 16:05:35 +08:00
陈大猫
835a1231a6 feat: add skip TLS verification option for self-hosted AI providers (#369)
* feat: add skip TLS verification option for AI providers

Self-hosted AI endpoints (vLLM, text-generation-webui, etc.) often use
self-signed TLS certificates which Node.js rejects by default, causing
502 Bad Gateway errors. Add a per-provider "Skip TLS certificate
verification" checkbox that sets rejectUnauthorized=false on both
streaming and non-streaming requests.

Ref #294

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

* fix: surface real error message instead of generic 502 Bad Gateway

- Pass the actual bridge error message in statusText so Vercel AI SDK
  shows the real cause (e.g. "HTTP is only allowed for localhost",
  "URL host is not in the allowed list", TLS errors)
- Show real error details for 5xx provider errors instead of generic
  "The AI provider returned a server error" message

Previously all connection-level errors were masked as "Bad Gateway"
making it impossible for users to diagnose configuration issues.

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

* fix: pass server error body details through to the user

- Read HTTP error response body before resolving (was resolving before
  body was read, losing the error detail)
- Parse OpenAI-compatible JSON error format to extract error.message
- Return error Response with body+statusText for non-2xx instead of
  empty stream, so Vercel AI SDK shows the real server error
- Now users see e.g. "502 model not loaded" instead of just "Bad Gateway"

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

* fix: widen link modifier key dropdown to prevent text wrapping

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

* Revert "fix: widen link modifier key dropdown to prevent text wrapping"

This reverts commit 1f756863910d7450c6ffd8c373ef156e90adcce7.

* fix: apply skipTLSVerify to model listing requests

ModelSelector.aiFetch() didn't pass providerId, so the provider-level
skipTLSVerify was not applied when refreshing/listing models. Add
skipTLSVerify as a direct parameter alongside the provider lookup.

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

* fix: keep error detail in Response body, not statusText

statusText only accepts single-line Latin-1 — multiline or non-ASCII
error messages from self-hosted gateways would throw TypeError before
the AI SDK could read them. Move detailed error to body instead.

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

* fix: return JSON error body for AI SDK compatibility, fix FetchBridge type

- Wrap error responses in OpenAI-compatible JSON format so Vercel AI
  SDK's failedResponseHandler extracts the message correctly instead
  of showing a blank error
- Update FetchBridge type to match the expanded aiFetch parameter list

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

* fix: add ASCII statusText fallback for non-OpenAI SDK providers

Anthropic/Google SDKs fall back to Response.statusText when they can't
parse the error body. Add safe ASCII statusText alongside the JSON body.

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-17 16:05:09 +08:00
陈大猫
cd512d0800 fix: host-level keyword highlight toggle now overrides global setting (#368)
When a host explicitly disables keyword highlighting, global rules are
no longer applied to that terminal. Previously the OR logic
(globalEnabled || hostEnabled) meant per-host disable had no effect
when global highlighting was enabled.

Now: hostEnabled=false suppresses global rules; hostEnabled=undefined
inherits global setting (backward compatible).

Ref #294

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 14:38:59 +08:00
陈大猫
0c5ae13692 fix: widen settings dropdown selects to prevent text wrapping (#366)
Log Format "Plain Text (.txt)" and Link modifier key "None (click
directly)" were wrapping to two lines due to narrow widths.

Closes #294 (dropdown text wrapping)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 14:36:14 +08:00
陈大猫
6727248924 feat: add web search & URL fetch tools for AI agent (#365)
* feat: add web search and URL fetch tools for AI agent

Add web_search and url_fetch tools to Catty Agent, allowing the AI to
search the internet for current information and fetch webpage content.

- Support 5 search providers: Tavily, Exa, Bocha, Zhipu, SearXNG
- Settings UI with provider selection, API key encryption, and config
- web_search is conditional on config; url_fetch is always available
- Both tools are read-only and work in all permission modes (incl. observer)
- aiFetch skipHostCheck for AI tool requests to arbitrary URLs
- System prompt guidelines for when to use search/fetch
- i18n support (en + zh-CN)

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

* fix: address code review findings (SSRF, key exposure, state race)

- P1: Restore SSRF protection when skipHostCheck is true — still block
  localhost, RFC1918, link-local, and cloud metadata endpoints; only
  skip the domain allowlist for public HTTPS hosts
- P2: Move web search API key decryption to main process via dedicated
  IPC handler, matching the existing provider key security model
- P2: Use configRef to avoid stale closure in async settings callbacks
  that could overwrite newer user changes

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

* fix: address second review — DNS rebinding, url_fetch approval, maxResults

- P1: url_fetch now requires approval in confirm mode (outbound GET is
  a side effect that could exfiltrate data via query strings)
- P1: Add DNS resolution check when skipHostCheck is set — resolve
  hostname and reject if any IP is private/loopback/link-local, blocking
  DNS rebinding attacks against internal services
- P2: Slice search results after provider call to enforce maxResults
  consistently (Zhipu and SearXNG ignore the limit parameter)

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

* fix: address third review — localhost/IPv6 SSRF, API key blur race

- P1: Block localhost/loopback when skipHostCheck is enabled — restructure
  isAllowedFetchUrl to check private hosts first in the skipHostCheck path,
  preventing access to local services on allowlisted ports
- P1: Handle IPv6 private ranges (fc00::/7, fe80::/10, ::ffff: mapped),
  strip brackets from URL.hostname, block [::1] and fd00:: addresses
- P2: Guard handleApiKeyBlur against provider change during async
  encryption — skip stale write if provider switched while encrypting

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

* fix: address fourth review — main-process key isolation, SearXNG compat

- P1: Replace aiWebSearchDecryptKey IPC with __WEB_SEARCH_KEY__ placeholder
  pattern — renderer never sees plaintext keys; main process replaces
  placeholder in headers before HTTP request, matching provider key flow
- P1: Search API requests use normal allowlist path (not skipHostCheck),
  so SearXNG on localhost/HTTP/private networks works via aiSyncWebSearch;
  only url_fetch uses skipHostCheck for arbitrary public HTTPS URLs
- P2: Remove needsApproval from url_fetch — treat as read-only like
  sftp_read_file, consistent with observer mode allowlist

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

* fix: address fifth review — private LAN providers, maxResults default

- P1: Allow private-IP hosts that are explicitly in the provider/search
  allowlist (e.g. https://192.168.x.x model providers or SearXNG)
- P2: Remove .default(5) from web_search maxResults schema so the user's
  configured maxResults setting is used when the model omits the param

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

* fix: address sixth review — HTTPS scope, config gate, redirects

- P2: Scope HTTP exception to private/LAN IPs only — remote allowlisted
  hosts still require HTTPS to protect API keys in transit
- P2: Gate web_search tool on complete config (API key for providers that
  require it, apiHost for SearXNG) to avoid advertising a broken tool
- P2: Add redirect following (up to 5 hops) to aiFetch for url_fetch —
  handles 301/302/307 for short links, www canonicalization, etc.

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

* fix: address seventh review — redirect SSRF, decrypt race, HTTPS-only

- P1: Revalidate each redirect hop against SSRF guards (allowlist check
  + DNS resolution) before following, preventing open-redirect SSRF
- P2: Add sequence counter to API key decryption effect — stale promise
  results from a previous provider are discarded on provider switch
- P3: Restrict url_fetch to HTTPS-only URLs, matching the skipHostCheck
  policy that already rejects HTTP in the bridge

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

* fix: address eighth review — OS resolver, allowlisted HTTP hosts

- P1: Use dns.lookup (OS resolver) instead of dns.resolve4/6 for private
  IP checks — matches what http.request actually connects to, respects
  /etc/hosts, mDNS, and other local resolver sources
- P2: Allow HTTP for any explicitly allowlisted host (not just literal
  private IPs), so self-hosted SearXNG at http://searxng.lan works

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

* fix: address ninth review — HTTP scope, blur ordering, decrypt flag

- P1: Narrow HTTP exception to web search apiHost only — AI provider
  endpoints remain HTTPS-only to protect credentials in transit
- P2: Add blur sequence counter to prevent out-of-order encryption
  results from overwriting newer API key saves
- P2: Reset isDecrypting flag when cancelling decrypt on provider switch,
  preventing permanently disabled API key input

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

* fix: address tenth review — DNS pinning, prompt/tool alignment

- P1: Pin validated DNS result to the HTTP request via custom lookup
  function, preventing TOCTOU/DNS-rebinding between validation and
  actual connection
- P2: Extract isWebSearchReady() helper and use it consistently in
  both tool registration and system prompt, so the model isn't told
  web search is available when config is incomplete

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

* fix: address eleventh review — single DNS lookup, redirect pinning, CGNAT

- P1: Combine DNS validation and pinning into a single lookup call,
  eliminating the TOCTOU window between hasPrivateResolution and pinnedLookup
- P1: Pin DNS for redirect targets too — resolve/validate/pin in one step
  before following each redirect hop
- P2: Add 100.64.0.0/10 (CGNAT) to private IP ranges for Tailscale and
  similar CGNAT-addressed internal services

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

* fix: address twelfth review — apiHost validation, sync on enable

- P2: Validate apiHost is a well-formed URL in isWebSearchReady(),
  preventing tool exposure when user enters a malformed host
- P2: Add webSearchConfig.enabled to sync effect deps so the main
  process gets updated immediately when the toggle changes

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

* fix: remove DNS-level SSRF checks that break fakedns/proxy environments

DNS resolution validation (dns.lookup + IP pinning) breaks in proxy
environments where fakedns resolves all domains to LAN addresses.
Revert to hostname-level checks only (blocking localhost, 127.0.0.1,
metadata endpoints, etc.) which are sufficient without false positives.

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

* fix: resolve empty catch block lint warning

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-17 14:19:29 +08:00
yuzifu
bedf59bb48 update show host count in tree view 2026-03-17 10:17:57 +08:00
yuzifu
793ea94078 fix: show host count in tree view 2026-03-17 09:16:01 +08:00
陈大猫
0eee7bf95a Merge pull request #363 from binaricat/feat/osc52-clipboard
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
feat: add OSC-52 clipboard support
2026-03-16 22:04:39 +08:00
bincxz
b2406ec8a5 fix: auto-reject OSC-52 prompt for hidden tabs and restore focus
- Reject clipboard read requests when terminal is not visible (background
  tab), preventing invisible prompts that block remote programs
- Restore terminal focus after user responds to the prompt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 21:53:52 +08:00
bincxz
5fde9c2d61 fix: improve OSC-52 prompt UX
- Reject concurrent read requests instead of overwriting resolver
- Add autoFocus to Allow button for keyboard accessibility
- Support Escape key to deny the prompt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 21:49:47 +08:00
bincxz
06a6a0ac12 feat: add 'prompt' mode for OSC-52 clipboard reads
Add a fourth option 'Write + Prompt on Read' that allows clipboard
writes but shows a confirmation dialog before granting read access.
This lets users benefit from remote copy (tmux/vim) while maintaining
control over clipboard reads.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 21:42:22 +08:00
bincxz
024e60ead1 fix: reject unsupported OSC-52 selection targets
Only handle clipboard target ('c'); silently ignore unsupported targets
like 'p' (PRIMARY selection) which Electron cannot access, rather than
incorrectly mapping them to the system clipboard.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 21:24:49 +08:00
bincxz
fe71790f0a fix: add osc52Clipboard to syncable terminal settings
Ensures the OSC-52 clipboard preference is preserved across cloud sync.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 21:18:54 +08:00
bincxz
9371b3d01b fix: use Electron bridge for OSC-52 read and chunk base64 encoding
- Fall back to netcattyBridge.readClipboardText() for clipboard reads
  since navigator.clipboard.readText() may be unavailable in Electron
- Chunk String.fromCharCode() calls in 8KB batches to avoid stack
  overflow on large clipboard contents

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 21:14:25 +08:00
bincxz
5a1d279efd fix: add OSC-52 settings, UTF-8 support, and clipboard read
- Add osc52Clipboard setting (off/write-only/read-write), default write-only
- Fix UTF-8 decoding: use TextDecoder instead of atob for non-ASCII content
- Support clipboard read requests when mode is read-write
- Add settings UI with Select dropdown and i18n (en + zh-CN)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 21:08:11 +08:00
bincxz
8b0cbf02c3 feat: add OSC-52 clipboard support for terminal
Register an OSC-52 handler on the xterm parser to allow remote programs
(e.g. tmux, vim, neovim) to write to the local system clipboard via
escape sequences. Read requests are ignored for security.

Closes #362

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 20:52:29 +08:00
陈大猫
d19fe45a14 Merge pull request #361 from binaricat/fix/win-ssh-agent-pipe-detect
fix: use net.connect() for Windows SSH agent pipe detection
2026-03-16 20:40:26 +08:00
bincxz
344946b096 fix: use net.connect() for Windows SSH agent pipe detection
fs.statSync() is unreliable for Windows named pipes — it returns EBUSY
even when the pipe is fully usable, causing ssh-agent to appear
unavailable. Replaced with net.connect() which is the authoritative
check for named pipe connectivity.

Fixes #360

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 20:33:58 +08:00
陈大猫
fcd15707d2 Merge pull request #359 from binaricat/fix/auth-split-button
fix: split auth button for clear save/no-save options
2026-03-16 20:07:46 +08:00
bincxz
42c82e46ea fix: split auth button so "continue without save" is clearly separated
The auth dialog's "Continue and Save" button had a dropdown arrow embedded
inside it, but clicking anywhere on the button (including the arrow)
triggered save. Users expected the arrow to offer a no-save option but
couldn't discover it. Refactored to a proper split button: left side
triggers "Continue and Save", right arrow opens a dropdown with
"Continue" (without saving).

Refs #356

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 19:55:04 +08:00
陈大猫
0e1c3b621a Merge pull request #358 from binaricat/fix/snippet-package-rename
fix: snippet package rename losing snippets and blocking case changes
2026-03-16 19:45:31 +08:00
bincxz
3cd3bbaaf7 fix: snippet package rename losing snippets and blocking case changes
Two bugs in snippet package management:

1. Renaming a package with only case changes (e.g. Speedtest → speedtest)
   was rejected as duplicate because the case-insensitive check didn't
   exclude the package being renamed.

2. Renaming/moving/deleting a package caused its snippets to disappear
   because forEach(onSave) called the state updater multiple times with
   a stale closure, each call overwriting the previous. Only the last
   snippet's update survived. Fixed by adding onBulkSave prop that
   passes the entire updated array in one call.

Fixes #357

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 19:41:27 +08:00
陈大猫
8bfb50fcbb Merge pull request #355 from yuzifu/fix-distro-detect
fix distro detect
2026-03-16 19:30:54 +08:00
bincxz
c39ef879c3 fix: use effective passphrase for distro detection probe
The distro detection was using the stored key passphrase instead of the
runtime-resolved passphrase, causing silent failures when users retry
with a manually entered passphrase.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 19:22:20 +08:00
陈大猫
b3d5785477 fix: allow settings window as trusted IPC sender (#354)
* fix: allow settings window as trusted IPC sender

The settings window runs in a separate BrowserWindow with its own
webContents id. validateSender() only checked the main window id,
causing "Unauthorized IPC sender" errors when fetching AI model
lists from the settings page.

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

* fix: add validateSender to all remaining AI IPC handlers

15 handlers in aiBridge were missing sender validation, allowing
potential unauthorized IPC calls. Now every netcatty:ai:* handler
consistently validates the sender against trusted windows.

Affected handlers: chat:cancel, agents:discover, resolve-cli,
codex:get-integration, codex:start-login, codex:get-login-session,
codex:cancel-login, codex:logout, mcp:update-sessions,
mcp:set-command-blocklist, mcp:set-command-timeout,
mcp:set-max-iterations, mcp:set-permission-mode, acp:cancel,
acp:cleanup.

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

* fix: scope settings window trust to config-only IPC handlers

Per code review feedback: the previous commit allowed the settings
window to access ALL AI IPC handlers including high-risk ones like
exec, terminal:write, and agent:spawn.

Split into two validators:
- validateSender(): main window only (exec, terminal, agent, stream)
- validateSenderOrSettings(): main + settings (fetch, sync, codex
  login, MCP config, agent discovery)

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

* fix: refresh main window id on recreation and allow settings fetch

Two fixes from code review:

1. Always resolve mainWebContentsId from windowManager instead of
   caching it once, so a recreated main window is recognized.

2. Skip static host allowlist for settings window ai:fetch calls,
   since the settings UI lets users configure custom provider URLs
   that haven't been synced to providerFetchHosts yet. Basic URL
   safety (HTTPS-only, no file:// schemes) is still enforced.

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

* fix: enforce HTTPS/port safety for settings window fetch requests

Per review: previous commit skipped isAllowedFetchUrl entirely for
settings window, which removed SSRF protection. Now settings window
fetches still bypass the static host allowlist (since the user is
configuring new providers) but enforce the same safety rules:
- Remote hosts must use HTTPS
- Localhost must use known ports

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

* fix: sync provider config before fetching models in settings

Instead of bypassing the URL allowlist for settings window fetches
(which weakens SSRF protection), have ModelSelector sync the current
provider's baseURL to the backend allowlist before fetching models.
This keeps the full URL safety checks intact while allowing settings
to test custom provider endpoints.

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

* fix: use dedicated allowlist handler instead of syncing providers

Replace the approach of calling aiSyncProviders (which overwrites
the shared providerConfigs) with a new lightweight IPC handler
netcatty:ai:allowlist:add-host that only adds a host to the fetch
allowlist without affecting provider configs or API key resolution.

This preserves the SSRF protection while allowing settings to test
custom provider URLs that haven't been synced from the main window.

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

* fix: auto-expire temporary allowlist entries after 30 seconds

Temporary hosts added via allowlist:add-host now auto-remove after
30s to prevent permanently expanding the SSRF boundary. Built-in
ports and hosts re-added by provider sync are preserved.

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

* fix: prevent temp allowlist cleanup from removing synced providers

The setTimeout cleanup now checks whether the host/port belongs to
a currently synced provider config before removing it. This prevents
the scenario where a user saves a provider within the 30s TTL window
and then loses access when the timer fires.

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

* fix: preserve temp allowlist entries across provider sync rebuilds

rebuildProviderFetchHosts() clears and rebuilds the allowlist from
providerConfigs, which would wipe temporary entries added by
allowlist:add-host. Now re-adds active temp entries after rebuild
to prevent race conditions between settings model listing and
provider sync from the main window.

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-16 19:11:42 +08:00
yuzifu
05de49f7da fix distro detect
Support distro detection with passphrase keys
2026-03-16 17:32:33 +08:00
bincxz
f77c2b2de9 fix: resolve ESLint errors blocking dev startup
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 release/** to ESLint ignores (build artifacts were being linted)
- Remove unused eslint-disable directives in useAutoSync and useSettingsState
- Add missing setTerminalSettings dependency to rehydrateAllFromStorage

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 16:09:00 +08:00
陈大猫
f79f27d737 feat: add settings cloud sync support (#353)
* feat: add settings cloud sync support (closes #347)

Expand SyncPayload.settings to include all syncable user preferences
(theme, appearance, terminal, keyboard, editor, SFTP). Add
collectSyncableSettings/applySyncableSettings helpers in syncPayload.ts,
wire rehydrateAllFromStorage through App.tsx and SettingsPage.tsx so
in-memory React state updates after a cloud download.

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

* fix: include settings in auto-sync uploads and sync empty customCSS

P1: useAutoSync.buildPayload now includes collectSyncableSettings()
so settings are uploaded alongside vault data.

P2: customCSS uses != null check instead of truthy, so clearing CSS
on one device is properly synced to others.

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

* fix: include settings in auto-sync change detection hash

Settings-only changes (theme, terminal options, etc.) now trigger
auto-sync uploads. The data hash comparison includes the settings
snapshot alongside vault data.

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

* fix: trigger auto-sync on settings changes and sync custom terminal themes

P1: Added settingsVersion (derived from all synced settings via useMemo)
to useAutoSync debounce effect dependencies. Settings-only changes now
trigger auto-sync uploads.

P2: Custom terminal themes (STORAGE_KEY_CUSTOM_THEMES) are now included
in the sync payload so custom themes are available on other devices.

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

* fix: reload custom theme store after sync, include in change detection

P1: customThemeStore.loadFromStorage() is now called in
rehydrateAllFromStorage so synced custom themes are immediately
reflected in the live theme store.

P2a: customThemes added to settingsVersion dependencies so custom
theme edits trigger auto-sync.

P2b: Empty custom themes array is now preserved in sync payload
to properly propagate theme deletion.

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

* fix: notify subscribers after custom theme store reload

loadFromStorage now calls notify() to trigger useSyncExternalStore
subscribers, so synced custom terminal themes are immediately
visible in all windows after apply.

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-16 15:57:41 +08:00
陈大猫
ec35daa0dd feat: add auto-update toggle setting (#351)
* feat: add auto-update toggle setting (closes #346)

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

* fix: re-check auto-update toggle when startup timer fires

Address review feedback: the startup check effect now re-reads the
toggle from localStorage when the delayed timer fires, so toggling
off after launch cancels the pending check. Also avoids setting
hasCheckedOnStartupRef when disabled, allowing re-enable to trigger
a check without restart.

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

* fix: address review feedback on auto-update toggle

P1: When autoDownload=false, onUpdateAvailable no longer transitions
to 'downloading' status. Instead keeps autoDownloadStatus idle so
the manual download link surfaces correctly.

P2: Added reactive autoUpdateEnabled state (synced via storage event)
as a dependency to the startup check effect. Re-enabling the toggle
mid-session now re-triggers the deferred startup check.

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

* fix: address P1/P2 review feedback on auto-update toggle

P1: Main process update-available handler now checks updater.autoDownload
before setting _lastStatus to 'downloading'. When autoDownload=false,
status stays 'idle' so late-opened windows don't hydrate to a stuck
0% download state.

P2: useUpdateCheck now accepts autoUpdateEnabled as a prop from the
caller instead of relying solely on storage events (which don't fire
in the same window). SettingsPage passes settings.autoUpdateEnabled
directly, so toggling in the current window takes effect immediately.

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

* fix: preserve update-available info for late-opening windows

When autoDownload is off, use status 'available' (instead of 'idle')
in the main process snapshot so late-opening windows can hydrate
version info. The renderer maps 'available' to hasUpdate=true while
keeping autoDownloadStatus='idle' for the manual download path.

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

* fix: re-schedule auto-check on re-enable and guard startup timer

- IPC handler now calls startAutoCheck(2000) when re-enabling so the
  user gets automatic checks without restarting the app.
- startAutoCheck timer checks updater.autoDownload at fire time, so
  if the renderer disables auto-update via IPC before the 5s startup
  timer fires, the check is skipped.

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

* fix: deduplicate auto-check scheduling and clear error on fallback success

P1: startAutoCheck now cancels any existing timer before scheduling
a new one, preventing duplicate concurrent checks from multiple
windows or re-enable toggles.

P2: checkNow fallback now clears manualCheckStatus='error' when
electron-updater successfully finds an update (res.available=true),
so the UI shows 'available' instead of a stale error state.

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

* fix: only reschedule on actual re-enable and hydrate cache before toggle check

P2: Track previous autoDownload state in IPC handler so startAutoCheck
is only called on actual false→true transitions, not on every window
mount that syncs the current value.

P3: Move cache hydration (STORAGE_KEY_UPDATE_LATEST_RELEASE) before
the auto-update toggle check so cached update info is always visible
even when automatic updates are disabled.

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

* fix: persist auto-update preference in main process across restarts

Read/write auto-update preference to a JSON file in userData so the
main process honors it on next launch without waiting for renderer IPC.
getAutoUpdater() now initializes autoDownload from the persisted value.

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

* fix: suppress cached update toast when disabled and update IPC types

P2: Cache hydration now gates hasUpdate on autoUpdateEnabled so the
App.tsx toast doesn't fire when automatic updates are disabled.

P3: Updated global.d.ts to include 'available' in getUpdateStatus
status union and 'checking' in checkForUpdate return type.

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

* fix: preserve dismissed releases, show cached updates in Settings, guard concurrent checks

P2a: Updater fallback now checks STORAGE_KEY_UPDATE_DISMISSED_VERSION
before re-surfacing a release found by electron-updater.

P2b: Cache hydration always sets hasUpdate truthfully so Settings
shows the available update. Toast suppression for disabled auto-update
moved to App.tsx (reads localStorage directly).

P3: Re-enable IPC handler checks _isChecking before scheduling
startAutoCheck to prevent concurrent electron-updater calls.

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

* fix: use localStorageAdapter for lint compliance, skip IPC on initial mount

P1: Replace direct localStorage access with localStorageAdapter in
App.tsx toast guard to fix no-restricted-globals lint error.

P2: Skip setAutoUpdate IPC on initial mount to prevent overwriting
the main-process preference file when renderer localStorage has been
cleared (where the default would be true).

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

* fix: hydrate auto-update state from main-process preference on mount

Add getAutoUpdate IPC handler so the renderer can query the persisted
preference from auto-update-pref.json. On mount, useSettingsState
reconciles localStorage with the main-process truth, preventing the
toggle from showing 'enabled' when the user had previously disabled
it and localStorage was cleared.

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-16 14:54:40 +08:00
陈大猫
ed0775d9d2 Merge pull request #352 from binaricat/feat/global-hotkey-toggle
feat: add global hotkey enable/disable toggle
2026-03-16 12:41:54 +08:00
bincxz
1f31629ce0 feat: add global hotkey enable/disable toggle (closes #349)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 12:36:37 +08:00
陈大猫
cc4a904dea Merge pull request #350 from binaricat/fix/gemini-empty-function-response-name
fix: resolve Gemini API error caused by empty functionResponse name
2026-03-16 11:56:57 +08:00
bincxz
e9e1d87ff5 fix: resolve Gemini API error caused by empty functionResponse name
When rebuilding SDK messages from conversation history, tool-result
messages had toolName hardcoded to an empty string. This works for
OpenAI/Claude APIs but Gemini requires functionResponse.name to be
non-empty, causing AI_APICallError on every follow-up message.

Now looks up the tool name from the matching assistant tool call
via toolCallId.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 11:43:28 +08:00
陈大猫
a6b07f39ad Merge pull request #348 from yuzifu/fix-dropdown-lists-height
enable scrollbar in dropdown lists when content exceeds max-height
2026-03-16 11:23:36 +08:00
yuzifu
6892e11952 enable scrollbar in dropdown lists when content exceeds max-height 2026-03-16 11:07:56 +08:00
192 changed files with 12831 additions and 9500 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)

84
App.tsx
View File

@@ -17,6 +17,7 @@ import { resolveHostAuth } from './domain/sshAuth';
import { applySyncPayload } from './domain/syncPayload';
import { getCredentialProtectionAvailability } from './infrastructure/services/credentialProtection';
import { netcattyBridge } from './infrastructure/services/netcattyBridge';
import { localStorageAdapter } from './infrastructure/persistence/localStorageAdapter';
import { TopTabs } from './components/TopTabs';
import { Button } from './components/ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from './components/ui/dialog';
@@ -27,6 +28,7 @@ import { VaultView, VaultSection } from './components/VaultView';
import { KeyboardInteractiveModal, KeyboardInteractiveRequest } from './components/KeyboardInteractiveModal';
import { PassphraseModal, PassphraseRequest } from './components/PassphraseModal';
import { cn } from './lib/utils';
import { classifyLocalShellType } from './lib/localShell';
import { ConnectionLog, Host, HostProtocol, SerialConfig, TerminalTheme } from './types';
import { LogView as LogViewType } from './application/state/useSessionState';
import type { SftpView as SftpViewComponent } from './components/SftpView';
@@ -184,6 +186,7 @@ function App({ settings }: { settings: SettingsState }) {
sftpAutoSync,
sftpShowHiddenFiles,
sftpUseCompressedUpload,
sftpAutoOpenSidebar,
editorWordWrap,
setEditorWordWrap,
sessionLogsEnabled,
@@ -292,10 +295,12 @@ function App({ settings }: { settings: SettingsState }) {
snippetPackages,
portForwardingRules: portForwardingRulesForSync,
knownHosts,
settingsVersion: settings.settingsVersion,
onApplyPayload: (payload) => {
applySyncPayload(payload, {
importVaultData: importDataFromString,
importPortForwardingRules,
onSettingsApplied: settings.rehydrateAllFromStorage,
});
},
});
@@ -320,6 +325,8 @@ function App({ settings }: { settings: SettingsState }) {
useEffect(() => {
// Skip "update available" toast if auto-download has already started or completed
if (updateState.autoDownloadStatus !== 'idle') return;
// Don't show automatic notification when auto-update is disabled
if (localStorageAdapter.readString('netcatty_auto_update_enabled_v1') === 'false') return;
if (updateState.hasUpdate && updateState.latestRelease) {
const version = updateState.latestRelease.version;
toast.info(
@@ -374,16 +381,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
@@ -445,9 +447,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 {
@@ -459,7 +460,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(() => {
@@ -651,6 +652,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) {
@@ -722,7 +741,7 @@ function App({ settings }: { settings: SettingsState }) {
localHostname: systemInfoRef.current.hostname,
saved: false,
});
createLocalTerminal();
createLocalTerminalWithCurrentShell();
break;
case 'openHosts':
setActiveTabId('vault');
@@ -761,7 +780,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
@@ -776,7 +795,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');
@@ -816,7 +835,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) => {
@@ -962,7 +981,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: '',
@@ -975,7 +994,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) => {
@@ -1062,31 +1081,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) => {
@@ -1217,7 +1217,7 @@ function App({ settings }: { settings: SettingsState }) {
isMacClient={isMacClient}
onCloseSession={closeSession}
onRenameSession={startSessionRename}
onCopySession={copySession}
onCopySession={copySessionWithCurrentShell}
onRenameWorkspace={startWorkspaceRename}
onCloseWorkspace={closeWorkspace}
onCloseLogView={closeLogView}
@@ -1311,7 +1311,7 @@ function App({ settings }: { settings: SettingsState }) {
onSetDraggingSessionId={setDraggingSessionId}
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
onSetWorkspaceFocusedSession={setWorkspaceFocusedSession}
onSplitSession={splitSession}
onSplitSession={splitSessionWithCurrentShell}
isBroadcastEnabled={isBroadcastEnabled}
onToggleBroadcast={toggleBroadcast}
updateHosts={updateHosts}
@@ -1319,8 +1319,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',
@@ -115,6 +134,8 @@ const en: Messages = {
'settings.update.lastCheckedMinutesAgo': '{n} min ago',
'settings.update.lastCheckedHoursAgo': '{n} hr ago',
'settings.update.lastCheckedPrefix': 'Last checked: ',
'settings.update.autoUpdateEnabled': 'Automatic Updates',
'settings.update.autoUpdateEnabledDesc': 'Automatically check and download updates when available.',
// Settings > Session Logs
'settings.sessionLogs.title': 'Session Logs',
@@ -142,6 +163,8 @@ const en: Messages = {
'settings.globalHotkey.reset': 'Reset to default',
'settings.globalHotkey.closeToTray': 'Close to System Tray',
'settings.globalHotkey.closeToTrayDesc': 'When enabled, closing the window will minimize to the system tray instead of quitting.',
'settings.globalHotkey.enabled': 'Enable Global Hotkey',
'settings.globalHotkey.enabledDesc': 'Register system-wide keyboard shortcuts. When disabled, all global hotkeys are unregistered.',
'settings.globalHotkey.hint': 'Global hotkey works system-wide to quickly show or hide the window (Quake-style terminal).',
// Tray Panel
@@ -266,6 +289,17 @@ const en: Messages = {
'settings.terminal.behavior.bracketedPaste': 'Bracketed paste mode',
'settings.terminal.behavior.bracketedPaste.desc':
'Wrap pasted text with escape sequences so the shell can distinguish paste from typed input. Disable if you see ^[[200~ artifacts.',
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 clipboard',
'settings.terminal.behavior.osc52Clipboard.desc':
'Allow remote programs (tmux, vim, etc.) to access the local clipboard via OSC-52 escape sequences.',
'settings.terminal.behavior.osc52Clipboard.off': 'Disabled',
'settings.terminal.behavior.osc52Clipboard.writeOnly': 'Write only',
'settings.terminal.behavior.osc52Clipboard.readWrite': 'Read & Write',
'settings.terminal.behavior.osc52Clipboard.prompt': 'Write + Prompt on Read',
'terminal.osc52.readPrompt.title': 'Clipboard Read Request',
'terminal.osc52.readPrompt.desc': 'A remote program is requesting to read your clipboard. Allow?',
'terminal.osc52.readPrompt.allow': 'Allow',
'terminal.osc52.readPrompt.deny': 'Deny',
'settings.terminal.behavior.scrollOnInput': 'Scroll on input',
'settings.terminal.behavior.scrollOnInput.desc': 'Scroll terminal to bottom when typing',
'settings.terminal.behavior.scrollOnOutput': 'Scroll on output',
@@ -277,6 +311,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)',
@@ -594,12 +631,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',
@@ -730,6 +769,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}',
@@ -817,6 +863,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',
@@ -825,9 +894,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',
@@ -1033,6 +1105,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',
@@ -1045,6 +1118,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',
@@ -1403,6 +1478,7 @@ const en: Messages = {
'snippets.renameDialog.error.duplicate': 'A package with this name already exists',
'snippets.renameDialog.error.invalidChars': 'Package name can only contain letters, numbers, hyphens, and underscores',
'snippets.field.noAutoRun': 'Paste only (do not auto-execute)',
// Snippet Shortkey
'snippets.field.shortkey': 'Keyboard Shortcut',
'snippets.shortkey.placeholder': 'Click to set shortcut',
@@ -1498,6 +1574,7 @@ const en: Messages = {
'ai.providers.apiKey.placeholder': 'Enter API key',
'ai.providers.apiKey.decrypting': 'Decrypting...',
'ai.providers.baseUrl': 'Base URL',
'ai.providers.skipTLSVerify': 'Skip TLS certificate verification (for self-signed certs)',
'ai.providers.defaultModel': 'Default Model',
'ai.providers.defaultModel.placeholder': 'e.g. gpt-4o, claude-sonnet-4-20250514',
'ai.providers.refreshModels': 'Refresh models',
@@ -1531,7 +1608,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',
@@ -1548,7 +1625,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',
@@ -1603,6 +1679,21 @@ const en: Messages = {
// AI Error
'ai.codex.bridgeError': 'Codex main-process handlers are not loaded yet. Fully restart Netcatty, or restart the Electron dev process, then try again.',
// AI Web Search
'ai.webSearch.title': 'Web Search',
'ai.webSearch.enable': 'Enable Web Search',
'ai.webSearch.enable.description': 'Allow the AI agent to search the web for current information.',
'ai.webSearch.provider': 'Search Provider',
'ai.webSearch.provider.description': 'Choose a web search API provider.',
'ai.webSearch.apiKey': 'API Key',
'ai.webSearch.apiKey.description': 'API key for the selected search provider.',
'ai.webSearch.apiKey.placeholder': 'Enter API key...',
'ai.webSearch.apiHost': 'API Host',
'ai.webSearch.apiHost.description': 'Custom API endpoint. Leave default unless you use a proxy.',
'ai.webSearch.apiHost.searxngDescription': 'URL of your SearXNG instance (required).',
'ai.webSearch.maxResults': 'Max Results',
'ai.webSearch.maxResults.description': 'Maximum number of search results to return (1-20).',
// AI Safety Settings
'ai.safety.title': 'Safety',
'ai.safety.permissionMode': 'Permission Mode',

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': '当前版本',
@@ -99,6 +118,8 @@ const zhCN: Messages = {
'settings.update.lastCheckedMinutesAgo': '{n} 分钟前',
'settings.update.lastCheckedHoursAgo': '{n} 小时前',
'settings.update.lastCheckedPrefix': '上次检查:',
'settings.update.autoUpdateEnabled': '自动更新',
'settings.update.autoUpdateEnabledDesc': '有新版本时自动检查并下载更新。',
// Settings > Session Logs
'settings.sessionLogs.title': '会话日志',
@@ -126,6 +147,8 @@ const zhCN: Messages = {
'settings.globalHotkey.reset': '恢复默认',
'settings.globalHotkey.closeToTray': '关闭时最小化到托盘',
'settings.globalHotkey.closeToTrayDesc': '启用后,关闭窗口将最小化到系统托盘而不是退出程序。',
'settings.globalHotkey.enabled': '启用全局快捷键',
'settings.globalHotkey.enabledDesc': '注册系统级键盘快捷键。禁用后将取消所有全局快捷键注册。',
'settings.globalHotkey.hint': '全局快捷键在系统范围内工作,可快速显示或隐藏窗口(下拉式终端风格)。',
// Tray Panel
@@ -432,12 +455,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': '文件名编码',
@@ -527,6 +552,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': '密码',
@@ -535,9 +583,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': '暂无密钥',
@@ -715,6 +766,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': '隐藏日志',
@@ -727,6 +779,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} 主题',
@@ -1056,6 +1110,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}',
@@ -1142,6 +1203,17 @@ const zhCN: Messages = {
'settings.terminal.behavior.bracketedPaste': '括号粘贴模式',
'settings.terminal.behavior.bracketedPaste.desc':
'粘贴文本时使用转义序列包裹,以便终端区分粘贴和键入。如果出现 ^[[200~ 字样请关闭此选项。',
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 剪贴板',
'settings.terminal.behavior.osc52Clipboard.desc':
'允许远程程序tmux、vim 等)通过 OSC-52 转义序列访问本地剪贴板。',
'settings.terminal.behavior.osc52Clipboard.off': '关闭',
'settings.terminal.behavior.osc52Clipboard.writeOnly': '仅写入',
'settings.terminal.behavior.osc52Clipboard.readWrite': '读写',
'settings.terminal.behavior.osc52Clipboard.prompt': '写入 + 读取时询问',
'terminal.osc52.readPrompt.title': '剪贴板读取请求',
'terminal.osc52.readPrompt.desc': '远程程序正在请求读取您的剪贴板,是否允许?',
'terminal.osc52.readPrompt.allow': '允许',
'terminal.osc52.readPrompt.deny': '拒绝',
'settings.terminal.behavior.scrollOnInput': '输入时自动滚动',
'settings.terminal.behavior.scrollOnInput.desc': '输入时将终端滚动到底部',
'settings.terminal.behavior.scrollOnOutput': '输出时自动滚动',
@@ -1150,6 +1222,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': '无(直接点击)',
@@ -1418,6 +1492,7 @@ const zhCN: Messages = {
'snippets.renameDialog.error.duplicate': '已存在同名的代码包',
'snippets.renameDialog.error.invalidChars': '代码包名称只能包含字母、数字、连字符和下划线',
'snippets.field.noAutoRun': '仅粘贴(不自动执行)',
// Snippet Shortkey
'snippets.field.shortkey': '快捷键',
'snippets.shortkey.placeholder': '点击设置快捷键',
@@ -1513,6 +1588,7 @@ const zhCN: Messages = {
'ai.providers.apiKey.placeholder': '输入 API Key',
'ai.providers.apiKey.decrypting': '解密中...',
'ai.providers.baseUrl': 'Base URL',
'ai.providers.skipTLSVerify': '跳过 TLS 证书验证(用于自签名证书)',
'ai.providers.defaultModel': '默认模型',
'ai.providers.defaultModel.placeholder': '例如 gpt-4o, claude-sonnet-4-20250514',
'ai.providers.refreshModels': '刷新模型列表',
@@ -1546,7 +1622,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': '未找到',
@@ -1563,7 +1639,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': '批准',
@@ -1618,6 +1693,21 @@ const zhCN: Messages = {
// AI Error
'ai.codex.bridgeError': 'Codex 主进程处理器尚未加载。请完全重启 Netcatty 或重启 Electron 开发进程,然后重试。',
// AI Web Search
'ai.webSearch.title': '网络搜索',
'ai.webSearch.enable': '启用网络搜索',
'ai.webSearch.enable.description': '允许 AI 代理搜索互联网获取最新信息。',
'ai.webSearch.provider': '搜索供应商',
'ai.webSearch.provider.description': '选择一个网络搜索 API 供应商。',
'ai.webSearch.apiKey': 'API 密钥',
'ai.webSearch.apiKey.description': '所选搜索供应商的 API 密钥。',
'ai.webSearch.apiKey.placeholder': '输入 API 密钥...',
'ai.webSearch.apiHost': 'API 地址',
'ai.webSearch.apiHost.description': '自定义 API 端点。除非使用代理,否则保持默认值。',
'ai.webSearch.apiHost.searxngDescription': 'SearXNG 实例的 URL必填。',
'ai.webSearch.maxResults': '最大结果数',
'ai.webSearch.maxResults.description': '搜索返回的最大结果数1-20。',
// AI Safety Settings
'ai.safety.title': '安全',
'ai.safety.permissionMode': '权限模式',

View File

@@ -30,7 +30,8 @@ class CustomThemeStore {
this.setupCrossWindowSync();
}
private loadFromStorage = () => {
/** Reload themes from localStorage. Called internally and after sync apply. */
loadFromStorage = () => {
try {
const parsed = localStorageAdapter.read<TerminalTheme[]>(STORAGE_KEY_CUSTOM_THEMES);
if (Array.isArray(parsed)) {
@@ -39,7 +40,7 @@ class CustomThemeStore {
} catch {
// ignore corrupt data
}
this.cachedAllThemes = null; // invalidate cache
this.notify();
};
private saveToStorage = () => {

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

@@ -52,35 +52,27 @@ export const joinPath = (base: string, name: string): string => {
};
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,7 +12,9 @@ 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';
import type {
AISession,
@@ -22,6 +24,7 @@ import type {
ExternalAgentConfig,
ChatMessage,
AISessionScope,
WebSearchConfig,
} from '../../infrastructure/ai/types';
import { DEFAULT_COMMAND_BLOCKLIST } from '../../infrastructure/ai/types';
@@ -30,6 +33,62 @@ 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;
for (const sessionId of sessionIds) {
void bridge.aiAcpCleanup(sessionId).catch(() => {});
}
}
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;
@@ -56,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[]>(() =>
@@ -107,15 +177,57 @@ 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>>(() =>
localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP) ?? {}
);
// ── Web Search Config ──
const [webSearchConfig, setWebSearchConfigRaw] = useState<WebSearchConfig | null>(() =>
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) => {
@@ -126,6 +238,15 @@ export function useAIState() {
});
}, []);
const setWebSearchConfig = useCallback((config: WebSearchConfig | null) => {
setWebSearchConfigRaw(config);
if (config) {
localStorageAdapter.write(STORAGE_KEY_AI_WEB_SEARCH, config);
} else {
localStorageAdapter.remove(STORAGE_KEY_AI_WEB_SEARCH);
}
}, []);
// ── Persist helpers ──
const setProviders = useCallback((value: ProviderConfig[] | ((prev: ProviderConfig[]) => ProviderConfig[])) => {
setProvidersRaw(prev => {
@@ -279,16 +400,58 @@ 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;
}
} catch (err) {
console.warn('[useAIState] Cross-window sync: failed to process storage event for key', e.key, err);
}
};
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 ──
@@ -348,6 +511,7 @@ export function useAIState() {
};
setSessionsRaw(prev => {
const next = [session, ...prev];
setLatestAISessionsSnapshot(next);
persistSessions(next);
return next;
});
@@ -357,24 +521,36 @@ export function useAIState() {
}, [defaultAgentId, persistSessions, setActiveSessionId]);
const deleteSession = useCallback((sessionId: string, scopeKey?: string) => {
cleanupAcpSessions([sessionId]);
if (persistTimerRef.current) {
clearTimeout(persistTimerRef.current);
persistTimerRef.current = null;
}
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;
});
}
}, [persistSessions]);
const deleteSessionsByTarget = useCallback((scopeType: 'terminal' | 'workspace', targetId: string) => {
const removedSessionIds = sessionsRef.current
.filter(s => s.scope.type === scopeType && s.scope.targetId === targetId)
.map(s => s.id);
cleanupAcpSessions(removedSessionIds);
if (persistTimerRef.current) {
clearTimeout(persistTimerRef.current);
persistTimerRef.current = null;
@@ -383,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]);
@@ -396,11 +579,25 @@ 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;
});
}, [persistSessions]);
const updateSessionExternalSessionId = useCallback((sessionId: string, externalSessionId: string | undefined) => {
setSessionsRaw(prev => {
const next = prev.map(s => (
s.id === sessionId
? { ...s, externalSessionId, updatedAt: Date.now() }
: s
));
setLatestAISessionsSnapshot(next);
debouncedPersistSessions();
return next;
});
}, [debouncedPersistSessions]);
// Maximum messages per session to prevent unbounded memory growth
const MAX_MESSAGES_PER_SESSION = 500;
@@ -419,6 +616,7 @@ export function useAIState() {
}
return { ...s, messages: msgs, updatedAt: Date.now() };
});
setLatestAISessionsSnapshot(next);
debouncedPersistSessions();
return next;
});
@@ -432,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;
});
@@ -447,6 +646,7 @@ export function useAIState() {
msgs[idx] = updater(msgs[idx]);
return { ...s, messages: msgs, updatedAt: Date.now() };
});
setLatestAISessionsSnapshot(next);
debouncedPersistSessions();
return next;
});
@@ -459,25 +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>) => {
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) => {
@@ -541,6 +737,10 @@ export function useAIState() {
agentModelMap,
setAgentModel,
// Web search
webSearchConfig,
setWebSearchConfig,
// Sessions (per-scope active session)
sessions,
activeSessionIdMap,
@@ -549,6 +749,7 @@ export function useAIState() {
deleteSession,
deleteSessionsByTarget,
updateSessionTitle,
updateSessionExternalSessionId,
addMessageToSession,
updateLastMessage,
updateMessageById,

View File

@@ -16,6 +16,7 @@ import {
findSyncPayloadEncryptedCredentialPaths,
} from '../../domain/credentials';
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
import { collectSyncableSettings } from '../../domain/syncPayload';
import { STORAGE_KEY_PORT_FORWARDING } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { getEffectiveKnownHosts } from '../../infrastructure/syncHelpers';
@@ -31,7 +32,9 @@ interface AutoSyncConfig {
snippetPackages?: SyncPayload['snippetPackages'];
portForwardingRules?: SyncPayload['portForwardingRules'];
knownHosts?: SyncPayload['knownHosts'];
/** Opaque token that changes whenever a synced setting changes. */
settingsVersion?: number;
// Callbacks
onApplyPayload: (payload: SyncPayload) => void;
}
@@ -49,10 +52,13 @@ interface SyncNowOptions {
export const useAutoSync = (config: AutoSyncConfig) => {
const { t } = useI18n();
const sync = useCloudSync();
const { onApplyPayload } = config;
const syncTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastSyncedDataRef = useRef<string>('');
const hasCheckedRemoteRef = useRef(false);
const isInitializedRef = useRef(false);
const isSyncRunningRef = useRef(false);
const skipNextSyncRef = useRef(false);
const getSyncSnapshot = useCallback(() => {
let effectivePFRules = config.portForwardingRules;
@@ -97,19 +103,21 @@ export const useAutoSync = (config: AutoSyncConfig) => {
const buildPayload = useCallback((): SyncPayload => {
return {
...getSyncSnapshot(),
settings: collectSyncableSettings(),
syncedAt: Date.now(),
};
}, [getSyncSnapshot]);
// Create a hash of current data for comparison
// Create a hash of current data for comparison (includes settings)
const getDataHash = useCallback(() => {
return JSON.stringify(getSyncSnapshot());
return JSON.stringify({ ...getSyncSnapshot(), settings: collectSyncableSettings() });
}, [getSyncSnapshot]);
// Sync now handler - get fresh state directly from manager
const syncNow = useCallback(async (options?: SyncNowOptions) => {
const trigger: SyncTrigger = options?.trigger ?? 'auto';
isSyncRunningRef.current = true;
try {
// Get fresh state directly from CloudSyncManager singleton
let state = manager.getState();
@@ -156,6 +164,16 @@ export const useAutoSync = (config: AutoSyncConfig) => {
const results = await sync.syncNow(payload);
// Apply merged payloads first (before checking for failures) so local
// state gets updated even when some providers failed
for (const result of results.values()) {
if (result.mergedPayload) {
onApplyPayload(result.mergedPayload);
skipNextSyncRef.current = true;
break; // All providers share the same merged payload
}
}
for (const result of results.values()) {
if (!result.success) {
if (result.conflictDetected) {
@@ -175,8 +193,10 @@ export const useAutoSync = (config: AutoSyncConfig) => {
error instanceof Error ? error.message : t('common.unknownError'),
t('sync.autoSync.failedTitle'),
);
} finally {
isSyncRunningRef.current = false;
}
}, [sync, buildPayload, getDataHash, t]);
}, [sync, buildPayload, getDataHash, onApplyPayload, t]);
// Check remote version and pull if newer (on startup)
const checkRemoteVersion = useCallback(async () => {
@@ -198,19 +218,25 @@ 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);
if (remotePayload && remotePayload.syncedAt > state.localUpdatedAt) {
console.log('[AutoSync] Remote is newer, applying...');
config.onApplyPayload(remotePayload);
const { mergeSyncPayloads } = await import('../../domain/syncMerge');
const localPayload = buildPayload();
const mergeResult = mergeSyncPayloads(base, localPayload, remotePayload);
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
// go through syncAllProviders and save base on success).
toast.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle'));
}
} catch (error) {
console.error('[AutoSync] Failed to check remote version:', error);
// Don't show error toast for initial check - it's not critical
}
}, [sync, config, t]);
}, [sync, config, buildPayload, t]);
// Debounced auto-sync when data changes
useEffect(() => {
@@ -227,7 +253,15 @@ export const useAutoSync = (config: AutoSyncConfig) => {
}
const currentHash = getDataHash();
// After a merge, onApplyPayload changes local state which triggers
// this effect. Skip that cycle and just update the hash baseline.
if (skipNextSyncRef.current) {
skipNextSyncRef.current = false;
lastSyncedDataRef.current = currentHash;
return;
}
// Skip if data hasn't changed
if (currentHash === lastSyncedDataRef.current) {
return;
@@ -235,7 +269,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// Wait for the current sync to finish, then this effect will re-run
// because sync.isSyncing changed.
if (sync.isSyncing) {
if (sync.isSyncing || isSyncRunningRef.current) {
return;
}
@@ -246,7 +280,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// Debounce sync by 3 seconds
syncTimeoutRef.current = setTimeout(() => {
console.log('[AutoSync] Data changed, syncing...');
syncNow();
}, 3000);
@@ -255,7 +288,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
clearTimeout(syncTimeoutRef.current);
}
};
}, [sync.hasAnyConnectedProvider, sync.autoSyncEnabled, sync.isUnlocked, sync.isSyncing, getDataHash, syncNow]);
}, [sync.hasAnyConnectedProvider, sync.autoSyncEnabled, sync.isUnlocked, sync.isSyncing, getDataHash, syncNow, config.settingsVersion]);
// Check remote version on startup/unlock
useEffect(() => {

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

@@ -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 = {
@@ -569,6 +581,7 @@ export const useSessionState = () => {
workspaceId: workspace.id,
// Store the command to run after connection
startupCommand: snippet.command,
noAutoRun: snippet.noAutoRun,
}));
setSessions(prev => [...prev, ...sessionsWithWorkspace]);
@@ -614,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 = {
@@ -630,6 +648,7 @@ export const useSessionState = () => {
protocol: session.protocol,
port: session.port,
moshEnabled: session.moshEnabled,
shellType: nextShellType,
serialConfig: session.serialConfig,
};

View File

@@ -21,21 +21,23 @@ 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,
STORAGE_KEY_SESSION_LOGS_FORMAT,
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
STORAGE_KEY_CLOSE_TO_TRAY,
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
STORAGE_KEY_AUTO_UPDATE_ENABLED,
} from '../../infrastructure/config/storageKeys';
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
import { useCustomThemes } from '../state/customThemeStore';
import { customThemeStore, useCustomThemes } from '../state/customThemeStore';
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';
@@ -61,6 +63,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;
@@ -151,7 +154,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);
@@ -229,6 +231,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>(() => {
@@ -264,11 +270,25 @@ export const useSettingsState = () => {
if (stored === null) return true;
return stored === 'true';
});
const [autoUpdateEnabled, setAutoUpdateEnabled] = useState<boolean>(() => {
const stored = readStoredString(STORAGE_KEY_AUTO_UPDATE_ENABLED);
if (stored === null) return true; // Default to enabled
return stored === 'true';
});
const [hotkeyRegistrationError, setHotkeyRegistrationError] = useState<string | null>(null);
const [globalHotkeyEnabled, setGlobalHotkeyEnabled] = useState<boolean>(() => {
const stored = readStoredString(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED);
if (stored === null) return true; // Default to enabled
return stored === 'true';
});
const incomingTerminalSettingsSignatureRef = useRef<string | null>(null);
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 +336,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);
@@ -332,6 +363,62 @@ export const useSettingsState = () => {
setCustomCSS((prev) => (prev === storedCss ? prev : storedCss));
}, []);
const rehydrateAllFromStorage = useCallback(() => {
// Theme & appearance (already have helper)
syncAppearanceFromStorage();
syncCustomCssFromStorage();
// UI Font
const storedFont = readStoredString(STORAGE_KEY_UI_FONT_FAMILY);
if (storedFont) setUiFontFamilyId(storedFont);
// Language
const storedLang = readStoredString(STORAGE_KEY_UI_LANGUAGE);
if (storedLang) setUiLanguage(storedLang as UILanguage);
// Terminal
const storedTermTheme = readStoredString(STORAGE_KEY_TERM_THEME);
if (storedTermTheme) setTerminalThemeId(storedTermTheme);
const storedTermFont = readStoredString(STORAGE_KEY_TERM_FONT_FAMILY);
if (storedTermFont) setTerminalFontFamilyId(storedTermFont);
const storedTermSize = localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE);
if (storedTermSize != null) setTerminalFontSize(storedTermSize);
const storedTermSettings = readStoredString(STORAGE_KEY_TERM_SETTINGS);
if (storedTermSettings) {
try {
const parsed = JSON.parse(storedTermSettings);
setTerminalSettings(parsed);
} catch { /* ignore */ }
}
// Keyboard
const storedKb = readStoredString(STORAGE_KEY_CUSTOM_KEY_BINDINGS);
if (storedKb) {
try {
setCustomKeyBindings(JSON.parse(storedKb));
} catch { /* ignore */ }
}
// Editor
const storedWrap = readStoredString(STORAGE_KEY_EDITOR_WORD_WRAP);
if (storedWrap === 'true' || storedWrap === 'false') setEditorWordWrapState(storedWrap === 'true');
// SFTP
const storedDblClick = readStoredString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR);
if (storedDblClick === 'open' || storedDblClick === 'transfer') setSftpDoubleClickBehavior(storedDblClick);
const storedAutoSync = readStoredString(STORAGE_KEY_SFTP_AUTO_SYNC);
if (storedAutoSync === 'true' || storedAutoSync === 'false') setSftpAutoSync(storedAutoSync === 'true');
const storedHidden = readStoredString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES);
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');
// Custom terminal themes
customThemeStore.loadFromStorage();
}, [syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings]);
useLayoutEffect(() => {
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
@@ -340,12 +427,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
@@ -363,7 +449,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
@@ -372,7 +461,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
@@ -457,6 +549,15 @@ export const useSettingsState = () => {
if (key === STORAGE_KEY_HOTKEY_RECORDING && typeof value === 'boolean') {
setIsHotkeyRecordingState(value);
}
if (key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && typeof value === 'boolean') {
setGlobalHotkeyEnabled((prev) => (prev === value ? prev : value));
}
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));
}
});
return () => {
try {
@@ -484,53 +585,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,
});
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,
};
// 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);
}
}
@@ -553,64 +677,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);
}
@@ -618,33 +742,58 @@ 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 !== 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 !== s.autoUpdateEnabled) {
setAutoUpdateEnabled(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, 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;
@@ -659,11 +808,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]);
@@ -674,10 +825,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');
@@ -685,56 +833,72 @@ 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) {
if (toggleWindowHotkey && globalHotkeyEnabled) {
setHotkeyRegistrationError(null);
bridge
.registerGlobalHotkey(toggleWindowHotkey)
@@ -755,21 +919,71 @@ export const useSettingsState = () => {
});
}
}
}, [toggleWindowHotkey, notifySettingsChanged]);
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.
// This reconciles localStorage (renderer) with auto-update-pref.json (main)
// in case localStorage was cleared or is stale.
useEffect(() => {
const bridge = netcattyBridge.get();
void bridge?.getAutoUpdate?.().then((result) => {
if (result && typeof result.enabled === 'boolean') {
setAutoUpdateEnabled((prev) => {
if (prev === result.enabled) return prev;
// Sync localStorage with the main-process truth
localStorageAdapter.writeString(STORAGE_KEY_AUTO_UPDATE_ENABLED, result.enabled ? 'true' : 'false');
return result.enabled;
});
}
}).catch(() => { /* bridge unavailable */ });
}, []);
// Persist auto-update enabled setting.
// 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);
// Notify main process on user-initiated changes
const bridge = netcattyBridge.get();
bridge?.setAutoUpdate?.(autoUpdateEnabled).catch((err: unknown) => {
console.warn('[AutoUpdate] Failed to set auto-update:', err);
});
}, [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 => {
@@ -832,11 +1046,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]
@@ -867,7 +1076,6 @@ export const useSettingsState = () => {
currentTerminalTheme,
terminalFontFamilyId,
setTerminalFontFamilyId,
currentTerminalFont,
terminalFontSize,
setTerminalFontSize,
terminalSettings,
@@ -892,6 +1100,8 @@ export const useSettingsState = () => {
setSftpShowHiddenFiles,
sftpUseCompressedUpload,
setSftpUseCompressedUpload,
sftpAutoOpenSidebar,
setSftpAutoOpenSidebar,
// Editor Settings
editorWordWrap,
setEditorWordWrap: useCallback((enabled: boolean) => {
@@ -899,7 +1109,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,
@@ -912,6 +1121,21 @@ export const useSettingsState = () => {
setToggleWindowHotkey,
closeToTray,
setCloseToTray,
autoUpdateEnabled,
setAutoUpdateEnabled,
hotkeyRegistrationError,
globalHotkeyEnabled,
setGlobalHotkeyEnabled,
rehydrateAllFromStorage,
// Opaque version that changes when any synced setting changes, used by useAutoSync.
// eslint-disable-next-line react-hooks/exhaustive-deps
settingsVersion: useMemo(() => Math.random(), [
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
uiFontFamilyId, uiLanguage, customCSS,
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
customKeyBindings, editorWordWrap,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar,
customThemes,
]),
};
};

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

@@ -90,13 +90,13 @@ export const useTerminalBackend = () => {
return bridge.onSessionData(sessionId, cb);
}, []);
const onSessionExit = useCallback((sessionId: string, cb: (evt: { exitCode?: number; signal?: number }) => void) => {
const onSessionExit = useCallback((sessionId: string, cb: (evt: { exitCode?: number; signal?: number; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => void) => {
const bridge = netcattyBridge.get();
if (!bridge?.onSessionExit) throw new Error("onSessionExit unavailable");
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

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { checkForUpdates, getReleaseUrl, type ReleaseInfo, type UpdateCheckResult } from '../../infrastructure/services/updateService';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { STORAGE_KEY_UPDATE_DISMISSED_VERSION, STORAGE_KEY_UPDATE_LAST_CHECK, STORAGE_KEY_UPDATE_LATEST_RELEASE } from '../../infrastructure/config/storageKeys';
import { STORAGE_KEY_UPDATE_DISMISSED_VERSION, STORAGE_KEY_UPDATE_LAST_CHECK, STORAGE_KEY_UPDATE_LATEST_RELEASE, STORAGE_KEY_AUTO_UPDATE_ENABLED } from '../../infrastructure/config/storageKeys';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
// Check for updates at most once per hour
@@ -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';
@@ -56,7 +52,13 @@ export interface UseUpdateCheckResult {
* - Respects dismissed version to avoid nagging
* - Provides manual check capability
*/
export function useUpdateCheck(): UseUpdateCheckResult {
export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean }): UseUpdateCheckResult {
// Accept auto-update toggle from the caller (e.g. useSettingsState) so it
// reacts immediately in the same window. Falls back to reading localStorage
// when no caller provides the value (e.g. in non-settings contexts).
const autoUpdateEnabled = options?.autoUpdateEnabled ??
(localStorageAdapter.readString(STORAGE_KEY_AUTO_UPDATE_ENABLED) !== 'false');
const [updateState, setUpdateState] = useState<UpdateState>({
isChecking: false,
hasUpdate: false,
@@ -136,14 +138,20 @@ export function useUpdateCheck(): UseUpdateCheckResult {
return;
}
// 'available' means an update was found but auto-download is disabled.
// Surface the version info (hasUpdate + latestRelease) but keep
// autoDownloadStatus at 'idle' so the manual download path shows.
const isAvailableOnly = snapshot.status === 'available';
setUpdateState((prev) => {
// Don't overwrite if the renderer already has a newer state
if (prev.autoDownloadStatus !== 'idle') return prev;
return {
...prev,
autoDownloadStatus: snapshot.status,
downloadPercent: snapshot.percent,
downloadError: snapshot.error,
hasUpdate: isAvailableOnly ? true : prev.hasUpdate,
autoDownloadStatus: isAvailableOnly ? 'idle' : snapshot.status,
downloadPercent: isAvailableOnly ? 0 : snapshot.percent,
downloadError: isAvailableOnly ? null : snapshot.error,
// Use snapshot version if no release data or if versions differ
latestRelease: (!prev.latestRelease || (snapshot.version && prev.latestRelease.version !== snapshot.version)) ? (snapshot.version ? {
version: snapshot.version,
@@ -186,15 +194,18 @@ export function useUpdateCheck(): UseUpdateCheckResult {
if (isDismissed) {
dismissedAutoDownloadRef.current = true;
}
// When auto-update is disabled, autoDownload=false in the main process
// so no download will start. Don't transition to 'downloading' or the
// UI will be stuck at 0%. Keep status idle and let the manual download
// link surface instead.
const isAutoUpdateOff = localStorageAdapter.readString(STORAGE_KEY_AUTO_UPDATE_ENABLED) === 'false';
const shouldTrackDownload = !isDismissed && !isAutoUpdateOff;
setUpdateState((prev) => ({
...prev,
hasUpdate: !isDismissed,
// Only transition to 'downloading' if the user hasn't dismissed this
// version — otherwise leave the status at 'idle' so no download
// progress/ready toast appears for a release they don't want.
autoDownloadStatus: isDismissed ? prev.autoDownloadStatus : 'downloading',
downloadPercent: isDismissed ? prev.downloadPercent : 0,
downloadError: isDismissed ? prev.downloadError : null,
autoDownloadStatus: shouldTrackDownload ? 'downloading' : prev.autoDownloadStatus,
downloadPercent: shouldTrackDownload ? 0 : prev.downloadPercent,
downloadError: shouldTrackDownload ? null : prev.downloadError,
// Use electron-updater's version if GitHub API hasn't resolved yet or
// if the updater reports a different version than the cached release.
latestRelease: (!prev.latestRelease || prev.latestRelease.version !== info.version) ? {
@@ -439,6 +450,20 @@ export function useUpdateCheck(): UseUpdateCheckResult {
} else if (res?.checking) {
// Another check is already in flight — don't change status; the
// in-flight check will resolve via IPC events.
} else if (nextStatus === 'error' && res?.available) {
// GitHub API failed but electron-updater found an update.
// Respect dismissed versions before surfacing.
const dismissed = localStorageAdapter.readString(STORAGE_KEY_UPDATE_DISMISSED_VERSION);
if (res.version && res.version === dismissed) {
// User dismissed this version — don't re-surface
} else {
setUpdateState((prev) => ({
...prev,
manualCheckStatus: 'available',
hasUpdate: true,
error: null,
}));
}
} else if (nextStatus === 'error' && !res?.error && !res?.available) {
// GitHub API failed but electron-updater says no update available.
// Clear the error status so Settings doesn't stay stuck in error state.
@@ -519,12 +544,12 @@ export function useUpdateCheck(): UseUpdateCheckResult {
if (IS_UPDATE_DEMO_MODE) {
return;
}
debugLog('Version check effect', {
hasChecked: hasCheckedOnStartupRef.current,
debugLog('Version check effect', {
hasChecked: hasCheckedOnStartupRef.current,
currentVersion: updateState.currentVersion
});
if (hasCheckedOnStartupRef.current) {
return;
}
@@ -533,12 +558,11 @@ export function useUpdateCheck(): UseUpdateCheckResult {
return;
}
// Check if we've checked recently
// Hydrate cached release info so update status is visible across windows.
// When auto-update is disabled, hydrate release data (for the Settings UI)
// but don't set hasUpdate (which would trigger the toast in App.tsx).
const lastCheck = localStorageAdapter.readNumber(STORAGE_KEY_UPDATE_LAST_CHECK);
const now = Date.now();
if (lastCheck && now - lastCheck < UPDATE_CHECK_INTERVAL_MS) {
hasCheckedOnStartupRef.current = true;
// Hydrate cached release info so late-opening windows show the result
if (lastCheck) {
const cachedRelease = localStorageAdapter.readString(STORAGE_KEY_UPDATE_LATEST_RELEASE);
if (cachedRelease) {
try {
@@ -556,6 +580,19 @@ export function useUpdateCheck(): UseUpdateCheckResult {
// Ignore corrupted cache
}
}
}
// Respect auto-update toggle — skip automatic check when disabled.
// Don't set hasCheckedOnStartupRef so re-enabling (which changes the
// autoUpdateEnabled dependency) can re-trigger this effect.
if (!autoUpdateEnabled) {
return;
}
// Check if we've checked recently
const now = Date.now();
if (lastCheck && now - lastCheck < UPDATE_CHECK_INTERVAL_MS) {
hasCheckedOnStartupRef.current = true;
return;
}
@@ -563,6 +600,13 @@ export function useUpdateCheck(): UseUpdateCheckResult {
debugLog('Starting delayed update check for version:', updateState.currentVersion);
startupCheckTimeoutRef.current = setTimeout(async () => {
// Re-check the toggle at fire time — the user may have toggled it
// after the timer was scheduled.
const stillEnabled = localStorageAdapter.readString(STORAGE_KEY_AUTO_UPDATE_ENABLED);
if (stillEnabled === 'false') {
debugLog('Skipping startup check — auto-update disabled after timer was scheduled');
return;
}
// If electron-updater's auto-check already started a download, skip the
// redundant GitHub API check to avoid duplicate toast notifications.
if (autoDownloadStatusRef.current !== 'idle') {
@@ -601,7 +645,7 @@ export function useUpdateCheck(): UseUpdateCheckResult {
clearTimeout(startupCheckTimeoutRef.current);
}
};
}, [updateState.currentVersion, performCheck]);
}, [updateState.currentVersion, autoUpdateEnabled, performCheck]);
return {
updateState,

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,
@@ -29,6 +28,7 @@ import type {
DiscoveredAgent,
ExternalAgentConfig,
ProviderConfig,
WebSearchConfig,
} from '../infrastructure/ai/types';
import { getAgentModelPresets } from '../infrastructure/ai/types';
import { useAgentDiscovery } from '../application/state/useAgentDiscovery';
@@ -39,8 +39,9 @@ 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';
// -------------------------------------------------------------------
// Props
@@ -54,6 +55,7 @@ interface AIChatSidePanelProps {
createSession: (scope: AISessionScope, agentId?: string) => AISession;
deleteSession: (sessionId: string, scopeKey?: string) => void;
updateSessionTitle: (sessionId: string, title: string) => void;
updateSessionExternalSessionId: (sessionId: string, externalSessionId: string | undefined) => void;
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
updateLastMessage: (
sessionId: string,
@@ -82,6 +84,9 @@ interface AIChatSidePanelProps {
commandBlocklist?: string[];
maxIterations?: number;
// Web search
webSearchConfig?: WebSearchConfig | null;
// Context
scopeType: 'terminal' | 'workspace';
scopeTargetId?: string;
@@ -96,8 +101,15 @@ interface AIChatSidePanelProps {
label: string;
os?: string;
username?: string;
protocol?: string;
shellType?: string;
connected: boolean;
}>;
resolveExecutorContext?: (scope: {
type: 'terminal' | 'workspace';
targetId?: string;
label?: string;
}) => ExecutorContext;
// Visibility
isVisible?: boolean;
@@ -111,6 +123,35 @@ function generateId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function buildAcpHistoryMessages(messages: ChatMessage[]): Array<{ role: 'user' | 'assistant'; content: string }> {
return messages.flatMap((message) => {
if (message.role === 'system') return [];
if (message.role === 'user') {
return message.content ? [{ role: 'user' as const, content: message.content }] : [];
}
if (message.role === 'assistant') {
const parts: string[] = [];
if (message.content) parts.push(message.content);
if (message.toolCalls?.length) {
parts.push(...message.toolCalls.map((tc) => `Tool call: ${tc.name}(${JSON.stringify(tc.arguments ?? {})})`));
}
if (!parts.length) return [];
return [{ role: 'assistant' as const, content: parts.join('\n\n') }];
}
if (message.role === 'tool' && message.toolResults?.length) {
return message.toolResults.map((tr) => ({
role: 'assistant' as const,
content: `Tool result:\n${tr.content}`,
}));
}
return [];
});
}
// -------------------------------------------------------------------
// Component
// -------------------------------------------------------------------
@@ -122,6 +163,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
createSession,
deleteSession,
updateSessionTitle,
updateSessionExternalSessionId,
addMessageToSession,
updateLastMessage,
updateMessageById,
@@ -137,11 +179,13 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
setGlobalPermissionMode,
commandBlocklist,
maxIterations = 20,
webSearchConfig,
scopeType,
scopeTargetId,
scopeHostIds,
scopeLabel,
terminalSessions = [],
resolveExecutorContext,
isVisible = true,
}) => {
const { t } = useI18n();
@@ -159,15 +203,18 @@ 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;
const resolveExecutorContextRef = useRef(resolveExecutorContext);
resolveExecutorContextRef.current = resolveExecutorContext;
// ── Streaming hook ──
const {
streamingSessionIds,
setStreamingForScope,
abortControllersRef,
processCattyStream,
sendToCattyAgent,
sendToExternalAgent,
reportStreamError,
@@ -178,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;
@@ -213,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]);
@@ -227,16 +260,23 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
}
}, [providers]);
// Abort all active streams and clean up on unmount
// Sync web search config to main process (allowlist + encrypted API key for server-side decryption).
// Note: This is fire-and-forget; if the first search fires before sync completes, it will fail
// with a clear error and succeed on retry. Making this blocking would require async tool creation.
useEffect(() => {
const bridge = getNetcattyBridge();
if (bridge?.aiSyncWebSearch) {
void bridge.aiSyncWebSearch(webSearchConfig?.apiHost || null, webSearchConfig?.apiKey || null);
}
}, [webSearchConfig?.apiHost, webSearchConfig?.apiKey, webSearchConfig?.enabled]);
// Preserve active streams across tab switches. The panel is conditionally
// mounted per tab, so unmounting here should not cancel in-flight work.
useEffect(() => {
const controllers = abortControllersRef.current;
return () => {
controllers.forEach(c => c.abort());
controllers.clear();
// Clear pending approval (clears timeout too via setPendingApproval)
setPendingApproval(null);
// no-op: stream lifecycle is managed by explicit stop/delete actions
};
}, [abortControllersRef, setPendingApproval]);
}, []);
// Agent discovery
const {
@@ -353,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) => {
@@ -364,9 +404,25 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
}
}, [updateSessionTitle]);
const buildExecutorContextForScope = useCallback((scope: {
type: 'terminal' | 'workspace';
targetId?: string;
label?: string;
}): ExecutorContext => {
const resolved = resolveExecutorContextRef.current?.(scope);
if (resolved) return resolved;
return {
sessions: terminalSessionsRef.current,
workspaceId: scope.type === 'workspace' ? scope.targetId : undefined,
workspaceName: scope.type === 'workspace' ? scope.label : undefined,
};
}, []);
/** 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);
@@ -397,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
@@ -429,7 +485,10 @@ 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 ?? []),
terminalSessions,
providers,
selectedAgentModel,
@@ -443,6 +502,11 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
abortControllersRef.current.delete(sessionId);
autoTitleSession(sessionId, trimmed);
} else {
const toolScope = {
type: scopeType,
targetId: scopeTargetId,
label: scopeLabel,
} as const;
await sendToCattyAgent(sessionId, sendScopeKey, trimmed, abortController, currentSession ?? undefined, assistantMsgId, {
activeProvider,
activeModelId,
@@ -452,18 +516,19 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
globalPermissionMode,
commandBlocklist,
terminalSessions,
setPendingApproval,
webSearchConfig,
getExecutorContext: () => buildExecutorContextForScope(toolScope),
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,
scopeType, scopeTargetId, scopeLabel, globalPermissionMode, commandBlocklist, setPendingApproval,
abortControllersRef, terminalSessions, providers, selectedAgentModel, updateSessionExternalSessionId,
scopeType, scopeTargetId, scopeLabel, globalPermissionMode, commandBlocklist, webSearchConfig, buildExecutorContextForScope,
]);
const handleStop = useCallback(() => {
@@ -476,13 +541,15 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
updateLastMessage(activeSessionId, msg => ({
...msg,
statusText: '',
executionStatus: msg.executionStatus === 'running' ? 'completed' : msg.executionStatus,
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) => {
@@ -500,8 +567,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
const handleDeleteSession = useCallback(
(e: React.MouseEvent, sessionId: string) => {
e.stopPropagation();
const bridge = getNetcattyBridge();
void bridge?.aiAcpCleanup?.(sessionId).catch(() => {});
deleteSession(sessionId, scopeKey);
// Active session clearing is handled by deleteSession with scopeKey
},
@@ -577,22 +642,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
<ChatMessageList
messages={messages}
isStreaming={isStreaming}
onApprove={(messageId) => void handleApprovalResponse(messageId, true, {
terminalSessions,
scopeType,
scopeTargetId,
scopeLabel,
globalPermissionMode,
commandBlocklist,
})}
onReject={(messageId) => void handleApprovalResponse(messageId, false, {
terminalSessions,
scopeType,
scopeTargetId,
scopeLabel,
globalPermissionMode,
commandBlocklist,
})}
activeSessionId={activeSessionId}
/>
{/* Recent sessions (Zed-style, shown when no messages) */}
@@ -637,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);
@@ -978,6 +966,10 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
const result = await sync.syncToProvider(provider, payload);
if (result.success) {
// Apply merged data if a three-way merge happened
if (result.mergedPayload && onApplyPayload) {
onApplyPayload(result.mergedPayload);
}
toast.success(t('cloudSync.sync.success', { provider }));
} else if (result.conflictDetected) {
// Conflict modal will show automatically

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

@@ -61,6 +61,7 @@ interface TreeNodeProps {
toggleHostSelection?: (hostId: string) => void;
}
const TreeNode: React.FC<TreeNodeProps> = ({
node,
depth,
@@ -89,6 +90,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
const hasChildren = node.children && Object.keys(node.children).length > 0;
const paddingLeft = `${depth * 20 + 12}px`;
const isManaged = managedGroupPaths?.has(node.path) ?? false;
const hostsCountInNode = node.totalHostCount ?? node.hosts.length;
const childNodes = useMemo(() => {
if (!node.children) return [];
@@ -171,7 +173,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
)}
{(node.hosts.length > 0 || hasChildren) && (
<span className="text-xs opacity-70 bg-background/50 px-2 py-0.5 rounded-full border border-border">
{node.hosts.length}
{hostsCountInNode}
</span>
)}
</div>

View File

@@ -621,7 +621,7 @@ echo $3 >> "$FILE"`);
</Button>
</DropdownTrigger>
</div>
<DropdownContent className="w-44" align="start" alignToParent>
<DropdownContent className="w-48" align="start" alignToParent>
<Button
variant="ghost"
className="w-full justify-start gap-2"

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

@@ -16,7 +16,7 @@ import { ScrollArea } from './ui/scroll-area';
interface ScriptsSidePanelProps {
snippets: Snippet[];
packages: string[];
onSnippetClick: (command: string) => void;
onSnippetClick: (command: string, noAutoRun?: boolean) => void;
isVisible?: boolean;
}
@@ -115,8 +115,8 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
});
}, [selectedPackage]);
const handleSnippetClick = useCallback((command: string) => {
onSnippetClick(command);
const handleSnippetClick = useCallback((command: string, noAutoRun?: boolean) => {
onSnippetClick(command, noAutoRun);
}, [onSnippetClick]);
if (!isVisible) return null;
@@ -196,7 +196,7 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
{displayedSnippets.map((s) => (
<button
key={s.id}
onClick={() => handleSnippetClick(s.command)}
onClick={() => handleSnippetClick(s.command, s.noAutoRun)}
className="w-full text-left px-3 py-2 hover:bg-accent/50 transition-colors flex flex-col gap-0.5"
>
<span className="text-xs font-medium truncate">{s.label}</span>

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,13 +45,64 @@ 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 SettingsSyncTabWithVault: React.FC = () => {
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,
keys,
@@ -90,6 +141,7 @@ const SettingsSyncTabWithVault: React.FC = () => {
importDataFromString={importDataFromString}
importPortForwardingRules={importPortForwardingRules}
clearVaultData={clearVaultData}
onSettingsApplied={onSettingsApplied}
/>
);
};
@@ -97,8 +149,7 @@ const SettingsSyncTabWithVault: React.FC = () => {
const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }) => {
const { t } = useI18n();
const { notifyRendererReady, closeSettingsWindow } = useWindowControls();
const { updateState, checkNow, installUpdate, openReleasePage } = useUpdateCheck();
const aiState = useAIState();
const { updateState, checkNow, installUpdate, openReleasePage } = useUpdateCheck({ autoUpdateEnabled: settings.autoUpdateEnabled });
const [activeTab, setActiveTab] = useState("application");
const [mountedTabs, setMountedTabs] = useState(() => new Set(["application"]));
@@ -230,17 +281,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
)}
{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") && (
@@ -260,37 +301,12 @@ 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}
/>
</React.Suspense>
</AITabErrorBoundary>
<SettingsAITabContainer />
)}
{mountedTabs.has("sync") && (
<React.Suspense fallback={null}>
<SettingsSyncTabWithVault />
<SettingsSyncTabWithVault onSettingsApplied={settings.rehydrateAllFromStorage} />
</React.Suspense>
)}
@@ -307,6 +323,10 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
closeToTray={settings.closeToTray}
setCloseToTray={settings.setCloseToTray}
hotkeyRegistrationError={settings.hotkeyRegistrationError}
globalHotkeyEnabled={settings.globalHotkeyEnabled}
setGlobalHotkeyEnabled={settings.setGlobalHotkeyEnabled}
autoUpdateEnabled={settings.autoUpdateEnabled}
setAutoUpdateEnabled={settings.setAutoUpdateEnabled}
updateState={updateState}
checkNow={checkNow}
installUpdate={installUpdate}

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

@@ -28,6 +28,7 @@ interface SnippetsManagerProps {
hotkeyScheme: HotkeyScheme;
keyBindings: KeyBinding[];
onSave: (snippet: Snippet) => void;
onBulkSave: (snippets: Snippet[]) => void;
onDelete: (id: string) => void;
onPackagesChange: (packages: string[]) => void;
onRunSnippet?: (snippet: Snippet, targetHosts: Host[]) => void;
@@ -51,6 +52,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
hotkeyScheme,
keyBindings,
onSave,
onBulkSave,
onDelete,
onPackagesChange,
onRunSnippet,
@@ -300,6 +302,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
package: editingSnippet.package || '',
targets: targetSelection,
shortkey: editingSnippet.shortkey,
noAutoRun: editingSnippet.noAutoRun,
});
setRightPanelMode('none');
}
@@ -436,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;
}
@@ -486,11 +489,8 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
// Update packages first, then save snippets
onPackagesChange(keep);
// Only save snippets that were actually modified
const modifiedSnippets = updatedSnippets.filter((s, index) =>
s.package !== snippets[index].package
);
modifiedSnippets.forEach(onSave);
// Bulk-save all snippets to avoid stale-closure overwrites
onBulkSave(updatedSnippets);
// Reset selected package if it was deleted
if (selectedPackage && (selectedPackage === path || selectedPackage.startsWith(path + '/'))) {
@@ -527,7 +527,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
});
onPackagesChange(Array.from(new Set(updatedPackages)));
updatedSnippets.forEach(onSave);
onBulkSave(updatedSnippets);
if (selectedPackage === source) setSelectedPackage(newPath);
};
@@ -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;
}
@@ -568,8 +568,8 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
return;
}
// Validate: duplicate (case-insensitive)
const existingPackage = packages.find(p => p.toLowerCase() === newPath.toLowerCase());
// Validate: duplicate (case-insensitive), excluding the package being renamed
const existingPackage = packages.find(p => p !== renamingPackagePath && p.toLowerCase() === newPath.toLowerCase());
if (existingPackage) {
setRenameError(t('snippets.renameDialog.error.duplicate'));
return;
@@ -595,7 +595,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
});
onPackagesChange(Array.from(new Set(updatedPackages)));
updatedSnippets.forEach(onSave);
onBulkSave(updatedSnippets);
// Update selected package if it was renamed
if (selectedPackage === renamingPackagePath) {
@@ -792,6 +792,17 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
/>
</Card>
{/* No Auto Run */}
<label className="flex items-center gap-2 cursor-pointer px-1">
<input
type="checkbox"
checked={editingSnippet.noAutoRun ?? false}
onChange={(e) => setEditingSnippet({ ...editingSnippet, noAutoRun: e.target.checked || undefined })}
className="rounded border-input"
/>
<span className="text-xs text-muted-foreground">{t('snippets.field.noAutoRun')}</span>
</label>
{/* Shortkey */}
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center justify-between">
@@ -1192,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

@@ -4,11 +4,11 @@ import { SerializeAddon } from "@xterm/addon-serialize";
import { SearchAddon } from "@xterm/addon-search";
import "@xterm/xterm/css/xterm.css";
import { Cpu, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine } from "lucide-react";
import React, { memo, useEffect, useMemo, useRef, useState } from "react";
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
// 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";
@@ -118,12 +123,13 @@ interface TerminalProps {
terminalSettings?: TerminalSettings;
sessionId: string;
startupCommand?: string;
noAutoRun?: boolean;
serialConfig?: SerialConfig;
hotkeyScheme?: "disabled" | "mac" | "pc";
keyBindings?: KeyBinding[];
onHotkeyAction?: (action: string, event: KeyboardEvent) => void;
onStatusChange?: (sessionId: string, status: TerminalSession["status"]) => void;
onSessionExit?: (sessionId: string) => void;
onSessionExit?: (sessionId: string, evt: { exitCode?: number; signal?: number; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => void;
onTerminalDataCapture?: (sessionId: string, data: string) => void;
onOsDetected?: (hostId: string, distro: string) => void;
onCloseSession?: (sessionId: string) => void;
@@ -151,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
@@ -184,6 +196,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
terminalSettings,
sessionId,
startupCommand,
noAutoRun,
serialConfig,
hotkeyScheme = "disabled",
keyBindings = [],
@@ -207,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;
@@ -238,22 +253,23 @@ const TerminalComponent: React.FC<TerminalProps> = ({
useEffect(() => {
if (xtermRuntimeRef.current) {
// Merge global rules with host-level rules
// Host-level rules are appended to global rules, allowing hosts to add custom highlighting
const globalRules = terminalSettings?.keywordHighlightRules ?? [];
const hostRules = host?.keywordHighlightRules ?? [];
// Check if highlighting is enabled at either global or host level
const globalEnabled = terminalSettings?.keywordHighlightEnabled ?? false;
const hostEnabled = host?.keywordHighlightEnabled ?? false;
// Host-level toggle: undefined = inherit global, true/false = explicit override
const hostEnabled = host?.keywordHighlightEnabled;
// Global and host-level highlights are independent:
// global toggle controls global rules, host toggle controls host-specific rules
const effectiveGlobalEnabled = globalEnabled;
const effectiveHostEnabled = hostEnabled ?? false;
// Merge rules: include only rules from enabled sources
const mergedRules = [
...(globalEnabled ? globalRules : []),
...(hostEnabled ? hostRules : [])
...(effectiveGlobalEnabled ? globalRules : []),
...(effectiveHostEnabled ? hostRules : [])
];
// Enable highlighting if either global or host-level is enabled
const isEnabled = globalEnabled || hostEnabled;
const isEnabled = effectiveGlobalEnabled || effectiveHostEnabled;
xtermRuntimeRef.current.keywordHighlighter.setRules(mergedRules, isEnabled);
}
@@ -296,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;
@@ -334,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',
});
@@ -371,17 +388,39 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const [pendingHostKeyInfo, setPendingHostKeyInfo] = useState<HostKeyInfo | null>(null);
const pendingConnectionRef = useRef<(() => void) | null>(null);
// OSC-52 clipboard read prompt
const [osc52ReadPromptVisible, setOsc52ReadPromptVisible] = useState(false);
const osc52ReadResolverRef = useRef<((allowed: boolean) => void) | null>(null);
const handleOsc52ReadRequest = useCallback((): Promise<boolean> => {
// Reject if terminal is not visible (background tab) — user can't see the prompt
if (!isVisibleRef.current) return Promise.resolve(false);
// Reject if another prompt is already pending (avoid resolver overwrite)
if (osc52ReadResolverRef.current) return Promise.resolve(false);
return new Promise((resolve) => {
osc52ReadResolverRef.current = resolve;
setOsc52ReadPromptVisible(true);
});
}, []);
const handleOsc52ReadResponse = useCallback((allowed: boolean) => {
setOsc52ReadPromptVisible(false);
osc52ReadResolverRef.current?.(allowed);
osc52ReadResolverRef.current = null;
// Restore focus to terminal
termRef.current?.focus();
}, []);
// Subscribe to custom theme changes so editing triggers re-render
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
@@ -427,6 +466,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
resolvedChainHosts,
sessionId,
startupCommand,
noAutoRun,
terminalSettings,
terminalSettingsRef,
terminalBackend,
@@ -462,6 +502,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onTerminalDataCapture,
onOsDetected,
onCommandExecuted,
sessionLog,
});
sessionStartersRef.current = sessionStarters;
@@ -473,6 +514,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
setProgressLogs([]);
setShowLogs(false);
setIsCancelling(false);
setIsDisconnectedDialogDismissed(false);
const boot = async () => {
try {
@@ -502,6 +544,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
serialLocalEcho: serialConfig?.localEcho,
serialLineMode: serialConfig?.lineMode,
serialLineBufferRef,
onOsc52ReadRequest: handleOsc52ReadRequest,
});
xtermRuntimeRef.current = runtime;
@@ -516,12 +559,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const globalRules = terminalSettingsRef.current?.keywordHighlightRules ?? [];
const hostRules = host?.keywordHighlightRules ?? [];
const globalEnabled = terminalSettingsRef.current?.keywordHighlightEnabled ?? false;
const hostEnabled = host?.keywordHighlightEnabled ?? false;
const hostEnabled = host?.keywordHighlightEnabled;
const effectiveGlobalEnabled = globalEnabled;
const effectiveHostEnabled = hostEnabled ?? false;
const mergedRules = [
...(globalEnabled ? globalRules : []),
...(hostEnabled ? hostRules : [])
...(effectiveGlobalEnabled ? globalRules : []),
...(effectiveHostEnabled ? hostRules : [])
];
const isEnabled = globalEnabled || hostEnabled;
const isEnabled = effectiveGlobalEnabled || effectiveHostEnabled;
runtime.keywordHighlighter.setRules(mergedRules, isEnabled);
const term = runtime.term;
@@ -595,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));
@@ -639,7 +662,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}, 200);
return () => {
if (stepTimer) clearInterval(stepTimer);
clearInterval(countdown);
clearTimeout(timeout);
clearInterval(prog);
@@ -647,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;
@@ -693,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 = {
@@ -741,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;
@@ -750,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;
@@ -768,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;
@@ -816,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)
@@ -852,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;
@@ -1015,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,
@@ -1078,6 +1142,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onCloseSession?.(sessionId);
};
const handleDismissDisconnectedDialog = () => {
setIsDisconnectedDialogDismissed(true);
};
const handleCloseDisconnectedSession = () => {
onCloseSession?.(sessionId);
};
const handleHostKeyClose = () => {
setNeedsHostKeyVerification(false);
setPendingHostKeyInfo(null);
@@ -1118,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();
@@ -1309,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}>
@@ -1357,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>
)}
@@ -1678,10 +1780,31 @@ const TerminalComponent: React.FC<TerminalProps> = ({
</div>
)}
{/* OSC-52 clipboard read prompt */}
{osc52ReadPromptVisible && (
<div
className="absolute inset-0 z-40 flex items-center justify-center bg-background/60"
onKeyDown={(e) => {
if (e.key === 'Escape') handleOsc52ReadResponse(false);
}}
>
<div className="rounded-lg border bg-card p-4 shadow-lg max-w-sm space-y-3">
<p className="text-sm font-medium">{t("terminal.osc52.readPrompt.title")}</p>
<p className="text-sm text-muted-foreground">{t("terminal.osc52.readPrompt.desc")}</p>
<div className="flex justify-end gap-2">
<Button variant="secondary" size="sm" onClick={() => handleOsc52ReadResponse(false)}>
{t("terminal.osc52.readPrompt.deny")}
</Button>
<Button size="sm" autoFocus onClick={() => handleOsc52ReadResponse(true)}>
{t("terminal.osc52.readPrompt.allow")}
</Button>
</div>
</div>
</div>
)}
{/* 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}
@@ -1692,6 +1815,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
showLogs={showLogs}
_setShowLogs={setShowLogs}
keys={keys}
onDismissDisconnected={handleDismissDisconnectedDialog}
authProps={{
authMethod: auth.authMethod,
setAuthMethod: auth.setAuthMethod,
@@ -1717,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,13 +327,68 @@ 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) => {
onUpdateSessionStatus(sessionId, 'disconnected');
}, [onUpdateSessionStatus]);
const handleSessionExit = useCallback((sessionId: string, evt: { exitCode?: number; signal?: number; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => {
// Auto-close the tab/session when the user actively exited (e.g. typed `exit`)
// reason === "exited" means the remote process/shell exited normally (stream-level close),
// as opposed to network errors, timeouts, or connection-level drops
if (evt.reason === "exited") {
onCloseSession(sessionId);
} else {
onUpdateSessionStatus(sessionId, 'disconnected');
}
}, [onUpdateSessionStatus, onCloseSession]);
const handleOsDetected = useCallback((hostId: string, distro: string) => {
onUpdateHostDistro(hostId, distro);
@@ -197,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);
@@ -237,6 +461,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
activeTabIdRef.current = activeTabId;
const activeWorkspaceRef = useRef(activeWorkspace);
activeWorkspaceRef.current = activeWorkspace;
const sessionsRef = useRef(sessions);
sessionsRef.current = sessions;
const workspacesRef = useRef(workspaces);
workspacesRef.current = workspaces;
const hostsRef = useRef(hosts);
hostsRef.current = hosts;
const onSetWorkspaceFocusedSessionRef = useRef(onSetWorkspaceFocusedSession);
onSetWorkspaceFocusedSessionRef.current = onSetWorkspaceFocusedSession;
@@ -518,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;
@@ -761,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> => {
@@ -864,11 +1105,18 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}, [handleOpenAI]);
// Execute snippet on the focused terminal session
const handleSnippetClickForFocusedSession = useCallback((command: string) => {
const handleSnippetClickForFocusedSession = useCallback((command: string, noAutoRun?: boolean) => {
const sessionId = activeWorkspace?.focusedSessionId ?? activeSession?.id;
if (!sessionId) return;
const payload = `${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;
@@ -896,75 +1144,144 @@ 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;
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);
// AI Chat state
const aiState = useAIState();
const { cleanupOrphanedSessions } = aiState;
// On mount: clean up orphaned AI sessions after a short delay
// (allows sessions/workspaces to fully initialize)
const hasCleanedUpRef = useRef(false);
// 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(() => {
if (hasCleanedUpRef.current) return;
// Guard: wait until both sessions AND workspaces have loaded to avoid
// racing with partial state (e.g. sessions loaded but workspaces not yet).
if (sessions.length === 0 || workspaces.length === 0) return;
hasCleanedUpRef.current = true;
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';
targetId?: string;
label?: string;
}) => {
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;
return workspace?.root ? collectSessionIds(workspace.root) : [];
})()
: scope.targetId ? [scope.targetId] : [];
const workspaceName = scope.type === 'workspace'
? latestWorkspaces.find((w) => w.id === scope.targetId)?.title ?? scope.label
: undefined;
return {
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 buildAITerminalSessionInfo(session, host, localOs);
}),
workspaceId: scope.type === 'workspace' ? scope.targetId : undefined,
workspaceName,
};
}, []);
// Subscribe to custom theme changes so editing triggers re-render
const customThemes = useCustomThemes();
@@ -1146,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 }}
@@ -1314,54 +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}
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}
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?.name ?? activeSession?.label ?? ''}
terminalSessions={aiTerminalSessions}
/>
</div>
)}
<AIChatPanelsHost
mountedTabIds={mountedAiTabIds}
activeTabId={activeTabId}
activeSidePanelTab={activeSidePanelTab}
contextsByTabId={aiContextsByTabId}
resolveExecutorContext={resolveAIExecutorContext}
/>
</div>
</div>
</div>
@@ -1487,6 +1782,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
terminalSettings={terminalSettings}
sessionId={session.id}
startupCommand={session.startupCommand}
noAutoRun={session.noAutoRun}
serialConfig={session.serialConfig}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
@@ -1510,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>
);
@@ -1570,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>
);
};
@@ -1611,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

@@ -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';
@@ -89,7 +89,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 +97,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>

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 } 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;
@@ -9,21 +11,92 @@ export interface ToolCallProps extends HTMLAttributes<HTMLDivElement> {
result?: unknown;
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, 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={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)}
@@ -33,10 +106,28 @@ export const ToolCall = ({ name, args, result, isError, isLoading, className, ..
? <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 && (
@@ -47,6 +138,38 @@ export const ToolCall = ({ name, args, result, isError, isLoading, className, ..
</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>
@@ -58,6 +181,14 @@ export const ToolCall = ({ name, args, result, isError, isLoading, className, ..
</pre>
</div>
)}
{isInterrupted && 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">Status</div>
<div className="text-[11px] text-muted-foreground/50">
Interrupted
</div>
</div>
)}
</div>
)}
</div>

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,19 +18,143 @@ 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(
visibleMessages
.filter((m) => m.role === 'tool')
.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 (
@@ -44,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) => {
@@ -53,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}
/>
@@ -78,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>
)}
@@ -101,25 +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'}
/>
))}
{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 && (
@@ -133,7 +276,9 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-destructive/10 border border-destructive/20 text-sm">
<AlertCircle className="h-4 w-4 text-destructive shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-destructive font-medium">{message.errorInfo.message}</p>
<p className="text-destructive font-medium whitespace-pre-wrap break-words [overflow-wrap:anywhere]">
{message.errorInfo.message}
</p>
{message.errorInfo.retryable && (
<p className="text-muted-foreground text-xs mt-1">{t('ai.chat.retryHint')}</p>
)}
@@ -145,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">
@@ -156,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;
@@ -180,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

@@ -10,22 +10,25 @@
* - Error reporting
*/
import React, { useCallback, useRef, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { streamText, stepCountIs, type ModelMessage } from 'ai';
import type {
AIPermissionMode,
AISession,
ChatMessage,
ChatMessageAttachment,
ExternalAgentConfig,
ProviderConfig,
WebSearchConfig,
} from '../../../infrastructure/ai/types';
import { isWebSearchReady } from '../../../infrastructure/ai/types';
import { buildSystemPrompt } from '../../../infrastructure/ai/cattyAgent/systemPrompt';
import { createModelFromConfig } from '../../../infrastructure/ai/sdk/providers';
import { createCattyTools } from '../../../infrastructure/ai/sdk/tools';
import type { NetcattyBridge } from '../../../infrastructure/ai/cattyAgent/executor';
import type { NetcattyBridge, ExecutorContext } from '../../../infrastructure/ai/cattyAgent/executor';
import { runExternalAgentTurn } from '../../../infrastructure/ai/externalAgentAdapter';
import { runAcpAgentTurn } from '../../../infrastructure/ai/acpAgentAdapter';
import { classifyError, sanitizeErrorMessage } from '../../../infrastructure/ai/errorClassifier';
import { classifyError } from '../../../infrastructure/ai/errorClassifier';
// -------------------------------------------------------------------
// Stream chunk type interfaces (Issue #13: replace unsafe casts)
@@ -61,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. */
@@ -85,14 +102,14 @@ 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 {
credentialsDecrypt?: (value: string) => Promise<string>;
aiSyncProviders?: (providers: Array<{ id: string; providerId: string; apiKey?: string; baseURL?: string; enabled: boolean }>) => Promise<{ ok: boolean }>;
aiSyncWebSearch?: (apiHost: string | null, apiKey: string | null) => Promise<{ ok: boolean }>;
aiMcpUpdateSessions?: (sessions: TerminalSessionInfo[], chatSessionId?: string) => Promise<unknown>;
aiAcpCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
[key: string]: ((...args: unknown[]) => unknown) | undefined;
@@ -106,6 +123,8 @@ export interface TerminalSessionInfo {
label: string;
os?: string;
username?: string;
protocol?: string;
shellType?: string;
connected: boolean;
}
@@ -115,29 +134,27 @@ 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>;
}
// 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)}`;
}
const sharedStreamingSessionIds = new Set<string>();
const sharedAbortControllers = new Map<string, AbortController>();
const streamingSubscribers = new Set<() => void>();
function emitStreamingStoreChange(): void {
streamingSubscribers.forEach(listener => {
try {
listener();
} catch (err) {
console.error('[AIChatStreaming] Failed to notify streaming subscriber:', err);
}
});
}
// -------------------------------------------------------------------
// Hook parameters
// -------------------------------------------------------------------
@@ -160,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>,
@@ -169,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,
@@ -179,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: (
@@ -203,12 +221,16 @@ export interface SendToCattyContext {
globalPermissionMode: AIPermissionMode;
commandBlocklist?: string[];
terminalSessions: TerminalSessionInfo[];
setPendingApproval: (ctx: PendingApprovalContext | null) => void;
webSearchConfig?: WebSearchConfig | null;
getExecutorContext?: () => ExecutorContext;
autoTitleSession: (sessionId: string, text: string) => void;
}
/** Context values needed by sendToExternalAgent that change frequently. */
export interface SendToExternalContext {
existingSessionId?: string;
updateExternalSessionId?: (sessionId: string, externalSessionId: string | undefined) => void;
historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>;
terminalSessions: TerminalSessionInfo[];
providers: ProviderConfig[];
selectedAgentModel?: string;
@@ -225,17 +247,34 @@ export function useAIChatStreaming({
updateMessageById,
}: UseAIChatStreamingParams): UseAIChatStreamingReturn {
// Per-session streaming state (keyed by sessionId)
const [streamingSessionIds, setStreamingSessions] = useState<Set<string>>(new Set());
const [streamingSessionIds, setStreamingSessions] = useState<Set<string>>(
() => new Set(sharedStreamingSessionIds),
);
useEffect(() => {
const syncFromStore = () => {
setStreamingSessions(new Set(sharedStreamingSessionIds));
};
streamingSubscribers.add(syncFromStore);
syncFromStore();
return () => {
streamingSubscribers.delete(syncFromStore);
};
}, []);
const setStreamingForScope = useCallback((key: string, val: boolean) => {
setStreamingSessions(prev => {
const next = new Set(prev);
if (val) next.add(key); else next.delete(key);
return next;
});
const hadKey = sharedStreamingSessionIds.has(key);
if (val) {
sharedStreamingSessionIds.add(key);
} else {
sharedStreamingSessionIds.delete(key);
}
if (hadKey !== val) {
emitStreamingStoreChange();
}
}, []);
// Per-scope abort controllers
const abortControllersRef = useRef<Map<string, AbortController>>(new Map());
const abortControllersRef = useRef<Map<string, AbortController>>(sharedAbortControllers);
// -------------------------------------------------------------------
// reportStreamError
@@ -247,12 +286,14 @@ export function useAIChatStreaming({
err: unknown,
) => {
if (abortSignal.aborted) return;
const errorStr = err instanceof Error ? err.message : String(err);
let errorStr: string;
if (err instanceof Error) errorStr = err.message;
else if (typeof err === 'object' && err !== null && 'message' in err) errorStr = String((err as { message: unknown }).message);
else if (typeof err === 'string') errorStr = err;
else { try { errorStr = JSON.stringify(err) ?? 'Unknown error'; } catch { errorStr = 'Unknown error'; } }
// Log the full unsanitized error for debugging
console.error('[AIChatSidePanel] Stream error (full):', errorStr);
const errorInfo = classifyError(errorStr);
// Sanitize the displayed message to avoid leaking paths, keys, or other sensitive info
errorInfo.message = sanitizeErrorMessage(errorInfo.message);
updateLastMessage(sessionId, msg => ({
...msg,
statusText: '',
@@ -279,7 +320,7 @@ export function useAIChatStreaming({
sdkMessages: Array<ModelMessage>,
signal: AbortSignal,
currentAssistantMsgId: string,
): Promise<ApprovalInfo | null> => {
): Promise<void> => {
const result = streamText({
model,
messages: sdkMessages,
@@ -293,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 = '';
@@ -411,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',
@@ -420,7 +461,7 @@ export function useAIChatStreaming({
content: typeof toolOutput === 'string'
? toolOutput
: JSON.stringify(toolOutput),
isError: false,
isError: toolError,
}],
timestamp: Date.now(),
executionStatus: 'completed',
@@ -428,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();
@@ -460,7 +485,11 @@ export function useAIChatStreaming({
id: generateId(),
role: 'assistant',
content: '',
errorInfo: classifyError(String(typedChunk.error)),
errorInfo: classifyError(
typedChunk.error instanceof Error ? typedChunk.error.message
: typeof typedChunk.error === 'string' ? typedChunk.error
: (() => { try { return JSON.stringify(typedChunk.error) ?? 'Unknown error'; } catch { return 'Unknown error'; } })(),
),
timestamp: Date.now(),
});
break;
@@ -474,7 +503,7 @@ export function useAIChatStreaming({
flushText();
reader.releaseLock();
}
return pendingApprovalInfo;
return;
}, [maxIterations, addMessageToSession, updateMessageById]);
// -------------------------------------------------------------------
@@ -544,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;
@@ -569,6 +604,9 @@ export function useAIChatStreaming({
maybeCreateAssistantMsg();
updateLastMessage(sessionId, msg => ({ ...msg, statusText: message }));
},
onSessionId: (externalSessionId: string) => {
context.updateExternalSessionId?.(sessionId, externalSessionId);
},
onError: (error: string) => {
reportStreamError(sessionId, abortController.signal, error);
setStreamingForScope(sessionId, false);
@@ -578,6 +616,8 @@ export function useAIChatStreaming({
abortController.signal,
agentProviderId,
context.selectedAgentModel,
context.existingSessionId,
context.historyMessages,
attachedImages.length > 0 ? attachedImages : undefined,
);
} else {
@@ -615,21 +655,35 @@ export function useAIChatStreaming({
currentSession: AISession | undefined,
assistantMsgId: string,
context: SendToCattyContext,
attachments?: ChatMessageAttachment[],
) => {
const bridge = getNetcattyBridge();
const tools = createCattyTools(bridge, {
const getExecutorContext = context.getExecutorContext ?? (() => ({
sessions: context.terminalSessions,
workspaceId: context.scopeTargetId,
workspaceName: context.scopeLabel,
}, context.commandBlocklist, context.globalPermissionMode);
workspaceId: context.scopeType === 'workspace' ? context.scopeTargetId : undefined,
workspaceName: context.scopeType === 'workspace' ? context.scopeLabel : undefined,
}));
const tools = createCattyTools(
bridge,
getExecutorContext,
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),
});
// Guard: activeProvider must exist for Catty agent path
@@ -656,13 +710,50 @@ export function useAIChatStreaming({
try {
// Issue #5: Build SDK messages including tool-call and tool-result messages
// so the LLM maintains full conversation context
const allMessages = currentSession?.messages ?? [];
// Collect all tool call IDs that have a corresponding tool result,
// so we can skip orphaned tool calls (e.g. from user stopping mid-execution)
const resolvedToolCallIds = new Set<string>();
for (const m of allMessages) {
if (m.role === 'tool' && m.toolResults) {
for (const tr of m.toolResults) resolvedToolCallIds.add(tr.toolCallId);
}
}
const findToolName = (toolCallId: string): string => {
for (const prev of allMessages) {
if (prev.role === 'assistant' && prev.toolCalls) {
const tc = prev.toolCalls.find(t => t.id === toolCallId);
if (tc) return tc.name;
}
}
return 'unknown';
};
const sdkMessages: Array<ModelMessage> = [];
for (const m of (currentSession?.messages ?? [])) {
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) {
// Build assistant content parts: text + tool calls
// Only include tool calls that have matching results
const resolvedCalls = m.toolCalls.filter(tc => resolvedToolCallIds.has(tc.id));
const contentParts: Array<
{ type: 'text'; text: string } |
{ type: 'tool-call'; toolCallId: string; toolName: string; input: unknown }
@@ -670,7 +761,7 @@ export function useAIChatStreaming({
if (m.content) {
contentParts.push({ type: 'text' as const, text: m.content });
}
for (const tc of m.toolCalls) {
for (const tc of resolvedCalls) {
contentParts.push({
type: 'tool-call' as const,
toolCallId: tc.id,
@@ -678,33 +769,42 @@ export function useAIChatStreaming({
input: tc.arguments ?? {},
});
}
sdkMessages.push({ role: 'assistant', content: contentParts });
// If all tool calls were orphaned, just include the text content
if (contentParts.length > 0) {
sdkMessages.push({ role: 'assistant', content: contentParts.length === 1 && contentParts[0].type === 'text' ? (contentParts[0] as { type: 'text'; text: string }).text : contentParts });
}
} else if (m.content) {
sdkMessages.push({ role: 'assistant', content: m.content });
}
} else if (m.role === 'tool' && m.toolResults?.length) {
// Map tool results to SDK tool message format
sdkMessages.push({
role: 'tool',
content: m.toolResults.map(tr => ({
type: 'tool-result' as const,
toolCallId: tr.toolCallId,
toolName: '',
toolName: findToolName(tr.toolCallId),
output: { type: 'text' as const, value: tr.content },
})),
});
}
}
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,
});
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,280 +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,
} 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,
TerminalSessionInfo,
} 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)}`;
}
// -------------------------------------------------------------------
// 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 {
terminalSessions: TerminalSessionInfo[];
scopeType: 'terminal' | 'workspace';
scopeTargetId?: string;
scopeLabel?: string;
globalPermissionMode: AIPermissionMode;
commandBlocklist?: string[];
}
// -------------------------------------------------------------------
// 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>(null);
// Timeout ID for auto-clearing stale pending approval (Issue #14)
const pendingApprovalTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
/** Set pending approval context with a 5-minute auto-clear timeout. */
const setPendingApproval = useCallback((ctx: PendingApprovalContext | null) => {
// Clear any existing timeout
if (pendingApprovalTimeoutRef.current) {
clearTimeout(pendingApprovalTimeoutRef.current);
pendingApprovalTimeoutRef.current = null;
}
pendingApprovalContextRef.current = ctx;
if (ctx) {
pendingApprovalTimeoutRef.current = setTimeout(() => {
// Auto-clear after 5 minutes if user never responds
if (pendingApprovalContextRef.current?.sessionId === ctx.sessionId) {
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(),
});
}
pendingApprovalTimeoutRef.current = 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 } = 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 closure issues (e.g. user changed permission mode during approval wait)
const bridge = getNetcattyBridge();
const freshTools = createCattyTools(bridge, {
sessions: approvalContext.terminalSessions,
workspaceId: approvalContext.scopeTargetId,
workspaceName: approvalContext.scopeLabel,
}, approvalContext.commandBlocklist, approvalContext.globalPermissionMode);
const freshSystemPrompt = buildSystemPrompt({
scopeType: approvalContext.scopeType, scopeLabel: approvalContext.scopeLabel,
hosts: approvalContext.terminalSessions.map(s => ({
sessionId: s.sessionId, hostname: s.hostname, label: s.label,
os: s.os, username: s.username, connected: s.connected,
})),
permissionMode: approvalContext.globalPermissionMode,
});
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,
});
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

@@ -14,6 +14,7 @@ import type {
AIProviderId,
ExternalAgentConfig,
ProviderConfig,
WebSearchConfig,
} from "../../../infrastructure/ai/types";
import { PROVIDER_PRESETS } from "../../../infrastructure/ai/types";
import { useAgentDiscovery } from "../../../application/state/useAgentDiscovery";
@@ -38,6 +39,7 @@ import { AddProviderDropdown } from "./ai/AddProviderDropdown";
import { CodexConnectionCard } from "./ai/CodexConnectionCard";
import { ClaudeCodeCard } from "./ai/ClaudeCodeCard";
import { SafetySettings } from "./ai/SafetySettings";
import { WebSearchSettings } from "./ai/WebSearchSettings";
// ---------------------------------------------------------------------------
// Props
@@ -64,6 +66,8 @@ interface SettingsAITabProps {
setCommandTimeout: (value: number) => void;
maxIterations: number;
setMaxIterations: (value: number) => void;
webSearchConfig: WebSearchConfig | null;
setWebSearchConfig: (config: WebSearchConfig | null) => void;
}
// ---------------------------------------------------------------------------
@@ -91,6 +95,8 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
setCommandTimeout,
maxIterations,
setMaxIterations,
webSearchConfig,
setWebSearchConfig,
}) => {
const { t } = useI18n();
const [editingProviderId, setEditingProviderId] = useState<string | null>(null);
@@ -508,6 +514,12 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
</div>
)}
{/* -- Web Search Section -- */}
<WebSearchSettings
webSearchConfig={webSearchConfig}
setWebSearchConfig={setWebSearchConfig}
/>
{/* -- Safety Section -- */}
<SafetySettings
globalPermissionMode={globalPermissionMode}

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

@@ -15,6 +15,7 @@ export default function SettingsSyncTab(props: {
importDataFromString: (data: string) => void;
importPortForwardingRules: (rules: PortForwardingRule[]) => void;
clearVaultData: () => void;
onSettingsApplied?: () => void;
}) {
const {
vault,
@@ -22,6 +23,7 @@ export default function SettingsSyncTab(props: {
importDataFromString,
importPortForwardingRules,
clearVaultData,
onSettingsApplied,
} = props;
const onBuildPayload = useCallback((): SyncPayload => {
@@ -56,9 +58,10 @@ export default function SettingsSyncTab(props: {
applySyncPayload(payload, {
importVaultData: importDataFromString,
importPortForwardingRules,
onSettingsApplied,
});
},
[importDataFromString, importPortForwardingRules],
[importDataFromString, importPortForwardingRules, onSettingsApplied],
);
const clearAllLocalData = useCallback(() => {

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;
@@ -55,6 +80,10 @@ interface SettingsSystemTabProps {
closeToTray: boolean;
setCloseToTray: (enabled: boolean) => void;
hotkeyRegistrationError: string | null;
globalHotkeyEnabled: boolean;
setGlobalHotkeyEnabled: (enabled: boolean) => void;
autoUpdateEnabled: boolean;
setAutoUpdateEnabled: (enabled: boolean) => void;
// Unified update state — from useUpdateCheck hook in SettingsPageContent
updateState: UpdateState;
checkNow: () => Promise<unknown>;
@@ -74,6 +103,10 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
closeToTray,
setCloseToTray,
hotkeyRegistrationError,
globalHotkeyEnabled,
setGlobalHotkeyEnabled,
autoUpdateEnabled,
setAutoUpdateEnabled,
updateState,
checkNow,
installUpdate,
@@ -90,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('');
@@ -136,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;
@@ -367,6 +473,15 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
)}
</div>
</div>
<SettingRow
label={t('settings.update.autoUpdateEnabled')}
description={t('settings.update.autoUpdateEnabledDesc')}
>
<Toggle
checked={autoUpdateEnabled}
onChange={setAutoUpdateEnabled}
/>
</SettingRow>
<p className="text-xs text-muted-foreground">
{updateState.lastCheckedAt && (
<span>
@@ -432,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">
@@ -580,7 +837,7 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
value={sessionLogsFormat}
options={formatOptions}
onChange={(val) => setSessionLogsFormat(val as SessionLogFormat)}
className="w-32"
className="w-44"
disabled={!sessionLogsEnabled}
/>
</SettingRow>
@@ -599,42 +856,55 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
</div>
<div className="bg-muted/30 rounded-lg p-4 space-y-4">
{/* Toggle Window Hotkey */}
{/* Enable/Disable Global Hotkey */}
<SettingRow
label={t("settings.globalHotkey.toggleWindow")}
description={t("settings.globalHotkey.toggleWindowDesc")}
label={t('settings.globalHotkey.enabled')}
description={t('settings.globalHotkey.enabledDesc')}
>
<div className="flex items-center gap-2">
<button
onClick={(e) => {
e.stopPropagation();
setIsRecordingHotkey(true);
}}
className={cn(
"px-3 py-1.5 text-sm font-mono rounded border transition-colors min-w-[100px] text-center",
isRecordingHotkey
? "border-primary bg-primary/10 animate-pulse"
: "border-border hover:border-primary/50",
)}
>
{isRecordingHotkey
? t("settings.shortcuts.recording")
: toggleWindowHotkey || t("settings.globalHotkey.notSet")}
</button>
{toggleWindowHotkey && (
<button
onClick={handleResetHotkey}
className="p-1 hover:bg-muted rounded"
title={t("settings.globalHotkey.reset")}
>
<RotateCcw size={14} />
</button>
)}
</div>
<Toggle
checked={globalHotkeyEnabled}
onChange={setGlobalHotkeyEnabled}
/>
</SettingRow>
{(hotkeyError || hotkeyRegistrationError) && (
<p className="text-sm text-destructive">{hotkeyError || hotkeyRegistrationError}</p>
)}
<div className={cn(!globalHotkeyEnabled && "opacity-50 pointer-events-none")}>
{/* Toggle Window Hotkey */}
<SettingRow
label={t("settings.globalHotkey.toggleWindow")}
description={t("settings.globalHotkey.toggleWindowDesc")}
>
<div className="flex items-center gap-2">
<button
onClick={(e) => {
e.stopPropagation();
setIsRecordingHotkey(true);
}}
className={cn(
"px-3 py-1.5 text-sm font-mono rounded border transition-colors min-w-[100px] text-center",
isRecordingHotkey
? "border-primary bg-primary/10 animate-pulse"
: "border-border hover:border-primary/50",
)}
>
{isRecordingHotkey
? t("settings.shortcuts.recording")
: toggleWindowHotkey || t("settings.globalHotkey.notSet")}
</button>
{toggleWindowHotkey && (
<button
onClick={handleResetHotkey}
className="p-1 hover:bg-muted rounded"
title={t("settings.globalHotkey.reset")}
>
<RotateCcw size={14} />
</button>
)}
</div>
</SettingRow>
{(hotkeyError || hotkeyRegistrationError) && (
<p className="text-sm text-destructive mt-2">{hotkeyError || hotkeyRegistrationError}</p>
)}
</div>
{/* Close to Tray */}
<SettingRow

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 {
@@ -575,6 +571,23 @@ export default function SettingsTerminalTab(props: {
<Toggle checked={!terminalSettings.disableBracketedPaste} onChange={(v) => updateTerminalSetting("disableBracketedPaste", !v)} />
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.osc52Clipboard")}
description={t("settings.terminal.behavior.osc52Clipboard.desc")}
>
<Select
value={terminalSettings.osc52Clipboard ?? 'write-only'}
options={[
{ value: "off", label: t("settings.terminal.behavior.osc52Clipboard.off") },
{ value: "write-only", label: t("settings.terminal.behavior.osc52Clipboard.writeOnly") },
{ value: "read-write", label: t("settings.terminal.behavior.osc52Clipboard.readWrite") },
{ value: "prompt", label: t("settings.terminal.behavior.osc52Clipboard.prompt") },
]}
onChange={(v) => updateTerminalSetting("osc52Clipboard", v as "off" | "write-only" | "read-write" | "prompt")}
className="w-40"
/>
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.scrollOnInput")}
description={t("settings.terminal.behavior.scrollOnInput.desc")}
@@ -603,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")}
@@ -616,7 +636,7 @@ export default function SettingsTerminalTab(props: {
{ value: "meta", label: t("settings.terminal.behavior.linkModifier.meta") },
]}
onChange={(v) => updateTerminalSetting("linkModifier", v as LinkModifier)}
className="w-40"
className="w-48"
/>
</SettingRow>
</div>

View File

@@ -15,7 +15,8 @@ export const ModelSelector: React.FC<{
placeholder?: string;
apiKey?: string;
providerId?: AIProviderId;
}> = ({ value, onChange, baseURL, modelsEndpoint, placeholder, apiKey, providerId }) => {
skipTLSVerify?: boolean;
}> = ({ value, onChange, baseURL, modelsEndpoint, placeholder, apiKey, providerId, skipTLSVerify }) => {
const { t } = useI18n();
const [models, setModels] = useState<FetchedModel[]>([]);
const [isLoading, setIsLoading] = useState(false);
@@ -35,6 +36,11 @@ export const ModelSelector: React.FC<{
setIsLoading(true);
setError(null);
try {
// Temporarily allow the provider's host in the backend fetch allowlist
// so model listing works for URLs not yet synced from the main window.
if (bridge.aiAllowlistAddHost && baseURL) {
await bridge.aiAllowlistAddHost(baseURL);
}
const url = `${baseURL.replace(/\/+$/, "")}${modelsEndpoint}`;
const headers: Record<string, string> = {};
if (apiKey) {
@@ -45,7 +51,7 @@ export const ModelSelector: React.FC<{
headers["Authorization"] = `Bearer ${apiKey}`;
}
}
const result = await bridge.aiFetch(url, "GET", headers);
const result = await bridge.aiFetch(url, "GET", headers, undefined, undefined, undefined, undefined, skipTLSVerify);
if (!result.ok) {
setError(`Failed to fetch models (${result.error || "unknown error"})`);
return;
@@ -63,7 +69,7 @@ export const ModelSelector: React.FC<{
} finally {
setIsLoading(false);
}
}, [baseURL, modelsEndpoint, apiKey, providerId]);
}, [baseURL, modelsEndpoint, apiKey, providerId, skipTLSVerify]);
// Auto-fetch when dropdown first opens
useEffect(() => {

View File

@@ -19,6 +19,7 @@ export const ProviderConfigForm: React.FC<{
apiKey: "",
baseURL: provider.baseURL ?? PROVIDER_PRESETS[provider.providerId]?.defaultBaseURL ?? "",
defaultModel: provider.defaultModel ?? "",
skipTLSVerify: provider.skipTLSVerify ?? false,
});
const isCustom = provider.providerId === "custom";
const [showApiKey, setShowApiKey] = useState(false);
@@ -46,6 +47,7 @@ export const ProviderConfigForm: React.FC<{
const updates: Partial<ProviderConfig> = {
baseURL: form.baseURL || undefined,
defaultModel: form.defaultModel || undefined,
skipTLSVerify: form.skipTLSVerify || undefined,
...(isCustom && form.name.trim() ? { name: form.name.trim() } : {}),
};
@@ -120,9 +122,21 @@ export const ProviderConfigForm: React.FC<{
modelsEndpoint={preset?.modelsEndpoint}
apiKey={form.apiKey}
providerId={provider.providerId}
skipTLSVerify={form.skipTLSVerify}
/>
</div>
{/* Skip TLS Verification */}
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={form.skipTLSVerify}
onChange={(e) => setForm((prev) => ({ ...prev, skipTLSVerify: e.target.checked }))}
className="rounded border-input"
/>
<span className="text-xs text-muted-foreground">{t('ai.providers.skipTLSVerify')}</span>
</label>
{/* Actions */}
<div className="flex items-center gap-2 pt-1">
<Button variant="default" size="sm" onClick={() => void handleSave()}>

View File

@@ -0,0 +1,220 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Globe, Eye, EyeOff } from "lucide-react";
import type { WebSearchConfig, WebSearchProviderId } from "../../../../infrastructure/ai/types";
import { WEB_SEARCH_PROVIDER_PRESETS } from "../../../../infrastructure/ai/types";
import { encryptField, decryptField } from "../../../../infrastructure/persistence/secureFieldAdapter";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Select, SettingRow } from "../../settings-ui";
const SEARCH_ICON_PATHS: Record<WebSearchProviderId, string> = {
tavily: "/ai/search/tavily.svg",
exa: "/ai/search/exa.png",
bocha: "/ai/search/bocha.webp",
zhipu: "/ai/search/zhipu.png",
searxng: "/ai/search/searxng.svg",
};
const SearchProviderIcon: React.FC<{ providerId: WebSearchProviderId }> = ({ providerId }) => (
<img
src={SEARCH_ICON_PATHS[providerId]}
alt=""
className="w-4 h-4 shrink-0"
/>
);
const PROVIDER_OPTIONS: Array<{ value: WebSearchProviderId; label: string; icon: React.ReactNode }> = Object.entries(
WEB_SEARCH_PROVIDER_PRESETS,
).map(([id, preset]) => ({
value: id as WebSearchProviderId,
label: preset.name,
icon: <SearchProviderIcon providerId={id as WebSearchProviderId} />,
}));
export const WebSearchSettings: React.FC<{
webSearchConfig: WebSearchConfig | null;
setWebSearchConfig: (config: WebSearchConfig | null) => void;
}> = ({ webSearchConfig, setWebSearchConfig }) => {
const { t } = useI18n();
const [apiKeyInput, setApiKeyInput] = useState("");
const [showApiKey, setShowApiKey] = useState(false);
const [isDecrypting, setIsDecrypting] = useState(false);
const config = useMemo(() => webSearchConfig ?? {
providerId: "tavily" as WebSearchProviderId,
enabled: false,
maxResults: 5,
}, [webSearchConfig]);
// Ref to always read the latest config in async callbacks (avoids stale closure)
const configRef = useRef(config);
configRef.current = config;
const preset = WEB_SEARCH_PROVIDER_PRESETS[config.providerId];
// Decrypt API key on mount or when provider changes (with cancellation guard)
const decryptSeqRef = useRef(0);
useEffect(() => {
if (config.apiKey) {
const seq = ++decryptSeqRef.current;
setIsDecrypting(true);
decryptField(config.apiKey)
.then((decrypted) => {
if (decryptSeqRef.current === seq) setApiKeyInput(decrypted ?? "");
})
.catch(() => {
if (decryptSeqRef.current === seq) setApiKeyInput(config.apiKey ?? "");
})
.finally(() => {
if (decryptSeqRef.current === seq) setIsDecrypting(false);
});
} else {
decryptSeqRef.current++;
setApiKeyInput("");
setIsDecrypting(false);
}
}, [config.apiKey, config.providerId]);
const updateConfig = useCallback(
(updates: Partial<WebSearchConfig>) => {
setWebSearchConfig({ ...configRef.current, ...updates });
},
[setWebSearchConfig],
);
const handleProviderChange = useCallback(
(val: string) => {
const providerId = val as WebSearchProviderId;
const newPreset = WEB_SEARCH_PROVIDER_PRESETS[providerId];
setWebSearchConfig({
...configRef.current,
providerId,
apiKey: undefined,
apiHost: newPreset.defaultApiHost || undefined,
});
setApiKeyInput("");
},
[setWebSearchConfig],
);
// Sequence counter for blur saves — prevents out-of-order encryption results
const blurSeqRef = useRef(0);
const handleApiKeyBlur = useCallback(async () => {
if (!apiKeyInput.trim()) {
blurSeqRef.current++;
updateConfig({ apiKey: undefined });
return;
}
const seq = ++blurSeqRef.current;
const providerAtBlur = configRef.current.providerId;
const encrypted = await encryptField(apiKeyInput.trim());
// Only apply if this is still the latest blur and provider hasn't changed
if (blurSeqRef.current === seq && configRef.current.providerId === providerAtBlur) {
updateConfig({ apiKey: encrypted });
}
}, [apiKeyInput, updateConfig]);
return (
<div className="space-y-4">
<div className="flex items-center gap-2">
<Globe size={18} className="text-muted-foreground" />
<h3 className="text-base font-medium">{t("ai.webSearch.title")}</h3>
</div>
<div className="bg-muted/30 rounded-lg p-4 space-y-1">
{/* Enable/Disable */}
<SettingRow
label={t("ai.webSearch.enable")}
description={t("ai.webSearch.enable.description")}
>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={config.enabled}
onChange={(e) => updateConfig({ enabled: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-muted-foreground/20 peer-focus-visible:ring-2 peer-focus-visible:ring-ring rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border after:border-gray-300 after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-primary" />
</label>
</SettingRow>
{/* Provider */}
<SettingRow
label={t("ai.webSearch.provider")}
description={t("ai.webSearch.provider.description")}
>
<Select
value={config.providerId}
options={PROVIDER_OPTIONS}
onChange={handleProviderChange}
className="w-48"
/>
</SettingRow>
{/* API Key (hidden for SearXNG) */}
{preset.requiresApiKey && (
<SettingRow
label={t("ai.webSearch.apiKey")}
description={t("ai.webSearch.apiKey.description")}
>
<div className="flex items-center gap-1.5">
<input
type={showApiKey ? "text" : "password"}
value={isDecrypting ? "" : apiKeyInput}
placeholder={isDecrypting ? t("ai.providers.apiKey.decrypting") : t("ai.webSearch.apiKey.placeholder")}
onChange={(e) => setApiKeyInput(e.target.value)}
onBlur={() => void handleApiKeyBlur()}
className="w-64 h-9 rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
disabled={isDecrypting}
/>
<button
type="button"
onClick={() => setShowApiKey(!showApiKey)}
className="p-1.5 rounded hover:bg-muted text-muted-foreground"
>
{showApiKey ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
</SettingRow>
)}
{/* API Host */}
<SettingRow
label={t("ai.webSearch.apiHost")}
description={
config.providerId === "searxng"
? t("ai.webSearch.apiHost.searxngDescription")
: t("ai.webSearch.apiHost.description")
}
>
<input
type="text"
value={config.apiHost ?? preset.defaultApiHost}
onChange={(e) => updateConfig({ apiHost: e.target.value || undefined })}
placeholder={preset.defaultApiHost || "https://..."}
className="w-64 h-9 rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</SettingRow>
{/* Max Results */}
<SettingRow
label={t("ai.webSearch.maxResults")}
description={t("ai.webSearch.maxResults.description")}
>
<input
type="number"
value={config.maxResults ?? 5}
onChange={(e) => {
const val = parseInt(e.target.value, 10);
if (!isNaN(val) && val >= 1 && val <= 20) {
updateConfig({ maxResults: val });
}
}}
min={1}
max={20}
className="w-20 h-9 rounded-md border border-input bg-background px-3 text-sm text-right focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</SettingRow>
</div>
</div>
);
};

View File

@@ -41,6 +41,7 @@ export interface ProviderFormState {
apiKey: string;
baseURL: string;
defaultModel: string;
skipTLSVerify: boolean;
}
export interface FetchedModel {
@@ -49,7 +50,8 @@ export interface FetchedModel {
}
export interface FetchBridge {
aiFetch?: (url: string, method?: string, headers?: Record<string, string>, body?: string) => Promise<{ ok: boolean; data: string; error?: string }>;
aiFetch?: (url: string, method?: string, headers?: Record<string, string>, body?: string, providerId?: string, skipHostCheck?: boolean, followRedirects?: boolean, skipTLSVerify?: boolean) => Promise<{ ok: boolean; data: string; error?: string }>;
aiAllowlistAddHost?: (baseURL: string) => Promise<{ ok: boolean }>;
}
export interface NetcattyAiBridge {
@@ -75,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, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderPlus, Loader2, Pencil, RefreshCw, Shield, Trash2 } from "lucide-react";
import { Button } from "../ui/button";
import {
ContextMenu,
@@ -58,6 +58,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 +191,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")}
@@ -339,17 +380,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 +458,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

@@ -10,6 +10,7 @@ import { SSHKey } from '../../types';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Dropdown, DropdownContent, DropdownTrigger } from '../ui/dropdown';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
export type TerminalAuthMethod = 'password' | 'key' | 'certificate';
@@ -265,25 +266,34 @@ export const TerminalAuthDialog: React.FC<TerminalAuthDialogProps> = ({
<Button variant="secondary" onClick={onCancel}>
{t("common.close")}
</Button>
<div className="flex items-center gap-2">
<Popover>
<PopoverTrigger asChild>
<Button disabled={!isValid} onClick={onSubmit}>
{t("terminal.auth.continueSave")}
<ChevronDown size={14} className="ml-2" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-40 p-1 z-50" align="end">
<button
className="w-full px-3 py-2 text-sm text-left hover:bg-secondary rounded-md"
onClick={onSubmitWithoutSave ?? onSubmit}
<Dropdown>
<div className="flex items-center rounded-md bg-primary text-primary-foreground">
<Button
disabled={!isValid}
onClick={onSubmit}
className="rounded-r-none bg-transparent hover:bg-white/10 shadow-none"
>
{t("terminal.auth.continueSave")}
</Button>
<DropdownTrigger asChild>
<Button
disabled={!isValid}
className="px-2 rounded-l-none bg-transparent hover:bg-white/10 border-l border-primary-foreground/20 shadow-none"
>
{t("common.continue")}
</button>
</PopoverContent>
</Popover>
</div>
<ChevronDown size={14} />
</Button>
</DropdownTrigger>
</div>
<DropdownContent className="w-44 p-1 z-50" align="end">
<button
className="w-full px-3 py-2 text-sm text-left hover:bg-secondary rounded-md"
onClick={onSubmitWithoutSave ?? onSubmit}
disabled={!isValid}
>
{t("common.continue")}
</button>
</DropdownContent>
</Dropdown>
</div>
</>
);

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>
</>
);
};

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