Compare commits

...

354 Commits

Author SHA1 Message Date
bincxz
df11beff8c fix: clear mainWindow reference on window destroy (#587)
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
The mainWindow variable was never cleared when the window was destroyed,
unlike settingsWindow which had a proper 'closed' handler. This caused
getMainWindow() to return a destroyed window object, preventing the
activate handler from correctly detecting the main window was gone and
creating a new one.

Fixes #587

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 19:16:59 +08:00
陈大猫
c14da33e5b Merge pull request #588 from binaricat/fix/settings-window-title
fix: settings window title and dock reopen behavior
2026-03-31 19:11:37 +08:00
bincxz
f1ce541885 fix: dock click opens main window instead of settings window (#587)
On macOS, when the main window is closed but the settings window is
still open, clicking the Dock icon would focus the settings window
instead of re-creating the main window.

- focusMainWindow() now explicitly finds the main window via
  getWindowManager() instead of using getAllWindows()[0]
- activate handler creates a new main window even when other
  windows (settings) are still open

Fixes #587

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 19:05:13 +08:00
bincxz
07e003fe43 fix: distinguish settings window title from main window
Set the settings window title to "netcatty Settings" and prevent
the HTML <title> tag from overriding it, so macOS Dock menu and
Window menu can distinguish between the two windows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 19:02:36 +08:00
陈大猫
81f53c9a7f Merge pull request #585 from binaricat/feat/always-immersive-mode
feat: enable immersive mode permanently
2026-03-31 16:25:57 +08:00
bincxz
2d8cea2e7d fix: remove stale immersive mode sync/rehydration handlers
Address Codex review: remove references to setImmersiveModeState
in rehydration, IPC sync, and cross-window storage handlers that
would throw after the state setter was removed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:37:59 +08:00
bincxz
b724cfc775 feat: enable immersive mode permanently and remove settings toggle
Immersive mode is now always on — the UI chrome automatically adapts
to match the active terminal theme. The toggle in Appearance settings
has been removed and the TerminalLayer preview logic simplified.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 15:29:12 +08:00
bincxz
10ff2cc092 ui: increase unfocused workspace terminal opacity from 0.65 to 0.82
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:44:59 +08:00
bincxz
4124c03b80 fix: maintain scroll position when terminal search bar opens/closes
Re-fit terminal and restore viewport scroll position after search bar
toggle to prevent content jumping. Preserves bottom-stick behavior
and removes toolbar bottom border for cleaner appearance.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:39:16 +08:00
bincxz
56a3994a52 fix: prevent tab indicator line color flash during theme switching
Keep top tabs theme vars applied based on focused terminal theme,
not just during sidebar preview. Prevents the color flash when
switching themes or closing the theme sidebar panel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:21:14 +08:00
陈大猫
e1e730e439 Merge pull request #584 from binaricat/feat/expand-builtin-themes
feat: add 12 new built-in terminal color themes
2026-03-31 14:15:51 +08:00
bincxz
bb17647954 feat: add 12 new built-in terminal color themes
Add popular terminal themes sourced from official repos and
iTerm2-Color-Schemes:

- GitHub Dark / GitHub Light (primer/github-vscode-theme)
- Ubuntu (classic Ubuntu terminal)
- One Dark Pro (Binaryify/OneDark-Pro)
- Horizon (jolaleye/horizon-theme-vscode)
- Palenight (whizkydee/vscode-palenight-theme)
- Panda (tinkertrain/panda-syntax-vscode)
- Snazzy (sindresorhus/hyper-snazzy)
- Synthwave '84 (robb0wen/synthwave-vscode)
- Vesper (minimal dark theme)
- Kanso Dark / Kanso Light (zen-inspired)

Total built-in themes: 62 → 74

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 13:51:03 +08:00
bincxz
56a0baebeb ui: use accent color for active tab indicator and remove toolbar border
- Active tab top line uses accent/primary color instead of foreground
- Remove terminal toolbar bottom border to reduce visual clutter

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 13:41:01 +08:00
bincxz
d2a6c67e4e refactor: extract shared ThemeList component for theme selection UI
Unify theme item style across ThemeSelectPanel (host details) and
ThemeSelectModal (settings) with a shared ThemeList component featuring
compact swatch previews, dark/light/custom grouping, and no-rounded
selection highlight.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 13:10:22 +08:00
bincxz
56f70d015d ui: optimize host details and chain panel layout
- SFTP Filename Encoding: inline layout with label and select on same row
- Linux Distribution: extract from Appearance into its own Card with Tux icon
- Chain panel: remove non-functional Add Host button, add search filter for
  available hosts, fix long hostname overflow with truncation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 12:57:17 +08:00
陈大猫
cf9f84767c Merge pull request #583 from binaricat/feat/show-transport-error-in-disconnect-dialog
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: show transport error in disconnect dialog
2026-03-31 10:41:25 +08:00
bincxz
3a862cbd0c feat: show transport error in disconnect dialog
When a session disconnects due to a transport error (e.g. "Keepalive timeout",
"ECONNRESET"), the error message is now surfaced in the disconnect dialog
instead of showing a generic "Disconnected" label.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 10:37:42 +08:00
陈大猫
6af2a99680 Merge pull request #582 from binaricat/fix/ssh-keepalive-disabled-not-honored
fix: honor keepaliveInterval=0 as disabled instead of falling back to 10s
2026-03-31 10:32:06 +08:00
bincxz
b3d37d134a fix: honor keepaliveInterval=0 as disabled instead of falling back to 10s
When keepaliveInterval was set to 0 (the default, documented as "disabled"),
the code treated 0 as falsy and fell back to 10000ms. This caused ssh2 to
send keepalive@openssh.com global requests every 10s. Devices with non-OpenSSH
SSH implementations (e.g. NOKIA/ALCATEL) that don't reply to these requests
would have their connections terminated after ~40s (4 × 10s keepalive timeout).

Closes #581

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 10:27:08 +08:00
bincxz
a9e561ee51 feat: show "Waiting for remote..." during ZMODEM upload finalization
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
After all file data is written to the buffer, the progress bar shows
100% but the remote rz is still processing. Now a "finalizing" flag
is sent with the last progress event, and the UI displays "Waiting
for remote..." instead of the misleading 100% uploading state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 00:43:26 +08:00
bincxz
e808b1709e fix: increase ZMODEM handshake timeout from 10s to 120s
10s was too short for large files (466MB+). After sending all data,
the remote rz still needs time to read from TCP buffer and write to
disk before it can reply with ZRINIT/ZFIN. 120s accommodates slow
links and large files while still catching genuinely dead sessions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 00:38:45 +08:00
bincxz
d75b58e4d8 fix: timeout on ZMODEM handshake rejects instead of resolving
withTimeout was resolving silently after 10s, which made a stalled
xfer.end()/zsession.close() look like a successful transfer. Now it
rejects with "ZMODEM handshake timeout", so the .catch handler fires
and shows an error toast instead of a false success.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 00:28:57 +08:00
bincxz
e2430cdcab fix: cancel sentry on all session cleanup paths + upload timeout guard
- terminalBridge: cancel zmodemSentry in telnet error/close, serial
  error/close, and cleanupAllSessions before deleting sessions
- sshBridge: cancel zmodemSentry in all 4 SSH cleanup paths (stream
  close, conn error, conn timeout, conn close)
- zmodemHelper: wrap xfer.end() and zsession.close() with 10s timeout
  to prevent indefinite hang when cancel/abort leaves internal
  zmodem.js Promises unresolved (prevents fd leak)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 00:20:07 +08:00
bincxz
8e6ac8de10 revert: remove ZACK ignore handler (caused by SOCKS5 proxy, not protocol)
The "Unhandled header: ZACK" was triggered by a SOCKS5 proxy on the
server causing abnormal protocol behavior, not a real lrzsz issue.
The handler's condition was too broad (any active send) and could
mask genuine protocol errors. Keep ZRINIT and ZRPOS handlers which
have narrow conditions and address real scenarios.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 00:11:03 +08:00
bincxz
5495877e5a fix: ignore stray ZACK headers during ZMODEM upload
zmodem.js only handles ZACK in specific Send session states (after
ZSINIT, during file negotiation). Some receivers send extra ZACKs as
generic acknowledgements that arrive outside these states, causing
"Unhandled header: ZACK". Since ZACK is just an ack, ignoring it
is safe and keeps the transfer going.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 00:05:15 +08:00
bincxz
5078b3776e fix: use setImmediate instead of setTimeout(50) for drain wait
setTimeout(50) per chunk would cap upload speed at ~1.28MB/s because
ssh2's 32KB highWaterMark triggers backpressure on almost every 64KB
write. setImmediate yields to the I/O phase without a fixed delay,
letting TCP flush as fast as possible.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 00:03:11 +08:00
bincxz
f5d6b8b4d8 fix: add backpressure handling to ZMODEM upload loop
Large file uploads (466MB+) could saturate the SSH/PTY write buffer
with all data sent synchronously, causing the ZEOF/ZFIN handshake
at the end to be delayed — the UI shows 100% but the transfer hangs
while TCP flushes the backlog.

- All writeToRemote callbacks now return stream.write() result
- Sentry sender tracks _needsDrain flag when write returns false
- Upload loop calls waitForDrain() which yields 50ms when backpressure
  is detected, letting TCP flush buffered writes between chunks

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 23:58:42 +08:00
bincxz
1c560dbc16 fix: reject CLI paths that fail --version probe
In both discover and resolve-cli handlers, treat --version failure
(exception or empty output) as an invalid CLI. This catches .app
bundles, broken symlinks, and other non-executable paths that pass
the filesystem check but aren't actually usable CLI tools.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 23:48:15 +08:00
bincxz
4b8b0ed74c fix: reject .app directories in CLI path normalization
normalizeCliPathForPlatform used existsSync which returns true for
directories like /Applications/Codex.app. Added statSync.isFile()
check on non-Windows platforms so .app bundles are not mistaken for
CLI executables.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 23:45:58 +08:00
陈大猫
308d825db7 feat: ZMODEM (lrzsz) file transfer support (#579)
* feat: add ZMODEM (lrzsz) file transfer support for terminal sessions

Adds ZMODEM protocol detection and file transfer capability to all
terminal session types (Local, SSH, Telnet, Mosh, Serial). Uses
zmodem.js library with main-process sentry pattern to intercept
binary data before string decoding, avoiding IPC pipeline changes.

- zmodemHelper.cjs: shared ZMODEM sentry with Electron dialog integration
- terminalBridge.cjs: encoding:null for PTY + sentry wrappers for all session types
- sshBridge.cjs: sentry wrapper for SSH stream data
- preload.cjs + global.d.ts: ZMODEM event IPC bridge and TypeScript types
- useZmodemTransfer.ts: React hook for ZMODEM transfer state

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

* fix: preserve charset decoding and add ZMODEM progress UI

- zmodemHelper: pass raw Buffer to onData, let callers handle decoding
- terminalBridge: use StringDecoder for telnet/serial, UTF-8 for local/mosh
- sshBridge: restore iconv decoder for SSH session charset support
- ZmodemProgressIndicator: floating progress bar with cancel button
- Terminal.tsx: wire useZmodemTransfer hook + toast notifications

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

* fix: ZMODEM listener cleanup, stream leak, and toast dedup

- preload: clean up zmodemListeners on session exit (memory leak)
- zmodemHelper: add ws.on('error') handler to close write stream on failure
- Terminal: use ref guard to prevent duplicate toast notifications

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

* fix: address code review findings for ZMODEM

- cancel/consume error now send IPC event to renderer (prevents stuck UI)
- sanitize download filename with path.basename (path traversal prevention)
- add on_detect concurrency guard (deny if transfer already active)
- formatBytes: handle negative, zero, and TB+ values safely
- closeSession: cancel active ZMODEM before destroying transport

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

* fix: prevent double-notification on cancel and stream error resilience

- Guard .then()/.catch() in promise chain: skip if cancel() already handled
- Download: add writeAborted flag to stop on_input after stream error
- Upload: pre-compute file stats to avoid O(N²) statSync calls

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

* fix: use zsession.abort() instead of close() on dialog cancel

close() is only available on Send sessions. Calling it on a Receive
session throws, leaving the sentry's internal _zsession dangling and
causing subsequent terminal data to be consumed by the abandoned
ZMODEM session (terminal freeze). abort() is defined on the base
ZmodemSession class and properly fires session_end to reset the sentry.

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

* fix: handle ZFIN/OO mismatch as successful transfer

When sz exits over SSH, the shell prompt often arrives before the
ZMODEM "OO" end marker, causing zmodem.js to throw a protocol error.
Since ZFIN was already exchanged (= all file data transferred), treat
this specific error as a successful completion and forward the shell
prompt data back to the terminal via sentry re-consume.

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

* fix: codex review — UTF-8 decoder, ZFIN abort, session exit cleanup

- terminalBridge: use StringDecoder for local/mosh PTY to handle
  multi-byte UTF-8 split across buffer boundaries (prevents garbled
  CJK/emoji output)
- zmodemHelper: on ZFIN/OO success path, use _on_session_end() instead
  of abort() to avoid sending CAN (Ctrl-X) bytes to the remote shell
- useZmodemTransfer: listen to onSessionExit to reset state when the
  session dies mid-transfer (prevents stuck progress indicator)

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

* fix: codex review — file collision handling and stream flush

- Download: auto-rename with (1), (2), etc. if file already exists
  in the target directory, preventing silent overwrite
- Download: wait for all write streams to finish flushing before
  resolving the session_end promise, ensuring data is on disk when
  the UI reports completion

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

* fix: codex review — Windows PTY string compat and Telnet binary safety

- Local/Mosh PTY: handle string data from Windows node-pty which
  ignores encoding: null; convert to Buffer before sentry.consume()
- Telnet: bypass IAC negotiation during active ZMODEM transfer to
  preserve 0xFF bytes in binary data
- Telnet writeToRemote: escape 0xFF as 0xFF 0xFF per Telnet spec
  so ZMODEM binary data is not treated as IAC commands

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

* fix: codex review — Windows PTY guard, Telnet IAC, stream cleanup

- Local/Mosh: skip ZMODEM sentry on Windows where node-pty can't
  provide raw bytes; fall back to original string pipeline
- Telnet: always run IAC negotiation (even during ZMODEM) since the
  Telnet layer still escapes 0xFF as IAC IAC; the existing handler
  already correctly collapses IAC IAC → single 0xFF
- Download: destroy un-ended write streams on session_end to prevent
  hanging promises and leaked file descriptors on abort

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

* fix: codex review — early session start, progress throttle, no dup start

- Download: call zsession.start() before showing folder picker dialog
  so lrzsz doesn't time out waiting for ZRINIT
- Download: throttle progress IPC to ~10 updates/sec (100ms interval)
  to avoid overwhelming renderer on fast links
- Download: remove duplicate zsession.start() at bottom of Promise

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

* fix: handle ZRPOS and prevent terminal flood after ZMODEM abort

- Add 500ms cooldown after ZMODEM abort: suppress residual protocol
  bytes from remote rz/sz that would otherwise flood the terminal
- Send 8x CAN (Ctrl-X) on abort/cancel/error to force remote end to
  stop transmitting even if the initial abort sequence was lost
- Handles "Unhandled header: ZRPOS" gracefully (zmodem.js doesn't
  support error recovery, so abort is the correct response)

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

* fix: send Ctrl+C after abort in all cancel/error paths

Debian's rz stays attached to the TTY after receiving CAN sequences.
The cancel() path already sent Ctrl+C via scheduleRemoteInterruptAfterCancel,
but dialog-cancel and consume-error paths did not. Now all three abort
paths (dialog cancel, consume error, explicit cancel) send Ctrl+C after
150ms to ensure the remote rz/sz process exits and the shell regains control.

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

* feat: add interruptRemote for SSH ZMODEM sentry

Pass SSH stream.signal("INT") as interruptRemote callback so the
ZMODEM helper can send SIGINT to the remote process when cancelling
transfers, complementing the Ctrl+C byte sent via writeToRemote.

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

* fix: dialog-cancel abort uses module-level helper to avoid ReferenceError

sendExtraAbortBytes and writeToRemote are closure-scoped inside
createZmodemSentry, not accessible from handleUpload/handleDownload.
Extract abortRemoteProcess as a module-level function that takes
writeToRemote as a parameter, used in both dialog-cancel paths.

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

* fix: dialog cancel throws instead of returning to avoid false complete

When user dismisses the file/folder picker, handleUpload/handleDownload
now throw "Transfer cancelled" instead of returning normally. This
ensures the .catch() handler fires (sending error event) rather than
.then() (which would incorrectly send complete event).

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

* fix: codex review — preserve transferType in progress events

- useZmodemTransfer: copy transferType from progress events so the
  transfer direction is preserved if renderer re-subscribes after
  the initial detect event was missed
- zmodemHelper: clean up upload loop comments (backpressure handled
  via 64KB chunks + setImmediate yield per iteration)

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

* fix: codex review — guard stale session cleanup, delete partial downloads

- Promise chain .then/.catch/.finally now compare currentZSession
  identity (=== zsession) instead of truthiness, preventing a new
  transfer from being clobbered by the old promise settling
- Aborted/incomplete downloads are deleted from disk on session_end
  so users don't end up with corrupt partial files

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

* fix: unconditional cooldown suppression after ZMODEM abort

The previous cooldown checked if data "looks like residual ZMODEM"
which fails for sz's file content (arbitrary printable bytes). Now
cooldown unconditionally drops ALL incoming data for 2 seconds after
abort, with repeated CAN bursts to ensure the remote sz stops. This
prevents the terminal flood seen when cancelling large sz downloads
on fast connections.

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-30 23:39:35 +08:00
陈大猫
af074c5704 Merge pull request #578 from binaricat/fix/tool-call-duplicate-and-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: resolve tool call duplication and ordering in chat UI
2026-03-30 19:06:49 +08:00
bincxz
c60afdd8fe fix: preserve approval controls for tool calls in non-last assistant messages
When a stream error appends a new assistant message, the previous
one is no longer lastAssistantMessage. Its pending approval tool
calls were rendered as interrupted, losing approve/reject buttons.
Now they retain approval status and controls.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 18:56:28 +08:00
bincxz
a1d05ca5b3 fix: resolve tool call duplication and ordering in chat UI
Tool calls were rendered both in the assistant message (as pending)
and in separate tool-result messages (as completed), causing
duplicates. Additionally, new pending tool calls appeared above
completed ones due to message ordering.

Fix: render completed tool calls only from tool-result messages,
and render pending tool calls after all results so they appear
at the bottom in chronological order. Unresolved tool calls from
earlier assistant messages or cancelled sessions are shown inline
as interrupted.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 18:54:17 +08:00
陈大猫
327ca3806a Merge pull request #577 from tces1/dev
feat: add GitHub Copilot CLI agent support
2026-03-30 18:24:39 +08:00
bincxz
2f71dd3927 revert: don't override copilot acpCommand with resolved path
On Windows the resolved path may be a .cmd shim which spawn()
cannot execute without shell: true. Keep acpCommand as the bare
"copilot" from AGENT_DEFAULTS and let the system resolve it via
PATH at launch time.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 18:16:50 +08:00
bincxz
3844edd49f fix: clean up copilot temp dir even when provider init fails
Move COPILOT_HOME temp dir cleanup before the acpProviders entry
check so it runs even if provider creation failed before the entry
was stored in the map.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 17:57:00 +08:00
bincxz
8f97a7e81d fix: use resolved path as copilot acpCommand and add Windows home fallback
- When building managed copilot agent config, set acpCommand to the
  resolved path instead of bare "copilot" so custom paths work for
  ACP launches
- Add USERPROFILE fallback in prepareCopilotHome for Windows where
  HOME may not be set

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 17:48:07 +08:00
bincxz
5daf1f0d6f fix: hoist copilotConfigInfo above try block to fix ReferenceError
copilotConfigInfo was declared with let inside the try block but
referenced in the finally block for temp dir cleanup. Block scoping
caused a ReferenceError that broke list-models for Copilot agents.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 17:38:39 +08:00
bincxz
b1a5b92ce4 fix: clean up transient copilot temp dirs and remove verbose MCP logs
- Add COPILOT_HOME cleanup in list-models finally block to prevent
  temp directory accumulation on each model fetch
- Remove verbose console.log in mcpServerBridge dispatch/connect/auth
  that fired on every MCP call for all agents

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 17:27:18 +08:00
bincxz
c99a70831a fix: address review issues in copilot agent integration
- Fix matchesManagedAgentConfig acpCommand matching for copilot by
  using a lookup table instead of hardcoded ternary
- Remove dead nodeRuntimePath variable and unused 4th arg to
  buildMcpServerConfig
- Fix model loading useEffect double-triggering by reading
  agentModelMap via ref instead of dependency
- Add temp COPILOT_HOME cleanup in cleanupAcpProvider
- Remove dead acpForceProviderReset Set (never populated after
  stop/resume refactor)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 17:22:59 +08:00
bincxz
4b0468b0d2 merge: resolve conflicts with main for copilot agent support
Adapt copilot agent additions to the refactored managed agent
architecture (resolveAgentPath + buildManagedAgentState pattern).
Add copilot to ManagedAgentKey type and MANAGED_AGENT_META.
Keep main's resolveMcpServerRuntimeCommand (process.execPath +
ELECTRON_RUN_AS_NODE) over PR's runtimeCommand parameter approach.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 17:14:45 +08:00
陈大猫
f32078f270 Merge pull request #575 from binaricat/codex/fix-codex-agent-path-and-mcp-startup
[codex] fix codex agent path detection and MCP startup
2026-03-30 17:02:06 +08:00
Eric Chan
a525c073b9 fix: matchesAgentCommand update for windows shim 2026-03-30 16:29:14 +08:00
bincxz
afceb92a55 fix: fall back to PATH search when stored CLI path is stale
When a previously stored custom path no longer exists (e.g. CLI
reinstalled to a different location), aiResolveCli now falls back
to PATH-based detection instead of returning unavailable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 16:27:32 +08:00
bincxz
4822894efb refactor: eliminate circular effect dependency in managed agent consolidation
Move agent dedup/consolidation from a useEffect (that depended on
externalAgents while also setting it) into resolveAgentPath, using
setExternalAgents(prev => ...) callback form. Use a ref for
defaultAgentId to avoid dependency cycles and keep it in sync
across concurrent codex+claude resolves.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 16:08:04 +08:00
Eric Chan
d9b51c3a50 feat: add GitHub Copilot CLI agent support 2026-03-30 15:53:08 +08:00
bincxz
15b1dba558 fix stale managed codex path reuse 2026-03-30 15:51:14 +08:00
bincxz
fd6b3930c1 fix codex managed-agent regressions 2026-03-30 15:26:44 +08:00
bincxz
53cb160a6e fix codex agent path detection and MCP startup 2026-03-30 15:04:06 +08:00
陈大猫
bb590f140d Merge pull request #574 from binaricat/fix/autocomplete-click-outside-dismiss
fix: dismiss autocomplete popup on click outside
2026-03-30 11:25:54 +08:00
bincxz
945992b80e fix: dismiss autocomplete popup on click outside
Closes #572

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:23:51 +08:00
陈大猫
b8de9ce2b6 Merge pull request #571 from binaricat/ui/compact-host-select-panel
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-29 22:34:08 +08:00
bincxz
2c7bce31d4 style: reduce border-radius on distro avatars
sm: rounded-md → rounded (4px), md: rounded-xl → rounded-lg (8px),
SelectHostPanel inline: rounded-lg → rounded-md (6px)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:29:36 +08:00
bincxz
004a5f18de fix: use rounded square distro avatar in port forwarding wizard
Use size="sm" (rounded-md) instead of className override that kept
the rounded-xl from the default md size, which appeared circular.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:29:03 +08:00
bincxz
731d57d355 fix: add missing TooltipProvider import
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:25:36 +08:00
bincxz
8c6ff1a6a4 fix: wrap tooltips with TooltipProvider in SelectHostPanel
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:24:36 +08:00
bincxz
f7630b3574 ui: compact host selection panel with smaller icons and text truncation
- Reduce item padding, gaps, icon sizes, and font sizes for a denser list
- Use rounded square (rounded-lg) avatars instead of circles, remove border
- Add tooltip on host label and connection string for long text overflow
- Shrink section headers and group items to match compact style
- Remove border from selected host items for cleaner look

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 22:23:43 +08:00
陈大猫
76bfe26561 Merge pull request #570 from binaricat/fix/sftp-keyboard-action-repeated-across-tabs
fix: isolate SFTP actions and selection state across panes and tabs
2026-03-29 22:13:47 +08:00
bincxz
7079ea66aa fix sftp cross-pane tab focus selection retention 2026-03-29 21:53:11 +08:00
bincxz
6562351955 fix: scope dialog actions and refine selection clearing
- Add dialogActionScopeId to distinguish SftpView and SftpSidePanel
  dialog actions, preventing cross-instance interference
- Refine selectionScope to clear tree selections per-pane instead of
  using clearAllExcept, avoiding side effects on other SFTP surfaces
- Remove selection clearing from tab switch/move/add handlers; clearing
  now only happens on focus side change and file interaction
- Reset keyboard selection and lastSelectedIndex when selections are
  externally cleared

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 21:44:15 +08:00
bincxz
986fdda008 fix sftp selection clearing across panes and tabs 2026-03-29 21:15:28 +08:00
bincxz
af2dc66113 fix: clear all selections when focus side changes
When the user switches focus between left and right panes, clear all
pane selections. Combined with the per-interaction clearing in
toggleSelection/rangeSelect, this ensures:
- Selecting files clears other panes' selections
- Switching sides clears all selections

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 20:15:01 +08:00
bincxz
cca4a3a37e fix: clear other selections on file interaction, not tab switch
Move selection clearing from tab switch and pane focus handlers into
toggleSelection/rangeSelect. This means:
- Switching tabs just to look around preserves all selections
- Actually clicking/selecting files clears other tabs' selections

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 20:13:24 +08:00
bincxz
75ec050c31 revert: restore clearSelectionsExcept to clear all tabs except target
Clearing same-side inactive tab selections on tab switch is intentional
UX — stale selections on hidden tabs would be confusing when switching
back. Reverts the "preserve same-side" change from 05c48b3.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 20:04:08 +08:00
bincxz
db604e4c41 fix: localize delete dialog labels and preserve moved tab tree selection
- Add i18n keys for "Host" and "Path" labels in delete confirmation
  dialog (was hardcoded English, broken under zh-CN)
- Pass moved tab ID as extra keepId when clearing tree selections after
  moveTabToOtherSide, since the ref still has pre-move state

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 19:50:18 +08:00
bincxz
05c48b3d28 fix: preserve selections in same-side inactive tabs
clearSelectionsExcept was clearing all tabs including same-side inactive
ones, causing users to lose file selections when switching between tabs
on the same side. Now only the opposite side's selections are cleared.

Also scoped tree selection clearing to only affect opposite-side pane
IDs, preventing mounted but hidden SFTP surfaces from losing state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 19:39:39 +08:00
bincxz
3bb98c9c27 fix: allow paste between different tabs on the same side
The paste check only compared sourceSide vs focusedSide, treating all
tabs on the same side as "same pane". Now it also compares connectionId
so copying from one tab and pasting to a different tab on the same side
works correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 19:24:11 +08:00
bincxz
7f4dcce3cb fix: don't clear dialog action from inactive panes
Revert the stale action clearing in inactive panes (e9ad65f). When
multiple tabs exist on the same side, the inactive tab's effect could
fire before the active tab's, clearing the action and causing it to
be handled by the wrong pane or not at all.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 19:19:34 +08:00
bincxz
766451d9bb fix: handle empty selection in tree view container keyboard navigation
The tree view's own onKeyDown handler had the same issue as the global
keyboard shortcuts: pressing ArrowDown with no selection would skip the
first item. Apply the same fix (reset focus to -1 for empty selection).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 19:01:58 +08:00
bincxz
6f5a2181b2 fix: suppress SFTP keyboard shortcuts when a dialog is open
Prevents SFTP shortcuts (Delete, Enter, etc.) from firing while
unrelated dialogs are open, which could cause unintended file
operations from outside the SFTP panel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 18:55:01 +08:00
bincxz
297adbb818 fix: clamp anchor for Shift+Arrow from empty selection
When no files are selected, Shift+Arrow would use anchor=-1 causing
invalid slice ranges. Now anchor is set to 0 when Shift is held, so
range selection starts from the first item correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 18:47:47 +08:00
bincxz
13eeb2cf6d fix: ArrowDown from cleared selection now lands on first item
When selections are cleared (e.g. by switching panes), pressing
ArrowDown would skip the first item because the keyboard focus
defaulted to index 0 and then moved to 1. Now an empty selection
resets focus to -1 so the first arrow press selects item 0.
Applies to both list and tree views.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 18:38:24 +08:00
bincxz
e9ad65fef6 fix: clear stale dialog actions when target pane is inactive
When a dialog action's targetSide matched but the pane was inactive,
the action was left in the store. If the pane later became active, it
would fire the stale action unexpectedly. Now inactive panes clear the
action to prevent this.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 18:25:55 +08:00
bincxz
ddb6b5af1e perf: only re-render selected rows on focus change
The showSelectionHighlight check in SftpFileRow's areEqual was causing
all rows to re-render when switching focus between panes. Now only rows
that are actually selected re-render on highlight changes, avoiding
unnecessary work for large file lists.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 18:23:35 +08:00
bincxz
c1171d4c7b fix sftp shift selection upward expansion 2026-03-29 18:19:04 +08:00
bincxz
21daccf6ed fix: enforce cross-pane selection mutual exclusivity and improve delete dialog
- Add clearSelectionsExcept to clear all file/tree selections except the
  target pane, called on focus change, tab switch, tab add, and tab move
- Fix SftpFileRow areEqual to include showSelectionHighlight so highlight
  updates when focus changes between panes
- Improve delete confirmation dialog with host/path context and separate
  single vs multi-delete descriptions
- Fix hover style on selected rows to prevent flicker

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 18:05:54 +08:00
bincxz
2eed15b4b2 feat: show host label in SFTP operation dialogs
Display the connection's host label at the top of new folder, new file,
rename, overwrite, and delete confirmation dialogs so users can see
which machine the operation targets.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 16:50:37 +08:00
bincxz
de7fdfc4b4 fix: ensure SftpSidePanel panes remain active for keyboard shortcuts
SftpSidePanel doesn't sync with the global activeTabStore, so
useActiveTabId would return the main SftpView's tab id, causing
side panel panes to be treated as inactive. Add forceActive prop
to bypass the activeTabId check for contexts that manage pane
visibility themselves.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 16:36:11 +08:00
bincxz
709ed12259 fix: prevent SFTP keyboard actions from repeating across all tabs (#569)
When multiple SFTP connections were open as tabs on the same side,
keyboard-triggered actions (delete, rename, new folder, new file) were
executed on every mounted tab instead of just the active one. This was
because all hidden SftpPaneView instances shared the same dialog action
handler and React batched their effects before clear() could prevent
duplicates.

- Add isActive parameter to useSftpDialogActionHandler so only the
  active tab responds to keyboard shortcut actions
- Compute real isActive state in SftpPaneView using useActiveTabId
  instead of hardcoding true
- Clear opposite side's file selection on pane focus change to prevent
  cross-pane selection leaking into actions

Closes #569

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 16:31:12 +08:00
bincxz
0826bbb435 style: use Netcatty logo in OAuth callback page
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
Replace the generic terminal SVG icon with the actual Netcatty brand
logo (blue rounded-rect with terminal + cat tail motif).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 13:49:25 +08:00
bincxz
ec87eb593e fix: show spinner and connecting text during cloud sync connection
Replace yellow pulsing dot with a spinning Loader2 icon when cloud
provider is in connecting state. Also show "Connecting..." text
instead of "Not connected" during the connection attempt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 13:44:03 +08:00
bincxz
ecbd50dde4 fix: use accent color for active tab indicator instead of foreground
The top indicator line on active tabs (sessions, logview, vaults, SFTP)
was hardcoded to foreground color (white), making it always white
regardless of the system accent color setting. Changed all 4 tab
indicator lines to use --top-tabs-accent / --accent.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 13:31:20 +08:00
bincxz
4dd7640452 fix: allow auto encoding through same-host fast path
The encoding guard was rejecting "auto" which is the default encoding
for nearly all connections, making same-host optimization never trigger.

Frontend now allows "auto" through. Backend resolves "auto" to the
actual session encoding via resolveEncodingForRequest and only proceeds
with exec cp when the resolved encoding is UTF-8.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 13:25:36 +08:00
陈大猫
0b08521e63 perf: optimize same-host SFTP transfer with remote cp command (#564)
* perf: optimize same-host SFTP transfer with remote cp command

When both panels are connected to the same remote host, use SSH exec
`cp -a` instead of downloading to local temp then re-uploading. This
eliminates 2x bandwidth usage and reduces latency for same-host transfers.

Closes #561

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

* perf: optimize same-host directory transfer with single cp -ra command

For same-host directory transfers, use a single `cp -ra` command via SSH
exec instead of recursively walking the directory and copying files one
by one. This makes directory copies nearly instant on the remote server.

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

* fix: use endpoint cache key for same-host detection and guard non-UTF-8 paths

Address two code review issues:

1. Compare per-connection cache keys (hostname+port+protocol+sudo+username)
   instead of just hostId for same-host detection. This prevents false
   positives when the same hostId has different session-time overrides.

2. Restrict exec-based cp paths to UTF-8 compatible encodings only.
   Non-UTF-8 encodings (e.g. gb18030) need encodePathForSession which
   shell exec cannot use — fall back to download+upload for those cases.

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

* fix: directory cp semantics, cancellation, and auto encoding guard

1. Use `cp -ra source/. target/` instead of `cp -ra source target` to
   copy directory contents into target, preserving merge semantics when
   the target directory already exists (avoids extra nesting level).

2. Check cancellation state before and after sameHostCopyDirectory call
   so cancelled transfers don't finalize as completed.

3. Exclude 'auto' from exec-safe encodings since auto can resolve to
   non-UTF-8 (e.g. gb18030) at the session level.

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

* fix: wire cancellation into same-host copy paths

1. Single file cp -a: check transfer.cancelled before and after
   execSshCommand so cancelled transfers don't proceed as success.

2. Directory cp -ra: accept transferId, register in activeTransfers
   so cancelTransfer can flag it, and check cancelled state at each
   async boundary. Cleanup via finally block.

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

* fix: abort remote cp process on transfer cancellation

Add execSshCommandCancellable() that wires the SSH exec stream into
transfer.abort, so cancelTransfer can close the stream and kill the
remote cp process immediately instead of waiting for it to finish.

Used in both single-file (cp -a) and directory (cp -ra) same-host paths.

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

* fix: close exec stream immediately if cancelled before callback fires

Check transfer.cancelled at the start of the exec callback and close
the stream right away, preventing the remote cp from running when
cancellation happened between the exec() call and callback delivery.

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

* fix: fallback to download+upload when remote cp is unavailable

On non-POSIX remotes (e.g. Windows SSH servers) where cp is absent,
same-host optimization now gracefully falls back to the existing
download+upload transfer path instead of failing the transfer.

- Single file: try cp -a first, fall back to temp file on non-zero exit
- Directory: sameHostCopyDirectory returns { success: false } instead of
  throwing, frontend falls back to recursive transferDirectory

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

* perf: cache cp unavailability to avoid repeated exec failures

Track sftpIds where remote cp failed in cpUnavailableSet so subsequent
file transfers in the same session skip the exec attempt and go directly
to download+upload, avoiding per-file exec round-trip overhead on
non-POSIX remotes.

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

* fix: skip transferFile for directories already handled by same-host copy

Add !task.isDirectory guard to the else branch so successful
sameHostCopyDirectory doesn't also trigger a redundant transferFile
call that would duplicate data.

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

* fix: dereference symlinks in same-host copy to match SFTP behavior

Use cp -aL instead of cp -a so symlinks are dereferenced (copied as
file contents), matching the existing SFTP download+upload flow which
always transfers resolved file data.

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

* revert: remove -L flag from same-host cp to avoid recursing symlinked dirs

Revert cp -aL back to cp -a. The -L flag dereferences all symlinks
including symlinked directories, which can unexpectedly recurse into
large unrelated directory trees. Using cp -a preserves symlinks as-is,
which is safer and consistent with how the transfer UI treats symlink
directories as non-recursive entries.

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

* fix: refine cp unavailability caching and remove dead import

1. Only cache sftpId in cpUnavailableSet on exit code 127 (command not
   found). Other failures (permission denied, disk full) are transient
   or path-specific and should not disable cp for the entire session.

2. Check cpUnavailableSet at the top of sameHostCopyDirectory to skip
   exec attempt on known non-POSIX remotes. Also cache 127 exits from
   directory copies.

3. Remove unused execSshCommand import from transferBridge (replaced by
   local execSshCommandCancellable) and revert its export from sftpBridge.

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-29 13:21:58 +08:00
陈大猫
59e768c447 fix: prevent key file path from overflowing panel (#551) (#567)
* fix: prevent key file path from overflowing host details panel

Add min-w-0 to flex containers and flex items displaying key file
paths. Without this, flex items default to min-width: auto which
prevents truncate from working and causes long file paths (e.g.
from the file picker) to blow out the panel width.

Closes #551

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

* fix: add overflow-hidden to AsidePanel to prevent content overflow

The root cause of key file paths overflowing the panel was the
AsidePanel container itself lacking overflow-hidden. Even though
inner elements had min-w-0 and truncate, the absolute-positioned
panel div allowed content to visually escape its bounds.

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

* fix: add overflow-hidden to credentials Card and key path row

Ensure truncation works by adding overflow-hidden at multiple
levels: the Port & Credentials Card container and each key file
path flex row.

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

* fix: use w-0 flex-1 to force key file path truncation

min-w-0 alone is insufficient in nested flex layouts. Setting w-0
with flex-1 forces the element to start at zero width and only grow
to fill available space, guaranteeing truncation works.

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-29 13:17:04 +08:00
陈大猫
6a37b8bbc6 fix: use system browser for OAuth flows (#563) (#565) 2026-03-29 12:43:21 +08:00
陈大猫
9397a781b5 refactor: unify directory download with upload transfer system (#560)
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
* refactor: unify directory download with upload transfer system

Directory downloads previously used a completely separate implementation
with custom queue management, progress tracking, and concurrency control
(~390 lines in useSftpViewFileOps.ts). This caused the download UI to
show only a single aggregate task without child file details, unlike
uploads which showed parent + child tasks.

Replace the custom download implementation with a new downloadToLocal()
method in useSftpTransfers that reuses the existing transferDirectory/
transferFile infrastructure. Downloads now:
- Show parent task with child file tasks (same as uploads)
- Use the configurable transfer concurrency setting
- Support cancellation through the same mechanism
- Share progress tracking and conflict detection code

Net reduction of ~260 lines.

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

* chore: remove dead code from directory download refactor

Remove listSftp, mkdirLocal, and RemoteFile imports that were only
used by the old custom directory download implementation.

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

* fix: handle symlink directories in transfers and remove dead code

- Use isNavigableDirectory() instead of type === "directory" in
  transferDirectory so symlinks pointing to directories are
  recursed into correctly (fixes both upload and download paths)
- Remove unused deleteLocalFile prop from useSftpViewFileOps

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

* fix: use connection ID for download tasks and cancel child streams

- Use pane connection ID (not SFTP session ID) as sourceConnectionId
  so download tasks are properly associated with the host and visible
  in filtered transfer views
- Cancel all active child transfer streams at the backend when parent
  is cancelled, not just the parent ID — stops data transfer immediately

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

* fix: add symlink cycle detection and propagate child failures

- Add visitedPaths Set to transferDirectory to detect and skip
  symlink directory cycles that would cause infinite recursion
- Check for failed child tasks after transferDirectory completes
  and mark parent as failed instead of falsely reporting success

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

* fix: use depth limit for symlink loops and handle EEXIST on mkdir

- Replace visited-paths cycle detection with a depth limit (64),
  which reliably catches symlink loops that generate new path strings
  each hop (e.g. /dir/link/link/link...)
- Handle EEXIST errors in mkdirLocal gracefully so re-downloading
  to an existing directory doesn't abort the entire transfer

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

* fix: throw on depth limit exceeded and mark downloads non-retryable

- Depth limit now throws instead of silently returning, so exceeding
  it surfaces as a failed transfer rather than an incomplete success
- Set retryable: false on downloadToLocal tasks since retryTransfer
  cannot resolve the synthetic "local" connection ID

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

* fix: track symlink depth only and verify EEXIST target is directory

- Change depth guard to only count symlink directory hops, not total
  directory depth, so legitimate deep trees are not rejected
- After catching EEXIST on mkdirLocal, stat the path to verify it is
  actually a directory — throw if a regular file exists at that path

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

* fix: remove dead props from callbacks and surface download failures

- Remove mkdirLocal and deleteLocalFile from useSftpViewPaneCallbacks
  interface and passthrough (fixes TS2353 build error)
- Show error toast when downloadToLocal returns "failed" status,
  not just when it throws

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

* fix: track child transfer IDs outside React state for reliable cancel

Child transfer IDs were only discoverable via transfersRef.current,
which lags behind setTransfers due to React batching. This caused
two race conditions:

1. Cancellation: child streams started between setTransfers and render
   were not cancelled at the backend, continuing to write data.
2. Failure detection: hasFailedChildren checked transfersRef which
   might not reflect recently-failed children, marking partial
   downloads as successful.

Fix: track active child IDs in activeChildIdsRef (a mutable Map
outside React state) for immediate visibility during cancellation.
Check child failure status inside setTransfers functional updater
where the latest state is guaranteed.

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

* fix: preserve actual progress on partial failure and count symlink dirs

- Don't force transferredBytes to totalBytes when some children failed,
  so the progress bar accurately reflects the partial completion
- Use isNavigableDirectory in countDirectoryFiles and estimateDirectoryBytes
  so symlink directories are included in size/count estimates

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

* fix: symlink count, progress on fast downloads, and child cancellation

1. countDirectoryFiles: use isNavigableDirectory so symlink dirs are
   recursed into, keeping totals consistent with transferDirectory
2. Final status: compute actual completedCount from children instead
   of relying on totalBytes which may be 0 if the background scan
   hasn't finished yet
3. Catch block: detect cancellation from error message (not just
   cancelledTasksRef) so child-initiated cancels don't show as errors

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

* fix: add symlink depth guard to countDirectoryFiles and estimateDirectoryBytes

Both helper functions now track symlink depth and stop recursing
when MAX_SYMLINK_DEPTH is exceeded, consistent with transferDirectory.
Prevents infinite recursion on symlink directory cycles during the
background file count/size scan.

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

* fix: reliable final status and non-retryable child tasks

1. transferDirectory now returns the count of failed child transfers,
   tracked outside React state. downloadToLocal uses this count
   directly instead of reading from setTransfers updater (which may
   be deferred by React batching), ensuring the correct status is
   returned to the caller for toast messages.

2. Child tasks explicitly inherit retryable from the parent task.
   For downloadToLocal (retryable: false), this prevents showing
   retry actions on failed children whose "local" targetConnectionId
   cannot be resolved by retryTransfer.

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

* fix: add ancestor path cycle detection for symlink directories

The depth-only guard allowed up to 32 pointless traversals before
stopping a symlink cycle (e.g. dir/link -> .). Add an ancestorPaths
Set that tracks the current recursion stack — if a directory's source
path is already in the set, it's an immediate cycle and is skipped
with zero wasted traversals. The depth limit remains as a hard backstop.

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

* fix: don't recurse into symlink directories during transfers

Revert to only recursing into real directories (type === "directory")
in transferDirectory, countDirectoryFiles, and estimateDirectoryBytes.
Symlink directories are now transferred as regular entries instead of
being followed, eliminating all symlink cycle risks without needing
complex cycle detection that can't reliably work with unresolved
remote paths.

Also clean up activeChildIdsRef in processTransfer (both success and
error paths) to prevent memory leaks from pane-to-pane directory
transfers.

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

* fix: filter "." entries and recurse into symlink dirs with depth guard

1. Filter both "." and ".." in all recursive functions — some SFTP
   servers include "." in readdir, causing infinite self-recursion.

2. Restore symlink directory recursion in transferDirectory with a
   symlinkDepth counter (max 32). Symlink dirs that exceed the limit
   are excluded from the dirs list (treated as files). This is needed
   because startStreamTransfer cannot transfer a directory as a file,
   so skipping symlink dirs caused child transfer failures.

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

* fix: add symlink depth guard to count/estimate helpers

countDirectoryFiles and estimateDirectoryBytes now track symlinkDepth
consistently with transferDirectory, preventing infinite recursion on
symlink cycles in the background file count/size estimation.

Also fixes:
- Remove fragile string-based cancellation detection in downloadToLocal
- Clean up cancelledTasksRef in downloadToLocal catch block
- Move MAX_SYMLINK_DEPTH before its first use

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

* fix: use path reconstruction instead of string replace for duplicate conflicts

resolveConflict's "duplicate" action used String.replace to swap the
filename in the target path, but this replaces the first occurrence
which can corrupt the path if the filename also appears in a parent
directory name. Use joinPath(getParentPath(...), newName) instead.

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

* fix: skip over-depth symlink directories instead of treating as files

When symlinkDepth exceeds MAX_SYMLINK_DEPTH, symlink directories
were falling through to regularFiles and being passed to transferFile,
which cannot transfer directories and would produce confusing errors.
Now they are skipped entirely with a warning log.

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

* fix: count skipped symlinks as errors and process subdirs concurrently

1. Symlink directories skipped at MAX_SYMLINK_DEPTH now increment
   totalErrors so the parent task is marked failed instead of
   silently reporting success with incomplete content.

2. Sibling subdirectories are now processed with Promise.all instead
   of sequential await, restoring cross-directory concurrency that
   the old download implementation had. Files within each directory
   still use the configurable worker pool concurrency.

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

* fix: sequential subdirs to prevent SFTP overload and check dir errors in processTransfer

1. Revert subdirectory processing to sequential (for...of await) to
   prevent unbounded concurrent SFTP requests from nested Promise.all
   + worker pools across the directory tree. File-level concurrency
   within each directory is still governed by getTransferConcurrency().

2. processTransfer now captures transferDirectory's error count return
   value and marks the parent task as "failed" when child transfers
   fail, instead of unconditionally marking "completed".

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

* refactor: remove redundant completed state update for directory transfers

Directory success path no longer writes "completed" in both the
directory-specific block and the generic block. The directory-specific
block now only handles the failure case with early return; success
falls through to the generic completed block.

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

* fix: route partial directory failures through shared completion path

The early return for directory transfer failures skipped cache
invalidation, target pane refresh, and onTransferComplete callbacks
(needed by cut/paste to clear clipboard). Now partial failures flow
through the same cleanup path as successes — cache is cleared,
target is refreshed, and completionHandler is called with the
correct "failed" status.

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

* fix: restrict symlink directory recursion to downloadToLocal only

Add followSymlinks parameter (default false) to transferDirectory,
countDirectoryFiles, and estimateDirectoryBytes. Only downloadToLocal
passes true — uploads and pane-to-pane copies retain their original
behavior of treating symlink directories as regular entries.

This prevents existing upload/copy flows from expanding symlinked
directory trees (which could duplicate content or trigger cycles),
while still allowing local downloads to recursively copy through
symlink directories with depth protection.

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

* fix: disable retry for partial dir failures and fix symlink file count

1. Mark partially failed directory transfers as retryable: false to
   prevent retry from replaying the entire directory without conflict
   checks, which would silently overwrite already-copied files.

2. In countDirectoryFiles and estimateDirectoryBytes, skip over-depth
   symlink directories entirely instead of counting them as files.
   This makes the totals consistent with transferDirectory which also
   skips these entries, preventing impossible progress like "10/11".

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-29 01:27:46 +08:00
陈大猫
255a4730e7 feat: make SFTP folder transfer concurrency configurable (#558)
* feat: make SFTP folder transfer concurrency configurable

The number of files transferred in parallel during folder uploads/
downloads was hardcoded to 4. Add a setting (1-16, default 4) in
Settings > SFTP so users can tune it for their server and network.

The value is read from localStorage at transfer start time, so
changes take effect on the next folder transfer without restart.

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

* fix: sync transfer concurrency setting across windows

Add notifySettingsChanged broadcast, IPC onSettingsChanged handler,
and storage event listener for the transfer concurrency setting so
changes propagate to all open windows immediately.

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

* fix: move setSftpTransferConcurrency after notifySettingsChanged

The useCallback referenced notifySettingsChanged before it was
defined (const is not hoisted), causing a ReferenceError on mount.
Move the definition after notifySettingsChanged.

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-28 22:04:48 +08:00
陈大猫
de0d1e1912 perf: use fallback viewport height for transfer child list virtualization (#559)
When the transfer child list crosses the virtualization threshold (80
items), viewportHeight may be 0 if the layout hasn't been measured yet.
Previously this caused all children to render on the first frame,
creating a lag spike when clicking "show details" on large transfers.

Use MAX_PANEL_HEIGHT (480px) as a fallback viewport, capping the
initial render to ~25 rows (17 visible + 8 overscan) instead of
potentially thousands.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 21:55:34 +08:00
陈大猫
dd50f95583 feat: add workspace focus indicator style setting (dim vs border) (#557)
* feat: add workspace focus indicator style setting (dim vs border)

Users can now choose between two focus indicator styles for split
terminal panes:
- Dim: reduces opacity of unfocused panes (current default)
- Border: shows a colored border on the focused pane (old style)

The setting is in Settings > Terminal > Workspace Focus Indicator.
Implementation uses a CSS data attribute on documentElement to
toggle between the two styles, avoiding prop threading.

Closes #556

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

* fix: sync workspace focus style across windows

Add cross-window notification handling for the workspace focus style
setting so changes in the Settings window take effect in the main
terminal window immediately.

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-28 21:31:15 +08:00
bincxz
e57376c461 fix: remove popd from FOLDER_ONLY and resolve score collision
- Remove popd from FOLDER_ONLY_COMMANDS since it does not accept
  path arguments (it pops from the directory stack)
- Change recent-history score from 700 to 720 to avoid collision
  with spec option suggestions (also 700), giving recent history
  a clear rank: path (750) > recent history (720) > options (700)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 20:53:58 +08:00
bincxz
3a5a558837 fix: clear kb selection state in sftpNavigateTo list view path
The list view branch of sftpNavigateTo was missing the
_kbSelectionState.delete() call that the tree view branch and
other navigation handlers already had.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 20:51:51 +08:00
bincxz
506ab33b11 fix: address review findings in keyboard shortcuts and autocomplete
Keyboard shortcuts:
- BASIC_NAV_KEYS fallback now only applies when hotkeyScheme is
  disabled, so user keybinding customizations are respected
- Clear _kbSelectionState on directory navigation (sftpOpen,
  sftpGoParent, sftpNavigateTo) to prevent stale anchor/focus
- Guard sftpOpen tree-view fallback to only fire in tree view mode
- Use treeActionSelection (filters "..") in sftpNavigateTo

Autocomplete PATH_COMMANDS:
- Remove subcommand-first tools (docker, kubectl, go, cargo, java,
  make, npx) that don't take paths as first arguments
- Add pushd (was in FOLDER_ONLY but missing from PATH_COMMANDS)
- Add tee, du, df, chroot

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 20:50:56 +08:00
bincxz
198d9c365a tweak: increase recent history suggestions from 3 to 5
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 20:44:37 +08:00
bincxz
fbc17356e0 feat: expand PATH_COMMANDS for better autocomplete path detection
Add many commonly used commands that accept file/directory arguments:
modern alternatives (exa, eza, fd, bat, helix, micro), search tools
(grep, ag, awk, sed), compression (bzip2, xz, zstd, 7z), build tools
(gcc, make, cargo, go), runtimes (deno, bun, tsx, php), container
tools (docker, kubectl), and misc utilities (realpath, md5sum, etc.).

Also add popd to FOLDER_ONLY_COMMANDS.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 20:43:46 +08:00
bincxz
a04a28049e fix: prioritize path suggestions over history for file commands
When typing arguments for file-related commands (cat, vim, cd, etc.),
files in the current directory should appear before history entries.
Lower the recent-history score from 900 to 700 so path suggestions
(score 750) rank higher. This makes "cat com<Tab>" show compose.yaml
before historical commands like "cat /other/path".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 20:42:46 +08:00
bincxz
65267b3c90 refactor: hoist BASIC_NAV_KEYS to module scope
Avoid creating a new object on every keydown event by moving the
constant lookup table outside the callback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 20:32:13 +08:00
bincxz
2196733133 fix: Enter and Backspace were blocked by early return on null match
When basicNavAction was set, matched was intentionally null but the
existing `if (!matched) return` check exited before reaching the
action handler. This made Enter and Backspace non-functional in all
hotkey modes, not just disabled mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 20:24:55 +08:00
bincxz
67348b42b1 fix: ensure Enter and Backspace work when hotkeys are disabled
Enter (open) and Backspace (go parent) are essential navigation keys
that must work even when the user has disabled custom SFTP hotkeys.
Add a basic navigation fallback that fires before the disabled check.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 20:23:44 +08:00
bincxz
e754b2bdc9 feat: add configurable Navigate To shortcut for SFTP
Add sftpNavigateTo keybinding (Ctrl+Enter / ⌘+Enter) to navigate
into a selected directory. Works in both tree view and list view.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 20:22:07 +08:00
bincxz
87e49bc897 refactor: move Enter and Backspace SFTP shortcuts to configurable keybindings
Move the hardcoded Enter (open file/directory) and Backspace (go to
parent) handlers into the keybinding system so users can customize
them in Settings. Arrow key navigation remains hardcoded as it has
complex anchor/focus state tracking unsuitable for simple action mapping.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 20:17:34 +08:00
bincxz
53212b8669 fix: stale anchor in Shift+Arrow after mouse click re-sync
When the keyboard selection state was re-synced (e.g. after a mouse
click changed the selection), the anchor variable still held the old
value from before re-sync. This caused Shift+Arrow to select from
position 0 instead of from the clicked item. Destructure anchor and
focus together so both are updated when re-sync occurs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 20:11:30 +08:00
bincxz
ce7549bb25 fix: correct Shift+Arrow multi-select in SFTP file list
Shift+Arrow selection was broken because the anchor position was
re-derived from the selected files Set on each keypress, causing
it to jump unpredictably. Track anchor and focus indices separately
per pane so Shift+Arrow correctly extends the range from the
original starting position.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 20:06:47 +08:00
bincxz
b5ff5a468e feat: add Backspace shortcut to navigate to parent directory in SFTP
Pressing Backspace in the SFTP file list now navigates to the parent
directory, similar to file managers like Windows Explorer and Finder.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 19:50:49 +08:00
陈大猫
b1f9ec43de fix: widen host edit panel and prevent content overflow (#555)
- Increase HostDetailsPanel width from 380px to 420px to give more
  room for inner content blocks
- Add max-w-full to AsidePanel/AsidePanelStack root so the panel
  never exceeds its parent container width
- Add min-w-0 to ScrollArea and inner content div in AsidePanelContent
  to allow flex children to shrink properly
- Use overflow-x-hidden instead of overflow-hidden to preserve
  vertical layout flexibility

Closes #551

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 19:45:48 +08:00
bincxz
eed2dfb811 fix: remove unnecessary onClearSelection dependency in useCallback
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 19:45:21 +08:00
bincxz
b7fa6c0405 fix: resolve lint errors from recent PRs
- Remove unnecessary eslint-disable directive in useAutoSync.ts
- Use localStorageAdapter.remove() instead of bare localStorage in
  useSftpFileAssociations.ts (no-restricted-globals)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 19:44:37 +08:00
陈大猫
c8d145f52e feat: add default file opener setting for SFTP (#554)
* feat: add default file opener setting for SFTP

Add a global default opener that is used as fallback when no
per-extension file association exists, eliminating the need to
select an editor for every new file type.

The default opener is stored as a special "*" key in the existing
file associations map, so it syncs and persists automatically.

Settings UI provides three options: always ask (current behavior),
built-in editor, or a chosen system application.

Closes #550

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

* fix: use reserved key for default opener to avoid extension collision

Replace "*" with "__default__" as the default opener storage key to
prevent a theoretical collision with files named "foo.*" where
getFileExtension would return "*".

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

* fix: skip built-in editor default for known binary files

When the global default opener is set to built-in editor, binary files
(zip, png, etc.) should not be opened as text. Fall back to the chooser
dialog for known binary formats instead.

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

* refactor: store default opener in separate localStorage key

Move the default opener out of the FileAssociationsMap into its own
storage key (STORAGE_KEY_SFTP_DEFAULT_OPENER) to completely eliminate
any possibility of key collision with file extensions.

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-28 19:30:54 +08:00
陈大猫
aeacd913f5 feat: sync global SFTP bookmarks via cloud sync (#553)
* feat: sync global SFTP bookmarks via cloud sync

Global SFTP path bookmarks were stored only in localStorage and not
included in the cloud sync payload, so they could not be synced across
devices. Add them to the sync settings, with auto-sync detection via
a custom event and in-memory snapshot rehydration on import.

Local bookmarks remain device-specific by design.

Closes #548

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

* fix: deduplicate global SFTP bookmarks by path during merge

When the same path is bookmarked independently on two devices, each
generates a different random ID. The entity-array merge preserves both,
creating duplicates. Add path-based deduplication after settings merge,
following the same pattern used for known hosts.

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

* fix: sync global bookmarks across renderer windows via storage event

When cloud sync imports bookmarks in the Settings window, the main
window's in-memory snapshot stays stale. Listen for cross-window
storage events on the bookmark key to auto-rehydrate.

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-28 18:59:58 +08:00
陈大猫
67b78abfce fix: sort directory symlinks with directories in SFTP file list (#552)
Symlinks pointing to directories (DirLinks) were sorted with regular
files instead of being grouped with directories. Reuse the existing
isNavigableDirectory() helper so these entries sort alongside real
directories.

Closes #549

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 18:15:19 +08:00
penguinway
e3b882bdf9 feat(sftp): add tree view explorer for SFTP pane (#547)
* feat(sftp): add onListDirectory to SftpPaneCallbacks interface

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

* feat(sftp): implement onListDirectory in left and right callbacks

* feat(sftp): add tree view i18n keys

* feat(sftp): add list/tree view mode toggle to toolbar

* feat(sftp): add viewMode state and tree view conditional rendering to SftpPaneView

* feat(sftp): implement SftpPaneTreeView with lazy loading and context menu

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

* fix(sftp): resolve lint errors in tree view implementation

Rename inner `t` and `ts` variables in onListDirectory callbacks to
`toSize`/`toTs`/`ms` to avoid shadowing the outer `t` translation param.

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

* fix(sftp): resolve post-merge lint errors

- Remove duplicate sftp.context.copyPath i18n key (upstream added it too)
- Remove unused AlertCircle import from SftpPaneFileList (upstream removed usage)

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

* perf(sftp): optimize SftpPaneTreeView render pipeline

Split useMemo into two stages so selection changes no longer
rebuild the full node descriptor array. Extract stable
selection-aware callbacks (drag, copy, delete) via refs so
TreeNode React.memo can reliably bail out. Remove unused props
(onNavigateTo, draggedFiles), move NodeDescriptor type to
module scope, and fix selectedFiles undefined bug in context menu.

* feat(sftp): add path-aware rename and delete for tree view

Wire renameFileAtPath and deleteFilesAtPath through the full
callback stack so tree view context menu actions operate on
full paths instead of basenames. Update useSftpPaneDialogs to
accept entryPath in openRenameDialog and resolve parent dir
in handleDelete, keeping list view behaviour unchanged.

* fix: harden SFTP tree view actions and selection

* fix: support tree selection shortcuts and nested create targets

* fix: keep SFTP tree view sorting in sync

* Improve SFTP tree view interactions and refresh behavior

* Optimize SFTP tree refresh and pane state usage

* Reduce remaining SFTP tree performance overhead

* Fix nested SFTP drop target routing

* Restore keyboard access to parent tree entry

* Revert "Display approved AI commands in terminal sessions before their output. (#546)"

This reverts commit 6d19413025.

* Fix SFTP tree view review issues: accessibility, view persistence, and polish

- Add aria-pressed/aria-checked to view mode toggle buttons for accessibility
- Preserve tree expanded state across view mode switches (CSS hidden instead of unmount)
- Add cross-window localStorage sync for view mode preferences
- Add loading/reconnecting overlay UI for tree view
- Fix toggleExpand concurrent load guard and file list memo dependencies

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

* Fix review round 2: scroll jank, memo correctness, path handling, a11y

Critical:
- Fix rAF scroll throttle capturing stale scrollTop (use ref for latest value)
- Add sftpDefaultViewMode to memo comparator to react to settings changes
- Replace ad-hoc path splitting in handleDelete with getParentPath/getFileName
- Add fullPath to permissionsState prop type in SftpOverlays

Important:
- Remove treeSelectionState from handleNodeClick/handleTreeContainerKeyDown
  deps to prevent full tree re-render on every expand/collapse
- Add role="radiogroup" container and aria-label to view toggle buttons
- Wrap JSON.parse in try/catch for storage event handler
- Deduplicate getParentPath call in renameFileAtPath
- Parallelize reloadExpandedPaths with Promise.all

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

* Clean up review round 3: dead code, logging, and minor optimizations

- Remove dead isParentNavigation field from tree selection store (always
  false since ".." entries are filtered before entering the store)
- Replace empty catch blocks in dialog handlers with logger.warn
- Extract duplicated initialViewMode expression in SftpPaneView
- Stabilize handleSetViewMode by using refs for callbacks instead of
  depending on the entire callbacks object
- Remove redundant FINISH_LOADING dispatch on error path in
  loadChildrenForPath (LOAD_ERROR already removes from loadingPaths)

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

* Add same-pane drag-move, move-to dialog, and fix breadcrumb/tree sync

Features:
- Same-pane drag-and-drop to move files between directories in tree view
- "Move to..." context menu with path input dialog and autocomplete
- "Move to parent directory" quick action in context menu
- "Navigate to" context menu item for directories
- Error state UI with retry button in tree view
- Breadcrumb path deferred display during loading

Fixes:
- Fix breadcrumb and tree content showing different paths during navigation
  by atomically syncing resolvedRootPath and rootEntries in a single effect
- Fix toolbar displayPath updating before files load (defer until !loading)
- Reconnection detection and session error reporting in tree directory listing

UI improvements:
- Column widths use minmax()+fr instead of percentages with min-width protection
- Column headers truncate with overflow protection
- buildSftpColumnTemplate utility shared between tree and list views
- Column resize limits per field

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

* Reapply "Display approved AI commands in terminal sessions before their output. (#546)"

This reverts commit f739e81e8d7691eb33965f6c431623a257fd8b4b.

* fix: resolve remote-to-local drag transfer source pane

* fix: invalidate target cache after transfers

* fix: reload tree root after create mutations

* fix: use receive callback for tree drop targets

* fix: trigger pane refresh after transfer completion

* fix: handle transfer refresh tokens only once

* fix: show move-to-parent for direct children

* fix: refresh list view after move-to-parent changes

* fix: address review issues in transfer refresh and retry flows

* feat: improve list view keyboard and folder drops

* fix: strengthen list view keyboard selection feedback

* style: make list view selection more obvious

* fix: keep list selection visible during keyboard navigation

* fix: rerender list rows when selection changes

* fix: sync list selection highlight updates

* style: align list selection with tree view

* style: hide list selection highlight when pane is unfocused

* feat: clear list selection when clicking empty space

* refine transfer row layout and clear list selection on empty click

* perf: make transfer size discovery asynchronous

* perf: parallelize SFTP transfers and show per-file progress for directories

- Parallelize file transfers within directories (4 concurrent workers)
- Batch pre-create all directories before file uploads begin
- Run conflict check and size discovery concurrently
- Parallelize external drag-drop file uploads (4 concurrent workers)
- Show individual child file progress under parent directory task
- Parent directory task displays file count progress (e.g. "3/10 files")
- Child tasks auto-cleanup on parent completion or cancellation

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

* refine sftp transfer panel ux

* fix sftp sidebar and upload task flow

* polish sftp transfer interactions

---------

Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-03-28 18:02:21 +08:00
Eric Chan
6d19413025 Display approved AI commands in terminal sessions before their output. (#546) 2026-03-27 19:59:59 +08:00
bincxz
2aad02a914 fix: replace nested button with div in session history list
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
HTML spec forbids <button> inside <button>. Change the outer session
list item from <button> to <div role="button"> to fix the hydration
warning while preserving click and keyboard accessibility.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 19:34:22 +08:00
bincxz
76baf87c29 fix: add missing abortControllersRef to useEffect dependency array
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 19:26:20 +08:00
陈大猫
2a75f863f8 fix: reset cloud sync connect button when OAuth popup is closed (#544)
* fix: reset cloud sync connect button when OAuth popup is closed

When users close the OAuth popup without completing authorization,
the connect button was stuck in "Connecting" state indefinitely
(up to 5-minute timeout).

Changes:
- Track OAuth popup window and poll for closure (Google, OneDrive)
- Cancel OAuth callback server when popup is closed, immediately
  rejecting the pending promise instead of waiting for timeout
- Reset provider status via disconnectProvider on auth failure so
  the connect button returns to clickable state
- Suppress toast for user-initiated cancellation (popup closed)
- Also reset GitHub provider status on device flow failure

Closes #542

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

* fix: use resetProviderStatus instead of disconnectProvider on auth failure

disconnectProvider tears down existing connections (signOut, delete
adapter, clear merge base). If a user was re-authenticating and
cancelled, this would destroy their working connection.

Add resetProviderStatus() that only resets the UI status to
'disconnected' without any teardown side effects.

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

* fix: add resetProviderStatus to CloudSyncHook interface

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

* fix: remove noreferrer from OAuth popup to enable window tracking

noreferrer implies noopener in browser spec, causing window.open()
to return null and defeating the popup closure detection entirely.
Safe to remove since OAuth targets are trusted providers (Google,
Microsoft) and the Referer is just a localhost URL.

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

* fix: guard resetProviderStatus and cancel delayed popup on early failure

- resetProviderStatus only resets if status is 'connecting', preserving
  already-authenticated providers when sync initialization fails
- Cancel the delayed setTimeout for window.open if callbackPromise
  rejects before 100ms, preventing a stray popup and leaking interval

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

* fix: reset GitHub provider status when device flow modal is closed

The modal onClose only hid the modal and stopped the polling flag,
but the provider status stayed at 'connecting'. Now calls
resetProviderStatus('github') so the button returns to clickable.

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-27 19:24:06 +08:00
陈大猫
262bc57a21 feat: enable Unicode 11 for improved Nerd Fonts rendering (#545)
Load @xterm/addon-unicode11 and set activeVersion to '11' for better
character width handling of Nerd Fonts, Powerline glyphs, and CJK
characters. This matches the approach used by tabby terminal.

Closes #543 (Nerd Fonts portion)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 19:07:44 +08:00
bincxz
9563ae9dcc Revert "feat: enable Unicode 11 for improved Nerd Fonts rendering"
This reverts commit 349b215d3d.
2026-03-27 18:56:03 +08:00
bincxz
349b215d3d feat: enable Unicode 11 for improved Nerd Fonts rendering
Load @xterm/addon-unicode11 and set activeVersion to '11' for better
character width handling of Nerd Fonts, Powerline glyphs, and CJK
characters. This matches the approach used by tabby terminal.

Closes #543 (Nerd Fonts portion)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 18:55:30 +08:00
Rory Chou
7639191c50 fix: preserve AI chat history across reconnects (#541)
* fix: preserve AI chat history across reconnects

* fix: retarget restored AI sessions on reconnect

* feat: format tool call results with proper line breaks

Extract stdout/stderr from structured results and unescape \n/\t
so command output displays with real line breaks like terminal output.
Supports both JSON object {stdout,stderr} and executor text formats.

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

* fix: restrict unescape to stdout/stderr fields only

Plain strings may contain legitimate backslash sequences (file paths,
regex patterns) that should not be converted. Only apply unescape to
stdout/stderr fields extracted from command execution results.

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

* fix: address review findings for AI chat reconnect

1. Add explicit activeTerminalTargetIds guard in shouldRetargetActiveSession
   to prevent retargeting sessions owned by other terminals, making the
   invariant locally verifiable.

2. Only preserve orphaned terminal sessions with hostIds — workspace,
   local, and serial sessions generate fresh IDs and would be permanently
   unreachable, wasting MAX_STORED_SESSIONS quota.

3. Clear stale streaming state when restoring a session whose ACP handle
   was already cleaned up (e.g., reconnect during mid-response), so the
   user can send new messages.

4. Restore overflow-hidden on user message bubbles to prevent content
   bleeding past rounded border corners.

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

* fix: address round 2 review findings

1. Fix streaming state clear: only clear for sessions whose targetId
   doesn't match current scope (restored from different terminal),
   not for built-in Catty chats that never set externalSessionId.

2. Exclude local/serial sessions from preservation: their synthetic
   hostIds (local-*/serial-*) change on every open and can never be
   matched back.

3. Preserve non-zero exitCode in formatted tool results so failed
   commands show a visible failure signal.

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

* fix: only clear streaming state during retarget, not for all restored sessions

The previous condition (targetId !== scopeTargetId) also fired for
built-in Catty sessions during normal operation, killing active streams.
Now streaming is only cleared when shouldRetargetActiveSession is true,
meaning the session came from a disconnected terminal where any
in-flight response is guaranteed to be dead.

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

* fix: address round 3 review findings

1. Clear externalSessionId during retarget to prevent stale ACP handle
   from surviving if retarget runs before orphan cleanup.

2. Only retarget in visible AI panels — hidden/background panels should
   not race to claim orphaned sessions.

3. Remove unescapeTerminalOutput — data flow trace confirms real newline
   characters arrive at the component. The unescape was corrupting
   legitimate backslash sequences in paths and patterns.

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

* fix: only ACP-cleanup deleted sessions, not preserved ones

Preserved sessions may be reused on reconnect. Running aiAcpCleanup
on them asynchronously could race with a newly started ACP conversation
on the same session ID, tearing down the fresh provider.

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

* fix: abort in-flight streams during retarget and restore ACP cleanup

1. Abort the active request's AbortController when retargeting a session
   with stale streaming state. Prevents late chunks from the old run
   appending into the restored chat.

2. Restore ACP cleanup for all orphaned sessions (not just deleted ones).
   Preserved sessions get a new externalSessionId on next use, so
   cleaning the old one prevents subprocess leaks without affecting
   future conversations.

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

* fix: guard hidden panels from session ownership and skip null map entries

1. Only assign restored sessions in visible panels — hidden panels
   should not race to own sessions via setActiveSessionId, preventing
   MCP/tool calls from being bound to the wrong terminal.

2. Skip null entries in activeSessionIdMap when building
   activeTerminalTargetIds — deleted chats should not block same-host
   history matching on other terminals.

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

* fix: guard MCP sync behind visibility and cancel exec/approvals on retarget

1. Only sync MCP session metadata from visible panels to prevent
   hidden panels from overwriting the scope mapping.

2. Cancel pending approvals and in-flight exec (Catty + ACP) during
   retarget, matching handleStop behavior. Prevents stale tool results
   and approval prompts from reappearing after session retarget.

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

* fix: restore MCP sync for hidden panels

MCP scope is keyed by chatSessionId so hidden panels don't overwrite
visible panels' mappings. The isVisible guard was breaking background
chats that need updated terminal session metadata after reconnects
or workspace changes.

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

* chore: remove unused deletedIds variable

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.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-27 18:47:32 +08:00
陈大猫
c3224d30c6 feat: network device mode for SSH + serial charset encoding support (#540)
* feat: add deviceType field to Host model for network device support

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

* feat: pass deviceType through session metadata pipeline

Thread deviceType from Host model through AITerminalSessionInfo, IPC
types, and mcpServerBridge so AI agents can inspect device type per session.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: route network device SSH sessions to raw PTY execution

When deviceType === 'network', handleExec now uses execViaRawPty
instead of execViaPty so vendor CLIs (Huawei VRP, Cisco IOS, etc.)
receive commands as-is without POSIX shell wrapping or markers.
The command blocklist is also skipped for network devices, consistent
with the existing serial session bypass. AI context description updated
to document the raw-execution behaviour for network device sessions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: add network device mode toggle to host settings UI

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

* feat: add network device awareness to Catty Agent system prompt

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

* fix: extend network device mode to Catty Agent exec path and host context

- Add network device detection and raw execution routing to aiBridge.cjs
  (the primary Catty Agent command path), not just the MCP bridge
- Export getSessionMeta from mcpServerBridge for reuse in aiBridge
- Surface deviceType in Catty Agent system prompt host list so the AI
  can identify which sessions are network devices
- Pass deviceType through buildSystemPrompt context

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

* fix: exempt network device sessions from client-side blocklist and update ACP context

- Add deviceType to ExecutorContext sessions type
- Skip renderer-side command blocklist for deviceType=network sessions
  in shared toolExecutors.ts (not just main-process side)
- Update ACP agent context hint to mention network device sessions

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

* fix: only show network device mode toggle for SSH hosts

Telnet and local hosts don't support the network device execution path,
so hiding the toggle prevents users from enabling a broken configuration.
Serial hosts already use raw mode by default.

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

* fix: exclude Mosh sessions from network device raw execution path

Mosh uses a shell-backed PTY and cannot connect to vendor CLIs, so
network device mode should only apply to SSH and serial sessions.

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

* fix: prefer session.protocol over metadata for Mosh detection

Mosh tabs report protocol:"ssh" in renderer metadata but "mosh" in
the main-process session object. Prioritize session.protocol (runtime
truth) to correctly exclude Mosh from network device raw execution.

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

* fix: suppress deviceType metadata for Mosh sessions

Mosh requires a shell-backed PTY and cannot connect to vendor CLIs,
so omit deviceType from AI-facing metadata when session is Mosh-backed.
This prevents the AI from being told to use vendor CLI syntax when the
actual execution path uses normal shell wrapping.

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

* fix: use exit code 0 for network device sessions and hide toggle for Mosh

- Network device / serial sessions return exitCode: null from vendor
  CLIs. Default to 0 instead of -1 so the AI doesn't misinterpret
  successful commands as failures.
- Hide the network device mode toggle when Mosh is enabled, since
  the setting is suppressed at runtime for Mosh sessions anyway.

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

* fix: preserve null exit codes and restrict raw mode to SSH/serial

- Preserve exitCode: null for network device sessions instead of
  coercing to 0, so the AI knows exit status is unavailable rather
  than seeing a misleading success code.
- Explicitly whitelist SSH/serial protocols for network device mode
  instead of just excluding mosh, preventing local/telnet sessions
  from accidentally entering raw execution.

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

* fix: use UTF-8 encoding for SSH network device raw execution

execViaRawPty hardcodes latin1 for serial port data decoding. Add an
encoding option (default: latin1) and pass utf8 from SSH network
device call sites so multi-byte characters aren't corrupted.

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

* fix: use host charset for serial port decoding instead of hardcoded latin1

- Extract charsetToNodeEncoding() to module scope in terminalBridge
- Serial sessions now read options.charset (from Host.charset) for
  both terminal display decoding and AI command output
- Store serialEncoding on session object so exec paths can use it
- Pass encoding through all execViaRawPty call sites
- Default encoding changed from latin1 to utf8 (matches most modern
  network equipment and is the safer default for CJK environments)

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

* fix: move serialEncoding declaration before session object creation

serialEncoding was referenced in the session object literal before its
const declaration, causing a TDZ ReferenceError that would crash every
serial connection.

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

* fix: tighten isNetworkDevice logic and clean up edge cases

- Align toolExecutors isNetworkDevice check with bridge logic: require
  explicit SSH/serial protocol match instead of trusting deviceType alone
- Remove empty-string protocol match from isSshOrSerial in both bridges
  to prevent local/unknown sessions from being treated as network devices
- Widen exitCode return type to `number | null` to match actual behavior
- Clear deviceType when enabling Mosh (incompatible combination)

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

* fix: update MCP server tool descriptions for network device sessions

The get_environment and terminal_execute tool descriptions only
mentioned serial/raw sessions for network devices. Updated to also
reference deviceType: network SSH sessions so external AI agents
(Claude, Codex) know about the new execution mode.

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

* fix: include deviceType in get_environment and guard execViaChannel fallback

- Add deviceType to executeWorkspaceGetInfo session mapping and return
  type so Catty Agent's get_environment tool matches MCP bridge output
- Guard both aiBridge and mcpServerBridge against falling through to
  execViaChannel for network device sessions — network devices require
  an interactive PTY and exec channels would produce broken behavior

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

* feat: add charset setting to serial host configuration UI

Serial hosts now have a charset input in the Advanced section,
defaulting to UTF-8. The value is saved to Host.charset and used
by the serial decoder in terminalBridge.

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

* feat: add charset to serial quick-connect modal with full pipeline

- Add charset input to SerialConnectModal (Advanced section)
- Thread charset through onConnect callback → handleConnectSerial →
  createSerialSession → TerminalSession.charset
- Add charset field to TerminalSession interface
- Include charset in fallback host builder for quick-connect sessions
  so createTerminalSessionStarters can pass it to startSerialSession
- Saved hosts also store charset via onSaveHost

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

* fix: constrain serial connect modal height with scrollable content

Modal content could overflow the viewport when Advanced section was
expanded. Add max-h-[85vh] to DialogContent with flex layout so the
content area scrolls while header and footer buttons stay visible.

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

* fix: propagate charset through all serial session creation paths

- Add charset to startSerialSession type in global.d.ts
- Copy host.charset to TerminalSession in connectToHost serial path
- Copy host.charset in createWorkspaceWithHosts serial path
- Propagate session.charset in splitSession (both workspace and standalone)
- Propagate session.charset in copySession

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

* fix: propagate charset in remaining session creation paths

Add host.charset to connectToHost (non-serial), createWorkspaceWithHosts
(non-serial), and runSnippet session creation for consistency.

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-27 18:33:16 +08:00
陈大猫
40d80fe535 perf: comprehensive UI and state management optimization (#539)
* perf: comprehensive performance optimization across UI and state management

- Replace Array.find/includes with Map/Set lookups for O(1) access in hot paths
- Add requestAnimationFrame throttling to all mousemove resize handlers
- Remove redundant forceUpdate + useSyncExternalStore double subscription
- Extract terminal search decoration config to module-level constant
- Pause server stats polling and resize handlers for hidden terminals
- Add timer cleanup for useEffect/useLayoutEffect with setTimeout
- Use useEffectEvent to stabilize effect callbacks and reduce effect re-runs
- Use useDeferredValue for QuickSwitcher search input
- Batch activeTabStore notifications with microtask coalescing
- Memoize sessionLogConfig and activityTrackedSessions to prevent child re-renders
- Use ref pattern for stable onTerminalDataCapture callback
- Skip TerminalLayer pre-warming when no sessions or workspaces exist

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

* fix: flush final resize value before canceling RAF

Apply the last computed size synchronously on mouseup/cleanup before
canceling the pending requestAnimationFrame. This prevents the final
drag delta from being dropped when mouseup fires before the queued
frame executes.

Addresses review feedback from codex on all 3 RAF-throttled resize
handlers: split resize, side panel resize, and SFTP column resize.

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

* fix: initialize lastClientXRef on resize start to prevent click-collapse

Without initialization, a click on the resize handle without dragging
would use lastClientXRef=0, computing a large negative diff and
collapsing the column to minimum width.

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

* fix: revert useDeferredValue for QuickSwitcher search

useDeferredValue can lag behind the actual input, causing quickResults
to reflect a stale query when the user types fast and presses Enter.
This is a correctness regression - the selected item may not match the
user's intent. The host list is typically small (<200), so synchronous
filtering is fast enough without deferral.

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

* fix: restore runtime activity guard to prevent stale badge on tab switch

The pre-filtered activityTrackedSessions reduces subscriptions for
disconnected sessions, but removing the runtime shouldMarkSessionActivity
check introduced a race: between tab switch and effect re-subscription,
old listeners could mark the newly-focused session as unread. Restore
the activeTabIdRef.current guard inside the callback as a safety net.

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

* fix: defer initialConnectDoneRef flag until auto-connect executes

Moving the flag inside the setTimeout callback prevents it from being
set when the timer is canceled by cleanup. Previously, if the effect
re-ran before the setTimeout(0) fired, the timer was cleared but the
ref was already true, permanently skipping the initial local connect.

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

* fix: capture resizingRef fields before setState updater

Destructure field/startX/startWidth from the ref upfront so the
functional updater closure never reads resizingRef.current after
it may have been cleared by handleResizeEnd.

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

* fix: remove activeTabId from activityTrackedSessions to stabilize subscriptions

Depending on activeTabId caused subscriptions to tear down and recreate
on every tab switch, resetting the ChunkedEscapeFilter mid-sequence and
producing false unread badges. The runtime guard via activeTabIdRef
already handles the active-tab check, so pre-filtering only needs to
exclude disconnected sessions.

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

* fix: fetch server stats immediately when tab becomes visible again

Use hasFetchedRef to distinguish first connect (2s delay for connection
stabilization) from tab resume (immediate fetch). Prevents showing
stale CPU/memory data for 2 seconds after switching back to a terminal.

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

* fix: restore cold-start prewarm and reset network stats on tab resume

1. Revert shouldPrewarm guard - TerminalLayer should always prewarm
   after 1.2s regardless of session/workspace count, as the purpose is
   to hide lazy-load latency before the user opens their first terminal.

2. Reset netRxSpeed/netTxSpeed to 0 when resuming a hidden terminal
   tab. The backend computes network throughput as a delta from the
   previous sample, so the first fetch after a long hidden interval
   would show artificially low throughput averaged over the gap.

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

* fix: reset hasFetchedRef on disconnect and preserve built-in theme precedence

1. Clear hasFetchedRef when connection drops so reconnects get the 2s
   stabilization delay before first stats fetch.

2. Reverse theme merge order in themeById Map so built-in themes are
   written last and take precedence over custom themes with duplicate
   IDs, matching the original find() semantics and other resolution
   sites (customThemeStore.getThemeById, Terminal.tsx).

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

* fix: also clear per-interface network speeds on tab resume

Reset rxSpeed/txSpeed on each netInterfaces entry in addition to the
aggregate values, so the network hovercard doesn't show stale
throughput while waiting for the first fresh poll after resume.

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

* fix: reset capture ref on retry and skip warmup for established connections

1. Reset terminalDataCapturedRef in handleRetry() so log capture works
   for retried sessions (retry doesn't change sessionId, so the effect
   that resets the ref never re-runs).

2. Track connection start time to skip the 2s warmup delay when a tab
   becomes visible for a connection that was already established while
   hidden. Only apply the warmup for truly fresh connections (<2s old).

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

* fix: prevent overlapping stats requests and track connection time while hidden

1. Add fetchInFlightRef guard to prevent concurrent getServerStats
   requests that could race and corrupt baseline CPU/network data.

2. Move connectedAtRef initialization before the isVisible check so
   connections that complete while the tab is hidden record their
   start time. This ensures the warmup delay is correctly skipped
   when the tab becomes visible for an already-stable connection.

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

* fix: reset fetchInFlightRef on disconnect to unblock reconnect stats

A pending getServerStats request from a previous connection could keep
fetchInFlightRef set, causing the reconnected session's initial fetch
to be skipped until the old request timed out.

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

* fix: clear fetchInFlightRef when tab becomes hidden

Ensures the resume fetch isn't blocked by an in-flight request from
the previous visible cycle. Any stale response from the old request
will be quickly overwritten by the fresh immediate fetch on resume.

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

* fix: use generation counter to invalidate stale stats responses

Replace fetchInFlightRef with a generation counter that increments on
each fetch. Stale responses from before a hide/show cycle are discarded
by comparing the captured generation against the current value, fully
preventing pre-hide requests from overwriting zeroed network stats.

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

* fix: increment fetch generation on effect setup to invalidate in-flight requests

The generation was only incremented inside fetchStats, but the resume
setTimeout hadn't fired yet when old responses arrived. Incrementing
at effect setup time ensures any pre-hide in-flight request is
immediately stale, preventing it from overwriting zeroed network stats.

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-27 14:45:47 +08:00
bincxz
ce1a00bed9 update Vaults icon from Shield to FolderLock for better visual consistency with SFTP
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
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 03:21:02 +08:00
bincxz
7df88f5bf7 fix: keep terminal autocomplete popup off the input line 2026-03-27 03:05:45 +08:00
bincxz
eeb42b1d20 fix: make vault and sftp theme switching instant 2026-03-27 02:51:23 +08:00
bincxz
23475fb1ce improve terminal theme preview synchronization 2026-03-27 02:36:21 +08:00
bincxz
fadd84606a refine terminal connection auth dialog styling 2026-03-27 01:39:02 +08:00
bincxz
d3e1a96702 optimize terminal theme side panel updates 2026-03-27 01:33:33 +08:00
bincxz
91fd44cccf fix terminal autocomplete path and popup behavior 2026-03-27 01:22:35 +08:00
陈大猫
5b6f45c896 perf: reduce workspace and theme switch rerenders (#537)
* fix: replace workspace pane border with text dimming for unfocused panes

Replace the 2px primary-color border and Tailwind ring with a subtler
focus indicator: unfocused panes reduce xterm canvas opacity to 70%,
making text slightly dimmer without adding visual clutter.

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

* fix: use visibility:hidden for terminal caching and restore focus on tab switch

- Replace display:none with visibility:hidden for TerminalLayer and
  workspace panes to preserve xterm canvas state across tab switches
- Restore focus to the correct pane when terminal layer becomes visible
  again, preventing opacity flash from :focus-within CSS
- Reduce autocomplete popup box-shadow intensity

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-27 01:03:12 +08:00
陈大猫
c924259fc0 fix: add local autocomplete specs and isolate command history per host (#536)
Add local spec files for commands missing from @withfig/autocomplete
(journalctl, yum, awk) and load them with priority over the upstream
package. Also enforce strict per-host isolation for command history —
previously cross-host matching by OS leaked host-specific commands
(e.g. cd /cq/) into unrelated sessions.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 00:04:42 +08:00
bincxz
f896f2a071 fix: polish autocomplete popup and bridge 2026-03-26 23:34:10 +08:00
bincxz
1851a8de71 Merge remote-tracking branch 'origin/main' 2026-03-26 23:22:15 +08:00
bincxz
53dd266f42 Merge branch 'feat/path-completion' 2026-03-26 23:21:51 +08:00
bincxz
5e05d25c2b fix: tighten autocomplete directory listing 2026-03-26 23:21:31 +08:00
bincxz
2d57015ac5 fix: harden path completion edge cases 2026-03-26 23:13:52 +08:00
bincxz
579dab56c2 fix: tighten path completion popup updates 2026-03-26 22:50:14 +08:00
bincxz
f1fea53af6 fix: avoid preload API collision with sftp 2026-03-26 22:38:44 +08:00
bincxz
aabae00970 fix: refine path completion popup behavior 2026-03-26 22:35:48 +08:00
Eric Chan
9136569809 feat: Add session activity indicator and store (#528)
* Add session activity indicator and store

Introduce a SessionActivityStore (useSyncExternalStore) to track which tabs/workspaces have unread terminal activity. TerminalLayer now strips terminal control sequences, listens for session data, and marks tabs as active when not focused; it also clears activity on focus change and prunes stale IDs. TopTabs consumes the activity map to render a breathing activity dot on session/workspace tabs and adjusts the workspace tab layout to show the dot next to the pane count. Add CSS animation for the activity indicator.

* fix: buffer incomplete escape sequences across data chunks

Add ChunkedEscapeFilter to carry partial ANSI/OSC escape-sequence
tails between successive data chunks, preventing false-positive
activity badges from split control sequences on busy sessions.

Also fix missing trailing newline in sessionActivity.ts.

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

* fix: remove 256-byte cap on pending escape sequence tails

Long OSC sequences (e.g. clipboard/title payloads) can exceed 256
bytes. Removing the cap ensures they are fully buffered across
chunks instead of being misclassified as printable output.

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

* fix: buffer OSC tails that end on bare ESC awaiting backslash

OSC sequences terminated with ESC\ can split at the ESC boundary.
Extend the incomplete tail regex to also match an in-progress OSC
sequence ending with ESC (awaiting the closing backslash).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.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-26 21:52:10 +08:00
bincxz
f2bcbe5123 fix: popup 用 Portal + position:fixed 渲染,不被分屏裁剪
之前 popup 在终端面板内部渲染,分屏时被 overflow:hidden 裁剪,
子面板展开会挤压相邻面板空间。

改为 React Portal 渲染到 document.body:
- containerRef 获取终端容器的 getBoundingClientRect
- 从相对坐标转换为 viewport 固定坐标
- position: fixed + zIndex: 10000 浮在所有内容之上
- effectiveMaxHeight 根据 viewport 底部剩余空间动态计算
- 移除 overlay div,popup 完全独立于终端 DOM 层级

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:49:34 +08:00
bincxz
3dcb792a55 fix: 深目录 prompt 检测 + 打字卡顿性能优化
1. prompt 扫描限制只对 > 和 › 生效(容易与重定向混淆),
   $ 和 # 扫描完整行——修复长 CWD 路径下 prompt 检测失败
2. 路径补全只在明确路径触发(/ ./ ../ ~/)或建议不足时才发 IPC,
   避免每次按键都做远程 ls
3. 快速打字时 debounce 延迟从 2x 增到 3x(300ms),减少 IPC 频率

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:45:12 +08:00
bincxz
5ca996d2d2 fix: 子面板选择时构建完整路径而非只写 entry 名
之前 handleSubDirSelect 只写最后一级名称(如 ca-certificates/),
导致 cd /usr/local/share/ca-certificates/ 变成 cd /ca-certificates/。

修复:从面板的 dirPath 构建完整路径,用 Ctrl+U 清除当前输入,
重写完整命令(如 cd /usr/local/share/ca-certificates/)。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:37:35 +08:00
bincxz
9ea1c3a92e fix: 子面板聚焦时 → 键不再被顶层 ghost text handler 拦截
顶层 → handler 条件加 subDirFocusLevel < 0 守卫:
当焦点在子面板中时(focusLevel >= 0),整个顶层 → 处理器被跳过,
让后续的子面板导航块处理 → 键实现深层展开。

之前的 bug:顶层 → handler 的 "enter sub-dir from main" 条件不匹配,
但随后的 ghost text accept 条件匹配并消费了事件,
子面板的 → handler 永远执行不到。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:33:43 +08:00
bincxz
af85401a69 fix: → 键正确移焦点到新面板 + 面板不超出底部边界
1. expandSubDir 添加 moveFocus 参数:
   - ↑↓ 自动预加载时 moveFocus=false(焦点不动,只预加载)
   - → 键主动进入时 moveFocus=true(焦点移到新面板,selectedIndex=0)
2. effectiveMaxHeight 根据 position.y 动态计算,确保面板不超出底部

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:32:02 +08:00
bincxz
5d3af6d107 fix: 子面板自动滚动 + ↑↓导航自动展开下一级目录
1. 选中项使用 callback ref 自动 scrollIntoView,
   解决滚动条不跟随选中项的问题
2. 在子面板中 ↑↓ 导航到目录项时自动调用 expandSubDir
   预加载下一级内容,实现连续级联浏览体验

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:23:55 +08:00
bincxz
68ab65764e feat: 多级级联目录面板 — 支持无限深层展开
重构子目录面板从单个 subDirEntries 改为 subDirPanels 面板栈:
- subDirPanels: SubDirPanel[] — 级联面板数组
- subDirFocusLevel: number — 当前焦点层级(-1=主面板)
- → 键在任意层级选中目录后展开下一级面板
- ← 键返回上一级(收起当前面板)
- ↑↓ 在当前层级导航(同时收起右侧已展开的更深面板)
- 已展开但未聚焦的层级用 hover 色标记选中项
- 去掉子面板白色边框

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:21:10 +08:00
bincxz
514bea824a fix: fetchSuggestions 初始化顺序错误 — 用 ref 间接调用
handleSubDirSelect 定义在 fetchSuggestions 之前,直接引用会触发
ReferenceError: Cannot access before initialization。
改用 fetchSuggestionsRef 间接引用,在 fetchSuggestions 定义后同步更新。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 21:02:12 +08:00
陈大猫
de874fc8c5 fix: 修复双击检查更新崩溃 & 优化更新 UX (#522) (#531)
* fix: prevent double-click update crash and improve update UX (#522)

- Add state guards to prevent checkForUpdates during active download
- Disable "Check for Updates" button during checking/downloading/ready
- Make version badge trigger in-app download instead of opening GitHub
- Change error toast action from "Open Releases" to "View in Settings"
- Add "Download Now" button in system settings as primary action
- Keep GitHub release link as secondary fallback in settings

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

* fix: reset download state when downloadUpdate() rejects

Clears _isDownloading and broadcasts error status on catch so the
update UI does not get stuck after a failed download attempt.

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

* fix: only show Download Now after a completed update check

Prevents downloadUpdate() from being called with stale cached state
before electron-updater has run checkForUpdates(), avoiding a
"Please check update first" error.

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

* fix: use correct broadcast function and prime updater before download

- Replace undefined broadcastUpdateStatus with broadcastToAllWindows
- Call checkForUpdate before downloadUpdate to ensure electron-updater
  has populated update metadata

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

* fix: use correct error payload field and guard unsupported platforms

- Use { error: ... } instead of { message: ... } in download error
  broadcast to match renderer expectations
- Bail out of startDownload when checkForUpdate returns unsupported
  or throws, instead of entering a failing download path

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

* fix: guard startDownload against in-flight and no-update check results

Bail out when checkForUpdate returns checking, not-available, or
unsupported states to prevent calling downloadUpdate prematurely.

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

* fix: remove duplicate error broadcast and fallback to releases on unsupported

- Remove redundant broadcastToAllWindows in download catch (global
  error listener already handles it)
- Open release page instead of silently returning when platform
  does not support auto-update

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

* fix: check supported before available to ensure release page fallback

Unsupported platforms return { available: false, supported: false },
so the supported check must come first to open the release page
instead of silently returning.

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

* fix: skip download when update is already ready or downloading

Guard against re-downloading when checkForUpdate returns ready or
downloading sentinel, preventing overwrite of valid install state.

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

* fix: fallback to release page when electron-updater reports no update

When GitHub API found an update but electron-updater does not,
open the release page instead of silently doing nothing.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:59:11 +08:00
bincxz
14ba1e779c fix: 二级菜单白边、深层展开、底部溢出
1. 去掉子面板多余的 borderLeft — sharedBoxStyle 已有完整边框
2. 选择子目录后 50ms 延迟 re-trigger fetchSuggestions,
   实现无限深层展开(cd /usr/ → lib/ → → python3/ → ...)
3. overlay 容器和内部 div 设 overflow: visible,
   防止子面板在终端底部时被父容器裁剪

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:58:19 +08:00
bincxz
0c1e269718 fix: 二级目录面板 — → 键优先进入子面板 + 对齐选中项位置
1. → 键优先级修复:当 popup 有选中的目录且子目录已加载时,
   → 进入子面板而非接受 ghost text
2. 子面板用 marginTop 对齐选中项的行位置,不再固定在顶/底部
3. 未聚焦时也显示 border-left 边框区分主/子面板

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:46:38 +08:00
bincxz
a96f5c332c feat: 目录级联展开 — 选中目录时右侧显示子目录面板
选中一个目录补全项时,自动获取其子目录内容并在右侧展开面板:
- ↑↓ 在主面板导航时自动 fetch 目录内容
- → 进入子目录面板(焦点转移到右侧)
- ← 返回主面板
- 在子目录面板中 ↑↓ 导航,Enter/Tab/→ 选择并插入
- 选中项带 › 展开指示符
- 子面板带 cursor 颜色左边框标识焦点
- 最多显示 50 个子目录条目

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:39:50 +08:00
bincxz
a0b8d74582 fix: 路径补全图标从 emoji 改为 lucide-react 图标
Folder/File/Link 替代 📁📄🔗,与项目已有图标风格一致。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:29:26 +08:00
陈大猫
e6166a1de3 feat: AI Provider 高级参数配置 (#532) (#533)
* feat: expose advanced AI model parameters in provider settings (#532)

Add collapsible "Advanced Parameters" section to provider config with
optional max_tokens, temperature, top_p, frequency_penalty, and
presence_penalty fields. Parameters are merged into streamText() calls
only when explicitly set, otherwise provider defaults apply.

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

* fix: use maxOutputTokens instead of maxTokens for ai@6 SDK

The streamText CallSettings in ai@6 expects maxOutputTokens, not
maxTokens. Without this fix the user's max_tokens setting is silently
ignored.

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

* fix: allow negative penalty input and clamp params on save

- Use raw string state for penalty fields so typing "-" is not
  discarded before the digit is entered
- Clamp all parameters to valid ranges on save (temperature 0-2,
  topP 0-1, penalties -2 to 2)

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

* fix: use raw string state for all numeric advanced param inputs

Prevents intermediate text like "0." from being normalized to "0"
during keyboard entry of decimal values for temperature, topP, and
maxTokens fields.

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

* fix: clamp max_tokens to minimum of 1 after rounding

Prevents Math.round(0.4) = 0 from being persisted and causing
streamText to reject with "maxOutputTokens must be >= 1".

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

* fix: reject non-finite max_tokens before persisting

Guard with Number.isFinite to prevent Infinity from being stored
and forwarded to streamText.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:26:13 +08:00
bincxz
ae797e5fb1 feat: 远程路径补全 — cd/ls/cat 等命令自动列出文件和目录
通过 SSH exec channel 在远程机器上执行 ls 命令获取目录内容,
在补全菜单中显示文件/目录列表。

实现:
- sshBridge.cjs: 新增 netcatty:ssh:listdir IPC handler,
  使用 session.conn.exec() 在独立 channel 执行 ls -1Fap,
  不影响交互式终端
- main.cjs: 新增 netcatty:local:listdir,本地终端用 fs.readdir
- preload.cjs: 暴露 listRemoteDir/listLocalDir API
- remotePathCompleter.ts: 路径补全核心模块
  - shouldDoPathCompletion: 检测 fig spec template/generators、
    PATH_COMMANDS 白名单、或输入以 /  ./  ../  ~/ 开头
  - resolvePathComponents: 解析目录路径和过滤前缀
  - getPathSuggestions: 编排检测→解析→IPC→格式化
  - 5 秒 TTL 缓存 + in-flight 请求去重
- completionEngine.ts: SuggestionSource 新增 "path" 类型,
  CompletionSuggestion 新增 fileType 字段,
  getCompletions 接受 sessionId/protocol/cwd 参数
- AutocompletePopup.tsx: 路径建议显示 📁/📄/🔗 图标
- Terminal.tsx: 传入 protocol 和 getCwd

支持:SSH 远程目录、本地终端、cd 仅显示目录、
  空格文件名转义、head -100 限制输出

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:23:45 +08:00
陈大猫
9a7d4decff feat: 终端命令自动补全系统 (#527)
* feat: 终端命令自动补全系统

实现类似 WindTerm/Fish 的终端命令自动补全功能,不依赖机器学习:

- 历史命令持久化存储:按主机分组,频率+时间衰减排序,跨会话共享
- 前缀匹配引擎:支持精确前缀匹配和模糊匹配(首字符+连续字符+词边界加权)
- Prompt 检测器:识别 bash/$、zsh/%、fish/> 等常见 prompt 模式,排除 vim/less 等程序
- Ghost Text 插件:xterm.js 自定义 addon,光标后灰色行内建议,→ 接受全部,Ctrl+→ 接受一词
- 弹出补全菜单:浮动列表 UI,↑↓ 导航,Tab/Enter 选中,Esc 关闭,来源标记(h/c/s/o/a)
- @withfig/autocomplete 集成:600+ 命令规范的子命令、选项、参数补全
- 上下文感知:解析命令行 token,根据当前位置提供对应类型的补全
- 用户配置:启用/禁用、Ghost Text、弹出菜单、防抖延迟、最小字符数等
- 快速打字防误触:检测打字速度,快速输入时抑制建议
- 输入防抖 100ms,异步匹配不阻塞 UI

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

* fix: 补全菜单混合展示历史命令和 spec 子命令

- 输入已知命令名(如 docker)时即使没有空格也预览子命令
- 历史命令条数从 8 降为 5,留空间给 spec 建议
- 修复 wordIndex === 0 时 spec 补全被跳过的问题

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

* fix: 补全菜单在终端底部时向上展开

当光标在终端下方、空间不足时,弹出菜单向上展开(底边对齐光标行),
避免溢出终端区域。列表顺序和选中逻辑不变——最可能的选项始终在顶部,
用户初始向下选择。参考 Termius 的做法。

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

* fix: 补全菜单跟随终端主题 + Enter 直接执行命令

1. 补全菜单颜色从终端主题动态派生(color-mix),不再硬编码色值,
   确保与任何主题视觉一致
2. 在弹出菜单中按 Enter 选择命令时,直接插入并发送 \r 执行,
   无需用户再按一次回车
3. Tab/鼠标点击仍然只插入不执行(保留选择后编辑的能力)

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

* fix: 修复 PR review 发现的全部 20 个问题

功能修复:
- #1 修复 selectAndExecute 导致命令双重录入历史:用 suppressNextEnterRecordRef
  标志位让 handleInput 的 Enter 分支跳过已经录入过的命令
- #2 修复 Prompt 末尾 $ 误判:重写 findPromptBoundary 为从左到右逐字符扫描,
  排除 $HOME/$PATH 等变量引用(检查 $ 前是否有空格、是否在 token 内部)
- #6 快速打字检测实际生效:快速打字时 debounce 延迟翻倍(200ms),等用户停顿
- #8 resolveSpecContext 处理带参数的 option(如 --name value):
  识别 option 的 args 字段,自动跳过下一个 token
- #9 Ghost text 位置随终端滚动/渲染更新:注册 term.onRender 回调
- #13 Escape 键不再拦截 vi-mode:仅在 popup 可见时消费 Escape,
  ghost text 显示时不拦截(ghost text 是被动的,不应阻止 shell 交互)
- #14 所有 setState 统一使用 EMPTY_STATE 常量,不再遗漏 expandUpward 字段

架构修复:
- #3 消除 CustomEvent 通信:改为 onAcceptText 回调注入,
  Terminal.tsx 直接传 writeToSession 回调给 hook,
  删除 createXTermRuntime 中约 20 行 listener 代码和 cleanupAutocompleteListener 字段
- #7 xterm 私有 API 访问集中到 xtermUtils.ts:getCellDimensions 统一入口,
  带缓存机制,仅在首次访问或 terminal 切换时触发 DOM 测量
- #16 删除 getCommandNameSuggestions 中多余的动态自导入 await import("./figSpecLoader")

性能修复:
- #5 合并 ghost text 和 popup 的查询路径:删除独立的 getInlineSuggestion,
  fetchSuggestions 只调一次 getCompletions,ghost text 取 completions[0]
- #10 preloadCommonSpecs 分批加载(每批 8 个,requestIdleCallback 间隔),
  延迟 200ms 启动,且检查 enabled 才执行
- #11 scoreEntry 改为 scoreEntryAt(entry, now),now 在查询开始时缓存一次
- #15 scrollIntoView 从 smooth 改为 instant,消除快速导航动画排队
- #19 loadSpec 添加 in-flight 去重(inFlightLoads Map),同一 spec 并发加载只触发一次 import
- #20 存储满时淘汰改为按 score 排序后保留前半,而非按插入顺序

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

* fix: 修复二审发现的全部 10 个问题

功能修复:
- #1(高) insertSuggestion 改用实时 detectPrompt 而非过时的 lastPromptRef,
  修复用户继续打字后 Tab 选择建议导致字符重复插入的 bug
- #2(中) handleInput Enter 录入历史优先用实时 detectPrompt,
  修复快速打字场景下 recordCommand 记录不完整命令
- #9 suppressNextEnterRecordRef 添加 100ms 安全超时清除,防止 flag 残留
- #10 getNextWord 从 index 1 开始搜索分隔符,修复 ghost text 以 / 开头时
  一次接受全部而非逐段的问题

性能修复:
- #3(中) GhostTextAddon 注册 term.onResize 调用 invalidateCellDimensionCache,
  确保 resize/字体变化后 cell 尺寸缓存正确失效
- #4 updatePosition 缓存 lastLeft/lastTop,位置无变化时跳过 DOM 写入;
  字体属性移到 show() 中只设置一次,不再每帧写 6 个 style
- #5 统一 clearState() 函数替代所有 setState({...EMPTY_STATE}),
  带 popupVisible 守卫避免无效 re-render
- #6 hasSpec 中 specs.includes() 改为 Set.has(),O(1) 查找

架构修复:
- #7 Terminal.tsx 中 autocompleteAcceptTextRef 去掉多余的 useCallback 包装
- #8 删除 AutocompletePopup 的 onClose 死代码 prop

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

* fix: popup 默认不选中任何项,用户按 ↑/↓ 后才选中

修复输入 ls 等简单命令时回车误执行联想结果的问题:
- selectedIndex 初始为 -1(无选中),Enter 直接执行用户输入的命令
- 用户按 ↑/↓ 导航后 selectedIndex >= 0,此时 Enter 才执行选中的建议
- Tab 仍然可以直接接受第一条建议(主动接受行为)
- Enter 无选中时关闭 popup 并让按键透传到终端

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

* fix: fig spec 改为从静态资源 fetch 加载,修复生产构建中补全不工作

根因:@vite-ignore 动态 import 在 Electron 生产构建中无法解析
node_modules 路径(app:// 协议只能访问 dist/ 目录)。

修复方案(与 Monaco 编辑器相同的模式):
- 新增 scripts/copy-fig-specs.cjs,prebuild 时将全部 739 个 fig spec
  从 node_modules/@withfig/autocomplete/build/ 复制到 public/fig-specs/
- Vite 自动将 public/ 内容复制到 dist/,app:// 协议可以正常访问
- figSpecLoader.ts 改用 fetch + Blob URL + dynamic import 加载 spec,
  同时保留 @vite-ignore import 作为 fallback(兼容 dev 模式)
- public/fig-specs 加入 .gitignore(构建时生成,不进版本控制)

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

* fix: ESLint 忽略 public/fig-specs 目录(第三方生成代码)

与 public/monaco 相同的处理方式——这些是从 node_modules 复制的
第三方构建产物,不应被项目 ESLint 规则检查。

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

* fix: 输入完整子命令名时展示其选项(如 git commit 显示 --message 等)

当 currentToken 完全匹配一个子命令时(如 "git commit" 中的 "commit"),
导航进入该子命令并展示其 options 和 sub-subcommands 作为预览。

之前的逻辑因为 name !== currentToken 过滤掉了完全匹配的项,
且 resolveSpecContext 的 consumedTokens 不包含当前 token,
导致停留在父级而看不到子级的选项。

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

* fix: 修复 fig spec index.js 解析失败导致补全不工作

根因:index.js 格式为 var e=[...],diffVersionedCompletions=[...];
正则 /var\s+\w+\s*=\s*(\[[\s\S]*?\]);/ 要求 ] 后紧跟 ;,
但第一个数组后面是 , 不是 ;,导致非贪婪匹配跳到第二个 ];,
捕获了两个数组拼在一起,JSON.parse 失败,spec 列表为空。

修复:改用 indexOf 找第一个 [ 和对应的 ],直接截取子串解析。
fig spec 的 index 是简单的字符串平坦数组,无嵌套括号。

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

* fix: fig spec 改用 URL 直接 dynamic import,移除 fetch+Blob 方案

fetch + Blob URL + import() 方案可能被 Electron CSP 策略阻止。
改为直接用完整 URL 做 dynamic import:
- dev: import("http://localhost:5173/fig-specs/git.js")
- prod: import("app://./fig-specs/git.js")

两种环境下动态 import 都能正常解析模块,无需 fetch 中间步骤。
同时简化 getAvailableSpecs 也用同样方式,移除 fetch+正则解析。

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

* fix: fig spec 改为通过 Electron IPC 加载,彻底解决 dev/prod 加载问题

之前的方案(静态文件 + dynamic import / fetch + Blob URL)都因为
Vite dev server 对 .js 文件的模块转换和 Electron CSP 限制而失败。

新方案:通过 main process 的 Node.js require() 加载 fig spec,
通过 IPC 传给 renderer:
- main.cjs: 添加 netcatty:figspec:list 和 netcatty:figspec:load handler
- preload.cjs: 暴露 listFigSpecs() 和 loadFigSpec() API
- figSpecLoader.ts: 通过 window.netcatty bridge 调用 IPC

优势:
- main process 直接访问 node_modules,dev 和 production 都可靠
- 无需复制文件到 public/、无需 @vite-ignore hack
- spec 数据通过 IPC 序列化传输,无 CSP 限制
- 删除了 scripts/copy-fig-specs.cjs 和 public/fig-specs/ 相关代码

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

* fix: main process fig spec 加载改用 import() 替代 require()

@withfig/autocomplete 是 ESM 包("type": "module"),
CommonJS 的 require() 无法加载 ESM 模块会抛 ERR_REQUIRE_ESM。
改用 dynamic import() 在 async handler 中加载。

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

* fix: fig spec 加载用 pathToFileURL 绕过 package.json exports 限制

@withfig/autocomplete 的 exports 字段只允许 import "." 和 "./dynamic",
Node.js 严格遵守 exports map 拒绝解析 build/git.js 等子路径。

改为手动拼接文件绝对路径 + pathToFileURL 转换为 file:// URL 后 import,
完全绕过 Node.js 的 package exports 限制。

同时修复 promptDetector 不再 trim 尾部空格(用 cursorX 确定实际输入长度),
确保 "git commit " 的尾部空格被保留,触发空 token 显示选项列表。

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

* feat: 补全菜单添加详情面板 + 清理调试日志

- 选中或悬停补全项时,右侧显示详情面板(类似 VS Code IntelliSense)
  - 显示完整命令名、来源类型标签(Option/Subcommand/History 等)
  - 显示完整的描述文本(不再截断)
- source 标记移到左侧,与描述分离,更易读
- 悬停和键盘选中都能触发详情面板
- 向上展开时详情面板也正确对齐
- 清理所有临时调试 console.log

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

* chore: 清理全部调试日志

移除 autocomplete 模块中所有临时 console.log 调试语句,
仅保留 figSpecLoader 中的 console.warn 用于真实错误报告。

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

* fix: 三审问题修复 — 移除多余 prop、过滤子路径 spec、防路径遍历

1. 移除 Terminal.tsx 传给 AutocompletePopup 的多余 onClose prop
2. getCommandNameSuggestions 过滤含 / 的 spec 名(aws/s3 等不是直接命令)
3. figspec:load IPC handler 添加 .. 路径遍历检查

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

* fix: Codex review 5 个问题全部修复

1. [P1] fuzzy 匹配建议不以 userInput 开头时,用 Ctrl+U 清行再写入完整命令,
   避免 substring 截断产生损坏的命令行
2. [P2] Ghost addon 初始化改用 polling 等待 termRef,解决首次挂载时
   termRef.current 为 null 导致 ghost text 永远不激活的问题
3. [P2] popup overlay 改为 pointer-events-none 透传,仅 popup 自身设
   pointer-events: auto,不再阻止终端区域的鼠标交互
4. [P2] getCompletions 异步返回后重新 detectPrompt 校验输入是否已变,
   丢弃过时的补全结果避免覆盖新状态
5. [P2] prompt 检测支持折行:当 line.isWrapped 时向上回溯查找 prompt 行,
   拼接多行内容作为完整 userInput

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

* fix: Codex review 第二轮 3 个问题修复

1. [P2] broadcast 模式下 autocomplete 插入也触发广播 —
   onAcceptText 回调中调用 onBroadcastInputRef 通知其他 session
2. [P2] 支持无尾随空格的 prompt(如 cmd.exe C:\path>)—
   prompt 字符后允许直接是行尾,boundary 为 i+1
3. [P2] 光标移动 escape 序列(Left/Home/End)清除过时建议 —
   不再静默忽略,改为 clearState() 清除 popup 和 ghost text

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

* fix: Codex review 第三轮 3 个问题修复

1. [P2] commandBufferRef 处理 Ctrl+U 清行 — fuzzy 匹配发送 \x15 时
   重置 buffer,避免 onCommandExecuted 记录错误的拼接命令
2. [P2] fetchVersionRef 递增计数器废弃过时异步结果 — clearState/Escape
   关闭 popup 时 bump version,getCompletions 返回后检查 version 匹配,
   防止已关闭的 popup 被旧请求重新打开
3. [P2] prompt scanLimit 从 80 提高到 200 — 支持包含 git branch、
   kube context、长路径的 prompt,超过 80 列不再失效

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

* fix: Codex review 第四轮 3 个问题修复

1. [P1] 拒绝绝对路径 — figspec:load IPC handler 检查 commandName
   不以 / 或 \ 开头,防止 path.join 丢弃前缀导致任意 JS 执行
2. [P1] cmd.exe prompt > 后不要求空格 — 对 > ❯ ➜ › 等 prompt 字符
   不强制要求后跟空格,支持 C:\src>dir 格式
3. [P2] serial line mode 下 autocomplete 走 serialLineBufferRef —
   在串口 lineMode 时不直接 writeToSession,而是缓冲到 line buffer
   并处理 local echo,与正常按键输入行为一致

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

* fix: Codex review 第五轮 — translateToString(false) 保留尾部空格

translateToString(true) 会 trim 行尾空格,导致 cursorX 截取的
userInput 与实际行内容不一致。改为 translateToString(false) 保留
原始空格,确保 "git commit " 的尾部空格被正确保留用于触发选项补全。

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

* feat: 设置页添加自动补全开关(启用/Ghost Text/弹出菜单)

在终端设置页末尾新增「自动补全」区域,包含三个开关:
- 启用自动补全:总开关
- 行内建议(Ghost Text):光标后灰色建议文本
- 弹出菜单:浮动补全列表

子开关在总开关关闭时 disabled。中英文 i18n 翻译齐全。

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

* fix: Codex review 第六轮 3 个问题修复

1. [P1] 光标不在行尾时禁止补全 — 检测 cursorX 后方是否有字符,
   有则 clearState 不显示建议,避免 mid-line 插入导致文本重复
2. [P2] Enter 录入历史改为先尝试实时 detectPrompt,失败则 fallback
   到 lastPromptRef 缓存,应对高延迟 SSH 下 buffer 未回显的情况
3. [P2] fuzzy 替换在 Windows host 上用退格清行而非 Ctrl+U —
   cmd.exe/PowerShell 不支持 Ctrl+U,改为发送 \b 退格序列

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

* fix: Codex review 第七轮 — commandBuffer 退格处理 + 接受后历史记录

1. [P2] commandBufferRef 处理 \b 退格 — Windows fuzzy 替换用退格
   清行时正确移除 buffer 末尾字符,避免记录拼接错误的命令
2. [P3] lastAcceptedCommandRef 追踪接受的补全文本 — Tab/→ 接受后
   立即 Enter 时用追踪值录入历史,不依赖可能未回显的 buffer

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

* fix: Codex review 第八轮 — 历史记录准确性 + 设置同步

1. [P2] 用户继续编辑后清除 lastAcceptedCommandRef — Tab 接受
   "git status" 后追加 " --short" 再 Enter 时记录完整编辑后的命令
2. [P2] Ghost text →/Tab 接受路径也设置 lastAcceptedCommandRef —
   确保所有接受路径在快速 Enter 时都能准确记录命令
3. [P2] autocomplete 设置加入 SYNCABLE_TERMINAL_KEYS —
   跨设备同步时保留自动补全偏好

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

* fix: Codex review 第九轮 — REPL 误识别 + 本地终端 OS 检测

1. [P1] local terminal 的 hostOs 改用 navigator.platform 检测实际 OS,
   避免 Windows 上 fallback 到 "linux" 导致 Ctrl+U 清行失败
2. [P2] 回退 > 无条件接受改动,恢复要求 > 后跟空格或行尾 —
   避免 python >>>、mysql>、sqlite> 等 REPL 被误识别为 shell prompt
3. 新增 REPL NON_PROMPT_PATTERNS:>>>(python)和 word>(mysql/redis)

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

* fix: Codex review 第十轮 4 个问题修复

1. [P1] cmd.exe prompt C:\path> — 对 > 特判:前面是 \ 或 / 时允许无空格,
   避免误匹配 REPL(python>>>、mysql>)的同时支持 Windows cmd prompt
2. [P2] serial lineMode autocomplete 不再 early return — fall through 到
   共享的 commandBuffer/broadcast 更新逻辑
3. [P2] serial 字符模式 + localEcho 时 autocomplete 插入文本也本地回显
4. [P3] 运行时关闭 autocomplete 时调用 clearState() 清除已显示的 popup

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

* fix: Codex review 第十一轮 — option args、PS2 误识别、bridge 缓存

1. [P2] resolveSpecContext 返回 option 的 args — 当光标在 option 参数
   位置时(如 git archive --format |),返回该 option 的 args 而非
   subcommand 的 args,使 tar/zip 等枚举值能正确补全
2. [P2] 排除 bare > 作为 shell prompt — bash PS2 续行提示 > 加入
   NON_PROMPT_PATTERNS,避免在多行命令续行和 REPL 中误触发补全
3. [P3] bridge 不存在时不缓存 null — preload 时 bridge 可能未就绪,
   缓存 null 会永久禁用该命令的 spec 补全

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

* fix: Codex review 第十二轮 — prompt 检测取最后一个分隔符

Starship/Powerlevel10k 等 prompt 包含多个 prompt 字符
(如 ➜  repo git:(main) $),之前在第一个 ➜ 就停了,
把后续 prompt 文本当成用户输入。

改为收集所有候选 prompt 边界,返回最后一个。确保
"➜  repo git:(main) $ ls" 中 userInput 正确为 "ls"。

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

* fix: Codex review 第十三轮 — prompt 搜索范围限制 + cmd.exe 路径

1. [P2] prompt 扫描限制在行前 60% — 避免 "echo foo > bar" 中的
   重定向符 > 被当作 prompt 结束(prompt 不会出现在行尾部分)
2. [P3] cmd.exe 路径检测扩展 — 除了 \ / 前缀,也检测行首是否有
   驱动器号 (X:) 模式,支持 C:\Users\me> 等标准 Windows prompt

P1 (高延迟 SSH buffer 滞后) 和 P2 (Enter 时 stale prompt) 属于
prompt 检测方案的固有局限,根本解决需要 OSC 133 Shell Integration,
不在本 PR 范围内。已有 lastAcceptedCommandRef fallback 缓解。

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 19:45:34 +08:00
陈大猫
fa29515095 feat: SFTP 全局书签支持 (#529) (#530)
* feat: add global SFTP bookmarks shared across all hosts (#529)

- Add global bookmark support with separate localStorage storage
- Global bookmarks appear on all hosts with a globe icon indicator
- "+Global" button in bookmark popover to save path as global
- Global bookmarks sorted before host-specific bookmarks
- Improve SFTP error display: use Unplug icon, refined styling,
  auto-expand connection logs on error

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

* fix: toggle bookmark correctly removes global-only bookmarks

When a path is only globally bookmarked, the toggle button now
removes the global bookmark instead of creating a duplicate host one.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 19:19:33 +08:00
陈大猫
34f9d2a663 chore: 死代码清理与架构分层修复 (#524)
* chore: 移除死代码并修复架构分层违规

- 删除未使用的 ACP 模块 (infrastructure/ai/acp/)
- 删除未使用的 AI 组件 (ExecutionPlan, PermissionDialog)
- 将 syncPayload.ts 从 domain 移至 application 层,修复分层违规
- 移除未使用的导出 (useSecurityState, useProviderStatus, GitHubAuthState,
  getAgentCommandLabel, ImageAttachment, HotkeyActions)
- 收窄 Electron bridge module.exports,移除未使用的导出函数
- 将仅内部使用的函数/类型取消导出 (isSupportedLocale, SyncDashboard)

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

* chore: 二次审查清理 — 移除更多死代码和架构违规

- 移除未使用的 ConversationEmptyState 组件和类型
- 移除未使用的 PromptInputSelect 系列组件 (5 个导出)
- 移除 global.d.ts 中残留的 SMBConfig 类型和 cloudSyncSmb* 方法声明
- 移除 useAutoSync.ts 中未使用的 toast 导入 (同时修复 application→components 反向依赖)
- 清理因删除而产生的多余 import

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

* chore: 消除直接 localStorage 访问,提取 safeSend 共享工具

localStorage 集中化:
- 新增 storageKeys 常量: SIDE_PANEL_WIDTH, PF_RECONNECT_CANCEL, DEBUG_HOTKEYS, DEBUG_UPDATE_DEMO
- TerminalLayer/SettingsApplicationTab/App.tsx/useUpdateCheck 改用 localStorageAdapter
- CloudSyncManager 内部方法改用 localStorageAdapter
- portForwardingService 改用 localStorageAdapter + 集中 key

safeSend 去重:
- 新增 electron/bridges/ipcUtils.cjs 共享模块
- sshBridge/sftpBridge/portForwardingBridge/sshAuthHelper/aiBridge 统一引用

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

* chore: 终审清理 — 移除未使用的 require 和废弃类型别名

- 移除 sftpBridge.cjs 中未使用的 require("node:net")
- 移除 aiBridge.cjs 中未使用的 require("node:path")
- 移除 types.ts 中已废弃的 ChatMessageImage 类型别名

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

* fix: 修复 ESLint 错误 — 组件不再直接导入 infrastructure

- 新增 useStoredNumber hook,TerminalLayer 通过 hook 访问侧边栏宽度
- SettingsApplicationTab 的 isUpdateDemoMode 改为从 useUpdateCheck hook 传入
- 移除 useCloudSync.ts 中未使用的 CloudSyncManager 导入和 GitHubAuthState 接口

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

* chore: 提取 notification port,消除 application 层对 components 的依赖

将 toast 通知抽象为 application/notification.ts 端口,
UI 层通过 setNotify 注入实现,useAutoSync 改用 notify 接口。

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 14:14:37 +08:00
陈大猫
90d161c1b5 refactor: 精简 MCP server 工具集,移除 SFTP/multiExec/terminalWrite
精简 ACP agent 工具集,与 Catty Agent 保持一致,只保留核心工具:
- get_environment
- terminal_execute
- terminal_send_input

移除内容:
- 7 个 sftp_* 工具 (sftp_list_directory, sftp_read_file, sftp_write_file,
  sftp_mkdir, sftp_remove, sftp_rename, sftp_stat)
- multi_host_execute 工具
- ENABLE_SFTP_TOOLS 环境变量和 sftpAvailable 字段
- WRITE_METHODS 中的 sftp/multiExec 条目
- dispatch 中的 sftp/multiExec 路由和 multiExec scope 验证
- mcpServerBridge 中的 sessionSupportsSftp/scopeHasSftpSessions 函数
- getContext description 中的 SFTP 说明

bridge 层的 SFTP/multiExec handler 函数保留(UI SFTP 面板仍在使用)。

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 12:51:43 +08:00
陈大猫
7a5b6f506e feat: Catty Agent 支持串口会话命令执行 (#520)
* feat: Catty Agent 支持串口会话命令执行 (#520)

串口连接的网络设备(华为交换机、Cisco 路由器等)使用厂商自有 CLI,
无法识别 Agent 原有的 shell 包裹语法(__NCMCP_ markers、eval、trap)。

新增 execViaRawPty 函数,直接发送原始命令到串口,通过 idle timeout
检测命令完成,无 shell 语法包裹。

- 新增 execViaRawPty:原始命令执行,2s idle timeout 检测完成
- terminalBridge: 串口 session 添加 protocol/shellKind 字段
- mcpServerBridge: handleGetContext 发现串口会话,handleExec/handleTerminalWrite 支持串口
- aiBridge: ai:exec 和 ai:terminal:write 增加 serialPort 分支
- systemPrompt: Agent 提示词增加串口会话使用指南

Closes #520

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

* fix: review 问题全量修复

P1:
- handleExec 移除死代码(内层 if 条件永远为 true)
- 串口会话跳过 shell 安全黑名单(shutdown 在 Cisco 是正常接口命令)
- MCP tool 描述更新:terminal_execute/get_environment/multi_host_execute 不再只说 "shell command"
- 串口检查增加 protocol === "serial" guard,不再纯靠 duck typing

P2:
- execViaRawPty 编码改为 latin1,与 terminalBridge 终端解码一致
- exitCode 改为 null(而非 -1),MCP 响应中 null 时不输出 exit code 行
- idle timer 改为收到第一个数据后才启动,避免慢设备超时返回空输出
- idle timeout 默认从 2s 调为 3s,适配低速串口
- serialPort.write 统一用 safeWrite 包裹 try-catch
- echo 剥离仅在 lines.length > 1 时执行,避免误删唯一输出行

P3:
- cancelKey 用简单自增序列替代 crypto.randomBytes
- serialPort.on 前增加 typeof 检查
- finish 函数签名差异增加注释说明

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

* fix: 第二轮 review 问题修复

P2:
- MCP server terminal 类工具 (terminal_execute/terminal_send_input/multi_host_execute)
  跳过 blocklist,由 bridge 层做 session-aware 检查,解决串口 shutdown 等命令
  在 MCP 层就被拦截的问题
- handleTerminalWrite (mcpServerBridge + aiBridge) 串口会话跳过 blocklist,
  与 handleExec 保持一致
- handleMultiExec 移除外层 blocklist,每个 session 由 handleExec 独立检查
- 移除 execViaRawPty 中的死代码 receivedFirstChunk 变量
- handleGetContext 返回的 description 补充 serial 会话说明

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

* fix: Codex review 问题修复

- [P2] toolExecutors.ts executeTerminalExecute 也需要跳过串口 blocklist,
  否则 Catty Agent renderer 侧的 checkCommandSafety 会在命令到达 bridge
  之前拦截 shutdown 等合法设备命令
- [P2] execViaRawPty 增加 noResponseTimer,无输出命令(enable、
  configure terminal 等)不再等满 60s 整体超时,而是 2×idleMs 后正常返回
- [P1] 串口 blocklist skip 设计决策加注释:serial 协议由用户主动选择,
  如果串口连的是 Linux shell 应使用 local 协议

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

* fix: Codex 第二轮 review 修复

- [P2] noResponseTimer 从 2×idleMs 调整为 min(idleMs*4, timeoutMs/4),
  默认 12s,避免截断慢速网络设备操作
- [P1] 串口 blocklist skip 设计说明扩充:serial 协议由用户主动选择,
  且 execViaRawPty 不做 shell 解释,blocklist 中的 shell 元字符
  即使发到串口连接的 Linux shell 也不会被解释执行

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

* fix: execViaRawPty echo 阶段使用更长的 idle timeout

ping/traceroute/copy 等命令在回显后可能沉默数秒才产出真正输出。
引入 chunkCount 区分 echo 阶段(前 2 个 chunk)和正式输出阶段:
echo 阶段使用 2×idleMs(默认 6s),正式输出阶段使用 idleMs(3s)。
避免在回显后就误判命令已完成导致输出截断。

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

* fix: noResponseTimer 增加无输出提示

设备无响应时返回提示信息 "(no output received — command may have
completed silently or may still be running)",让 AI 知道命令可能
仍在执行,避免误认为命令已成功完成后立即发送下一条命令。

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

* fix: Codex 第 5 轮 review 修复

- [P2] terminal_send_input 串口写入时将 \n 转换为 \r,
  网络设备期望 CR 作为回车而非 LF
- [P2] execViaRawPty 增加 512KB 输出上限,达到上限后停止
  重置 idle timer,避免 noisy session(持续发日志的设备)
  导致命令永远无法完成

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 12:04:17 +08:00
陈大猫
c49346f6cc fix: 编辑器查找/替换输入框无法粘贴内容 (#512) (#515)
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
自定义粘贴处理器拦截了所有 Ctrl+V 事件,包括查找/替换控件内的输入框。
当焦点在 .find-widget 内时,改为读取剪贴板并直接插入到输入框中,
而非将内容粘贴到编辑器主体。

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

* feat: 沉浸模式默认开启

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #474

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #463

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

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

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

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

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

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

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

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

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

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

The same issue existed for SFTP connections using encrypted keys.

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

Closes #463

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

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

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

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

Closes #452

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

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

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

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

* fix: truncate long hostnames in connection dialog

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

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

* fix: constrain connection dialog header so truncate works correctly

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

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

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

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

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

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

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

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

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

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

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

---------

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

Inspired by #467 (@crawt).

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

Inspired by #466 (@crawt).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

---------

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

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

Closes #463

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

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

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

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

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

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

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

---------

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

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

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

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

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

Fixes #455

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

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

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

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

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

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

Fixes #458

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

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

Fixes #456

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #446, closes #448

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

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

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

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

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

* chore: remove 43 unused exports and dead type definitions

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

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

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

---------

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

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

Closes #424

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

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

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

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

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

Two fixes for workspace focus-switching issues:

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

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

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

* fix: allow SFTP focus switching during file transfers

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

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

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

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

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

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

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

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

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

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

* perf: eliminate redundant stat calls before file transfers

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

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

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

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

* perf: show immediate progress feedback during transfer setup

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

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

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

* fix: show accurate transfer progress instead of simulated values

The progress system had fundamental issues:

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

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

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

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

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

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

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

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

* perf: reduce SFTP transfer concurrency from 64 to 4

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

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

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

* fix: address review findings from PR #443

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

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

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

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

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

Two P1 fixes from automated review:

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

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

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

---------

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

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

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

Closes #440

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

Ref #436

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

Closes #435

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

Closes #434

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

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

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

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

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

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

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

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

* Refine terminal connection dialog visuals

* Polish terminal connection dialog layout

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

---------

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

* feat: add reset to global terminal appearance

* fix: preserve legacy host appearance overrides

* fix: show legacy appearance reset controls

* refactor: reorder terminal global reset actions

* refactor: present global theme as theme option

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

* refactor: remove unused legacy SFTP modal

* fix: use directory picker for SFTP folder downloads

* fix: wire folder downloads through SFTP side panel

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

* feat: show hybrid progress for SFTP folder downloads

* feat: parallelize SFTP folder downloads

* feat: adapt SFTP folder download concurrency by file size

* feat: pool isolated channels for fast SFTP downloads

* fix: address SFTP download review findings

* fix: wait for in-flight fast download channels

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

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

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

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

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

* chore: remove old approval flow code

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: eliminate PTY wrapper echo leakage and duplicate prompts

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

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

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

* fix: surface denied inline approvals as errors in UI

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

* Fix local AI session metadata and shell safety

* Fix local session cloning and multi-exec errors

* Refactor local shell detection helpers

* Fix local shell helper import path

* Fix CJS imports in renderer

* Use ESM local shell helpers in renderer

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

* Fix local AI session metadata and shell safety

* Fix local session cloning and multi-exec errors

* Refactor local shell detection helpers

* Fix local shell helper import path

* Fix CJS imports in renderer

* Use ESM local shell helpers in renderer

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

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

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

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

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

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

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

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

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

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

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

* fix: use transform scale for smooth zoom animation

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

Closes #400

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 16:24:29 +08:00
242 changed files with 25118 additions and 12568 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:

497
App.tsx
View File

@@ -1,6 +1,7 @@
import React, { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { Suspense, lazy, useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react';
import { activeTabStore, useActiveTabId, useIsSftpActive, useIsTerminalLayerVisible, useIsVaultActive } from './application/state/activeTabStore';
import { useAutoSync } from './application/state/useAutoSync';
import { useImmersiveMode } from './application/state/useImmersiveMode';
import { useManagedSourceSync } from './application/state/useManagedSourceSync';
import { usePortForwardingAutoStart } from './application/state/usePortForwardingAutoStart';
import { usePortForwardingState } from './application/state/usePortForwardingState';
@@ -14,10 +15,15 @@ import { initializeUIFonts } from './application/state/uiFontStore';
import { I18nProvider, useI18n } from './application/i18n/I18nProvider';
import { matchesKeyBinding } from './domain/models';
import { resolveHostAuth } from './domain/sshAuth';
import { applySyncPayload } from './domain/syncPayload';
import { resolveHostTerminalThemeId } from './domain/terminalAppearance';
import { collectSessionIds } from './domain/workspace';
import { TERMINAL_THEMES } from './infrastructure/config/terminalThemes';
import { useCustomThemes } from './application/state/customThemeStore';
import { applySyncPayload } from './application/syncPayload';
import { getCredentialProtectionAvailability } from './infrastructure/services/credentialProtection';
import { netcattyBridge } from './infrastructure/services/netcattyBridge';
import { localStorageAdapter } from './infrastructure/persistence/localStorageAdapter';
import { STORAGE_KEY_DEBUG_HOTKEYS } from './infrastructure/config/storageKeys';
import { TopTabs } from './components/TopTabs';
import { Button } from './components/ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from './components/ui/dialog';
@@ -28,7 +34,8 @@ import { VaultView, VaultSection } from './components/VaultView';
import { KeyboardInteractiveModal, KeyboardInteractiveRequest } from './components/KeyboardInteractiveModal';
import { PassphraseModal, PassphraseRequest } from './components/PassphraseModal';
import { cn } from './lib/utils';
import { ConnectionLog, Host, HostProtocol, SerialConfig, TerminalTheme } from './types';
import { classifyLocalShellType } from './lib/localShell';
import { ConnectionLog, Host, HostProtocol, SerialConfig, TerminalSession, TerminalTheme } from './types';
import { LogView as LogViewType } from './application/state/useSessionState';
import type { SftpView as SftpViewComponent } from './components/SftpView';
import type { TerminalLayer as TerminalLayerComponent } from './components/TerminalLayer';
@@ -97,8 +104,7 @@ const LazyCreateWorkspaceDialog = lazy(() =>
const IS_DEV = import.meta.env.DEV;
const HOTKEY_DEBUG =
IS_DEV &&
typeof window !== "undefined" &&
window.localStorage?.getItem("debug.hotkeys") === "1";
localStorageAdapter.readString(STORAGE_KEY_DEBUG_HOTKEYS) === "1";
const LazySftpView = lazy(() =>
import('./components/SftpView').then((m) => ({ default: m.SftpView })),
@@ -171,6 +177,7 @@ function App({ settings }: { settings: SettingsState }) {
const {
setTheme,
resolvedTheme,
terminalThemeId,
setTerminalThemeId,
currentTerminalTheme,
terminalFontFamilyId,
@@ -186,13 +193,26 @@ function App({ settings }: { settings: SettingsState }) {
sftpShowHiddenFiles,
sftpUseCompressedUpload,
sftpAutoOpenSidebar,
sftpDefaultViewMode,
editorWordWrap,
setEditorWordWrap,
sessionLogsEnabled,
sessionLogsDir,
sessionLogsFormat,
reapplyCurrentTheme,
immersiveMode,
workspaceFocusStyle,
} = settings;
// Sync workspace focus indicator style to DOM for CSS targeting
useEffect(() => {
if (workspaceFocusStyle === 'border') {
document.documentElement.setAttribute('data-workspace-focus', 'border');
} else {
document.documentElement.removeAttribute('data-workspace-focus');
}
}, [workspaceFocusStyle]);
const {
hosts,
keys,
@@ -270,6 +290,74 @@ function App({ settings }: { settings: SettingsState }) {
// isMacClient is used for window controls styling
const isMacClient = typeof navigator !== 'undefined' && /Mac|Macintosh/.test(navigator.userAgent);
// ---------------------------------------------------------------------------
// Immersive Mode — derive UI chrome colors from the active terminal's theme
// ---------------------------------------------------------------------------
const activeTabId = useActiveTabId();
const customThemes = useCustomThemes();
// Resolve the effective TerminalTheme for the currently focused terminal tab
const hostById = useMemo(
() => new Map(hosts.map((host) => [host.id, host])),
[hosts],
);
const sessionById = useMemo(
() => new Map(sessions.map((session) => [session.id, session])),
[sessions],
);
const workspaceById = useMemo(
() => new Map(workspaces.map((workspace) => [workspace.id, workspace])),
[workspaces],
);
const themeById = useMemo(
() => new Map([...customThemes, ...TERMINAL_THEMES].map((theme) => [theme.id, theme])),
[customThemes],
);
const activeTerminalTheme = useMemo<TerminalTheme | null>(() => {
if (activeTabId === 'vault' || activeTabId === 'sftp') return null;
const resolveTheme = (s: TerminalSession): TerminalTheme => {
const host = hostById.get(s.hostId) ?? null;
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
return themeById.get(themeId) || currentTerminalTheme;
};
// Workspace
const workspace = workspaceById.get(activeTabId);
if (workspace) {
// Focus mode: use the focused (or first remaining) session's theme
if (workspace.viewMode === 'focus') {
const wsSessionIds = collectSessionIds(workspace.root);
const focused = (workspace.focusedSessionId
? sessionById.get(workspace.focusedSessionId)
: null)
?? wsSessionIds.map((id) => sessionById.get(id)).find(Boolean);
return focused ? resolveTheme(focused) : null;
}
// Split mode: require all sessions to share the same theme
const sessionIds = collectSessionIds(workspace.root);
const wsSessions = sessionIds
.map((id) => sessionById.get(id))
.filter(Boolean) as TerminalSession[];
if (wsSessions.length === 0) return null;
const firstTheme = resolveTheme(wsSessions[0]);
const allSame = wsSessions.every(s => resolveTheme(s).id === firstTheme.id);
return allSame ? firstTheme : null;
}
// Single session tab
const session = sessionById.get(activeTabId);
if (!session) return null;
return resolveTheme(session);
}, [activeTabId, currentTerminalTheme, hostById, sessionById, themeById, workspaceById]);
useImmersiveMode({
isImmersive: immersiveMode,
activeTabId,
activeTerminalTheme,
restoreOriginalTheme: reapplyCurrentTheme,
});
// Get port forwarding rules and import function
const { rules: portForwardingRules, importRules: importPortForwardingRules, startTunnel, stopTunnel } = usePortForwardingState();
@@ -315,10 +403,148 @@ function App({ settings }: { settings: SettingsState }) {
}, [handleSyncNow]);
// Update check hook - checks for new versions on startup
const { updateState, dismissUpdate, openReleasePage, installUpdate } = useUpdateCheck();
const { updateState, dismissUpdate, installUpdate } = useUpdateCheck();
// Window controls - must be before update toast effect which uses openSettingsWindow
const { openSettingsWindow } = useWindowControls();
const _handleTrayJumpToSession = useEffectEvent((sessionId: string) => {
const session = sessions.find((item) => item.id === sessionId);
if (session?.workspaceId) {
setActiveTabId(session.workspaceId);
setWorkspaceFocusedSession(session.workspaceId, sessionId);
return;
}
setActiveTabId(sessionId);
});
const _handleTrayTogglePortForward = useEffectEvent((ruleId: string, start: boolean) => {
const rule = portForwardingRules.find((item) => item.id === ruleId);
if (!rule) return;
const host = rule.hostId ? hosts.find((item) => item.id === rule.hostId) : undefined;
if (!host) {
toast.error(t("pf.error.hostNotFound"));
return;
}
if (start) {
void startTunnel(rule, host, hosts, keys, identities, (status, error) => {
if (status === "error" && error) toast.error(error);
}, rule.autoStart);
return;
}
void stopTunnel(ruleId);
});
const _handleTrayPanelConnect = useEffectEvent((hostId: string) => {
const host = hosts.find((item) => item.id === hostId);
if (!host) {
toast.error(t("pf.error.hostNotFound"));
return;
}
const { username, hostname: localHost } = systemInfoRef.current;
if (host.protocol === 'serial') {
const portName = host.hostname.split('/').pop() || host.hostname;
const sessionId = connectToHost(host);
addConnectionLog({
sessionId,
hostId: host.id,
hostLabel: host.label || `Serial: ${portName}`,
hostname: host.hostname,
username,
protocol: 'serial',
startTime: Date.now(),
localUsername: username,
localHostname: localHost,
saved: false,
});
return;
}
const protocol = host.moshEnabled ? 'mosh' : (host.protocol || 'ssh');
const resolvedAuth = resolveHostAuth({ host, keys, identities });
const sessionId = connectToHost(host);
addConnectionLog({
sessionId,
hostId: host.id,
hostLabel: host.label,
hostname: host.hostname,
username: resolvedAuth.username || 'root',
protocol: protocol as 'ssh' | 'telnet' | 'local' | 'mosh',
startTime: Date.now(),
localUsername: username,
localHostname: localHost,
saved: false,
});
});
const _handleGlobalHotkeyKeyDown = useEffectEvent((e: KeyboardEvent) => {
const isMac = hotkeyScheme === 'mac';
const target = e.target as HTMLElement;
const isFormElement = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
const isMonacoElement =
target instanceof HTMLElement &&
!!target.closest?.('.monaco-editor, .monaco-diff-editor, .monaco-inputbox');
const isXtermInput =
target instanceof HTMLElement &&
!!target.closest?.(".xterm, .xterm-helper-textarea, .xterm-screen, .xterm-viewport");
if ((isFormElement || isMonacoElement) && !isXtermInput && e.key !== 'Escape') {
return;
}
const isTerminalElement =
target instanceof HTMLElement &&
!!target.closest?.(".xterm, .xterm-helper-textarea, .xterm-screen, .xterm-viewport");
const isTerminalInPath = Boolean(
e.composedPath?.().some(
(node) =>
node instanceof HTMLElement &&
(node.classList.contains("xterm") ||
node.classList.contains("xterm-helper-textarea") ||
node.classList.contains("xterm-screen") ||
node.classList.contains("xterm-viewport") ||
node.hasAttribute("data-session-id")),
),
);
for (const binding of keyBindings) {
const keyStr = isMac ? binding.mac : binding.pc;
if (!matchesKeyBinding(e, keyStr, isMac)) continue;
if (HOTKEY_DEBUG) console.log('[Hotkeys] Matched binding:', binding.action, keyStr);
if (binding.category === 'sftp') {
continue;
}
const terminalActions = ['copy', 'paste', 'selectAll', 'clearBuffer', 'searchTerminal'];
if (terminalActions.includes(binding.action)) {
if (isTerminalElement) {
return;
}
continue;
}
e.preventDefault();
e.stopPropagation();
if (HOTKEY_DEBUG) {
console.log('[Hotkeys] Global handle', {
action: binding.action,
key: e.key,
meta: e.metaKey,
ctrl: e.ctrlKey,
alt: e.altKey,
shift: e.shiftKey,
targetTag: target?.tagName,
isTerminalElement,
isTerminalInPath,
});
}
executeHotkeyAction(binding.action, e);
return;
}
});
const _handleEscapeKeyDown = useEffectEvent((e: KeyboardEvent) => {
if (e.key === 'Escape' && isQuickSwitcherOpen) {
setIsQuickSwitcherOpen(false);
}
});
// Show toast notification when update is available (only when auto-download is idle)
useEffect(() => {
@@ -350,7 +576,7 @@ function App({ settings }: { settings: SettingsState }) {
}, [updateState.hasUpdate, updateState.latestRelease, updateState.autoDownloadStatus, t, openSettingsWindow, dismissUpdate]);
// Track previous autoDownloadStatus so toast effects fire only on actual transitions,
// not when unrelated deps (openReleasePage, installUpdate) change their reference.
// not when unrelated deps (installUpdate, openSettingsWindow) change their reference.
const prevAutoDownloadStatusRef = useRef(updateState.autoDownloadStatus);
useEffect(() => {
const prev = prevAutoDownloadStatusRef.current;
@@ -373,23 +599,18 @@ function App({ settings }: { settings: SettingsState }) {
t('update.downloadFailed.message'),
{
title: t('update.downloadFailed.title'),
actionLabel: t('update.openReleases'),
onClick: () => openReleasePage(),
actionLabel: t('update.viewInSettings'),
onClick: () => void openSettingsWindow(),
}
);
}
}, [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]
);
}, [updateState.autoDownloadStatus, updateState.latestRelease?.version, t, installUpdate, openSettingsWindow]);
// Auto-start port forwarding rules on app launch
usePortForwardingAutoStart({
hosts,
keys: portForwardingKeys,
keys,
identities,
});
// Sync tray menu data + handle tray actions
@@ -430,111 +651,34 @@ function App({ settings }: { settings: SettingsState }) {
if (!bridge?.onTrayFocusSession || !bridge?.onTrayTogglePortForward) return;
const unsubscribeFocus = bridge.onTrayFocusSession((sessionId) => {
// Find the session to check if it belongs to a workspace
const session = sessions.find((s) => s.id === sessionId);
if (session?.workspaceId) {
// Session is in a workspace - navigate to workspace and focus the session
setActiveTabId(session.workspaceId);
setWorkspaceFocusedSession(session.workspaceId, sessionId);
} else {
// Standalone session or session not found - just set tab
setActiveTabId(sessionId);
}
_handleTrayJumpToSession(sessionId);
});
const unsubscribeToggle = bridge.onTrayTogglePortForward((ruleId, start) => {
const rule = portForwardingRules.find((r) => r.id === ruleId);
if (!rule) return;
const host = rule.hostId ? hosts.find((h) => h.id === rule.hostId) : undefined;
if (!host) {
toast.error(t("pf.error.hostNotFound"));
return;
}
const keysForPf = keys.map((k) => ({ id: k.id, privateKey: k.privateKey, passphrase: k.passphrase }));
if (start) {
void startTunnel(rule, host, keysForPf, (status, error) => {
if (status === "error" && error) toast.error(error);
}, rule.autoStart);
} else {
void stopTunnel(ruleId);
}
_handleTrayTogglePortForward(ruleId, start);
});
return () => {
unsubscribeFocus?.();
unsubscribeToggle?.();
};
}, [hosts, keys, portForwardingRules, sessions, setActiveTabId, setWorkspaceFocusedSession, startTunnel, stopTunnel, t]);
}, []);
// Tray panel actions (from main process)
useEffect(() => {
const handlerJump = (sessionId: string) => {
// Find the session to check if it belongs to a workspace
const session = sessions.find((s) => s.id === sessionId);
if (session?.workspaceId) {
// Session is in a workspace - navigate to workspace and focus the session
setActiveTabId(session.workspaceId);
setWorkspaceFocusedSession(session.workspaceId, sessionId);
} else {
// Standalone session or session not found - just set tab
setActiveTabId(sessionId);
}
};
const handlerConnect = (hostId: string) => {
const host = hosts.find((h) => h.id === hostId);
if (!host) {
toast.error(t("pf.error.hostNotFound"));
return;
}
const { username, hostname: localHost } = systemInfoRef.current;
if (host.protocol === 'serial') {
const portName = host.hostname.split('/').pop() || host.hostname;
const sessionId = connectToHost(host);
addConnectionLog({
sessionId,
hostId: host.id,
hostLabel: host.label || `Serial: ${portName}`,
hostname: host.hostname,
username,
protocol: 'serial',
startTime: Date.now(),
localUsername: username,
localHostname: localHost,
saved: false,
});
return;
}
const protocol = host.moshEnabled ? 'mosh' : (host.protocol || 'ssh');
const resolvedAuth = resolveHostAuth({ host, keys, identities });
const sessionId = connectToHost(host);
addConnectionLog({
sessionId,
hostId: host.id,
hostLabel: host.label,
hostname: host.hostname,
username: resolvedAuth.username || 'root',
protocol: protocol as 'ssh' | 'telnet' | 'local' | 'mosh',
startTime: Date.now(),
localUsername: username,
localHostname: localHost,
saved: false,
});
};
const bridge = netcattyBridge.get();
if (!bridge?.onTrayPanelJumpToSession || !bridge?.onTrayPanelConnectToHost) return;
const unsubscribeJump = bridge.onTrayPanelJumpToSession(handlerJump);
const unsubscribeConnect = bridge.onTrayPanelConnectToHost(handlerConnect);
const unsubscribeJump = bridge.onTrayPanelJumpToSession((sessionId) => {
_handleTrayJumpToSession(sessionId);
});
const unsubscribeConnect = bridge.onTrayPanelConnectToHost((hostId) => {
_handleTrayPanelConnect(hostId);
});
return () => {
unsubscribeJump?.();
unsubscribeConnect?.();
};
}, [addConnectionLog, connectToHost, hosts, identities, keys, sessions, setActiveTabId, setWorkspaceFocusedSession, t]);
}, []);
// Keyboard-interactive authentication (2FA/MFA) event listener
useEffect(() => {
@@ -657,6 +801,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) {
@@ -728,7 +890,7 @@ function App({ settings }: { settings: SettingsState }) {
localHostname: systemInfoRef.current.hostname,
saved: false,
});
createLocalTerminal();
createLocalTerminalWithCurrentShell();
break;
case 'openHosts':
setActiveTabId('vault');
@@ -767,7 +929,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
@@ -782,7 +944,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');
@@ -822,7 +984,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) => {
@@ -833,96 +995,21 @@ function App({ settings }: { settings: SettingsState }) {
useEffect(() => {
if (hotkeyScheme === 'disabled' || isHotkeyRecording) return;
const isMac = hotkeyScheme === 'mac';
if (HOTKEY_DEBUG) {
console.log('[Hotkeys] Registering global hotkey handler, scheme:', hotkeyScheme, 'bindings count:', keyBindings.length);
}
const handleGlobalKeyDown = (e: KeyboardEvent) => {
// Don't handle if we're in an input or textarea (except for Escape)
// Note: xterm terminal handles its own key interception via attachCustomKeyEventHandler
const target = e.target as HTMLElement;
const isFormElement = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
const isMonacoElement =
target instanceof HTMLElement &&
!!target.closest?.('.monaco-editor, .monaco-diff-editor, .monaco-inputbox');
const isXtermInput =
target instanceof HTMLElement &&
!!target.closest?.(".xterm, .xterm-helper-textarea, .xterm-screen, .xterm-viewport");
// Monaco is not always contentEditable/input, so treat it as an editor surface.
if ((isFormElement || isMonacoElement) && !isXtermInput && e.key !== 'Escape') {
return;
}
const isTerminalElement =
target instanceof HTMLElement &&
!!target.closest?.(".xterm, .xterm-helper-textarea, .xterm-screen, .xterm-viewport");
const isTerminalInPath = Boolean(
e.composedPath?.().some(
(node) =>
node instanceof HTMLElement &&
(node.classList.contains("xterm") ||
node.classList.contains("xterm-helper-textarea") ||
node.classList.contains("xterm-screen") ||
node.classList.contains("xterm-viewport") ||
node.hasAttribute("data-session-id")),
),
);
// Check each key binding
for (const binding of keyBindings) {
const keyStr = isMac ? binding.mac : binding.pc;
if (matchesKeyBinding(e, keyStr, isMac)) {
if (HOTKEY_DEBUG) console.log('[Hotkeys] Matched binding:', binding.action, keyStr);
// SFTP shortcuts are handled by SFTP-specific hooks.
if (binding.category === 'sftp') {
continue;
}
// Terminal-specific actions should be handled by the terminal
// Don't handle them at app level
const terminalActions = ['copy', 'paste', 'selectAll', 'clearBuffer', 'searchTerminal'];
if (terminalActions.includes(binding.action)) {
if (isTerminalElement) {
return; // Let terminal handle it
}
continue; // Ignore terminal actions outside terminal
}
e.preventDefault();
e.stopPropagation();
if (HOTKEY_DEBUG) {
console.log('[Hotkeys] Global handle', {
action: binding.action,
key: e.key,
meta: e.metaKey,
ctrl: e.ctrlKey,
alt: e.altKey,
shift: e.shiftKey,
targetTag: target?.tagName,
isTerminalElement,
isTerminalInPath,
});
}
executeHotkeyAction(binding.action, e);
return;
}
}
_handleGlobalHotkeyKeyDown(e);
};
window.addEventListener('keydown', handleGlobalKeyDown, true);
return () => window.removeEventListener('keydown', handleGlobalKeyDown, true);
}, [hotkeyScheme, keyBindings, isHotkeyRecording, executeHotkeyAction]);
}, [hotkeyScheme, isHotkeyRecording]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isQuickSwitcherOpen) {
setIsQuickSwitcherOpen(false);
}
_handleEscapeKeyDown(e);
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [isQuickSwitcherOpen]);
}, []);
const quickResults = useMemo(() => {
if (!isQuickSwitcherOpen) return [];
@@ -935,7 +1022,7 @@ function App({ settings }: { settings: SettingsState }) {
)
: hosts;
return filtered;
}, [hosts, quickSearch, isQuickSwitcherOpen]);
}, [quickSearch, hosts, isQuickSwitcherOpen]);
const handleDeleteHost = useCallback((hostId: string) => {
const target = hosts.find(h => h.id === hostId);
@@ -968,7 +1055,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: '',
@@ -981,7 +1068,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) => {
@@ -1024,10 +1111,10 @@ function App({ settings }: { settings: SettingsState }) {
}, [addConnectionLog, connectToHost, identities, keys]);
// Wrapper to create serial session with logging
const handleConnectSerial = useCallback((config: SerialConfig) => {
const handleConnectSerial = useCallback((config: SerialConfig, options?: { charset?: string }) => {
const { username, hostname } = systemInfoRef.current;
const portName = config.path.split('/').pop() || config.path;
const sessionId = createSerialSession(config);
const sessionId = createSerialSession(config, options);
addConnectionLog({
sessionId,
hostId: '',
@@ -1191,7 +1278,7 @@ function App({ settings }: { settings: SettingsState }) {
}, []);
return (
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" onContextMenu={handleRootContextMenu}>
<div className={cn("flex flex-col h-screen text-foreground font-sans netcatty-shell", immersiveMode && activeTerminalTheme && "immersive-transition")} onContextMenu={handleRootContextMenu}>
<TopTabs
theme={resolvedTheme}
hosts={hosts}
@@ -1204,7 +1291,7 @@ function App({ settings }: { settings: SettingsState }) {
isMacClient={isMacClient}
onCloseSession={closeSession}
onRenameSession={startSessionRename}
onCopySession={copySession}
onCopySession={copySessionWithCurrentShell}
onRenameWorkspace={startWorkspaceRename}
onCloseWorkspace={closeWorkspace}
onCloseLogView={closeLogView}
@@ -1212,6 +1299,7 @@ function App({ settings }: { settings: SettingsState }) {
onToggleTheme={handleToggleTheme}
onOpenSettings={handleOpenSettings}
onSyncNow={handleSyncNowManual}
isImmersiveActive={immersiveMode && activeTerminalTheme !== null}
onStartSessionDrag={setDraggingSessionId}
onEndSessionDrag={handleEndSessionDrag}
onReorderTabs={reorderTabs}
@@ -1233,6 +1321,8 @@ function App({ settings }: { settings: SettingsState }) {
sessions={sessions}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
terminalThemeId={terminalThemeId}
terminalFontSize={terminalFontSize}
onOpenSettings={handleOpenSettings}
onOpenQuickSwitcher={handleOpenQuickSwitcher}
onCreateLocalTerminal={handleCreateLocalTerminal}
@@ -1261,7 +1351,21 @@ function App({ settings }: { settings: SettingsState }) {
/>
</VaultViewContainer>
<SftpViewMount hosts={hosts} keys={keys} identities={identities} updateHosts={updateHosts} />
<SftpViewMount
hosts={hosts}
keys={keys}
identities={identities}
updateHosts={updateHosts}
sftpDefaultViewMode={sftpDefaultViewMode}
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
sftpAutoSync={sftpAutoSync}
sftpShowHiddenFiles={sftpShowHiddenFiles}
sftpUseCompressedUpload={sftpUseCompressedUpload}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
editorWordWrap={editorWordWrap}
setEditorWordWrap={setEditorWordWrap}
/>
<TerminalLayerMount
hosts={hosts}
@@ -1298,10 +1402,11 @@ function App({ settings }: { settings: SettingsState }) {
onSetDraggingSessionId={setDraggingSessionId}
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
onSetWorkspaceFocusedSession={setWorkspaceFocusedSession}
onSplitSession={splitSession}
onSplitSession={splitSessionWithCurrentShell}
isBroadcastEnabled={isBroadcastEnabled}
onToggleBroadcast={toggleBroadcast}
updateHosts={updateHosts}
sftpDefaultViewMode={sftpDefaultViewMode}
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
sftpAutoSync={sftpAutoSync}
sftpShowHiddenFiles={sftpShowHiddenFiles}

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)

View File

@@ -5,6 +5,9 @@ const en: Messages = {
'common.save': 'Save',
'common.cancel': 'Cancel',
'common.close': 'Close',
'common.reset': 'Reset',
'common.zoomIn': 'Zoom in',
'common.zoomOut': 'Zoom out',
'common.settings': 'Settings',
'common.search': 'Search',
'common.searchPlaceholder': 'Search...',
@@ -30,6 +33,7 @@ const en: Messages = {
'common.back': 'Back',
'common.apply': 'Apply',
'common.use': 'Use',
'common.useGlobal': 'Use global',
'common.saveChanges': 'Save Changes',
'common.advanced': 'Advanced',
'common.left': 'Left',
@@ -95,6 +99,21 @@ const en: Messages = {
'settings.system.credentials.unavailableHint': 'Credentials encrypted on another user profile or machine cannot be decrypted here. Re-enter and save credentials on this device.',
'settings.system.credentials.portabilityHint': 'Cloud Sync is portable because it uses your master key encryption. Local safeStorage encryption is device/user scoped.',
// Settings > System > Crash Logs
'settings.system.crashLogs.title': 'Crash Logs',
'settings.system.crashLogs.description': 'View error logs from the main process to help diagnose unexpected behavior.',
'settings.system.crashLogs.noLogs': 'No crash logs found.',
'settings.system.crashLogs.entries': '{count} entries',
'settings.system.crashLogs.clear': 'Clear all logs',
'settings.system.crashLogs.cleared': 'Cleared {count} log file(s).',
'settings.system.crashLogs.source': 'Source',
'settings.system.crashLogs.time': 'Time',
'settings.system.crashLogs.message': 'Message',
'settings.system.crashLogs.stack': 'Stack Trace',
'settings.system.crashLogs.hint': 'Crash logs are retained for 30 days and automatically rotated.',
'settings.system.crashLogs.collapse': 'Collapse',
'settings.system.crashLogs.expand': 'Show details',
// Settings > System > Software Update
'settings.update.title': 'Software Update',
'settings.update.currentVersion': 'Current version',
@@ -212,6 +231,9 @@ const en: Messages = {
'settings.appearance.themeColor.desc': 'Pick a preset palette for each theme',
'settings.appearance.themeColor.light': 'Light palette',
'settings.appearance.themeColor.dark': 'Dark palette',
'settings.appearance.immersiveMode': 'Immersive Mode',
'settings.appearance.immersiveMode.desc':
'When enabled, the UI chrome (tab bar, sidebar, status bar) adapts its colors to match the active terminal theme for a visually cohesive experience.',
'settings.appearance.customCss': 'Custom CSS',
'settings.appearance.customCss.desc':
'Add custom CSS to personalize the app appearance. Changes apply immediately.',
@@ -292,6 +314,9 @@ const en: Messages = {
'settings.terminal.behavior.scrollOnPaste': 'Scroll on paste',
'settings.terminal.behavior.scrollOnPaste.desc':
'Scroll terminal to bottom when pasting text',
'settings.terminal.behavior.smoothScrolling': 'Smooth scrolling',
'settings.terminal.behavior.smoothScrolling.desc':
'Animate terminal viewport scrolling instead of jumping instantly',
'settings.terminal.behavior.linkModifier': 'Link modifier key',
'settings.terminal.behavior.linkModifier.desc': 'Hold this key to click on links in terminal',
'settings.terminal.behavior.linkModifier.none': 'None (click directly)',
@@ -330,6 +355,22 @@ const en: Messages = {
'settings.terminal.rendering.renderer.desc': 'Choose the terminal rendering technology. Auto will use Canvas on low-memory devices. Changes take effect on new terminal sessions.',
'settings.terminal.rendering.auto': 'Auto',
// Settings > Terminal > Workspace Focus Indicator
'settings.terminal.section.workspaceFocus': 'Workspace Focus Indicator',
'settings.terminal.workspaceFocus.style': 'Focus indicator style',
'settings.terminal.workspaceFocus.style.desc': 'How to indicate which pane is focused in split view.',
'settings.terminal.workspaceFocus.dim': 'Dim unfocused panes',
'settings.terminal.workspaceFocus.border': 'Border on focused pane',
// Settings > Terminal > Autocomplete
'settings.terminal.section.autocomplete': 'Autocomplete',
'settings.terminal.autocomplete.enabled': 'Enable autocomplete',
'settings.terminal.autocomplete.enabled.desc': 'Show command suggestions based on history and command specs as you type.',
'settings.terminal.autocomplete.ghostText': 'Ghost text',
'settings.terminal.autocomplete.ghostText.desc': 'Show inline gray suggestion text after the cursor (like fish shell).',
'settings.terminal.autocomplete.popupMenu': 'Popup menu',
'settings.terminal.autocomplete.popupMenu.desc': 'Show a floating list of multiple suggestions.',
// Settings > Shortcuts
'settings.shortcuts.section.scheme': 'Hotkey Scheme',
'settings.shortcuts.scheme.label': 'Keyboard shortcuts',
@@ -579,6 +620,8 @@ const en: Messages = {
'sftp.filter.placeholder': 'Filter by filename...',
'sftp.bookmark.add': 'Bookmark this path',
'sftp.bookmark.remove': 'Remove bookmark',
'sftp.bookmark.addGlobal': '+Global',
'sftp.bookmark.addGlobalTooltip': 'Save as global bookmark (shared across all hosts)',
'sftp.bookmark.empty': 'No bookmarks yet',
'sftp.columns.name': 'Name',
'sftp.columns.modified': 'Modified',
@@ -595,8 +638,21 @@ const en: Messages = {
'sftp.dragDropToUpload': 'Drag and drop files here to upload',
'sftp.retry': 'Retry',
'sftp.context.open': 'Open',
'sftp.context.navigateTo': 'Navigate to',
'sftp.context.moveTo': 'Move to...',
'sftp.context.moveToParent': 'Move to parent directory',
'sftp.moveTo.title': 'Move to directory',
'sftp.moveTo.placeholder': 'Enter target directory path',
'sftp.moveTo.confirm': 'Move',
'sftp.moveTo.pathNotFound': 'Directory not found or inaccessible',
'sftp.context.download': 'Download',
'sftp.context.copyToOtherPane': 'Copy to other pane',
'sftp.viewMode.label': 'View mode',
'sftp.viewMode.list': 'List view',
'sftp.viewMode.tree': 'Tree view',
'sftp.tree.loadError': 'Failed to load directory',
'sftp.tree.loading': 'Loading...',
'sftp.kind.folder': 'Folder',
'sftp.context.rename': 'Rename',
'sftp.context.permissions': 'Permissions',
'sftp.context.delete': 'Delete',
@@ -609,12 +665,21 @@ 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.transfers.filesCount': '{count} files',
'sftp.transfers.filesProgress': '{current}/{total} files',
'sftp.transfers.expandChildren': 'Show files',
'sftp.transfers.collapseChildren': 'Hide files',
'sftp.transfers.expandChildList': 'Show detail',
'sftp.transfers.collapseChildList': 'Hide',
'sftp.transfers.dragToResize': 'Drag to resize',
'sftp.goUp': 'Go up',
'sftp.goToTerminalCwd': 'Go to terminal directory',
'sftp.encoding.label': 'Filename Encoding',
@@ -634,6 +699,9 @@ const en: Messages = {
'sftp.deleteConfirm.single': 'Delete "{name}"?',
'sftp.deleteConfirm.title': 'Delete {count} item(s)?',
'sftp.deleteConfirm.desc': 'This action cannot be undone. The following will be deleted:',
'sftp.deleteConfirm.descSingle': 'This action cannot be undone.',
'sftp.deleteConfirm.host': 'Host',
'sftp.deleteConfirm.path': 'Path',
'sftp.error.loadFailed': 'Failed to load directory',
'sftp.error.downloadFailed': 'Download failed',
'sftp.error.uploadFailed': 'Upload failed',
@@ -688,6 +756,7 @@ const en: Messages = {
'sftp.upload.phase.compressed': 'Compressed',
// SFTP File Opener
'sftp.context.copyPath': 'Copy file path',
'sftp.context.openWith': 'Open with...',
'sftp.context.edit': 'Edit',
'sftp.context.preview': 'Preview',
@@ -724,6 +793,15 @@ const en: Messages = {
// Settings > SFTP File Associations
'settings.tab.sftpFileAssociations': 'SFTP',
'settings.sftp.transferConcurrency': 'Transfer Concurrency',
'settings.sftp.transferConcurrency.desc': 'Number of files to transfer in parallel when uploading or downloading folders. Higher values may improve speed but can overwhelm some servers.',
'settings.sftp.defaultOpener': 'Default File Opener',
'settings.sftp.defaultOpener.desc': 'Choose the default application for opening files without a specific file association',
'settings.sftp.defaultOpener.ask': 'Always ask',
'settings.sftp.defaultOpener.askDesc': 'Show a dialog to choose an application each time',
'settings.sftp.defaultOpener.builtInDesc': 'Open text files in the built-in editor by default',
'settings.sftp.defaultOpener.systemApp': 'Choose Application...',
'settings.sftp.defaultOpener.systemAppDesc': 'Open files with a specific application by default',
'settings.sftpFileAssociations.title': 'SFTP File Associations',
'settings.sftpFileAssociations.desc': 'Configure default applications for opening files by extension',
'settings.sftpFileAssociations.extension': 'Extension',
@@ -752,6 +830,13 @@ const en: Messages = {
'settings.sftp.autoOpenSidebar.enable': 'Enable auto-open sidebar',
'settings.sftp.autoOpenSidebar.enableDesc': 'The SFTP sidebar will open automatically when a terminal session connects to a remote host',
'settings.sftp.defaultViewMode': 'Default View Mode',
'settings.sftp.defaultViewMode.desc': 'Choose the default view mode when opening a new SFTP tab. Per-host preferences override this setting.',
'settings.sftp.defaultViewMode.list': 'List View',
'settings.sftp.defaultViewMode.listDesc': 'Display files in a flat list for the current directory',
'settings.sftp.defaultViewMode.tree': 'Tree View',
'settings.sftp.defaultViewMode.treeDesc': 'Display files in a hierarchical tree structure',
'sftp.autoSync.success': 'File synced to remote: {fileName}',
'sftp.autoSync.error': 'Failed to sync file: {error}',
@@ -839,6 +924,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',
@@ -847,9 +955,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',
@@ -860,6 +971,10 @@ const en: Messages = {
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent is not available',
'hostDetails.agentForwarding.agentNotRunningHint': 'No SSH agent detected. Enable OpenSSH Authentication Agent in Windows Services, or use a compatible agent such as Bitwarden, 1Password, or gpg-agent.',
'hostDetails.section.agentForwarding': 'SSH Agent',
'hostDetails.section.deviceType': 'Device Type',
'hostDetails.deviceType': 'Network Device Mode',
'hostDetails.deviceType.desc': 'Enable for network equipment (switches, routers, firewalls) connected via SSH. Commands are sent as-is without shell wrapping, compatible with vendor CLIs like Huawei VRP and Cisco IOS.',
'hostDetails.deviceType.warning': 'AI agent commands will be sent directly without exit code tracking. Only enable for devices that do not run a standard shell.',
'hostDetails.section.legacyAlgorithms': 'Legacy Algorithms',
'hostDetails.legacyAlgorithms': 'Allow Legacy Algorithms',
'hostDetails.legacyAlgorithms.desc': 'Enable deprecated SSH algorithms (diffie-hellman-group1, ssh-dss, 3des-cbc, etc.) for connecting to older network equipment.',
@@ -1055,6 +1170,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',
@@ -1067,6 +1183,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',
@@ -1465,6 +1583,7 @@ const en: Messages = {
'serial.field.localEchoDesc': 'Echo typed characters locally (for devices without remote echo)',
'serial.field.lineMode': 'Line Mode',
'serial.field.lineModeDesc': 'Buffer input and send on Enter (instead of character-by-character)',
'serial.field.charset': 'Charset',
'serial.connectionError': 'Failed to connect to serial port',
'serial.field.baudRatePlaceholder': 'Select or enter baud rate...',
'serial.field.baudRateEmpty': 'Enter a custom baud rate',
@@ -1531,6 +1650,10 @@ const en: Messages = {
'ai.providers.noMatchingModels': 'No matching models',
'ai.providers.clickToLoadModels': 'Click to load models',
'ai.providers.showingModels': 'Showing first 100 of {count} models. Type to filter.',
'ai.providers.advancedParams': 'Advanced Parameters',
'ai.providers.advancedParams.hint': 'Leave blank to use provider defaults.',
'ai.providers.advancedParams.maxTokens.placeholder': 'e.g. 4096',
'ai.providers.advancedParams.default': 'Provider default',
// AI Codex
'ai.codex': 'Codex',
@@ -1555,7 +1678,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',
@@ -1564,6 +1687,17 @@ const en: Messages = {
'ai.claude.customPathPlaceholder': 'e.g. /usr/local/bin/claude',
'ai.claude.check': 'Check',
// AI GitHub Copilot CLI
'ai.copilot.title': 'GitHub Copilot CLI',
'ai.copilot.description': 'Uses GitHub Copilot CLI via ACP over stdio (`copilot --acp --stdio`). Once detected, it can be selected as an external coding agent.',
'ai.copilot.detecting': 'Detecting...',
'ai.copilot.detected': 'Detected',
'ai.copilot.notFound': 'Not found',
'ai.copilot.path': 'Path:',
'ai.copilot.notFoundHint': 'Could not find copilot in PATH. Install it or specify the executable path below.',
'ai.copilot.customPathPlaceholder': 'e.g. /usr/local/bin/copilot',
'ai.copilot.check': 'Check',
// AI Default Agent
'ai.defaultAgent': 'Default Agent',
'ai.defaultAgent.description': 'Agent to use when starting a new AI session',
@@ -1572,7 +1706,6 @@ const en: Messages = {
// AI Chat
'ai.chat.noProvider': 'No AI provider is configured. Go to **Settings → AI → Providers** to add and enable a provider.',
'ai.chat.toolDenied': 'Action was rejected by the user.',
'ai.chat.toolApprovalTitle': 'Permission Required',
'ai.chat.toolApproved': 'Approved',
'ai.chat.toolApprovalHint': 'Press Enter to approve, Escape to reject',
'ai.chat.approve': 'Approve',

View File

@@ -5,6 +5,9 @@ const zhCN: Messages = {
'common.save': '保存',
'common.cancel': '取消',
'common.close': '关闭',
'common.reset': '重置',
'common.zoomIn': '放大',
'common.zoomOut': '缩小',
'common.settings': '设置',
'common.search': '搜索',
'common.connect': '连接',
@@ -20,6 +23,7 @@ const zhCN: Messages = {
'common.back': '返回',
'common.apply': '应用',
'common.use': '使用',
'common.useGlobal': '跟随全局',
'common.left': '左侧',
'common.right': '右侧',
'common.more': '更多',
@@ -79,6 +83,21 @@ const zhCN: Messages = {
'settings.system.credentials.unavailableHint': '在其他用户或机器上加密的凭据无法在此处解密。请在当前设备重新输入并保存凭据。',
'settings.system.credentials.portabilityHint': '云同步可跨设备,因为使用主密钥加密;本地 safeStorage 加密仅绑定当前系统用户/设备。',
// Settings > System > Crash Logs
'settings.system.crashLogs.title': '崩溃日志',
'settings.system.crashLogs.description': '查看主进程错误日志,帮助诊断异常行为。',
'settings.system.crashLogs.noLogs': '未找到崩溃日志。',
'settings.system.crashLogs.entries': '{count} 条记录',
'settings.system.crashLogs.clear': '清除所有日志',
'settings.system.crashLogs.cleared': '已清除 {count} 个日志文件。',
'settings.system.crashLogs.source': '来源',
'settings.system.crashLogs.time': '时间',
'settings.system.crashLogs.message': '消息',
'settings.system.crashLogs.stack': '堆栈跟踪',
'settings.system.crashLogs.hint': '崩溃日志保留 30 天,超期自动清理。',
'settings.system.crashLogs.collapse': '收起',
'settings.system.crashLogs.expand': '查看详情',
// Settings > System > Software Update
'settings.update.title': '软件更新',
'settings.update.currentVersion': '当前版本',
@@ -196,6 +215,9 @@ const zhCN: Messages = {
'settings.appearance.themeColor.desc': '为浅色与深色主题选择预设配色',
'settings.appearance.themeColor.light': '浅色主题',
'settings.appearance.themeColor.dark': '深色主题',
'settings.appearance.immersiveMode': '沉浸模式',
'settings.appearance.immersiveMode.desc':
'启用后UI 外观(标签栏、侧边栏、状态栏)会自动适配当前终端主题的配色,营造视觉一体化的沉浸体验。',
'settings.appearance.customCss': '自定义 CSS',
'settings.appearance.customCss.desc': '使用自定义 CSS 个性化界面,修改会立即生效。',
'settings.appearance.customCss.placeholder':
@@ -406,6 +428,8 @@ const zhCN: Messages = {
'sftp.filter.placeholder': '按文件名筛选...',
'sftp.bookmark.add': '收藏此路径',
'sftp.bookmark.remove': '取消收藏',
'sftp.bookmark.addGlobal': '+全局',
'sftp.bookmark.addGlobalTooltip': '保存为全局收藏(所有主机共享)',
'sftp.bookmark.empty': '暂无收藏路径',
'sftp.columns.name': '名称',
'sftp.columns.modified': '修改时间',
@@ -422,8 +446,21 @@ const zhCN: Messages = {
'sftp.dragDropToUpload': '拖拽文件到这里上传',
'sftp.retry': '重试',
'sftp.context.open': '打开',
'sftp.context.navigateTo': '跳转到这里',
'sftp.context.moveTo': '移动到...',
'sftp.context.moveToParent': '移动到上级目录',
'sftp.moveTo.title': '移动到目录',
'sftp.moveTo.placeholder': '输入目标目录路径',
'sftp.moveTo.confirm': '移动',
'sftp.moveTo.pathNotFound': '目录不存在或无法访问',
'sftp.context.download': '下载',
'sftp.context.copyToOtherPane': '复制到另一侧',
'sftp.viewMode.label': '视图模式',
'sftp.viewMode.list': '列表视图',
'sftp.viewMode.tree': '树形视图',
'sftp.tree.loadError': '加载目录失败',
'sftp.tree.loading': '加载中...',
'sftp.kind.folder': '文件夹',
'sftp.context.rename': '重命名',
'sftp.context.permissions': '权限',
'sftp.context.delete': '删除',
@@ -436,12 +473,21 @@ 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.transfers.filesCount': '{count} 个文件',
'sftp.transfers.filesProgress': '{current}/{total} 个文件',
'sftp.transfers.expandChildren': '展开文件',
'sftp.transfers.collapseChildren': '收起文件',
'sftp.transfers.expandChildList': '展开详情',
'sftp.transfers.collapseChildList': '收起',
'sftp.transfers.dragToResize': '拖拽调整高度',
'sftp.goUp': '上一级',
'sftp.goToTerminalCwd': '定位到终端当前目录',
'sftp.encoding.label': '文件名编码',
@@ -461,6 +507,9 @@ const zhCN: Messages = {
'sftp.deleteConfirm.single': '删除 "{name}"',
'sftp.deleteConfirm.title': '删除 {count} 个项目?',
'sftp.deleteConfirm.desc': '此操作不可撤销,将删除以下内容:',
'sftp.deleteConfirm.descSingle': '此操作不可撤销。',
'sftp.deleteConfirm.host': '主机',
'sftp.deleteConfirm.path': '路径',
'sftp.error.loadFailed': '加载目录失败',
'sftp.error.downloadFailed': '下载失败',
'sftp.error.uploadFailed': '上传失败',
@@ -531,6 +580,29 @@ const zhCN: Messages = {
'hostDetails.section.credentials': '凭据',
'hostDetails.section.portCredentials': '端口与凭据',
'hostDetails.section.appearance': '外观',
'hostDetails.distro.title': 'Linux 发行版',
'hostDetails.distro.desc': '可在连接后自动探测,也可以手动覆盖图标所用的发行版。',
'hostDetails.distro.mode': '来源',
'hostDetails.distro.mode.auto': '自动探测',
'hostDetails.distro.mode.manual': '手动覆盖',
'hostDetails.distro.detectedLabel': '当前值',
'hostDetails.distro.manualLabel': '手动指定',
'hostDetails.distro.pending': '首次连接后自动探测',
'hostDetails.distro.unknown': '未知',
'hostDetails.distro.option.linux': '通用 Linux',
'hostDetails.distro.option.ubuntu': 'Ubuntu',
'hostDetails.distro.option.debian': 'Debian',
'hostDetails.distro.option.centos': 'CentOS',
'hostDetails.distro.option.rocky': 'Rocky Linux',
'hostDetails.distro.option.fedora': 'Fedora',
'hostDetails.distro.option.arch': 'Arch Linux',
'hostDetails.distro.option.alpine': 'Alpine',
'hostDetails.distro.option.amazon': 'Amazon Linux',
'hostDetails.distro.option.opensuse': 'openSUSE / SLES',
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
'hostDetails.distro.option.almalinux': 'AlmaLinux',
'hostDetails.distro.option.oracle': 'Oracle Linux',
'hostDetails.distro.option.kali': 'Kali Linux',
'hostDetails.section.mosh': 'Mosh',
'hostDetails.username.placeholder': '用户名',
'hostDetails.password.placeholder': '密码',
@@ -539,9 +611,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': '暂无密钥',
@@ -552,6 +627,10 @@ const zhCN: Messages = {
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent 不可用',
'hostDetails.agentForwarding.agentNotRunningHint': '未检测到 SSH Agent。请启用 Windows OpenSSH Authentication Agent 服务,或使用兼容的 Agent如 Bitwarden、1Password、gpg-agent。',
'hostDetails.section.agentForwarding': 'SSH 代理',
'hostDetails.section.deviceType': '设备类型',
'hostDetails.deviceType': '网络设备模式',
'hostDetails.deviceType.desc': '适用于通过 SSH 连接的网络设备(交换机、路由器、防火墙)。命令将原样发送,不进行 Shell 包装,兼容华为 VRP、Cisco IOS 等厂商 CLI。',
'hostDetails.deviceType.warning': 'AI 代理命令将直接发送,无法获取退出码。仅建议在设备不运行标准 Shell 时启用。',
'hostDetails.section.legacyAlgorithms': '旧版算法',
'hostDetails.legacyAlgorithms': '允许旧版算法',
'hostDetails.legacyAlgorithms.desc': '启用已弃用的 SSH 算法diffie-hellman-group1、ssh-dss、3des-cbc 等)以连接老旧网络设备。',
@@ -719,6 +798,7 @@ const zhCN: Messages = {
'terminal.progress.disconnected': '已断开',
'terminal.progress.cancelling': '正在取消...',
'terminal.progress.startOver': '重新开始',
'terminal.connection.dismissDisconnectedDialog': '关闭断连提示',
'terminal.connection.chainOf': 'Chain {current} / {total}',
'terminal.connection.showLogs': '显示日志',
'terminal.connection.hideLogs': '隐藏日志',
@@ -731,6 +811,8 @@ const zhCN: Messages = {
'terminal.themeModal.tab.theme': '主题',
'terminal.themeModal.tab.font': '字体',
'terminal.themeModal.tab.custom': '自定义',
'terminal.themeModal.globalTheme': '全局主题',
'terminal.themeModal.globalFont': '全局字体',
'terminal.themeModal.fontSize': '字体大小',
'terminal.themeModal.livePreview': '实时预览',
'terminal.themeModal.themeType': '{type} 主题',
@@ -1003,6 +1085,7 @@ const zhCN: Messages = {
'sftp.upload.phase.compressed': '压缩传输',
// SFTP File Opener
'sftp.context.copyPath': '复制文件路径',
'sftp.context.openWith': '打开方式...',
'sftp.context.edit': '编辑',
'sftp.context.preview': '预览',
@@ -1039,6 +1122,15 @@ const zhCN: Messages = {
// Settings > SFTP File Associations
'settings.tab.sftpFileAssociations': 'SFTP',
'settings.sftp.transferConcurrency': '传输并发数',
'settings.sftp.transferConcurrency.desc': '上传或下载文件夹时并行传输的文件数量。较高的值可能提高速度,但可能导致某些服务器过载。',
'settings.sftp.defaultOpener': '默认文件打开方式',
'settings.sftp.defaultOpener.desc': '选择没有特定文件关联时的默认打开方式',
'settings.sftp.defaultOpener.ask': '每次询问',
'settings.sftp.defaultOpener.askDesc': '每次打开文件时弹出选择对话框',
'settings.sftp.defaultOpener.builtInDesc': '默认使用内置编辑器打开文本文件',
'settings.sftp.defaultOpener.systemApp': '选择应用程序...',
'settings.sftp.defaultOpener.systemAppDesc': '默认使用指定的外部应用程序打开文件',
'settings.sftpFileAssociations.title': 'SFTP 文件关联',
'settings.sftpFileAssociations.desc': '配置按扩展名打开文件的默认应用程序',
'settings.sftpFileAssociations.extension': '扩展名',
@@ -1067,6 +1159,13 @@ const zhCN: Messages = {
'settings.sftp.autoOpenSidebar.enable': '启用自动打开侧栏',
'settings.sftp.autoOpenSidebar.enableDesc': '当终端会话连接到远程主机时SFTP 侧栏将自动打开',
'settings.sftp.defaultViewMode': '默认视图模式',
'settings.sftp.defaultViewMode.desc': '选择打开新 SFTP 标签页时的默认视图模式。每个主机的偏好设置会覆盖此全局设置。',
'settings.sftp.defaultViewMode.list': '列表视图',
'settings.sftp.defaultViewMode.listDesc': '以平面列表显示当前目录的文件',
'settings.sftp.defaultViewMode.tree': '树形视图',
'settings.sftp.defaultViewMode.treeDesc': '以层级树形结构显示文件',
'sftp.autoSync.success': '文件已同步到远程:{fileName}',
'sftp.autoSync.error': '同步文件失败:{error}',
@@ -1172,6 +1271,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': '无(直接点击)',
@@ -1210,6 +1311,15 @@ const zhCN: Messages = {
'settings.terminal.rendering.renderer.desc': '选择终端渲染技术。自动模式会在低内存设备上使用 Canvas。更改将在新终端会话中生效。',
'settings.terminal.rendering.auto': '自动',
// Settings > Terminal > Autocomplete
'settings.terminal.section.autocomplete': '自动补全',
'settings.terminal.autocomplete.enabled': '启用自动补全',
'settings.terminal.autocomplete.enabled.desc': '输入时根据历史命令和命令规范显示补全建议。',
'settings.terminal.autocomplete.ghostText': '行内建议',
'settings.terminal.autocomplete.ghostText.desc': '在光标后显示灰色的建议文本(类似 fish shell。',
'settings.terminal.autocomplete.popupMenu': '弹出菜单',
'settings.terminal.autocomplete.popupMenu.desc': '显示包含多个建议的浮动列表。',
// Settings > Shortcuts
'settings.shortcuts.section.scheme': '快捷键方案',
'settings.shortcuts.scheme.label': '键盘快捷键',
@@ -1480,6 +1590,7 @@ const zhCN: Messages = {
'serial.field.localEchoDesc': '本地回显输入字符(用于没有远程回显的设备)',
'serial.field.lineMode': '行模式',
'serial.field.lineModeDesc': '缓冲输入,按回车后发送(而不是逐字符发送)',
'serial.field.charset': '字符编码',
'serial.connectionError': '连接串口失败',
'serial.field.baudRatePlaceholder': '选择或输入波特率...',
'serial.field.baudRateEmpty': '输入自定义波特率',
@@ -1546,6 +1657,10 @@ const zhCN: Messages = {
'ai.providers.noMatchingModels': '没有匹配的模型',
'ai.providers.clickToLoadModels': '点击加载模型',
'ai.providers.showingModels': '显示前 100 个,共 {count} 个模型。输入以筛选。',
'ai.providers.advancedParams': '高级参数',
'ai.providers.advancedParams.hint': '留空则使用提供商默认值。',
'ai.providers.advancedParams.maxTokens.placeholder': '例如 4096',
'ai.providers.advancedParams.default': '提供商默认',
// AI Codex
'ai.codex': 'Codex',
@@ -1570,7 +1685,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': '未找到',
@@ -1579,6 +1694,17 @@ const zhCN: Messages = {
'ai.claude.customPathPlaceholder': '例如 /usr/local/bin/claude',
'ai.claude.check': '检查',
// AI GitHub Copilot CLI
'ai.copilot.title': 'GitHub Copilot CLI',
'ai.copilot.description': '通过 ACP over stdio`copilot --acp --stdio`)接入 GitHub Copilot CLI。检测到后即可作为外部编程 Agent 使用。',
'ai.copilot.detecting': '检测中...',
'ai.copilot.detected': '已检测到',
'ai.copilot.notFound': '未找到',
'ai.copilot.path': '路径:',
'ai.copilot.notFoundHint': '在 PATH 中未找到 copilot。请安装或在下方指定可执行文件路径。',
'ai.copilot.customPathPlaceholder': '例如 /usr/local/bin/copilot',
'ai.copilot.check': '检查',
// AI Default Agent
'ai.defaultAgent': '默认 Agent',
'ai.defaultAgent.description': '创建新 AI 会话时使用的 Agent',
@@ -1587,7 +1713,6 @@ const zhCN: Messages = {
// AI Chat
'ai.chat.noProvider': '尚未配置 AI 提供商。请前往 **设置 → AI → 提供商** 添加并启用一个提供商。',
'ai.chat.toolDenied': '操作已被用户拒绝。',
'ai.chat.toolApprovalTitle': '需要权限确认',
'ai.chat.toolApproved': '已批准',
'ai.chat.toolApprovalHint': '按回车批准,按 Esc 拒绝',
'ai.chat.approve': '批准',

View File

@@ -0,0 +1,38 @@
/**
* Application-layer notification port.
*
* UI layers (e.g. toast) register their implementation via `setNotify`.
* Application code calls `notify.*` without importing any UI module.
*/
export interface NotifyOptions {
title?: string;
duration?: number;
onClick?: () => void;
actionLabel?: string;
}
type NotifyFn = (message: string, titleOrOptions?: string | NotifyOptions) => void;
interface Notify {
success: NotifyFn;
error: NotifyFn;
warning: NotifyFn;
info: NotifyFn;
}
const noop: NotifyFn = () => {};
let _impl: Notify = { success: noop, error: noop, warning: noop, info: noop };
/** Called once by the UI layer to wire up the real implementation. */
export function setNotify(impl: Notify): void {
_impl = impl;
}
export const notify: Notify = {
success: (...args) => _impl.success(...args),
error: (...args) => _impl.error(...args),
warning: (...args) => _impl.warning(...args),
info: (...args) => _impl.info(...args),
};

View File

@@ -6,6 +6,7 @@ type Listener = () => void;
class ActiveTabStore {
private activeTabId: string = 'vault';
private listeners = new Set<Listener>();
private pendingNotify = false;
getActiveTabId = () => this.activeTabId;
@@ -13,7 +14,10 @@ class ActiveTabStore {
if (this.activeTabId !== id) {
this.activeTabId = id;
// Defer listener notification to avoid "setState during render" if called from a render phase
if (this.pendingNotify) return;
this.pendingNotify = true;
Promise.resolve().then(() => {
this.pendingNotify = false;
this.listeners.forEach(listener => listener());
});
}

View File

@@ -0,0 +1,46 @@
import { TerminalSession } from '../../types';
type SessionActivityMap = Record<string, boolean>;
export const getValidSessionActivityIds = (sessions: TerminalSession[]): Set<string> => {
return new Set(sessions.map((session) => session.id));
};
export const shouldMarkSessionActivity = (
activeTabId: string | null,
session: Pick<TerminalSession, 'id' | 'workspaceId'>,
): boolean => {
return activeTabId !== session.id && activeTabId !== session.workspaceId;
};
export const getSessionActivityIdsToClear = (
activeTabId: string | null,
sessions: TerminalSession[],
): string[] => {
if (!activeTabId || activeTabId === 'vault' || activeTabId === 'sftp') {
return [];
}
const activeSession = sessions.find((session) => session.id === activeTabId);
if (activeSession) {
return [activeSession.id];
}
return sessions
.filter((session) => session.workspaceId === activeTabId)
.map((session) => session.id);
};
export const buildWorkspaceActivityMap = (
sessions: TerminalSession[],
sessionActivityMap: SessionActivityMap,
): Map<string, boolean> => {
const workspaceActivityMap = new Map<string, boolean>();
for (const session of sessions) {
if (!session.workspaceId || !sessionActivityMap[session.id]) continue;
workspaceActivityMap.set(session.workspaceId, true);
}
return workspaceActivityMap;
};

View File

@@ -0,0 +1,78 @@
import { useSyncExternalStore } from 'react';
type Listener = () => void;
class SessionActivityStore {
private snapshot: Record<string, boolean> = {};
private listeners = new Set<Listener>();
getSnapshot = () => this.snapshot;
subscribe = (listener: Listener) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
private emit() {
this.listeners.forEach((listener) => listener());
}
setTabActive = (tabId: string, hasActivity: boolean) => {
const alreadyActive = !!this.snapshot[tabId];
if (alreadyActive === hasActivity) return;
if (hasActivity) {
this.snapshot = { ...this.snapshot, [tabId]: true };
} else {
const { [tabId]: _removed, ...rest } = this.snapshot;
this.snapshot = rest;
}
this.emit();
};
clearTab = (tabId: string) => {
this.setTabActive(tabId, false);
};
clearTabs = (tabIds: Iterable<string>) => {
let changed = false;
const next = { ...this.snapshot };
for (const tabId of tabIds) {
if (!next[tabId]) continue;
delete next[tabId];
changed = true;
}
if (!changed) return;
this.snapshot = next;
this.emit();
};
prune = (validTabIds: Set<string>) => {
let changed = false;
const next: Record<string, boolean> = {};
for (const tabId of Object.keys(this.snapshot)) {
if (validTabIds.has(tabId)) {
next[tabId] = true;
} else {
changed = true;
}
}
if (!changed) return;
this.snapshot = next;
this.emit();
};
}
export const sessionActivityStore = new SessionActivityStore();
export const useSessionActivityMap = () => {
return useSyncExternalStore(
sessionActivityStore.subscribe,
sessionActivityStore.getSnapshot,
);
};

View File

@@ -7,10 +7,12 @@ export interface SftpPane {
loading: boolean;
reconnecting: boolean;
error: string | null;
connectionLogs: string[];
selectedFiles: Set<string>;
filter: string;
filenameEncoding: SftpFilenameEncoding;
showHiddenFiles: boolean;
transferMutationToken: number;
}
// Multi-tab state for left and right sides
@@ -33,10 +35,12 @@ export const createEmptyPane = (
loading: false,
reconnecting: false,
error: null,
connectionLogs: [],
selectedFiles: new Set(),
filter: "",
filenameEncoding: "auto",
showHiddenFiles,
transferMutationToken: 0,
});
// File watch event types

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,13 @@ export const useSftpConnections = ({
if (!activeTabId) return;
const isReconnectAttempt = reconnectingRef.current[side];
// 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 +125,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 +161,7 @@ export const useSftpConnections = ({
loading: true,
reconnecting: false,
error: null,
connectionLogs: [],
filenameEncoding, // Reset encoding for new connection
}));
@@ -205,13 +216,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 +325,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 +352,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 +454,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;
@@ -426,10 +468,16 @@ export const useSftpConnections = ({
error: err instanceof Error ? err.message : "Connection failed",
}
: null,
error: err instanceof Error ? err.message : "Connection failed",
files: isReconnectAttempt ? [] : prev.files,
selectedFiles: isReconnectAttempt ? new Set<string>() : prev.selectedFiles,
error: isReconnectAttempt
? "sftp.error.reconnectFailed"
: (err instanceof Error ? err.message : "Connection failed"),
loading: false,
reconnecting: false,
}));
} finally {
unsubSftpProgress?.();
}
}
},
@@ -454,32 +502,39 @@ export const useSftpConnections = ({
!initialConnectDoneRef.current &&
leftTabs.tabs.length === 0
) {
initialConnectDoneRef.current = true;
setTimeout(() => {
const timer = window.setTimeout(() => {
initialConnectDoneRef.current = true;
connect("left", "local");
}, 0);
return () => window.clearTimeout(timer);
}
}, [autoConnectLocalOnMount, connect, leftTabs.tabs.length]);
useEffect(() => {
const attemptReconnect = async (side: "left" | "right") => {
const reconnectTimers: number[] = [];
const scheduleReconnect = (side: "left" | "right") => {
const lastHost = lastConnectedHostRef.current[side];
if (lastHost && reconnectingRef.current[side]) {
await new Promise((resolve) => setTimeout(resolve, 1000));
if (reconnectingRef.current[side]) {
connect(side, lastHost);
}
}
if (!lastHost || !reconnectingRef.current[side]) return;
const timer = window.setTimeout(() => {
if (!reconnectingRef.current[side]) return;
void connect(side, lastHost);
}, 1000);
reconnectTimers.push(timer);
};
if (leftPane.reconnecting && reconnectingRef.current.left) {
attemptReconnect("left");
scheduleReconnect("left");
}
if (rightPane.reconnecting && reconnectingRef.current.right) {
attemptReconnect("right");
scheduleReconnect("right");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [leftPane.reconnecting, rightPane.reconnecting, connect]);
return () => {
reconnectTimers.forEach((timer) => window.clearTimeout(timer));
};
}, [leftPane.reconnecting, rightPane.reconnecting, connect, lastConnectedHostRef, reconnectingRef]);
const disconnect = useCallback(
async (side: "left" | "right") => {

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;
@@ -45,7 +45,8 @@ interface SftpExternalOperationsResult {
activeFileWatchCountRef: React.MutableRefObject<number>;
uploadExternalFiles: (
side: "left" | "right",
dataTransfer: DataTransfer
dataTransfer: DataTransfer,
targetPath?: string
) => Promise<UploadResult[]>;
uploadExternalEntries: (
side: "left" | "right",
@@ -377,6 +378,7 @@ export const useSftpExternalOperations = (
speed: 0,
startTime: Date.now(),
isDirectory: true,
progressMode: "bytes",
};
addExternalUpload(scanningTask);
}
@@ -404,6 +406,8 @@ export const useSftpExternalOperations = (
speed: 0,
startTime: Date.now(),
isDirectory: task.isDirectory,
progressMode: task.progressMode ?? "bytes",
parentTaskId: task.parentTaskId,
};
addExternalUpload(transferTask);
}
@@ -505,7 +509,7 @@ export const useSftpExternalOperations = (
}, []);
const uploadExternalFiles = useCallback(
async (side: "left" | "right", dataTransfer: DataTransfer): Promise<UploadResult[]> => {
async (side: "left" | "right", dataTransfer: DataTransfer, targetPath?: string): Promise<UploadResult[]> => {
const pane = getActivePane(side);
if (!pane?.connection) {
throw new Error("No active connection");
@@ -524,13 +528,15 @@ export const useSftpExternalOperations = (
throw new Error("SFTP session not found");
}
const uploadPaneId = pane.id;
const uploadTargetPath = targetPath || pane.connection.currentPath;
// Create a new upload controller for this upload
const controller = new UploadController();
uploadControllerRef.current = controller;
const callbacks = createUploadCallbacks(
pane.connection.id,
pane.connection.currentPath,
uploadTargetPath,
pane.connection.isLocal ? undefined : pane.connection.hostId,
pane.connection.isLocal ? undefined : connectionCacheKeyMapRef.current.get(pane.connection.id),
);
@@ -539,7 +545,7 @@ export const useSftpExternalOperations = (
const results = await uploadFromDataTransfer(
dataTransfer,
{
targetPath: pane.connection.currentPath,
targetPath: uploadTargetPath,
sftpId,
isLocal: pane.connection.isLocal,
bridge: createUploadBridge,
@@ -550,7 +556,14 @@ export const useSftpExternalOperations = (
controller
);
await refresh(side);
// Invalidate cache for the upload target so returning to that path
// triggers a fresh listing.
if (clearDirCacheEntry && targetPath) {
clearDirCacheEntry(pane.connection.id, uploadTargetPath);
}
if (uploadTargetPath === pane.connection.currentPath) {
await refresh(side, { tabId: uploadPaneId });
}
return results;
} catch (error) {
logger.error("[SFTP] Upload failed:", error);
@@ -560,6 +573,7 @@ export const useSftpExternalOperations = (
}
},
[
clearDirCacheEntry,
connectionCacheKeyMapRef,
getActivePane,
refresh,
@@ -594,6 +608,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,16 +640,15 @@ 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);
}
if (uploadTargetPath === pane.connection.currentPath) {
await refresh(side, { tabId: uploadPaneId });
}
return results;
} catch (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

@@ -3,9 +3,12 @@ import type { Host, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { logger } from "../../../lib/logger";
import { SftpPane } from "./types";
import { getParentPath, isNavigableDirectory, isWindowsRoot, joinPath } from "./utils";
import { getFileName, getParentPath, isNavigableDirectory, isWindowsRoot, joinPath } from "./utils";
import { buildCacheKey, setSharedRemoteHostCache } from "./sharedRemoteHostCache";
/** Shared empty set for navigation resets — never mutate this. */
const EMPTY_SET = new Set<string>();
interface UseSftpPaneActionsParams {
hosts: Host[];
getActivePane: (side: "left" | "right") => SftpPane | null;
@@ -25,12 +28,13 @@ interface UseSftpPaneActionsParams {
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
handleSessionError: (side: "left" | "right", error: Error) => void;
isSessionError: (err: unknown) => boolean;
clearSelectionsExcept: (target: { side: "left" | "right"; tabId: string } | null) => void;
dirCacheTtlMs: number;
}
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;
@@ -40,7 +44,9 @@ interface UseSftpPaneActionsResult {
setFilter: (side: "left" | "right", filter: string) => void;
getFilteredFiles: (pane: SftpPane) => SftpFileEntry[];
createDirectory: (side: "left" | "right", name: string) => Promise<void>;
createDirectoryAtPath: (side: "left" | "right", path: string, name: string) => Promise<void>;
createFile: (side: "left" | "right", name: string) => Promise<void>;
createFileAtPath: (side: "left" | "right", path: string, name: string) => Promise<void>;
deleteFiles: (side: "left" | "right", fileNames: string[]) => Promise<void>;
deleteFilesAtPath: (
side: "left" | "right",
@@ -49,6 +55,8 @@ interface UseSftpPaneActionsResult {
fileNames: string[],
) => Promise<void>;
renameFile: (side: "left" | "right", oldName: string, newName: string) => Promise<void>;
renameFileAtPath: (side: "left" | "right", oldPath: string, newName: string) => Promise<void>;
moveEntriesToPath: (side: "left" | "right", sourcePaths: string[], targetPath: string) => Promise<void>;
changePermissions: (side: "left" | "right", filePath: string, mode: string) => Promise<void>;
}
@@ -71,8 +79,39 @@ export const useSftpPaneActions = ({
listRemoteFiles,
handleSessionError,
isSessionError,
clearSelectionsExcept,
dirCacheTtlMs,
}: UseSftpPaneActionsParams): UseSftpPaneActionsResult => {
const normalizePathForCompare = useCallback((path: string): string => {
if (isWindowsRoot(path)) return path.replace(/\//g, "\\").toLowerCase();
if (/^[A-Za-z]:/.test(path)) {
return path.replace(/\//g, "\\").replace(/[\\]+$/, "").toLowerCase();
}
if (path === "/") return "/";
return path.replace(/\/+$/, "");
}, []);
const isSamePath = useCallback((a: string, b: string): boolean => {
return normalizePathForCompare(a) === normalizePathForCompare(b);
}, [normalizePathForCompare]);
const isDescendantPath = useCallback((candidate: string, parent: string): boolean => {
const normalizedCandidate = normalizePathForCompare(candidate);
const normalizedParent = normalizePathForCompare(parent);
if (normalizedCandidate === normalizedParent) return false;
if (/^[a-z]:\\$/.test(normalizedParent)) {
return normalizedCandidate.startsWith(normalizedParent);
}
if (normalizedParent === "/") {
return normalizedCandidate.startsWith("/");
}
const separator = normalizedParent.includes("\\") ? "\\" : "/";
return normalizedCandidate.startsWith(`${normalizedParent}${separator}`);
}, [normalizePathForCompare]);
// Build the shared cache key for the active pane. Prefer the last connected
// host (which includes session-time overrides), fall back to the vault hosts list.
const hostsRef = useRef(hosts);
@@ -114,23 +153,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 +180,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(),
selectedFiles: EMPTY_SET,
});
updateTab(side, activeTabId, (prev) => ({
updateTab(side, targetTabId, (prev) => ({
...prev,
connection: prev.connection
? { ...prev.connection, currentPath: path }
@@ -162,7 +195,7 @@ export const useSftpPaneActions = ({
files: cached.files,
loading: false,
error: null,
selectedFiles: new Set(),
selectedFiles: EMPTY_SET,
}));
if (!pane.connection.isLocal) {
// Use hostId as the shared cache key — this is safe because the
@@ -180,34 +213,33 @@ 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 }
: null,
selectedFiles: new Set(),
selectedFiles: EMPTY_SET,
loading: true,
error: null,
}));
@@ -221,16 +253,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 +273,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 +289,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,21 +305,21 @@ export const useSftpPaneActions = ({
timestamp: Date.now(),
});
lastConfirmedRef.current.set(activeTabId, {
lastConfirmedRef.current.set(targetTabId, {
connectionId,
path,
files,
selectedFiles: new Set(),
selectedFiles: EMPTY_SET,
});
updateTab(side, activeTabId, (prev) => ({
updateTab(side, targetTabId, (prev) => ({
...prev,
connection: prev.connection
? { ...prev.connection, currentPath: path }
: null,
files,
loading: false,
selectedFiles: new Set(),
selectedFiles: EMPTY_SET,
}));
if (!pane.connection.isLocal) {
setSharedRemoteHostCache(getActivePaneCacheKey(side, pane.connection.hostId, pane.connection.id), {
@@ -311,24 +331,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 +367,43 @@ 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 });
const hasRemoteSession = pane.connection.isLocal || sftpSessionsRef.current.has(pane.connection.id);
if (!hasRemoteSession) {
if (options?.tabId) return;
const lastHost = lastConnectedHostRef.current[side];
if (lastHost && !reconnectingRef.current[side]) {
reconnectingRef.current[side] = true;
updateActiveTab(side, (prev) => ({
...prev,
reconnecting: true,
error: "sftp.reconnecting.title",
}));
} else if (!lastHost) {
updateActiveTab(side, (prev) => ({
...prev,
error: "sftp.error.connectionLostManual",
}));
}
return;
}
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 +420,7 @@ export const useSftpPaneActions = ({
}
}
},
[getActivePane, navigateTo, updateActiveTab, lastConnectedHostRef, reconnectingRef],
[getActivePane, leftTabsRef, rightTabsRef, navigateTo, updateActiveTab, lastConnectedHostRef, reconnectingRef, sftpSessionsRef],
);
const navigateUp = useCallback(
@@ -405,42 +441,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);
}
},
@@ -449,6 +467,10 @@ export const useSftpPaneActions = ({
const toggleSelection = useCallback(
(side: "left" | "right", fileName: string, multiSelect: boolean) => {
const activeTabId = (side === "left" ? leftTabsRef : rightTabsRef).current.activeTabId;
if (activeTabId) {
clearSelectionsExcept({ side, tabId: activeTabId });
}
updateActiveTab(side, (prev) => {
const newSelection = new Set(multiSelect ? prev.selectedFiles : []);
if (newSelection.has(fileName)) {
@@ -459,11 +481,15 @@ export const useSftpPaneActions = ({
return { ...prev, selectedFiles: newSelection };
});
},
[updateActiveTab],
[updateActiveTab, clearSelectionsExcept, leftTabsRef, rightTabsRef],
);
const rangeSelect = useCallback(
(side: "left" | "right", fileNames: string[]) => {
const activeTabId = (side === "left" ? leftTabsRef : rightTabsRef).current.activeTabId;
if (activeTabId) {
clearSelectionsExcept({ side, tabId: activeTabId });
}
const newSelection = new Set<string>();
for (const name of fileNames) {
if (name && name !== "..") {
@@ -473,11 +499,11 @@ export const useSftpPaneActions = ({
updateActiveTab(side, (prev) => ({ ...prev, selectedFiles: newSelection }));
},
[updateActiveTab],
[updateActiveTab, clearSelectionsExcept, leftTabsRef, rightTabsRef],
);
const clearSelection = useCallback((side: "left" | "right") => {
updateActiveTab(side, (prev) => ({ ...prev, selectedFiles: new Set() }));
updateActiveTab(side, (prev) => ({ ...prev, selectedFiles: EMPTY_SET }));
}, [updateActiveTab]);
const selectAll = useCallback(
@@ -507,12 +533,12 @@ export const useSftpPaneActions = ({
);
}, []);
const createDirectory = useCallback(
async (side: "left" | "right", name: string) => {
const createDirectoryAtPath = useCallback(
async (side: "left" | "right", path: string, name: string) => {
const pane = getActivePane(side);
if (!pane?.connection) return;
const fullPath = joinPath(pane.connection.currentPath, name);
const fullPath = joinPath(path, name);
try {
if (pane.connection.isLocal) {
@@ -525,7 +551,9 @@ export const useSftpPaneActions = ({
}
await netcattyBridge.get()?.mkdirSftp(sftpId, fullPath, pane.filenameEncoding);
}
await refresh(side);
if (pane.connection.currentPath === path) {
await refresh(side);
}
} catch (err) {
if (isSessionError(err)) {
handleSessionError(side, err as Error);
@@ -537,12 +565,21 @@ export const useSftpPaneActions = ({
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
);
const createFile = useCallback(
const createDirectory = useCallback(
async (side: "left" | "right", name: string) => {
const pane = getActivePane(side);
if (!pane?.connection) return;
await createDirectoryAtPath(side, pane.connection.currentPath, name);
},
[createDirectoryAtPath, getActivePane],
);
const fullPath = joinPath(pane.connection.currentPath, name);
const createFileAtPath = useCallback(
async (side: "left" | "right", path: string, name: string) => {
const pane = getActivePane(side);
if (!pane?.connection) return;
const fullPath = joinPath(path, name);
try {
if (pane.connection.isLocal) {
@@ -569,7 +606,9 @@ export const useSftpPaneActions = ({
throw new Error("No write method available");
}
}
await refresh(side);
if (pane.connection.currentPath === path) {
await refresh(side);
}
} catch (err) {
if (isSessionError(err)) {
handleSessionError(side, err as Error);
@@ -581,6 +620,15 @@ export const useSftpPaneActions = ({
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
);
const createFile = useCallback(
async (side: "left" | "right", name: string) => {
const pane = getActivePane(side);
if (!pane?.connection) return;
await createFileAtPath(side, pane.connection.currentPath, name);
},
[createFileAtPath, getActivePane],
);
const deleteFiles = useCallback(
async (side: "left" | "right", fileNames: string[]) => {
const pane = getActivePane(side);
@@ -726,6 +774,139 @@ export const useSftpPaneActions = ({
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
);
// Rename using a full source path (for tree view where entryPath is already absolute).
// newName is still a basename; the new path is built as joinPath(parent, newName).
const renameFileAtPath = useCallback(
async (side: "left" | "right", oldPath: string, newName: string) => {
const pane = getActivePane(side);
if (!pane?.connection) return;
const parentPath = getParentPath(oldPath);
const newPath = joinPath(parentPath, newName);
try {
if (pane.connection.isLocal) {
await netcattyBridge.get()?.renameLocalFile?.(oldPath, newPath);
} else {
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
handleSessionError(side, new Error("SFTP session not found"));
return;
}
await netcattyBridge.get()?.renameSftp?.(sftpId, oldPath, newPath, pane.filenameEncoding);
}
if (pane.connection.currentPath === parentPath) {
await refresh(side);
}
} catch (err) {
if (isSessionError(err)) {
handleSessionError(side, err as Error);
return;
}
throw err;
}
},
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
);
const moveEntriesToPath = useCallback(
async (side: "left" | "right", sourcePaths: string[], targetPath: string) => {
const pane = getActivePane(side);
if (!pane?.connection || sourcePaths.length === 0) return;
const uniqueSources = Array.from(new Set(sourcePaths.filter(Boolean)));
const filteredSources = uniqueSources
.sort((a, b) => a.length - b.length)
.filter((path, index, arr) =>
!arr.slice(0, index).some((otherPath) => isSamePath(path, otherPath) || isDescendantPath(path, otherPath)),
);
const movableSources = filteredSources.filter((sourcePath) => {
if (isSamePath(sourcePath, targetPath)) return false;
if (isDescendantPath(targetPath, sourcePath)) return false;
const destinationPath = joinPath(targetPath, getFileName(sourcePath));
return !isSamePath(destinationPath, sourcePath);
});
if (movableSources.length === 0) return;
const sourceParentNames = new Map<string, string[]>();
for (const sourcePath of movableSources) {
const parentPath = getParentPath(sourcePath);
const names = sourceParentNames.get(parentPath) ?? [];
names.push(getFileName(sourcePath));
sourceParentNames.set(parentPath, names);
}
try {
if (pane.connection.isLocal) {
const renameLocalFile = netcattyBridge.get()?.renameLocalFile;
if (!renameLocalFile) {
throw new Error("Local rename unavailable");
}
for (const sourcePath of movableSources) {
const destinationPath = joinPath(targetPath, getFileName(sourcePath));
await renameLocalFile(sourcePath, destinationPath);
}
} else {
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
handleSessionError(side, new Error("SFTP session not found"));
return;
}
const renameSftp = netcattyBridge.get()?.renameSftp;
if (!renameSftp) {
throw new Error("SFTP rename unavailable");
}
for (const sourcePath of movableSources) {
const destinationPath = joinPath(targetPath, getFileName(sourcePath));
await renameSftp(sftpId, sourcePath, destinationPath, pane.filenameEncoding);
}
}
clearCacheForConnection(pane.connection.id);
const currentPath = pane.connection.currentPath;
const sourceParents = Array.from(sourceParentNames.keys());
const currentPathAffected =
sourceParents.some((path) => isSamePath(path, currentPath)) ||
isSamePath(targetPath, currentPath);
if (currentPathAffected) {
await refresh(side);
} else {
updateActiveTab(side, (prev) => {
if (!prev.connection || prev.connection.id !== pane.connection?.id) {
return prev;
}
const namesInCurrentPath = sourceParentNames.get(prev.connection.currentPath);
if (!namesInCurrentPath || namesInCurrentPath.length === 0) {
return prev;
}
const removeSet = new Set(namesInCurrentPath);
const nextSelection = new Set(prev.selectedFiles);
for (const name of removeSet) {
nextSelection.delete(name);
}
return {
...prev,
files: prev.files.filter((file) => !removeSet.has(file.name)),
selectedFiles: nextSelection,
};
});
}
} catch (err) {
if (isSessionError(err)) {
handleSessionError(side, err as Error);
return;
}
throw err;
}
},
[clearCacheForConnection, getActivePane, handleSessionError, isDescendantPath, isSamePath, isSessionError, refresh, sftpSessionsRef, updateActiveTab],
);
const changePermissions = useCallback(
async (
side: "left" | "right",
@@ -770,10 +951,14 @@ export const useSftpPaneActions = ({
setFilter,
getFilteredFiles,
createDirectory,
createDirectoryAtPath,
createFile,
createFileAtPath,
deleteFiles,
deleteFilesAtPath,
renameFile,
renameFileAtPath,
moveEntriesToPath,
changePermissions,
};
};

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>;
@@ -14,6 +14,7 @@ export interface SftpTabsState {
getActivePane: (side: "left" | "right") => SftpPane | null;
updateTab: (side: "left" | "right", tabId: string, updater: (pane: SftpPane) => SftpPane) => void;
updateActiveTab: (side: "left" | "right", updater: (pane: SftpPane) => SftpPane) => void;
clearSelectionsExcept: (target: { side: "left" | "right"; tabId: string } | null) => void;
setTabShowHiddenFiles: (side: "left" | "right", tabId: string, showHiddenFiles: boolean) => void;
addTab: (side: "left" | "right") => string;
closeTab: (side: "left" | "right", tabId: string) => void;
@@ -34,6 +35,8 @@ export interface SftpTabsState {
getActiveTabId: (side: "left" | "right") => string | null;
}
const EMPTY_SELECTION = new Set<string>();
export const useSftpTabsState = ({
defaultShowHiddenFiles = false,
}: {
@@ -95,6 +98,31 @@ export const useSftpTabsState = ({
[updateTab],
);
const clearSelectionsExcept = useCallback(
(target: { side: "left" | "right"; tabId: string } | null) => {
const clearSideSelections = (
prev: SftpSideTabs,
side: "left" | "right",
): SftpSideTabs => {
let changed = false;
const tabs = prev.tabs.map((tab) => {
const shouldKeepSelection =
target?.side === side && target.tabId === tab.id;
if (shouldKeepSelection || tab.selectedFiles.size === 0) {
return tab;
}
changed = true;
return { ...tab, selectedFiles: EMPTY_SELECTION };
});
return changed ? { ...prev, tabs } : prev;
};
setLeftTabs((prev) => clearSideSelections(prev, "left"));
setRightTabs((prev) => clearSideSelections(prev, "right"));
},
[],
);
const setTabShowHiddenFiles = useCallback(
(side: "left" | "right", tabId: string, showHiddenFiles: boolean) => {
updateTab(side, tabId, (prev) => {
@@ -258,6 +286,7 @@ export const useSftpTabsState = ({
getActivePane,
updateTab,
updateActiveTab,
clearSelectionsExcept,
setTabShowHiddenFiles,
addTab,
closeTab,

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -12,6 +12,7 @@ import {
STORAGE_KEY_AI_COMMAND_TIMEOUT,
STORAGE_KEY_AI_MAX_ITERATIONS,
STORAGE_KEY_AI_SESSIONS,
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
STORAGE_KEY_AI_AGENT_MODEL_MAP,
STORAGE_KEY_AI_WEB_SEARCH,
} from '../../infrastructure/config/storageKeys';
@@ -32,6 +33,12 @@ function getAIBridge() {
return (window as unknown as { netcatty?: Record<string, (...args: unknown[]) => unknown> }).netcatty;
}
const AI_STATE_CHANGED_EVENT = 'netcatty:ai-state-changed';
function emitAIStateChanged(key: string) {
window.dispatchEvent(new CustomEvent<{ key: string }>(AI_STATE_CHANGED_EVENT, { detail: { key } }));
}
function cleanupAcpSessions(sessionIds: string[]) {
const bridge = getAIBridge();
if (!bridge?.aiAcpCleanup || sessionIds.length === 0) return;
@@ -40,6 +47,83 @@ function cleanupAcpSessions(sessionIds: string[]) {
}
}
function isScopeKeyActive(scopeKey: string, activeTargetIds: Set<string>) {
const separatorIndex = scopeKey.indexOf(':');
if (separatorIndex === -1) return true;
const targetId = scopeKey.slice(separatorIndex + 1);
if (!targetId) return true;
return activeTargetIds.has(targetId);
}
export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
const currentSessions = latestAISessionsSnapshot
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
?? [];
const orphanedSessionIds = currentSessions
.filter((session) => session.scope.targetId && !activeTargetIds.has(session.scope.targetId))
.map((session) => session.id);
if (orphanedSessionIds.length > 0) {
const orphanedSessionIdSet = new Set(orphanedSessionIds);
// Determine which sessions can be restored via host-based matching
const preservedIds = new Set<string>();
for (const session of currentSessions) {
if (!orphanedSessionIdSet.has(session.id)) continue;
// Only preserve remote terminal sessions with real hostIds
const isRestorable = session.scope.type === 'terminal'
&& session.scope.hostIds?.length
&& session.scope.hostIds.some((id) => !id.startsWith('local-') && !id.startsWith('serial-'));
if (isRestorable) {
preservedIds.add(session.id);
}
}
// Cleanup ACP sessions for all orphans (both deleted and preserved).
// Preserved sessions will get a new externalSessionId on next use,
// so cleaning the old one is safe and prevents subprocess leaks.
cleanupAcpSessions(orphanedSessionIds);
const nextSessions = currentSessions
.filter((session) => !orphanedSessionIdSet.has(session.id) || preservedIds.has(session.id))
.map((session) => {
if (!preservedIds.has(session.id) || !session.externalSessionId) {
return session;
}
// Drop transient ACP session handles so the next turn starts cleanly.
return { ...session, externalSessionId: undefined };
});
const sessionsChanged = nextSessions.length !== currentSessions.length
|| nextSessions.some((session, index) => session !== currentSessions[index]);
if (sessionsChanged) {
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 of Object.keys(activeSessionIdMap)) {
if (isScopeKeyActive(scopeKey, activeTargetIds)) continue;
delete nextActiveSessionIdMap[scopeKey];
activeSessionMapChanged = true;
}
if (activeSessionMapChanged) {
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
}
}
/** Maximum number of sessions to keep in localStorage. */
const MAX_STORED_SESSIONS = 50;
@@ -66,6 +150,30 @@ 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;
}
function buildScopeKey(scope: AISessionScope) {
return `${scope.type}:${scope.targetId ?? ''}`;
}
function areHostIdsEqual(left?: string[], right?: string[]) {
const leftIds = left ?? [];
const rightIds = right ?? [];
if (leftIds.length !== rightIds.length) return false;
const rightSet = new Set(rightIds);
return leftIds.every((hostId) => rightSet.has(hostId));
}
export function useAIState() {
// ── Provider Config ──
const [providers, setProvidersRaw] = useState<ProviderConfig[]>(() =>
@@ -117,7 +225,9 @@ export function useAIState() {
sessionsRef.current = sessions;
}, [sessions]);
// Per-scope active session: keyed by `${scopeType}:${scopeTargetId}`
const [activeSessionIdMap, setActiveSessionIdMapRaw] = useState<Record<string, string | null>>({});
const [activeSessionIdMap, setActiveSessionIdMapRaw] = useState<Record<string, string | null>>(() =>
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {}
);
// Per-agent model selection: remembers last selected model per agent
const [agentModelMap, setAgentModelMapRaw] = useState<Record<string, string>>(() =>
@@ -129,8 +239,43 @@ export function useAIState() {
localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null
);
useEffect(() => {
setLatestAISessionsSnapshot(sessions);
}, [sessions]);
useEffect(() => {
setLatestAIActiveSessionMapSnapshot(activeSessionIdMap);
}, [activeSessionIdMap]);
useEffect(() => {
const validSessionIds = new Set(sessions.map((session) => session.id));
let changed = false;
const nextActiveSessionIdMap: Record<string, string | null> = {};
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
const nextSessionId = sessionId && validSessionIds.has(sessionId) ? sessionId : null;
nextActiveSessionIdMap[scopeKey] = nextSessionId;
if (nextSessionId !== sessionId) {
changed = true;
}
}
if (!changed) return;
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
setActiveSessionIdMapRaw(nextActiveSessionIdMap);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
}, [sessions, activeSessionIdMap]);
const setActiveSessionId = useCallback((scopeKey: string, id: string | null) => {
setActiveSessionIdMapRaw(prev => ({ ...prev, [scopeKey]: id }));
setActiveSessionIdMapRaw(prev => {
const next = { ...prev, [scopeKey]: id };
setLatestAIActiveSessionMapSnapshot(next);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
return next;
});
}, []);
const setAgentModel = useCallback((agentId: string, modelId: string) => {
@@ -303,9 +448,22 @@ export function useAIState() {
setHostPermissionsRaw(perms ?? []);
break;
}
case STORAGE_KEY_AI_SESSIONS: {
const nextSessions = localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? [];
setLatestAISessionsSnapshot(nextSessions);
setSessionsRaw(nextSessions);
break;
}
case STORAGE_KEY_AI_AGENT_MODEL_MAP:
setAgentModelMapRaw(localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP) ?? {});
break;
case STORAGE_KEY_AI_ACTIVE_SESSION_MAP: {
const nextActiveSessionIdMap =
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {};
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
setActiveSessionIdMapRaw(nextActiveSessionIdMap);
break;
}
case STORAGE_KEY_AI_WEB_SEARCH:
setWebSearchConfigRaw(localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null);
break;
@@ -315,7 +473,33 @@ export function useAIState() {
}
};
window.addEventListener('storage', handleStorage);
return () => window.removeEventListener('storage', handleStorage);
const handleLocalStateChanged = (event: Event) => {
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
if (!key) return;
switch (key) {
case STORAGE_KEY_AI_SESSIONS:
setSessionsRaw(
latestAISessionsSnapshot
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
?? [],
);
return;
case STORAGE_KEY_AI_ACTIVE_SESSION_MAP:
setActiveSessionIdMapRaw(
latestAIActiveSessionMapSnapshot
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
?? {},
);
return;
default:
handleStorage({ key } as StorageEvent);
}
};
window.addEventListener(AI_STATE_CHANGED_EVENT, handleLocalStateChanged);
return () => {
window.removeEventListener('storage', handleStorage);
window.removeEventListener(AI_STATE_CHANGED_EVENT, handleLocalStateChanged);
};
}, []);
// ── Sync initial safety settings to MCP Server on mount ──
@@ -375,6 +559,7 @@ export function useAIState() {
};
setSessionsRaw(prev => {
const next = [session, ...prev];
setLatestAISessionsSnapshot(next);
persistSessions(next);
return next;
});
@@ -391,12 +576,19 @@ export function useAIState() {
}
setSessionsRaw(prev => {
const next = prev.filter(s => s.id !== sessionId);
setLatestAISessionsSnapshot(next);
persistSessions(next);
return next;
});
if (scopeKey) {
setActiveSessionIdMapRaw(prev => {
if (prev[scopeKey] === sessionId) return { ...prev, [scopeKey]: null };
if (prev[scopeKey] === sessionId) {
const next = { ...prev, [scopeKey]: null };
setLatestAIActiveSessionMapSnapshot(next);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
return next;
}
return prev;
});
}
@@ -415,12 +607,19 @@ export function useAIState() {
const next = prev.filter(s => {
return !(s.scope.type === scopeType && s.scope.targetId === targetId);
});
setLatestAISessionsSnapshot(next);
persistSessions(next);
return next;
});
const scopeKey = `${scopeType}:${targetId}`;
setActiveSessionIdMapRaw(prev => {
if (prev[scopeKey] != null) return { ...prev, [scopeKey]: null };
if (prev[scopeKey] != null) {
const next = { ...prev, [scopeKey]: null };
setLatestAIActiveSessionMapSnapshot(next);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
return next;
}
return prev;
});
}, [persistSessions]);
@@ -428,6 +627,7 @@ export function useAIState() {
const updateSessionTitle = useCallback((sessionId: string, title: string) => {
setSessionsRaw(prev => {
const next = prev.map(s => s.id === sessionId ? { ...s, title, updatedAt: Date.now() } : s);
setLatestAISessionsSnapshot(next);
persistSessions(next);
return next;
});
@@ -440,11 +640,67 @@ export function useAIState() {
? { ...s, externalSessionId, updatedAt: Date.now() }
: s
));
setLatestAISessionsSnapshot(next);
debouncedPersistSessions();
return next;
});
}, [debouncedPersistSessions]);
const retargetSessionScope = useCallback((sessionId: string, scope: AISessionScope) => {
const currentSession = sessionsRef.current.find((session) => session.id === sessionId);
if (!currentSession) return;
const currentScope = currentSession.scope;
const scopeChanged =
currentScope.type !== scope.type
|| currentScope.targetId !== scope.targetId
|| !areHostIdsEqual(currentScope.hostIds, scope.hostIds);
const nextScopeKey = buildScopeKey(scope);
const currentScopeKey = buildScopeKey(currentScope);
if (scopeChanged) {
setSessionsRaw((prev) => {
let changed = false;
const next = prev.map((session) => {
if (session.id !== sessionId) return session;
changed = true;
// Clear stale ACP handle — retarget may run before orphan cleanup
return { ...session, scope, externalSessionId: undefined };
});
if (!changed) return prev;
sessionsRef.current = next;
setLatestAISessionsSnapshot(next);
persistSessions(next);
return next;
});
}
setActiveSessionIdMapRaw((prev) => {
let changed = false;
const next = { ...prev };
if (currentScopeKey !== nextScopeKey && next[currentScopeKey] === sessionId) {
delete next[currentScopeKey];
changed = true;
}
if (next[nextScopeKey] !== sessionId) {
next[nextScopeKey] = sessionId;
changed = true;
}
if (!changed) return prev;
setLatestAIActiveSessionMapSnapshot(next);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
return next;
});
}, [persistSessions]);
// Maximum messages per session to prevent unbounded memory growth
const MAX_MESSAGES_PER_SESSION = 500;
@@ -463,6 +719,7 @@ export function useAIState() {
}
return { ...s, messages: msgs, updatedAt: Date.now() };
});
setLatestAISessionsSnapshot(next);
debouncedPersistSessions();
return next;
});
@@ -476,6 +733,7 @@ export function useAIState() {
msgs[msgs.length - 1] = updater(msgs[msgs.length - 1]);
return { ...s, messages: msgs, updatedAt: Date.now() };
});
setLatestAISessionsSnapshot(next);
debouncedPersistSessions();
return next;
});
@@ -491,6 +749,7 @@ export function useAIState() {
msgs[idx] = updater(msgs[idx]);
return { ...s, messages: msgs, updatedAt: Date.now() };
});
setLatestAISessionsSnapshot(next);
debouncedPersistSessions();
return next;
});
@@ -503,29 +762,21 @@ export function useAIState() {
}
setSessionsRaw(prev => {
const next = prev.map(s => s.id === sessionId ? { ...s, messages: [], updatedAt: Date.now() } : s);
setLatestAISessionsSnapshot(next);
persistSessions(next);
return next;
});
}, [persistSessions]);
const cleanupOrphanedSessions = useCallback((activeTargetIds: Set<string>) => {
const removedSessionIds = sessionsRef.current
.filter(s => s.scope.targetId && !activeTargetIds.has(s.scope.targetId))
.map(s => s.id);
cleanupAcpSessions(removedSessionIds);
setSessionsRaw(prev => {
const next = prev.filter(s => {
// Keep sessions without a targetId (global scope)
if (!s.scope.targetId) return true;
// Keep sessions whose target still exists
return activeTargetIds.has(s.scope.targetId);
});
if (next.length !== prev.length) {
persistSessions(next);
}
return next;
});
}, [persistSessions]);
cleanupOrphanedAISessions(activeTargetIds);
setSessionsRaw(latestAISessionsSnapshot ?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? []);
setActiveSessionIdMapRaw(
latestAIActiveSessionMapSnapshot
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
?? {},
);
}, []);
// ── Provider CRUD helpers ──
const addProvider = useCallback((provider: ProviderConfig) => {
@@ -602,6 +853,7 @@ export function useAIState() {
deleteSessionsByTarget,
updateSessionTitle,
updateSessionExternalSessionId,
retargetSessionScope,
addMessageToSession,
updateLastMessage,
updateMessageById,

View File

@@ -7,7 +7,7 @@
* - Debounced sync to avoid too frequent API calls
*/
import { useCallback, useEffect, useRef } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCloudSync } from './useCloudSync';
import { useI18n } from '../i18n/I18nProvider';
import { getCloudSyncManager } from '../../infrastructure/services/CloudSyncManager';
@@ -16,11 +16,11 @@ import {
findSyncPayloadEncryptedCredentialPaths,
} from '../../domain/credentials';
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
import { collectSyncableSettings } from '../../domain/syncPayload';
import { collectSyncableSettings } from '../syncPayload';
import { STORAGE_KEY_PORT_FORWARDING } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { getEffectiveKnownHosts } from '../../infrastructure/syncHelpers';
import { toast } from '../../components/ui/toast';
import { notify } from '../notification';
interface AutoSyncConfig {
// Data to sync
@@ -60,6 +60,14 @@ export const useAutoSync = (config: AutoSyncConfig) => {
const isSyncRunningRef = useRef(false);
const skipNextSyncRef = useRef(false);
// Listen for SFTP bookmark changes to trigger auto-sync
const [bookmarksVersion, setBookmarksVersion] = useState(0);
useEffect(() => {
const handler = () => setBookmarksVersion((v) => v + 1);
window.addEventListener('sftp-bookmarks-changed', handler);
return () => window.removeEventListener('sftp-bookmarks-changed', handler);
}, []);
const getSyncSnapshot = useCallback(() => {
let effectivePFRules = config.portForwardingRules;
if (!effectivePFRules || effectivePFRules.length === 0) {
@@ -189,7 +197,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
throw error;
}
console.error('[AutoSync] Sync failed:', error);
toast.error(
notify.error(
error instanceof Error ? error.message : t('common.unknownError'),
t('sync.autoSync.failedTitle'),
);
@@ -218,7 +226,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
if (!connectedProvider) return;
try {
console.log('[AutoSync] Checking remote version...');
// Load base BEFORE downloading (downloadFromProvider overwrites the base)
const base = await manager.loadSyncBase(connectedProvider);
const remotePayload = await sync.downloadFromProvider(connectedProvider);
@@ -228,12 +235,11 @@ export const useAutoSync = (config: AutoSyncConfig) => {
const localPayload = buildPayload();
const mergeResult = mergeSyncPayloads(base, localPayload, remotePayload);
console.log('[AutoSync] Remote is newer, merged:', mergeResult.summary);
config.onApplyPayload(mergeResult.payload);
// Don't save base or skip auto-sync — let the data-change effect
// naturally trigger an upload of the merged payload (which will
// go through syncAllProviders and save base on success).
toast.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle'));
notify.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle'));
}
} catch (error) {
console.error('[AutoSync] Failed to check remote version:', error);
@@ -282,7 +288,6 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// Debounce sync by 3 seconds
syncTimeoutRef.current = setTimeout(() => {
console.log('[AutoSync] Data changed, syncing...');
syncNow();
}, 3000);
@@ -291,7 +296,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
clearTimeout(syncTimeoutRef.current);
}
};
}, [sync.hasAnyConnectedProvider, sync.autoSyncEnabled, sync.isUnlocked, sync.isSyncing, getDataHash, syncNow, config.settingsVersion]);
}, [sync.hasAnyConnectedProvider, sync.autoSyncEnabled, sync.isUnlocked, sync.isSyncing, getDataHash, syncNow, config.settingsVersion, bookmarksVersion]);
// Check remote version on startup/unlock
useEffect(() => {

View File

@@ -6,7 +6,7 @@
* Uses useSyncExternalStore for real-time state synchronization across all components.
*/
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
import {
type CloudProvider,
type SecurityState,
@@ -24,7 +24,6 @@ import {
isProviderReadyForSync,
} from '../../domain/sync';
import {
CloudSyncManager,
getCloudSyncManager,
type SyncManagerState,
} from '../../infrastructure/services/CloudSyncManager';
@@ -82,8 +81,10 @@ export interface CloudSyncHook {
code: string,
redirectUri: string
) => Promise<void>;
cancelOAuthConnect: () => void;
disconnectProvider: (provider: CloudProvider) => Promise<void>;
resetProviderStatus: (provider: CloudProvider) => void;
// Sync Actions
syncNow: (payload: SyncPayload) => Promise<Map<CloudProvider, SyncResult>>;
syncToProvider: (provider: CloudProvider, payload: SyncPayload) => Promise<SyncResult>;
@@ -103,12 +104,6 @@ export interface CloudSyncHook {
refresh: () => void;
}
export interface GitHubAuthState {
isAuthenticating: boolean;
deviceFlowState: DeviceFlowState | null;
error: string | null;
}
// ============================================================================
// Hook Implementation
// ============================================================================
@@ -127,17 +122,6 @@ const getSnapshot = (): SyncManagerState => {
};
export const useCloudSync = (): CloudSyncHook => {
// Force update mechanism to ensure React re-renders
const [, forceUpdate] = useState(0);
// Subscribe to state changes and force update
useEffect(() => {
const unsubscribe = manager.subscribeToStateChanges(() => {
forceUpdate(n => n + 1);
});
return unsubscribe;
}, []);
// Use useSyncExternalStore for real-time state sync across all components
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
@@ -273,7 +257,7 @@ export const useCloudSync = (): CloudSyncHook => {
throw new Error('Unexpected auth type');
}
const data = result.data as { url: string; redirectUri: string };
// Start OAuth callback server in Electron and wait for authorization
const bridge = netcattyBridge.get();
const startCallback = bridge?.startOAuthCallback;
@@ -281,32 +265,44 @@ export const useCloudSync = (): CloudSyncHook => {
// Get state from adapter for CSRF protection
const adapter = manager.getAdapter('google') as { getPKCEState?: () => string | null } | undefined;
const expectedState = adapter?.getPKCEState?.() || undefined;
// Start callback server and open browser
// Start callback server and open system browser
const callbackPromise = startCallback(expectedState);
// Open browser after starting server
setTimeout(() => {
window.open(data.url, "_blank", "width=600,height=700,noopener,noreferrer");
}, 100);
// Wait for callback
const { code } = await callbackPromise;
// Complete auth with the received code
await manager.completePKCEAuth('google', code, data.redirectUri);
// Use system browser to avoid white-screen issues in popup windows (#563)
// Race: if browser launch fails, surface the error immediately
let openTimer: ReturnType<typeof setTimeout> | null = null;
const browserPromise = new Promise<never>((_resolve, reject) => {
openTimer = setTimeout(async () => {
try {
await bridge?.openExternal(data.url);
} catch (err) {
bridge?.cancelOAuthCallback?.();
reject(err instanceof Error ? err : new Error('Failed to open browser for authentication'));
}
}, 100);
});
try {
const { code } = await Promise.race([callbackPromise, browserPromise]);
// Complete auth with the received code
await manager.completePKCEAuth('google', code, data.redirectUri);
} finally {
if (openTimer) clearTimeout(openTimer);
}
}
return data.url;
}, []);
const connectOneDrive = useCallback(async (): Promise<string> => {
const result = await manager.startProviderAuth('onedrive');
if (result.type !== 'url') {
throw new Error('Unexpected auth type');
}
const data = result.data as { url: string; redirectUri: string };
// Start OAuth callback server in Electron and wait for authorization
const bridge = netcattyBridge.get();
const startCallback = bridge?.startOAuthCallback;
@@ -314,22 +310,33 @@ export const useCloudSync = (): CloudSyncHook => {
// Get state from adapter for CSRF protection
const adapter = manager.getAdapter('onedrive') as { getPKCEState?: () => string | null } | undefined;
const expectedState = adapter?.getPKCEState?.() || undefined;
// Start callback server and open browser
// Start callback server and open system browser
const callbackPromise = startCallback(expectedState);
// Open browser after starting server
setTimeout(() => {
window.open(data.url, "_blank", "width=600,height=700,noopener,noreferrer");
}, 100);
// Wait for callback
const { code } = await callbackPromise;
// Complete auth with the received code
await manager.completePKCEAuth('onedrive', code, data.redirectUri);
// Use system browser to avoid white-screen issues in popup windows (#563)
let openTimer: ReturnType<typeof setTimeout> | null = null;
const browserPromise = new Promise<never>((_resolve, reject) => {
openTimer = setTimeout(async () => {
try {
await bridge?.openExternal(data.url);
} catch (err) {
bridge?.cancelOAuthCallback?.();
reject(err instanceof Error ? err : new Error('Failed to open browser for authentication'));
}
}, 100);
});
try {
const { code } = await Promise.race([callbackPromise, browserPromise]);
// Complete auth with the received code
await manager.completePKCEAuth('onedrive', code, data.redirectUri);
} finally {
if (openTimer) clearTimeout(openTimer);
}
}
return data.url;
}, []);
@@ -345,6 +352,10 @@ export const useCloudSync = (): CloudSyncHook => {
await manager.disconnectProvider(provider);
}, []);
const resetProviderStatus = useCallback((provider: CloudProvider): void => {
manager.resetProviderStatus(provider);
}, []);
const connectWebDAV = useCallback(async (config: WebDAVConfig): Promise<void> => {
await manager.connectConfigProvider('webdav', config);
}, []);
@@ -353,6 +364,11 @@ export const useCloudSync = (): CloudSyncHook => {
await manager.connectConfigProvider('s3', config);
}, []);
const cancelOAuthConnect = useCallback(() => {
const bridge = netcattyBridge.get();
bridge?.cancelOAuthCallback?.();
}, []);
// ========== Settings ==========
const setAutoSync = useCallback((enabled: boolean, intervalMinutes?: number) => {
@@ -450,8 +466,10 @@ export const useCloudSync = (): CloudSyncHook => {
connectWebDAV,
connectS3,
completePKCEAuth,
cancelOAuthConnect,
disconnectProvider,
resetProviderStatus,
// Sync Actions
syncNow: syncNowWithUnlock,
syncToProvider: syncToProviderWithUnlock,
@@ -472,60 +490,4 @@ export const useCloudSync = (): CloudSyncHook => {
};
};
// ============================================================================
// Convenience Hooks
// ============================================================================
/**
* Hook for just the security state (lighter weight)
*/
export const useSecurityState = () => {
const [manager] = useState<CloudSyncManager>(() => getCloudSyncManager());
const [securityState, setSecurityState] = useState<SecurityState>(
() => manager.getSecurityState()
);
useEffect(() => {
const unsubscribe = manager.subscribe((event) => {
if (event.type === 'SECURITY_STATE_CHANGED') {
setSecurityState(event.state);
}
});
return unsubscribe;
}, [manager]);
return {
securityState,
isUnlocked: securityState === 'UNLOCKED',
isLocked: securityState === 'LOCKED',
hasNoKey: securityState === 'NO_KEY',
};
};
/**
* Hook for provider status indicators
*/
export const useProviderStatus = (provider: CloudProvider) => {
const [manager] = useState<CloudSyncManager>(() => getCloudSyncManager());
const [connection, setConnection] = useState<ProviderConnection>(
() => manager.getProviderConnection(provider)
);
useEffect(() => {
const unsubscribe = manager.subscribe(() => {
setConnection(manager.getProviderConnection(provider));
});
return unsubscribe;
}, [manager, provider]);
return {
...connection,
isConnected: isProviderReadyForSync(connection),
isSyncing: connection.status === 'syncing',
hasError: connection.status === 'error',
dotColor: getSyncDotColor(connection.status),
lastSyncFormatted: formatLastSync(connection.lastSync),
};
};
export default useCloudSync;

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef } from 'react';
import { KeyBinding, matchesKeyBinding } from '../../domain/models';
export interface HotkeyActions {
interface HotkeyActions {
// Tab management
switchToTab: (tabIndex: number) => void;
nextTab: () => void;

View File

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

View File

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

View File

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

View File

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

View File

@@ -38,7 +38,9 @@ export const useSessionState = () => {
// Log views: stores open log replay tabs
const [logViews, setLogViews] = useState<LogView[]>([]);
const createLocalTerminal = useCallback(() => {
const createLocalTerminal = useCallback((options?: {
shellType?: TerminalSession['shellType'];
}) => {
const sessionId = crypto.randomUUID();
const localHostId = `local-${sessionId}`;
const newSession: TerminalSession = {
@@ -48,13 +50,15 @@ export const useSessionState = () => {
hostname: 'localhost',
username: 'local',
status: 'connecting',
protocol: 'local',
shellType: options?.shellType,
};
setSessions(prev => [...prev, newSession]);
setActiveTabId(sessionId);
return sessionId;
}, [setActiveTabId]);
const createSerialSession = useCallback((config: SerialConfig) => {
const createSerialSession = useCallback((config: SerialConfig, options?: { charset?: string }) => {
const sessionId = crypto.randomUUID();
const serialHostId = `serial-${sessionId}`;
const portName = config.path.split('/').pop() || config.path;
@@ -67,6 +71,7 @@ export const useSessionState = () => {
status: 'connecting',
protocol: 'serial',
serialConfig: config,
charset: options?.charset,
};
setSessions(prev => [...prev, newSession]);
setActiveTabId(sessionId);
@@ -99,6 +104,7 @@ export const useSessionState = () => {
status: 'connecting',
protocol: 'serial',
serialConfig: serialConfig,
charset: host.charset,
};
setSessions(prev => [...prev, newSession]);
setActiveTabId(sessionId);
@@ -116,6 +122,7 @@ export const useSessionState = () => {
protocol: host.protocol,
port: host.port,
moshEnabled: host.moshEnabled,
charset: host.charset,
};
setSessions(prev => [...prev, newSession]);
setActiveTabId(newSession.id);
@@ -317,6 +324,7 @@ export const useSessionState = () => {
status: 'connecting',
protocol: 'serial',
serialConfig: serialConfig,
charset: host.charset,
};
}
@@ -330,6 +338,7 @@ export const useSessionState = () => {
protocol: host.protocol,
port: host.port,
moshEnabled: host.moshEnabled,
charset: host.charset,
};
});
@@ -414,11 +423,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,8 +449,10 @@ export const useSessionState = () => {
protocol: session.protocol,
port: session.port,
moshEnabled: session.moshEnabled,
shellType: nextShellType,
charset: session.charset,
};
// Add pane to existing workspace
const hint: SplitHint = {
direction,
@@ -464,13 +481,15 @@ export const useSessionState = () => {
protocol: session.protocol,
port: session.port,
moshEnabled: session.moshEnabled,
shellType: nextShellType,
charset: session.charset,
};
const hint: SplitHint = {
direction,
position: direction === 'horizontal' ? 'bottom' : 'right',
};
const newWorkspace = createWorkspaceEntity(sessionId, newSession.id, hint);
setWorkspaces(prev => [...prev, newWorkspace]);
setActiveTabId(newWorkspace.id);
@@ -551,6 +570,7 @@ export const useSessionState = () => {
hostname: host.hostname,
username: host.username,
status: 'connecting' as const,
charset: host.charset,
// workspaceId will be set after workspace is created
}));
@@ -615,10 +635,15 @@ export const useSessionState = () => {
}, [setActiveTabId]);
// Copy a session - creates a new session with the same host connection
const copySession = useCallback((sessionId: string) => {
const copySession = useCallback((sessionId: string, options?: {
localShellType?: TerminalSession['shellType'];
}) => {
setSessions(prevSessions => {
const session = prevSessions.find(s => s.id === sessionId);
if (!session) return prevSessions;
const nextShellType = session.protocol === 'local'
? options?.localShellType
: session.shellType;
// Create a new session with the same connection info
const newSession: TerminalSession = {
@@ -631,6 +656,8 @@ export const useSessionState = () => {
protocol: session.protocol,
port: session.port,
moshEnabled: session.moshEnabled,
shellType: nextShellType,
charset: session.charset,
serialConfig: session.serialConfig,
};
@@ -664,9 +691,11 @@ export const useSessionState = () => {
...workspaces.map(w => w.id),
...logViews.map(lv => lv.id),
];
const allTabIdSet = new Set(allTabIds);
// Filter tabOrder to only include existing tabs, then add any new tabs at the end
const orderedIds = tabOrder.filter(id => allTabIds.includes(id));
const newIds = allTabIds.filter(id => !orderedIds.includes(id));
const orderedIds = tabOrder.filter(id => allTabIdSet.has(id));
const orderedIdSet = new Set(orderedIds);
const newIds = allTabIds.filter(id => !orderedIdSet.has(id));
return [...orderedIds, ...newIds];
}, [orphanSessions, workspaces, logViews, tabOrder]);
@@ -680,10 +709,12 @@ export const useSessionState = () => {
...workspaces.map(w => w.id),
...logViews.map(lv => lv.id),
];
const allTabIdSet = new Set(allTabIds);
// Build current effective order: existing order + new tabs at end
const orderedIds = prevTabOrder.filter(id => allTabIds.includes(id));
const newIds = allTabIds.filter(id => !orderedIds.includes(id));
const orderedIds = prevTabOrder.filter(id => allTabIdSet.has(id));
const orderedIdSet = new Set(orderedIds);
const newIds = allTabIds.filter(id => !orderedIdSet.has(id));
const currentOrder = [...orderedIds, ...newIds];
const draggedIndex = currentOrder.indexOf(draggedId);

View File

@@ -22,6 +22,8 @@ import {
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY,
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
STORAGE_KEY_EDITOR_WORD_WRAP,
STORAGE_KEY_SESSION_LOGS_ENABLED,
STORAGE_KEY_SESSION_LOGS_DIR,
@@ -30,6 +32,8 @@ import {
STORAGE_KEY_CLOSE_TO_TRAY,
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
STORAGE_KEY_AUTO_UPDATE_ENABLED,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
} from '../../infrastructure/config/storageKeys';
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
@@ -38,7 +42,6 @@ import { DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
import { DARK_UI_THEMES, LIGHT_UI_THEMES, UiThemeTokens, getUiThemeById } from '../../infrastructure/config/uiThemes';
import { UI_FONTS, DEFAULT_UI_FONT_ID } from '../../infrastructure/config/uiFonts';
import { uiFontStore, useUIFontsLoaded } from './uiFontStore';
import { useAvailableFonts } from './fontStore';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
@@ -65,6 +68,7 @@ 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;
const DEFAULT_SFTP_DEFAULT_VIEW_MODE: 'list' | 'tree' = 'list';
// Editor defaults
const DEFAULT_EDITOR_WORD_WRAP = false;
@@ -121,8 +125,11 @@ const applyThemeTokens = (
accentOverride: string,
) => {
const root = window.document.documentElement;
root.classList.remove('light', 'dark');
root.classList.add(resolvedTheme);
// If immersive mode is active (style tag present), it owns the dark/light class — don't override
if (!document.getElementById('netcatty-immersive-override')) {
root.classList.remove('light', 'dark');
root.classList.add(resolvedTheme);
}
root.style.setProperty('--background', tokens.background);
root.style.setProperty('--foreground', tokens.foreground);
root.style.setProperty('--card', tokens.card);
@@ -155,7 +162,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);
@@ -237,6 +243,14 @@ export const useSettingsState = () => {
const stored = readStoredString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
return stored === 'true' ? true : DEFAULT_SFTP_AUTO_OPEN_SIDEBAR;
});
const [sftpDefaultViewMode, setSftpDefaultViewMode] = useState<'list' | 'tree'>(() => {
const stored = readStoredString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE);
return (stored === 'list' || stored === 'tree') ? stored : DEFAULT_SFTP_DEFAULT_VIEW_MODE;
});
const [sftpTransferConcurrency, setSftpTransferConcurrencyState] = useState<number>(() => {
const stored = localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY);
return stored != null && stored >= 1 && stored <= 16 ? stored : 4;
});
// Editor Settings
const [editorWordWrap, setEditorWordWrapState] = useState<boolean>(() => {
@@ -287,6 +301,10 @@ export const useSettingsState = () => {
const localTerminalSettingsVersionRef = useRef(0);
const broadcastedLocalTerminalSettingsVersionRef = useRef(0);
// Fix 1: Mount guard — skip redundant IPC broadcasts & localStorage writes on initial mount.
// Set to true by the LAST useEffect declaration; all persist effects see false on first render.
const persistMountedRef = useRef(false);
const setTerminalSettings = useCallback((nextValue: SetStateAction<TerminalSettings>) => {
setTerminalSettingsState((prev) => {
const candidate = typeof nextValue === 'function'
@@ -322,6 +340,29 @@ export const useSettingsState = () => {
}
}, []);
// Immersive mode is always enabled — the toggle has been removed from settings
const immersiveMode = true;
const setImmersiveMode = useCallback((_enabled: boolean) => {
// no-op: immersive mode is always on
}, []);
const setSftpTransferConcurrency = useCallback((value: number) => {
const clamped = Math.max(1, Math.min(16, Math.round(value)));
setSftpTransferConcurrencyState(clamped);
localStorageAdapter.writeString(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY, String(clamped));
notifySettingsChanged(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY, clamped);
}, [notifySettingsChanged]);
const [workspaceFocusStyle, setWorkspaceFocusStyleState] = useState<'dim' | 'border'>(() => {
const stored = localStorageAdapter.readString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
return stored === 'border' ? 'border' : 'dim';
});
const setWorkspaceFocusStyle = useCallback((style: 'dim' | 'border') => {
setWorkspaceFocusStyleState(style);
localStorageAdapter.writeString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE, style);
notifySettingsChanged(STORAGE_KEY_WORKSPACE_FOCUS_STYLE, style);
}, [notifySettingsChanged]);
const syncAppearanceFromStorage = useCallback(() => {
const storedTheme = readStoredString(STORAGE_KEY_THEME);
const nextTheme = storedTheme && isValidTheme(storedTheme) ? storedTheme : theme;
@@ -334,6 +375,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);
@@ -401,10 +453,16 @@ export const useSettingsState = () => {
if (storedCompress === 'true' || storedCompress === 'false') setSftpUseCompressedUpload(storedCompress === 'true');
const storedAutoOpenSidebar = readStoredString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
if (storedAutoOpenSidebar === 'true' || storedAutoOpenSidebar === 'false') setSftpAutoOpenSidebar(storedAutoOpenSidebar === 'true');
const storedDefaultViewMode = readStoredString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE);
if (storedDefaultViewMode === 'list' || storedDefaultViewMode === 'tree') setSftpDefaultViewMode(storedDefaultViewMode);
// Workspace focus style
const storedFocusStyle = readStoredString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
if (storedFocusStyle === 'dim' || storedFocusStyle === 'border') setWorkspaceFocusStyleState(storedFocusStyle);
// Custom terminal themes
customThemeStore.loadFromStorage();
}, [syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings]);
}, [syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings, notifySettingsChanged]);
useLayoutEffect(() => {
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
@@ -414,12 +472,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
@@ -437,7 +494,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
@@ -446,7 +506,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
@@ -540,6 +603,17 @@ export const useSettingsState = () => {
if (key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && typeof value === 'boolean') {
setSftpAutoOpenSidebar((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE && typeof value === 'string') {
if (value === 'list' || value === 'tree') {
setSftpDefaultViewMode((prev) => (prev === value ? prev : value));
}
}
if (key === STORAGE_KEY_WORKSPACE_FOCUS_STYLE && (value === 'dim' || value === 'border')) {
setWorkspaceFocusStyleState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY && typeof value === 'number') {
setSftpTransferConcurrencyState((prev) => (prev === value ? prev : value));
}
});
return () => {
try {
@@ -567,53 +641,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, sftpDefaultViewMode,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
globalHotkeyEnabled, autoUpdateEnabled, immersiveMode,
});
settingsSnapshotRef.current = {
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
globalHotkeyEnabled, autoUpdateEnabled, immersiveMode,
};
// Listen for storage changes from other windows (cross-window sync)
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
const s = settingsSnapshotRef.current;
if (e.key === STORAGE_KEY_THEME && e.newValue) {
if (isValidTheme(e.newValue) && e.newValue !== theme) {
if (isValidTheme(e.newValue) && e.newValue !== s.theme) {
setTheme(e.newValue);
}
}
if (e.key === STORAGE_KEY_UI_THEME_LIGHT && e.newValue) {
if (isValidUiThemeId('light', e.newValue) && e.newValue !== lightUiThemeId) {
if (isValidUiThemeId('light', e.newValue) && e.newValue !== s.lightUiThemeId) {
setLightUiThemeId(e.newValue);
}
}
if (e.key === STORAGE_KEY_UI_THEME_DARK && e.newValue) {
if (isValidUiThemeId('dark', e.newValue) && e.newValue !== darkUiThemeId) {
if (isValidUiThemeId('dark', e.newValue) && e.newValue !== s.darkUiThemeId) {
setDarkUiThemeId(e.newValue);
}
}
if (e.key === STORAGE_KEY_ACCENT_MODE && e.newValue) {
if ((e.newValue === 'theme' || e.newValue === 'custom') && e.newValue !== accentMode) {
if ((e.newValue === 'theme' || e.newValue === 'custom') && e.newValue !== s.accentMode) {
setAccentMode(e.newValue);
}
}
if (e.key === STORAGE_KEY_COLOR && e.newValue) {
if (isValidHslToken(e.newValue) && e.newValue !== customAccent) {
if (isValidHslToken(e.newValue) && e.newValue !== s.customAccent) {
setCustomAccent(e.newValue.trim());
}
}
if (e.key === STORAGE_KEY_CUSTOM_CSS && e.newValue !== null) {
if (e.newValue !== customCSS) {
if (e.newValue !== s.customCSS) {
setCustomCSS(e.newValue);
}
}
if (e.key === STORAGE_KEY_UI_FONT_FAMILY && e.newValue) {
if (isValidUiFontId(e.newValue) && e.newValue !== uiFontFamilyId) {
if (isValidUiFontId(e.newValue) && e.newValue !== s.uiFontFamilyId) {
setUiFontFamilyId(e.newValue);
}
}
if (e.key === STORAGE_KEY_HOTKEY_SCHEME && e.newValue) {
const newScheme = e.newValue as HotkeyScheme;
if (newScheme !== hotkeyScheme) {
if (newScheme !== s.hotkeyScheme) {
setHotkeyScheme(newScheme);
}
}
if (e.key === STORAGE_KEY_UI_LANGUAGE && e.newValue) {
const next = resolveSupportedLocale(e.newValue);
if (next !== uiLanguage) {
if (next !== s.uiLanguage) {
setUiLanguage(next as UILanguage);
}
}
@@ -636,64 +733,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);
}
@@ -701,54 +798,77 @@ 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 !== sftpAutoOpenSidebar) {
if (newValue !== s.sftpAutoOpenSidebar) {
setSftpAutoOpenSidebar(newValue);
}
}
// Sync SFTP default view mode from other windows
if (e.key === STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE && e.newValue) {
if ((e.newValue === 'list' || e.newValue === 'tree') && e.newValue !== s.sftpDefaultViewMode) {
setSftpDefaultViewMode(e.newValue);
}
}
// Sync global hotkey enabled setting from other windows
if (e.key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== globalHotkeyEnabled) {
if (newValue !== s.globalHotkeyEnabled) {
setGlobalHotkeyEnabled(newValue);
}
}
// Sync auto-update enabled setting from other windows
if (e.key === STORAGE_KEY_AUTO_UPDATE_ENABLED && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== autoUpdateEnabled) {
if (newValue !== s.autoUpdateEnabled) {
setAutoUpdateEnabled(newValue);
}
}
// Sync workspace focus style from other windows
if (e.key === STORAGE_KEY_WORKSPACE_FOCUS_STYLE && e.newValue !== null) {
if (e.newValue === 'dim' || e.newValue === 'border') {
setWorkspaceFocusStyleState(e.newValue);
}
}
// Sync transfer concurrency from other windows
if (e.key === STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY && e.newValue !== null) {
const num = Number(e.newValue);
if (num >= 1 && num <= 16) {
setSftpTransferConcurrencyState(num);
}
}
};
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, sftpAutoOpenSidebar, editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, globalHotkeyEnabled, autoUpdateEnabled, mergeIncomingTerminalSettings]);
}, [mergeIncomingTerminalSettings]); // Fix 4: stable deps only — state comparisons use settingsSnapshotRef
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_TERM_THEME, terminalThemeId);
}, [terminalThemeId, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, terminalFontFamilyId);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_TERM_FONT_FAMILY, terminalFontFamilyId);
}, [terminalFontFamilyId, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeNumber(STORAGE_KEY_TERM_FONT_SIZE, terminalFontSize);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_TERM_FONT_SIZE, terminalFontSize);
}, [terminalFontSize, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.write(STORAGE_KEY_TERM_SETTINGS, terminalSettings);
if (!persistMountedRef.current) return;
const currentSignature = serializeTerminalSettings(terminalSettings);
const hasPendingUnbroadcastLocalChanges =
localTerminalSettingsVersionRef.current !== broadcastedLocalTerminalSettingsVersionRef.current;
@@ -763,11 +883,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]);
@@ -778,10 +900,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');
@@ -789,59 +908,76 @@ 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 SFTP default view mode
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE, sftpDefaultViewMode);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE, sftpDefaultViewMode);
}, [sftpDefaultViewMode, notifySettingsChanged]);
// Persist Session Logs settings
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_ENABLED, sessionLogsEnabled ? 'true' : 'false');
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_ENABLED, sessionLogsEnabled);
}, [sessionLogsEnabled, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_DIR, sessionLogsDir);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_DIR, sessionLogsDir);
}, [sessionLogsDir, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_FORMAT, sessionLogsFormat);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_FORMAT, sessionLogsFormat);
}, [sessionLogsFormat, notifySettingsChanged]);
// Persist and sync toggle window hotkey setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
notifySettingsChanged(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
// Register/unregister the global hotkey in main process
// Register/unregister the global hotkey in main process (needed on mount)
const bridge = netcattyBridge.get();
if (bridge?.registerGlobalHotkey) {
if (toggleWindowHotkey && globalHotkeyEnabled) {
@@ -865,25 +1001,32 @@ export const useSettingsState = () => {
});
}
}
localStorageAdapter.writeString(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
// Skip IPC on initial mount
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
}, [toggleWindowHotkey, globalHotkeyEnabled, notifySettingsChanged]);
// Persist global hotkey enabled setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, globalHotkeyEnabled ? 'true' : 'false');
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, globalHotkeyEnabled);
}, [globalHotkeyEnabled, notifySettingsChanged]);
// Persist and sync close to tray setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray ? 'true' : 'false');
notifySettingsChanged(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray);
// Update main process tray behavior
// Update main process tray behavior (needed on mount)
const bridge = netcattyBridge.get();
if (bridge?.setCloseToTray) {
bridge.setCloseToTray(closeToTray).catch((err) => {
console.warn('[SystemTray] Failed to set close-to-tray:', err);
});
}
localStorageAdapter.writeString(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray ? 'true' : 'false');
// Skip IPC on initial mount
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray);
}, [closeToTray, notifySettingsChanged]);
// Hydrate auto-update state from the main-process preference file on mount.
@@ -904,16 +1047,11 @@ export const useSettingsState = () => {
}, []);
// Persist auto-update enabled setting.
// Skip IPC on initial mount to avoid overwriting the main-process preference
// file when localStorage has been cleared (where the default is true).
const autoUpdateMountedRef = useRef(false);
// Initial mount still writes localStorage, but skips cross-window/main-process IPC.
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_AUTO_UPDATE_ENABLED, autoUpdateEnabled ? 'true' : 'false');
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_AUTO_UPDATE_ENABLED, autoUpdateEnabled);
if (!autoUpdateMountedRef.current) {
autoUpdateMountedRef.current = true;
return; // Skip IPC on initial mount
}
// Notify main process on user-initiated changes
const bridge = netcattyBridge.get();
bridge?.setAutoUpdate?.(autoUpdateEnabled).catch((err: unknown) => {
@@ -921,6 +1059,13 @@ export const useSettingsState = () => {
});
}, [autoUpdateEnabled, notifySettingsChanged]);
// Fix 1: Mark all persist effects as mounted.
// This MUST be declared AFTER all persist useEffects so that React runs it last
// during the initial mount cycle (effects fire in declaration order).
useEffect(() => {
persistMountedRef.current = true;
}, []);
// Get merged key bindings (defaults + custom overrides)
const keyBindings = useMemo((): KeyBinding[] => {
return DEFAULT_KEY_BINDINGS.map(binding => {
@@ -983,11 +1128,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]
@@ -995,6 +1135,12 @@ export const useSettingsState = () => {
setTerminalSettings(prev => ({ ...prev, [key]: value }));
}, [setTerminalSettings]);
/** Re-apply the current UI theme tokens (used to restore after immersive mode override). */
const reapplyCurrentTheme = useCallback(() => {
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
return {
theme,
setTheme,
@@ -1018,7 +1164,6 @@ export const useSettingsState = () => {
currentTerminalTheme,
terminalFontFamilyId,
setTerminalFontFamilyId,
currentTerminalFont,
terminalFontSize,
setTerminalFontSize,
terminalSettings,
@@ -1045,6 +1190,10 @@ export const useSettingsState = () => {
setSftpUseCompressedUpload,
sftpAutoOpenSidebar,
setSftpAutoOpenSidebar,
sftpDefaultViewMode,
setSftpDefaultViewMode,
sftpTransferConcurrency,
setSftpTransferConcurrency,
// Editor Settings
editorWordWrap,
setEditorWordWrap: useCallback((enabled: boolean) => {
@@ -1052,7 +1201,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,
@@ -1071,6 +1219,11 @@ export const useSettingsState = () => {
globalHotkeyEnabled,
setGlobalHotkeyEnabled,
rehydrateAllFromStorage,
reapplyCurrentTheme,
immersiveMode,
setImmersiveMode,
workspaceFocusStyle,
setWorkspaceFocusStyle,
// Opaque version that changes when any synced setting changes, used by useAutoSync.
// eslint-disable-next-line react-hooks/exhaustive-deps
settingsVersion: useMemo(() => Math.random(), [
@@ -1078,8 +1231,8 @@ export const useSettingsState = () => {
uiFontFamilyId, uiLanguage, customCSS,
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
customKeyBindings, editorWordWrap,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar,
customThemes,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
customThemes, immersiveMode, workspaceFocusStyle,
]),
};
};

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

@@ -3,10 +3,10 @@
* Uses a shared state pattern to sync across components
*/
import { useCallback, useEffect, useSyncExternalStore } from 'react';
import { STORAGE_KEY_SFTP_FILE_ASSOCIATIONS } from '../../infrastructure/config/storageKeys';
import { STORAGE_KEY_SFTP_FILE_ASSOCIATIONS, STORAGE_KEY_SFTP_DEFAULT_OPENER } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import type { FileAssociation, FileOpenerType, SystemAppInfo } from '../../lib/sftpFileUtils';
import { getFileExtension } from '../../lib/sftpFileUtils';
import { getFileExtension, isKnownBinaryFile } from '../../lib/sftpFileUtils';
export interface FileAssociationEntry {
openerType: FileOpenerType;
@@ -17,15 +17,16 @@ export interface FileAssociationsMap {
[extension: string]: FileAssociationEntry;
}
// Shared state and subscribers for cross-component synchronization
// ---------------------------------------------------------------------------
// Per-extension associations store
// ---------------------------------------------------------------------------
const subscribers = new Set<() => void>();
// Use a wrapper object so we can update the reference for useSyncExternalStore
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,29 +36,20 @@ function loadFromStorage(): FileAssociationsMap {
migrated[ext] = value as FileAssociationEntry;
}
}
console.log('[SftpFileAssociations] Migrated associations:', migrated);
return migrated;
}
return {};
}
// Initialize from storage
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());
}
@@ -70,15 +62,54 @@ function getSnapshot() {
return snapshotRef;
}
// ---------------------------------------------------------------------------
// Default opener store (separate from per-extension associations)
// ---------------------------------------------------------------------------
const defaultOpenerSubscribers = new Set<() => void>();
let defaultOpenerSnapshot: { entry: FileAssociationEntry | null } = {
entry: localStorageAdapter.read<FileAssociationEntry>(STORAGE_KEY_SFTP_DEFAULT_OPENER) ?? null,
};
function subscribeDefaultOpener(callback: () => void) {
defaultOpenerSubscribers.add(callback);
return () => defaultOpenerSubscribers.delete(callback);
}
function getDefaultOpenerSnapshot() {
return defaultOpenerSnapshot;
}
function updateDefaultOpener(entry: FileAssociationEntry | null) {
defaultOpenerSnapshot = { entry };
if (entry) {
localStorageAdapter.write(STORAGE_KEY_SFTP_DEFAULT_OPENER, entry);
} else {
localStorageAdapter.remove(STORAGE_KEY_SFTP_DEFAULT_OPENER);
}
defaultOpenerSubscribers.forEach(callback => callback());
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
export function useSftpFileAssociations() {
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const associations = snapshot.associations;
const defaultOpenerState = useSyncExternalStore(subscribeDefaultOpener, getDefaultOpenerSnapshot, getDefaultOpenerSnapshot);
// Listen for storage events from other tabs/windows
useEffect(() => {
const handleStorage = (e: StorageEvent) => {
if (e.key === STORAGE_KEY_SFTP_FILE_ASSOCIATIONS) {
updateAssociations(loadFromStorage());
} else if (e.key === STORAGE_KEY_SFTP_DEFAULT_OPENER) {
updateDefaultOpener(
localStorageAdapter.read<FileAssociationEntry>(STORAGE_KEY_SFTP_DEFAULT_OPENER) ?? null,
);
}
};
window.addEventListener('storage', handleStorage);
@@ -86,23 +117,49 @@ export function useSftpFileAssociations() {
}, []);
/**
* Get the opener entry for a file based on its extension
* Get the opener entry for a file based on its extension.
* Falls back to the default opener when no per-extension association exists.
*/
const getOpenerForFile = useCallback((fileName: string): FileAssociationEntry | null => {
const ext = getFileExtension(fileName);
return associations[ext] || null;
}, [associations]);
if (associations[ext]) return associations[ext];
// Fall back to default opener, but skip built-in editor for binary files
const fallback = defaultOpenerState.entry;
if (fallback && fallback.openerType === 'builtin-editor' && isKnownBinaryFile(fileName)) {
return null;
}
return fallback;
}, [associations, defaultOpenerState]);
/**
* Get the default (fallback) opener, if set.
*/
const getDefaultOpener = useCallback((): FileAssociationEntry | null => {
return defaultOpenerState.entry;
}, [defaultOpenerState]);
/**
* Set the default opener used when no per-extension association exists.
*/
const setDefaultOpener = useCallback((openerType: FileOpenerType, systemApp?: SystemAppInfo) => {
updateDefaultOpener({ openerType, systemApp });
}, []);
/**
* Remove the default opener.
*/
const removeDefaultOpener = useCallback(() => {
updateDefaultOpener(null);
}, []);
/**
* Set the opener type for a specific extension
*/
const setOpenerForExtension = useCallback((
extension: string,
extension: string,
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 },
@@ -119,16 +176,14 @@ export function useSftpFileAssociations() {
}, []);
/**
* Get all associations as an array
* Get all per-extension 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]);
/**
@@ -141,6 +196,9 @@ export function useSftpFileAssociations() {
return {
associations,
getOpenerForFile,
getDefaultOpener,
setDefaultOpener,
removeDefaultOpener,
setOpenerForExtension,
removeAssociation,
getAllAssociations,

View File

@@ -57,6 +57,7 @@ export const useSftpState = (
getActivePane,
updateTab,
updateActiveTab,
clearSelectionsExcept,
setTabShowHiddenFiles,
addTab,
closeTab,
@@ -110,6 +111,30 @@ export const useSftpState = (
}
}, []);
const getPaneByConnectionId = useCallback((connectionId: string) => {
for (const tab of leftTabsRef.current.tabs) {
if (tab.connection?.id === connectionId) return tab;
}
for (const tab of rightTabsRef.current.tabs) {
if (tab.connection?.id === connectionId) return tab;
}
return null;
}, [leftTabsRef, rightTabsRef]);
const getTabByConnectionId = useCallback((connectionId: string) => {
for (const tab of leftTabsRef.current.tabs) {
if (tab.connection?.id === connectionId) {
return { side: "left" as const, tabId: tab.id, pane: tab };
}
}
for (const tab of rightTabsRef.current.tabs) {
if (tab.connection?.id === connectionId) {
return { side: "right" as const, tabId: tab.id, pane: tab };
}
}
return null;
}, [leftTabsRef, rightTabsRef]);
// Ref to track pending reconnections to avoid multiple reconnect attempts
const reconnectingRef = useRef<{ left: boolean; right: boolean }>({
left: false,
@@ -183,10 +208,14 @@ export const useSftpState = (
selectAll,
getFilteredFiles,
createDirectory,
createDirectoryAtPath,
createFile,
createFileAtPath,
deleteFiles,
deleteFilesAtPath,
renameFile,
renameFileAtPath,
moveEntriesToPath,
changePermissions,
} = useSftpPaneActions({
hosts,
@@ -207,6 +236,7 @@ export const useSftpState = (
listRemoteFiles,
handleSessionError,
isSessionError,
clearSelectionsExcept,
dirCacheTtlMs: DIR_CACHE_TTL_MS,
});
@@ -244,6 +274,7 @@ export const useSftpState = (
conflicts,
activeTransfersCount,
startTransfer,
downloadToLocal,
addExternalUpload,
updateExternalUpload,
cancelTransfer,
@@ -254,8 +285,13 @@ export const useSftpState = (
resolveConflict,
} = useSftpTransfers({
getActivePane,
getPaneByConnectionId,
getTabByConnectionId,
updateTab,
refresh,
clearCacheForConnection,
sftpSessionsRef,
connectionCacheKeyMapRef,
listLocalFiles,
listRemoteFiles,
handleSessionError,
@@ -305,15 +341,20 @@ export const useSftpState = (
toggleSelection,
rangeSelect,
clearSelection,
clearSelectionsExcept,
selectAll,
setFilter,
setFilenameEncoding,
setShowHiddenFiles,
createDirectory,
createDirectoryAtPath,
createFile,
createFileAtPath,
deleteFiles,
deleteFilesAtPath,
renameFile,
renameFileAtPath,
moveEntriesToPath,
changePermissions,
readTextFile,
readBinaryFile,
@@ -324,6 +365,7 @@ export const useSftpState = (
cancelExternalUpload,
selectApplication,
startTransfer,
downloadToLocal,
addExternalUpload,
updateExternalUpload,
cancelTransfer,
@@ -332,6 +374,7 @@ export const useSftpState = (
dismissTransfer,
resolveConflict,
getSftpIdForConnection,
reportSessionError: handleSessionError,
});
methodsRef.current = {
getFilteredFiles,
@@ -352,15 +395,20 @@ export const useSftpState = (
toggleSelection,
rangeSelect,
clearSelection,
clearSelectionsExcept,
selectAll,
setFilter,
setFilenameEncoding,
setShowHiddenFiles,
createDirectory,
createDirectoryAtPath,
createFile,
createFileAtPath,
deleteFiles,
deleteFilesAtPath,
renameFile,
renameFileAtPath,
moveEntriesToPath,
changePermissions,
readTextFile,
readBinaryFile,
@@ -371,6 +419,7 @@ export const useSftpState = (
cancelExternalUpload,
selectApplication,
startTransfer,
downloadToLocal,
addExternalUpload,
updateExternalUpload,
cancelTransfer,
@@ -379,6 +428,7 @@ export const useSftpState = (
dismissTransfer,
resolveConflict,
getSftpIdForConnection,
reportSessionError: handleSessionError,
};
// Create stable method wrappers that call through methodsRef
@@ -402,6 +452,8 @@ export const useSftpState = (
toggleSelection: (...args: Parameters<typeof toggleSelection>) => methodsRef.current.toggleSelection(...args),
rangeSelect: (...args: Parameters<typeof rangeSelect>) => methodsRef.current.rangeSelect(...args),
clearSelection: (...args: Parameters<typeof clearSelection>) => methodsRef.current.clearSelection(...args),
clearSelectionsExcept: (...args: Parameters<typeof clearSelectionsExcept>) =>
methodsRef.current.clearSelectionsExcept(...args),
selectAll: (...args: Parameters<typeof selectAll>) => methodsRef.current.selectAll(...args),
setFilter: (...args: Parameters<typeof setFilter>) => methodsRef.current.setFilter(...args),
setFilenameEncoding: (...args: Parameters<typeof setFilenameEncoding>) =>
@@ -409,11 +461,17 @@ export const useSftpState = (
setShowHiddenFiles: (...args: Parameters<typeof setShowHiddenFiles>) =>
methodsRef.current.setShowHiddenFiles(...args),
createDirectory: (...args: Parameters<typeof createDirectory>) => methodsRef.current.createDirectory(...args),
createDirectoryAtPath: (...args: Parameters<typeof createDirectoryAtPath>) =>
methodsRef.current.createDirectoryAtPath(...args),
createFile: (...args: Parameters<typeof createFile>) => methodsRef.current.createFile(...args),
createFileAtPath: (...args: Parameters<typeof createFileAtPath>) =>
methodsRef.current.createFileAtPath(...args),
deleteFiles: (...args: Parameters<typeof deleteFiles>) => methodsRef.current.deleteFiles(...args),
deleteFilesAtPath: (...args: Parameters<typeof deleteFilesAtPath>) =>
methodsRef.current.deleteFilesAtPath(...args),
renameFile: (...args: Parameters<typeof renameFile>) => methodsRef.current.renameFile(...args),
renameFileAtPath: (...args: Parameters<typeof renameFileAtPath>) => methodsRef.current.renameFileAtPath(...args),
moveEntriesToPath: (...args: Parameters<typeof moveEntriesToPath>) => methodsRef.current.moveEntriesToPath(...args),
changePermissions: (...args: Parameters<typeof changePermissions>) => methodsRef.current.changePermissions(...args),
readTextFile: (...args: Parameters<typeof readTextFile>) => methodsRef.current.readTextFile(...args),
readBinaryFile: (...args: Parameters<typeof readBinaryFile>) => methodsRef.current.readBinaryFile(...args),
@@ -425,6 +483,7 @@ export const useSftpState = (
cancelExternalUpload: () => methodsRef.current.cancelExternalUpload(),
selectApplication: () => methodsRef.current.selectApplication(),
startTransfer: (...args: Parameters<typeof startTransfer>) => methodsRef.current.startTransfer(...args),
downloadToLocal: (...args: Parameters<typeof downloadToLocal>) => methodsRef.current.downloadToLocal(...args),
addExternalUpload: (...args: Parameters<typeof addExternalUpload>) => methodsRef.current.addExternalUpload(...args),
updateExternalUpload: (...args: Parameters<typeof updateExternalUpload>) => methodsRef.current.updateExternalUpload(...args),
cancelTransfer: (...args: Parameters<typeof cancelTransfer>) => methodsRef.current.cancelTransfer(...args),
@@ -433,6 +492,7 @@ export const useSftpState = (
dismissTransfer: (...args: Parameters<typeof dismissTransfer>) => methodsRef.current.dismissTransfer(...args),
resolveConflict: (...args: Parameters<typeof resolveConflict>) => methodsRef.current.resolveConflict(...args),
getSftpIdForConnection: (...args: Parameters<typeof getSftpIdForConnection>) => methodsRef.current.getSftpIdForConnection(...args),
reportSessionError: (...args: Parameters<typeof handleSessionError>) => methodsRef.current.reportSessionError(...args),
activeFileWatchCountRef,
}), [activeFileWatchCountRef]); // activeFileWatchCountRef is a stable ref

View File

@@ -0,0 +1,29 @@
import { useCallback, useState } from "react";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
/**
* Hook for reading a number from localStorage with lazy persistence.
* Unlike useStoredString/useStoredBoolean, this hook does NOT auto-persist
* on every state change — call `persist()` explicitly when ready (e.g. on
* mouseup after a drag). This avoids flooding localStorage during
* high-frequency updates like resize drags.
*/
export const useStoredNumber = (
storageKey: string,
fallback: number,
clamp?: { min: number; max: number },
) => {
const [value, setValue] = useState<number>(() => {
const stored = localStorageAdapter.readNumber(storageKey);
if (stored === null) return fallback;
if (clamp) return Math.max(clamp.min, Math.min(clamp.max, stored));
return stored;
});
const persist = useCallback(
(v: number) => localStorageAdapter.writeNumber(storageKey, v),
[storageKey],
);
return [value, setValue, persist] as const;
};

View File

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

View File

@@ -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, STORAGE_KEY_AUTO_UPDATE_ENABLED } 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, STORAGE_KEY_DEBUG_UPDATE_DEMO } from '../../infrastructure/config/storageKeys';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
// Check for updates at most once per hour
@@ -13,15 +13,10 @@ const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000;
// arrives after 8s the duplicate check is avoided.
const STARTUP_CHECK_DELAY_MS = 8000;
// Enable demo mode for development (set via localStorage: localStorage.setItem('debug.updateDemo', '1'))
const IS_UPDATE_DEMO_MODE = typeof window !== 'undefined' &&
window.localStorage?.getItem('debug.updateDemo') === '1';
const IS_UPDATE_DEMO_MODE = localStorageAdapter.readString(STORAGE_KEY_DEBUG_UPDATE_DEMO) === '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';
@@ -48,6 +43,8 @@ export interface UseUpdateCheckResult {
dismissUpdate: () => void;
openReleasePage: () => void;
installUpdate: () => void;
startDownload: () => void;
isUpdateDemoMode: boolean;
}
/**
@@ -518,6 +515,46 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean }): UseUp
netcattyBridge.get()?.installUpdate?.();
}, []);
const startDownload = useCallback(async () => {
if (autoDownloadStatusRef.current === 'downloading' || autoDownloadStatusRef.current === 'ready') return;
const bridge = netcattyBridge.get();
try {
const checkResult = await bridge?.checkForUpdate?.();
if (!checkResult || checkResult.checking === true || checkResult.ready === true || checkResult.downloading === true) return;
if (checkResult.supported === false) {
openReleasePage();
return;
}
if (checkResult.available === false) {
openReleasePage();
return;
}
} catch {
return;
}
setUpdateState((prev) => ({
...prev,
autoDownloadStatus: 'downloading',
downloadPercent: 0,
downloadError: null,
}));
void bridge?.downloadUpdate?.().then((res) => {
if (res && !res.success) {
setUpdateState((prev) => ({
...prev,
autoDownloadStatus: 'error',
downloadError: res.error || 'Download failed',
}));
}
}).catch(() => {
setUpdateState((prev) => ({
...prev,
autoDownloadStatus: 'error',
downloadError: 'Download failed',
}));
});
}, [openReleasePage]);
// Startup check with delay - runs once on mount
useEffect(() => {
debugLog('Startup check effect mounted, IS_UPDATE_DEMO_MODE:', IS_UPDATE_DEMO_MODE);
@@ -657,5 +694,7 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean }): UseUp
dismissUpdate,
openReleasePage,
installUpdate,
startDownload,
isUpdateDemoMode: IS_UPDATE_DEMO_MODE,
};
}

View File

@@ -12,11 +12,13 @@ import type {
Identity,
KnownHost,
PortForwardingRule,
SftpBookmark,
Snippet,
SSHKey,
} from './models';
import type { SyncPayload } from './sync';
} from '../domain/models';
import type { SyncPayload } from '../domain/sync';
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
import { rehydrateGlobalBookmarks } from '../components/sftp/hooks/useGlobalSftpBookmarks';
import {
STORAGE_KEY_THEME,
STORAGE_KEY_UI_THEME_LIGHT,
@@ -37,7 +39,9 @@ import {
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS,
STORAGE_KEY_CUSTOM_THEMES,
STORAGE_KEY_IMMERSIVE_MODE,
} from '../infrastructure/config/storageKeys';
// ---------------------------------------------------------------------------
@@ -56,7 +60,7 @@ export interface SyncableVaultData {
}
/** Callbacks used by `applySyncPayload` to import data into local state. */
export interface SyncPayloadImporters {
interface SyncPayloadImporters {
/** Import vault data (hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts). */
importVaultData: (jsonString: string) => void;
/** Import port-forwarding rules (lives outside the vault hook). */
@@ -74,9 +78,12 @@ const SYNCABLE_TERMINAL_KEYS = [
'scrollback', 'drawBoldInBrightColors', 'fontLigatures', 'fontWeight', 'fontWeightBold',
'linePadding', 'cursorShape', 'cursorBlink', 'minimumContrastRatio',
'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
'smoothScrolling',
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
'keepaliveInterval', 'disableBracketedPaste', 'osc52Clipboard',
'autocompleteEnabled', 'autocompleteGhostText', 'autocompletePopupMenu',
'autocompleteDebounceMs', 'autocompleteMinChars', 'autocompleteMaxSuggestions',
] as const;
/**
@@ -157,6 +164,14 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
const autoOpenSidebar = localStorageAdapter.readString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
if (autoOpenSidebar === 'true' || autoOpenSidebar === 'false') settings.sftpAutoOpenSidebar = autoOpenSidebar === 'true';
// SFTP Bookmarks (global only — local bookmarks are device-specific)
const globalBookmarks = localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS);
if (globalBookmarks && Array.isArray(globalBookmarks)) settings.sftpGlobalBookmarks = globalBookmarks;
// Immersive mode
const immersive = localStorageAdapter.readString(STORAGE_KEY_IMMERSIVE_MODE);
if (immersive === 'true' || immersive === 'false') settings.immersiveMode = immersive === 'true';
return Object.keys(settings).length > 0 ? settings : undefined;
}
@@ -164,7 +179,7 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
* Apply synced settings to localStorage. Merges terminal settings
* to preserve platform-specific fields.
*/
export function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>): void {
function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>): void {
// Theme & Appearance
if (settings.theme != null) localStorageAdapter.writeString(STORAGE_KEY_THEME, settings.theme);
if (settings.lightUiThemeId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_LIGHT, settings.lightUiThemeId);
@@ -215,6 +230,12 @@ export function applySyncableSettings(settings: NonNullable<SyncPayload['setting
if (settings.sftpShowHiddenFiles != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, String(settings.sftpShowHiddenFiles));
if (settings.sftpUseCompressedUpload != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, String(settings.sftpUseCompressedUpload));
if (settings.sftpAutoOpenSidebar != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, String(settings.sftpAutoOpenSidebar));
// SFTP Bookmarks (global only)
if (settings.sftpGlobalBookmarks != null) localStorageAdapter.write(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS, settings.sftpGlobalBookmarks);
// Immersive mode
if (settings.immersiveMode != null) localStorageAdapter.writeString(STORAGE_KEY_IMMERSIVE_MODE, String(settings.immersiveMode));
}
// ---------------------------------------------------------------------------
@@ -287,6 +308,8 @@ export function applySyncPayload(
// Apply synced settings
if (payload.settings) {
applySyncableSettings(payload.settings);
// Rehydrate in-memory bookmark snapshot after localStorage was updated
if (payload.settings.sftpGlobalBookmarks != null) rehydrateGlobalBookmarks();
importers.onSettingsApplied?.();
}
}

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
*/
@@ -22,6 +21,7 @@ import { useI18n } from '../application/i18n/I18nProvider';
import { useWindowControls } from '../application/state/useWindowControls';
import { useFileUpload } from '../application/state/useFileUpload';
import type {
AgentModelPreset,
AIPermissionMode,
AISession,
AISessionScope,
@@ -40,10 +40,24 @@ 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';
function isCopilotAgentConfig(agent?: ExternalAgentConfig): boolean {
if (!agent) return false;
const tokens = [
agent.id,
agent.name,
agent.icon,
agent.command,
agent.acpCommand,
]
.filter((value): value is string => typeof value === 'string' && value.length > 0)
.map((value) => value.split('/').pop()?.toLowerCase() ?? value.toLowerCase());
return tokens.some((token) => token.includes('copilot'));
}
// -------------------------------------------------------------------
// Props
// -------------------------------------------------------------------
@@ -57,6 +71,7 @@ interface AIChatSidePanelProps {
deleteSession: (sessionId: string, scopeKey?: string) => void;
updateSessionTitle: (sessionId: string, title: string) => void;
updateSessionExternalSessionId: (sessionId: string, externalSessionId: string | undefined) => void;
retargetSessionScope: (sessionId: string, scope: AISessionScope) => void;
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
updateLastMessage: (
sessionId: string,
@@ -102,6 +117,9 @@ interface AIChatSidePanelProps {
label: string;
os?: string;
username?: string;
protocol?: string;
shellType?: string;
deviceType?: string;
connected: boolean;
}>;
resolveExecutorContext?: (scope: {
@@ -151,6 +169,27 @@ function buildAcpHistoryMessages(messages: ChatMessage[]): Array<{ role: 'user'
});
}
function getSessionScopeMatchRank(
session: AISession,
scopeType: 'terminal' | 'workspace',
scopeTargetId?: string,
scopeHostIds?: string[],
activeTerminalTargetIds?: Set<string>,
): number {
if (session.scope.type !== scopeType) return 0;
if (session.scope.targetId === scopeTargetId) return 2;
if (scopeType !== 'terminal' || !scopeHostIds?.length || !session.scope.hostIds?.length) {
return 0;
}
if (session.scope.targetId && activeTerminalTargetIds?.has(session.scope.targetId)) {
return 0;
}
return session.scope.hostIds.some((hostId) => scopeHostIds.includes(hostId)) ? 1 : 0;
}
// -------------------------------------------------------------------
// Component
// -------------------------------------------------------------------
@@ -163,6 +202,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
deleteSession,
updateSessionTitle,
updateSessionExternalSessionId,
retargetSessionScope,
addMessageToSession,
updateLastMessage,
updateMessageById,
@@ -214,7 +254,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
streamingSessionIds,
setStreamingForScope,
abortControllersRef,
processCattyStream,
sendToCattyAgent,
sendToExternalAgent,
reportStreamError,
@@ -225,42 +264,122 @@ 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;
const isStreaming = activeSessionId ? streamingSessionIds.has(activeSessionId) : false;
const activeSessionIdForScope = activeSessionIdMap[scopeKey] ?? null;
const setActiveSessionId = useCallback((id: string | null) => {
setActiveSessionIdForScope(scopeKey, id);
}, [scopeKey, setActiveSessionIdForScope]);
// Restore agent selector from active session when scope changes
useEffect(() => {
if (activeSessionId) {
const session = sessions.find((s) => s.id === activeSessionId);
if (session) {
setCurrentAgentId(session.agentId);
const activeTerminalTargetIds = useMemo(() => {
const targetIds = new Set<string>();
for (const [sessionScopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
if (!sessionScopeKey.startsWith('terminal:') || !sessionId) continue;
const targetId = sessionScopeKey.slice('terminal:'.length);
if (!targetId || targetId === scopeTargetId) continue;
targetIds.add(targetId);
}
return targetIds;
}, [activeSessionIdMap, scopeTargetId]);
const historySessions = useMemo(
() =>
sessions
.map((session) => ({
session,
matchRank: getSessionScopeMatchRank(session, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds),
}))
.filter(({ matchRank }) => matchRank > 0)
.sort((a, b) => b.matchRank - a.matchRank || b.session.updatedAt - a.session.updatedAt)
.map(({ session }) => session),
[sessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds],
);
const activeSession = useMemo(() => {
if (activeSessionIdForScope) {
const session = sessions.find((s) => s.id === activeSessionIdForScope);
if (session && getSessionScopeMatchRank(session, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds) > 0) {
return session;
}
}
}, [scopeKey, activeSessionId, sessions]);
return historySessions[0] ?? null;
}, [sessions, activeSessionIdForScope, historySessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds]);
const activeSessionId = activeSession?.id ?? activeSessionIdForScope;
const isStreaming = activeSessionId ? streamingSessionIds.has(activeSessionId) : false;
const shouldRetargetActiveSession = useMemo(() => {
if (!activeSession || scopeType !== 'terminal' || !scopeTargetId || !scopeHostIds?.length) {
return false;
}
if (activeSession.scope.type !== scopeType || activeSession.scope.targetId === scopeTargetId) {
return false;
}
// Don't retarget sessions that are actively owned by another terminal
if (activeSession.scope.targetId && activeTerminalTargetIds.has(activeSession.scope.targetId)) {
return false;
}
return activeSession.scope.hostIds?.some((hostId) => scopeHostIds.includes(hostId)) ?? false;
}, [activeSession, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds]);
useEffect(() => {
if (!activeSession) return;
if (shouldRetargetActiveSession && isVisible) {
// Full cleanup of any in-flight work — the session came from a disconnected
// terminal, so any active response, pending approvals, or exec is dead.
if (streamingSessionIds.has(activeSession.id)) {
const controller = abortControllersRef.current.get(activeSession.id);
if (controller) {
controller.abort();
abortControllersRef.current.delete(activeSession.id);
}
setStreamingForScope(activeSession.id, false);
clearAllPendingApprovals(activeSession.id);
const bridge = getNetcattyBridge();
bridge?.aiCattyCancelExec?.(activeSession.id);
bridge?.aiAcpCancel?.('', activeSession.id);
}
retargetSessionScope(activeSession.id, {
type: scopeType,
targetId: scopeTargetId,
hostIds: scopeHostIds,
});
return;
}
if (isVisible && activeSessionIdForScope !== activeSession.id) {
setActiveSessionId(activeSession.id);
}
}, [
activeSession,
activeSessionIdForScope,
retargetSessionScope,
isVisible,
scopeHostIds,
scopeTargetId,
scopeType,
setActiveSessionId,
setStreamingForScope,
shouldRetargetActiveSession,
streamingSessionIds,
abortControllersRef,
]);
// Restore agent selector from active session when scope changes
useEffect(() => {
if (activeSession) {
setCurrentAgentId(activeSession.agentId);
}
}, [scopeKey, activeSession]);
// 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]);
@@ -308,12 +427,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
[enableAgent, setExternalAgents],
);
// Active session (scoped)
const activeSession = useMemo(
() => sessions.find((s) => s.id === activeSessionId) ?? null,
[sessions, activeSessionId],
);
const messages = activeSession?.messages ?? [];
// ── Export hook ──
@@ -327,15 +440,62 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
const providerDisplayName = activeProvider?.name ?? '';
const modelDisplayName = activeModelId || activeProvider?.defaultModel || '';
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, AgentModelPreset[]>>({});
// Agent model presets for the current external agent
const currentAgentConfig = useMemo(
() => currentAgentId !== 'catty' ? externalAgents.find(a => a.id === currentAgentId) : undefined,
[currentAgentId, externalAgents],
);
const isCopilotExternalAgent = useMemo(
() => isCopilotAgentConfig(currentAgentConfig),
[currentAgentConfig],
);
// Ref to read agentModelMap inside the effect without re-triggering it
// when setAgentModel updates the map (avoids double ACP spawn).
const agentModelMapRef = useRef(agentModelMap);
agentModelMapRef.current = agentModelMap;
useEffect(() => {
if (!currentAgentConfig?.acpCommand) return;
if (!isCopilotExternalAgent) return;
const bridge = getNetcattyBridge();
if (!bridge?.aiAcpListModels) return;
let cancelled = false;
void bridge.aiAcpListModels(
currentAgentConfig.acpCommand,
currentAgentConfig.acpArgs || [],
undefined,
undefined,
`models_${currentAgentId}`,
).then((result) => {
if (cancelled || !result?.ok || !Array.isArray(result.models)) return;
const knownModelIds = new Set(result.models.map((model) => model.id));
setRuntimeAgentModelPresets((prev) => ({
...prev,
[currentAgentId]: result.models ?? [],
}));
const storedModelId = agentModelMapRef.current[currentAgentId];
if (result.currentModelId && (!storedModelId || !knownModelIds.has(storedModelId))) {
setAgentModel(currentAgentId, result.currentModelId);
}
}).catch((err) => {
if (!cancelled) {
console.warn('[AIChatSidePanel] Failed to load ACP agent models:', err);
}
});
return () => {
cancelled = true;
};
}, [currentAgentConfig, currentAgentId, isCopilotExternalAgent, setAgentModel]);
const agentModelPresets = useMemo(
() => getAgentModelPresets(currentAgentConfig?.command),
[currentAgentConfig?.command],
() => runtimeAgentModelPresets[currentAgentId] ?? getAgentModelPresets(currentAgentConfig?.command),
[currentAgentId, currentAgentConfig?.command, runtimeAgentModelPresets],
);
// Per-agent model: recall last selection or use first preset as default
@@ -359,15 +519,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
setAgentModel(currentAgentId, modelId);
}, [currentAgentId, setAgentModel]);
// Filtered sessions for history (matching current scope type)
const historySessions = useMemo(
() =>
sessions
.filter((s) => s.scope.type === scopeType && s.scope.targetId === scopeTargetId)
.sort((a, b) => b.updatedAt - a.updatedAt),
[sessions, scopeType, scopeTargetId],
);
// -------------------------------------------------------------------
// Handlers
// -------------------------------------------------------------------
@@ -434,12 +585,34 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
/** Ensure a session exists for the current scope and return its ID. */
const ensureSession = useCallback((): string => {
if (activeSessionId) return activeSessionId;
if (activeSession && sessionsRef.current.some((session) => session.id === activeSession.id)) {
if (shouldRetargetActiveSession) {
retargetSessionScope(activeSession.id, {
type: scopeType,
targetId: scopeTargetId,
hostIds: scopeHostIds,
});
} else if (activeSessionIdForScope !== activeSession.id) {
setActiveSessionId(activeSession.id);
}
return activeSession.id;
}
const scope: AISessionScope = { type: scopeType, targetId: scopeTargetId, hostIds: scopeHostIds };
const session = createSession(scope, currentAgentId);
setActiveSessionId(session.id);
return session.id;
}, [activeSessionId, scopeType, scopeTargetId, scopeHostIds, currentAgentId, createSession, setActiveSessionId]);
}, [
activeSession,
activeSessionIdForScope,
createSession,
currentAgentId,
retargetSessionScope,
scopeHostIds,
scopeTargetId,
scopeType,
setActiveSessionId,
shouldRetargetActiveSession,
]);
// -------------------------------------------------------------------
// Main send handler (thin orchestrator)
@@ -482,7 +655,9 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
const assistantMsgId = generateId();
addMessageToSession(sessionId, {
id: assistantMsgId, role: 'assistant', content: '', timestamp: Date.now(),
model: isExternalAgent ? (agentConfig?.name || 'external') : (activeModelId || activeProvider?.defaultModel || ''),
model: isExternalAgent
? (selectedAgentModel || agentConfig?.name || 'external')
: (activeModelId || activeProvider?.defaultModel || ''),
providerId: isExternalAgent ? undefined : activeProvider?.providerId,
});
@@ -530,7 +705,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
terminalSessions,
webSearchConfig,
getExecutorContext: () => buildExecutorContextForScope(toolScope),
setPendingApproval,
autoTitleSession,
}, attachments.length > 0 ? attachments : undefined);
}
@@ -541,7 +715,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
setStreamingForScope, setInputValue, clearFiles,
sendToExternalAgent, sendToCattyAgent, reportStreamError, autoTitleSession, t,
abortControllersRef, terminalSessions, providers, selectedAgentModel, updateSessionExternalSessionId,
scopeType, scopeTargetId, scopeLabel, globalPermissionMode, commandBlocklist, webSearchConfig, buildExecutorContextForScope, setPendingApproval,
scopeType, scopeTargetId, scopeLabel, globalPermissionMode, commandBlocklist, webSearchConfig, buildExecutorContextForScope,
]);
const handleStop = useCallback(() => {
@@ -556,11 +730,13 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
statusText: '',
executionStatus: msg.executionStatus === 'running' ? 'cancelled' : msg.executionStatus,
}));
// Also clear any pending approval (clears timeout too via setPendingApproval)
if (pendingApprovalContextRef.current?.sessionId === activeSessionId) {
setPendingApproval(null);
}
}, [activeSessionId, setStreamingForScope, updateLastMessage, setPendingApproval, abortControllersRef, pendingApprovalContextRef]);
// Clear pending approvals for this session (so tool execute functions don't hang)
clearAllPendingApprovals(activeSessionId);
// Cancel in-flight command executions (Catty Agent + ACP Agent)
const bridge = getNetcattyBridge();
bridge?.aiCattyCancelExec?.(activeSessionId);
bridge?.aiAcpCancel?.('', activeSessionId);
}, [activeSessionId, setStreamingForScope, updateLastMessage, abortControllersRef]);
const handleSelectSession = useCallback(
(sessionId: string) => {
@@ -653,16 +829,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
<ChatMessageList
messages={messages}
isStreaming={isStreaming}
onApprove={(messageId) => void handleApprovalResponse(messageId, true, {
globalPermissionMode,
commandBlocklist,
webSearchConfig,
})}
onReject={(messageId) => void handleApprovalResponse(messageId, false, {
globalPermissionMode,
commandBlocklist,
webSearchConfig,
})}
activeSessionId={activeSessionId}
/>
{/* Recent sessions (Zed-style, shown when no messages) */}
@@ -767,9 +934,12 @@ const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
const timeStr = formatRelativeTime(time, t);
return (
<button
<div
key={session.id}
role="button"
tabIndex={0}
onClick={() => onSelect(session.id)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onSelect(session.id); }}
className={cn(
'w-full flex items-center justify-between py-2.5 border-b border-border/20 text-left transition-colors cursor-pointer group',
isActive ? 'text-foreground' : 'text-foreground/70 hover:text-foreground',
@@ -790,7 +960,7 @@ const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
<Trash2 size={12} />
</button>
</div>
</button>
</div>
);
})
)}

View File

@@ -102,11 +102,14 @@ interface StatusDotProps {
}
const StatusDot: React.FC<StatusDotProps> = ({ status, className }) => {
if (status === 'connecting') {
return <Loader2 className={cn('w-3.5 h-3.5 animate-spin text-muted-foreground', className)} />;
}
const colors = {
connected: 'bg-green-500',
syncing: 'bg-blue-500 animate-pulse',
error: 'bg-red-500',
connecting: 'bg-yellow-500 animate-pulse',
disconnected: 'bg-muted-foreground/50',
};
@@ -279,6 +282,7 @@ interface ProviderCardProps {
disabled?: boolean; // Disable connect button when another provider is connected
onEdit?: () => void;
onConnect: () => void;
onCancelConnect?: () => void;
onDisconnect: () => void;
onSync: () => void;
}
@@ -296,6 +300,7 @@ const ProviderCard: React.FC<ProviderCardProps> = ({
disabled,
onEdit,
onConnect,
onCancelConnect,
onDisconnect,
onSync,
}) => {
@@ -367,7 +372,9 @@ const ProviderCard: React.FC<ProviderCardProps> = ({
{error}
</p>
) : (
<p className="text-xs text-muted-foreground mt-1">{t('cloudSync.provider.notConnected')}</p>
<p className="text-xs text-muted-foreground mt-1">
{isConnecting ? t('cloudSync.provider.connecting') : t('cloudSync.provider.notConnected')}
</p>
)}
</div>
@@ -408,10 +415,20 @@ const ProviderCard: React.FC<ProviderCardProps> = ({
<CloudOff size={14} />
</Button>
</>
) : isConnecting && onCancelConnect ? (
<Button
size="sm"
variant="outline"
onClick={onCancelConnect}
className="gap-1"
>
<X size={14} />
{t('common.cancel')}
</Button>
) : (
<Button
size="sm"
onClick={() => { console.log('[ProviderCard] Connect clicked'); onConnect(); }}
onClick={() => { onConnect(); }}
className="gap-1"
disabled={disabled || isConnecting}
>
@@ -611,7 +628,7 @@ interface SyncDashboardProps {
onClearLocalData?: () => void;
}
export const SyncDashboard: React.FC<SyncDashboardProps> = ({
const SyncDashboard: React.FC<SyncDashboardProps> = ({
onBuildPayload,
onApplyPayload,
onClearLocalData,
@@ -689,15 +706,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 +797,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);
@@ -812,6 +817,9 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
toast.success(t('cloudSync.connect.github.success'));
} catch (error) {
setIsPollingGitHub(false);
setShowGitHubModal(false);
// Reset provider status so button is clickable again (without tearing down existing connections)
sync.resetProviderStatus('github');
const message = getNetworkErrorMessage(error, t('common.unknownError'));
toast.error(message, t('cloudSync.connect.github.failedTitle'));
}
@@ -825,10 +833,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
// Note: Auth flow is handled automatically by oauthBridge
toast.info(t('cloudSync.connect.browserContinue'));
} catch (error) {
toast.error(
error instanceof Error ? error.message : t('common.unknownError'),
t('cloudSync.connect.google.failedTitle'),
);
// Reset provider status so button is clickable again (without tearing down existing connections)
sync.resetProviderStatus('google');
const msg = error instanceof Error ? error.message : t('common.unknownError');
// Don't show toast for user-initiated cancellation (popup closed)
if (!msg.includes('cancelled')) {
toast.error(msg, t('cloudSync.connect.google.failedTitle'));
}
}
};
@@ -840,10 +851,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
// Note: Auth flow is handled automatically by oauthBridge
toast.info(t('cloudSync.connect.browserContinue'));
} catch (error) {
toast.error(
error instanceof Error ? error.message : t('common.unknownError'),
t('cloudSync.connect.onedrive.failedTitle'),
);
// Reset provider status so button is clickable again (without tearing down existing connections)
sync.resetProviderStatus('onedrive');
const msg = error instanceof Error ? error.message : t('common.unknownError');
// Don't show toast for user-initiated cancellation (popup closed)
if (!msg.includes('cancelled')) {
toast.error(msg, t('cloudSync.connect.onedrive.failedTitle'));
}
}
};
@@ -1091,6 +1105,7 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
error={sync.providers.google.error}
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.google)}
onConnect={handleConnectGoogle}
onCancelConnect={sync.cancelOAuthConnect}
onDisconnect={() => sync.disconnectProvider('google')}
onSync={() => handleSync('google')}
/>
@@ -1107,6 +1122,7 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
error={sync.providers.onedrive.error}
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.onedrive)}
onConnect={handleConnectOneDrive}
onCancelConnect={sync.cancelOAuthConnect}
onDisconnect={() => sync.disconnectProvider('onedrive')}
onSync={() => handleSync('onedrive')}
/>
@@ -1262,6 +1278,9 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
onClose={() => {
setShowGitHubModal(false);
setIsPollingGitHub(false);
// Reset provider status so button is clickable again.
// The background polling will continue until expiry but is harmless.
sync.resetProviderStatus('github');
}}
/>

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,16 +58,15 @@ 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;
// Size variants - all use rounded corners for consistency
const sizeClasses = {
sm: "h-6 w-6 rounded-md",
md: "h-11 w-11 rounded-xl",
sm: "h-6 w-6 rounded",
md: "h-11 w-11 rounded-lg",
lg: "h-14 w-14 rounded-xl",
};
const iconSizes = {
@@ -99,14 +98,14 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
<div
className={cn(
containerClass,
"flex items-center justify-center border border-border/40 overflow-hidden",
"flex items-center justify-center overflow-hidden",
bg,
className,
)}
>
<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,18 +20,31 @@ import {
Tag,
TerminalSquare,
User,
FileKey,
FolderOpen,
Trash2,
Variable,
Wifi,
Router,
X,
} from "lucide-react";
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[];
@@ -78,6 +93,8 @@ interface HostDetailsPanelProps {
allTags?: string[]; // All available tags for autocomplete
allHosts?: Host[]; // All hosts for chain selection
defaultGroup?: string | null; // Default group for new hosts (from current navigation)
terminalThemeId: string;
terminalFontSize: number;
onSave: (host: Host) => void;
onCancel: () => void;
onCreateGroup?: (groupPath: string) => void; // Callback to create a new group
@@ -93,6 +110,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
allTags = [],
allHosts = [],
defaultGroup,
terminalThemeId,
terminalFontSize,
onSave,
onCancel,
onCreateGroup,
@@ -100,7 +119,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
}) => {
const { t } = useI18n();
const { checkSshAgent } = useApplicationBackend();
const { terminalThemeId, terminalFontSize } = useSettingsState();
const [form, setForm] = useState<Host>(
() =>
initialData ||
@@ -115,8 +133,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 +153,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 +199,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 +368,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 +478,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
authMethod: identity.authMethod,
password: undefined,
identityFileId: undefined,
identityFilePaths: undefined,
}));
setSelectedCredentialType(null);
setCredentialPopoverOpen(false);
@@ -478,9 +570,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 +587,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(
@@ -537,6 +625,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
<AsidePanel
open={true}
onClose={onCancel}
width="w-[420px]"
title={
initialData ? t("hostDetails.title.details") : t("hostDetails.title.new")
}
@@ -650,7 +739,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</div>
</Card>
<Card className="p-3 space-y-3 bg-card border-border/80">
<Card className="p-3 space-y-3 bg-card border-border/80 overflow-hidden">
<div className="flex items-center gap-2">
<KeyRound size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">
@@ -891,6 +980,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 overflow-hidden">
<FileKey size={14} className="text-primary shrink-0" />
<span className="text-xs w-0 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 +1082,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 +1117,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 +1153,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 +1173,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 min-w-0">
<input
type="text"
className="flex-1 min-w-0 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>
@@ -1072,18 +1263,20 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
{t("hostDetails.sftp.sudo.passwordWarning")}
</p>
)}
<div className="space-y-1">
<div className="text-sm font-medium">
{t("hostDetails.sftp.encoding")}
</div>
<div className="text-xs text-muted-foreground">
{t("hostDetails.sftp.encoding.desc")}
<div className="flex items-center justify-between gap-4">
<div className="space-y-0.5">
<div className="text-sm font-medium">
{t("hostDetails.sftp.encoding")}
</div>
<div className="text-xs text-muted-foreground">
{t("hostDetails.sftp.encoding.desc")}
</div>
</div>
<Select
value={form.sftpEncoding || "auto"}
onValueChange={(val) => update("sftpEncoding", val as Host["sftpEncoding"])}
>
<SelectTrigger className="h-8">
<SelectTrigger className="h-8 w-28">
<SelectValue placeholder={t("sftp.encoding.label")} />
</SelectTrigger>
<SelectContent>
@@ -1095,6 +1288,111 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</div>
</Card>
{form.os === "linux" && (
<Card className="p-3 space-y-3 bg-card border-border/80">
<div className="flex items-center gap-2">
<img src="/distro/linux.svg" alt="Linux" className="h-3.5 w-3.5 opacity-70 dark:invert" />
<p className="text-xs font-semibold">{t("hostDetails.distro.title")}</p>
</div>
<p className="text-xs text-muted-foreground">{t("hostDetails.distro.desc")}</p>
<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>
</Card>
)}
<Card className="p-3 space-y-3 bg-card border-border/80">
<div className="flex items-center gap-2">
<Palette size={14} className="text-muted-foreground" />
@@ -1113,15 +1411,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 +1427,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 +1448,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 +1465,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"
>
+
@@ -1187,7 +1517,15 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
<ToggleRow
label="Mosh"
enabled={!!form.moshEnabled}
onToggle={() => update("moshEnabled", !form.moshEnabled)}
onToggle={() => {
const enabling = !form.moshEnabled;
if (enabling && form.deviceType === 'network') {
// Network device mode is incompatible with Mosh — clear it
setForm(prev => ({ ...prev, moshEnabled: true, deviceType: undefined }));
} else {
update("moshEnabled", enabling);
}
}}
/>
</Card>
@@ -1220,6 +1558,32 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
)}
</Card>
{/* Network Device Mode — only for SSH hosts without Mosh (serial already uses raw mode) */}
{(!form.protocol || form.protocol === 'ssh') && !form.moshEnabled && (
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center gap-2">
<Router size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t("hostDetails.section.deviceType")}</p>
</div>
<ToggleRow
label={t("hostDetails.deviceType")}
enabled={form.deviceType === 'network'}
onToggle={() => update("deviceType", form.deviceType === 'network' ? undefined : 'network')}
/>
<p className="text-xs text-muted-foreground break-words">
{t("hostDetails.deviceType.desc")}
</p>
{form.deviceType === 'network' && (
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
<p className="text-xs text-yellow-600 dark:text-yellow-400 break-words">
{t("hostDetails.deviceType.warning")}
</p>
</div>
)}
</Card>
)}
{/* Legacy Algorithms */}
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center gap-2">
@@ -1494,21 +1858,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 +1874,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

@@ -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

@@ -2,7 +2,7 @@ import {
Folder,
LayoutGrid,
Search,
Shield,
FolderLock,
Terminal,
TerminalSquare,
} from "lucide-react";
@@ -98,13 +98,17 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
// Reset state when opening
useEffect(() => {
if (isOpen) {
setSelectedIndex(0);
// Auto focus the input after a short delay
setTimeout(() => {
inputRef.current?.focus();
}, 50);
}
if (!isOpen) return;
const focusTimer = window.setTimeout(() => {
inputRef.current?.focus();
}, 50);
setSelectedIndex(0);
return () => {
window.clearTimeout(focusTimer);
};
}, [isOpen]);
// Handle clicks outside the container
@@ -287,7 +291,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
const isSelected = idx === selectedIndex;
const icon =
tabId === "vault" ? (
<Shield size={16} />
<FolderLock size={16} />
) : (
<Folder size={16} />
);

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

@@ -19,6 +19,12 @@ import { Input } from "./ui/input";
import { ScrollArea } from "./ui/scroll-area";
import { SortDropdown, SortMode } from "./ui/sort-dropdown";
import { TagFilterDropdown } from "./ui/tag-filter-dropdown";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "./ui/tooltip";
interface SelectHostPanelProps {
hosts: Host[];
@@ -198,6 +204,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
}, [currentPath]);
return (
<TooltipProvider delayDuration={300}>
<div
className={cn(
"absolute right-0 top-0 bottom-0 w-[380px] border-l border-border/60 bg-background z-40 flex flex-col app-no-drag",
@@ -271,7 +278,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
{/* Content */}
<ScrollArea className="flex-1">
<div className="p-4 space-y-4">
<div className="p-3 space-y-3">
{/* Breadcrumbs */}
{currentPath && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
@@ -301,20 +308,20 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
)}
{groupsWithCounts.length > 0 && (
<div>
<h4 className="text-sm font-semibold mb-3">{t("vault.groups.title")}</h4>
<h4 className="text-xs font-semibold mb-2 text-muted-foreground">{t("vault.groups.title")}</h4>
<div className="space-y-1">
{groupsWithCounts.map((group) => (
<div
key={group.path}
className="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-muted/70 cursor-pointer transition-colors"
className="flex items-center gap-2.5 px-2.5 py-2 rounded-lg hover:bg-muted/70 cursor-pointer transition-colors"
onClick={() => setCurrentPath(group.path)}
>
<div className="h-10 w-10 rounded-lg bg-primary/15 text-primary flex items-center justify-center">
<LayoutGrid size={18} />
<div className="h-8 w-8 rounded-lg bg-primary/15 text-primary flex items-center justify-center shrink-0">
<LayoutGrid size={15} />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium">{group.name}</div>
<div className="text-xs text-muted-foreground">
<div className="text-[13px] font-medium truncate">{group.name}</div>
<div className="text-[11px] text-muted-foreground">
{t("vault.groups.hostsCount", { count: group.count })}
</div>
</div>
@@ -327,18 +334,19 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
{/* Hosts Section */}
{filteredHosts.length > 0 && (
<div>
<h4 className="text-sm font-semibold mb-3">{t("vault.nav.hosts")}</h4>
<h4 className="text-xs font-semibold mb-2 text-muted-foreground">{t("vault.nav.hosts")}</h4>
<div className="space-y-1">
{filteredHosts.map((host) => {
const isSelected = selectedHostIds.includes(host.id);
const connectionStr = `${host.username}@${host.hostname}:${host.port || 22}`;
return (
<div
key={host.id}
className={cn(
"flex items-center gap-3 px-3 py-2.5 rounded-lg cursor-pointer transition-colors",
"flex items-center gap-2.5 px-2.5 py-2 rounded-lg cursor-pointer transition-colors",
isSelected
? "bg-muted border border-border"
? "bg-muted"
: "hover:bg-muted/70",
)}
onClick={() => onSelect(host)}
@@ -346,16 +354,32 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
<DistroAvatar
host={host}
fallback={host.os[0].toUpperCase()}
className="h-10 w-10"
className="h-8 w-8 rounded-md"
/>
<div className="flex-1 min-w-0">
<div className="font-medium">{host.label}</div>
<div className="text-xs text-muted-foreground truncate">
{host.username}@{host.hostname}:{host.port || 22}
</div>
<Tooltip>
<TooltipTrigger asChild>
<div className="text-[13px] font-medium truncate">
{host.label}
</div>
</TooltipTrigger>
<TooltipContent side="top" align="start">
<p>{host.label}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<div className="text-[11px] text-muted-foreground truncate">
{connectionStr}
</div>
</TooltipTrigger>
<TooltipContent side="top" align="start">
<p>{connectionStr}</p>
</TooltipContent>
</Tooltip>
</div>
{isSelected && (
<Check size={16} className="text-primary" />
<Check size={14} className="text-primary shrink-0" />
)}
</div>
);
@@ -413,6 +437,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
/>
)}
</div>
</TooltipProvider>
);
};

View File

@@ -35,7 +35,7 @@ interface SerialPort {
interface SerialConnectModalProps {
open: boolean;
onClose: () => void;
onConnect: (config: SerialConfig) => void;
onConnect: (config: SerialConfig, options?: { charset?: string }) => void;
onSaveHost?: (host: Host) => void;
}
@@ -65,6 +65,7 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
const [flowControl, setFlowControl] = useState<SerialFlowControl>('none');
const [localEcho, setLocalEcho] = useState(false);
const [lineMode, setLineMode] = useState(false);
const [charset, setCharset] = useState('UTF-8');
// Save configuration state
const [saveConfig, setSaveConfig] = useState(false);
@@ -131,12 +132,13 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
tags: ['serial'],
protocol: 'serial',
createdAt: Date.now(),
charset,
serialConfig: config, // Store full serial configuration for connection
};
onSaveHost(host);
}
onConnect(config);
onConnect(config, { charset });
onClose();
};
@@ -164,7 +166,7 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="max-w-md">
<DialogContent className="max-w-md max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Usb size={18} />
@@ -175,7 +177,7 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-4 py-2 overflow-y-auto flex-1 min-h-0">
{/* Serial Port Selection */}
<div className="space-y-2">
<div className="flex items-center justify-between">
@@ -368,6 +370,20 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
className="h-4 w-4 rounded border-input"
/>
</div>
{/* Charset */}
<div className="space-y-1">
<Label htmlFor="serial-charset" className="text-sm font-medium">
{t('serial.field.charset')}
</Label>
<Input
id="serial-charset"
placeholder={t("hostDetails.charset.placeholder")}
value={charset}
onChange={(e) => setCharset(e.target.value)}
className="h-9"
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>

View File

@@ -66,6 +66,7 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
const [flowControl, setFlowControl] = useState<SerialFlowControl>(initialData.serialConfig?.flowControl || 'none');
const [localEcho, setLocalEcho] = useState(initialData.serialConfig?.localEcho || false);
const [lineMode, setLineMode] = useState(initialData.serialConfig?.lineMode || false);
const [charset, setCharset] = useState(initialData.charset || 'UTF-8');
const [tags, setTags] = useState<string[]>(initialData.tags || []);
const [group, setGroup] = useState(initialData.group || '');
@@ -107,6 +108,7 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
port: baudRate,
tags,
group,
charset,
serialConfig: config,
};
@@ -392,6 +394,20 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
className="h-4 w-4 rounded border-input"
/>
</div>
{/* Charset */}
<div className="space-y-1">
<Label htmlFor="serial-charset" className="text-sm font-medium">
{t('serial.field.charset')}
</Label>
<Input
id="serial-charset"
placeholder={t("hostDetails.charset.placeholder")}
value={charset}
onChange={(e) => setCharset(e.target.value)}
className="h-9"
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>

View File

@@ -68,9 +68,11 @@ interface SettingsApplicationTabProps {
checkNow: UseUpdateCheckResult['checkNow'];
openReleasePage: UseUpdateCheckResult['openReleasePage'];
installUpdate: UseUpdateCheckResult['installUpdate'];
startDownload: UseUpdateCheckResult['startDownload'];
isUpdateDemoMode: boolean;
}
export default function SettingsApplicationTab({ updateState, checkNow, openReleasePage, installUpdate }: SettingsApplicationTabProps) {
export default function SettingsApplicationTab({ updateState, checkNow, openReleasePage, installUpdate, startDownload, isUpdateDemoMode }: SettingsApplicationTabProps) {
const { t } = useI18n();
const { openExternal, getApplicationInfo } = useApplicationBackend();
const [appInfo, setAppInfo] = useState<AppInfo>({ name: "Netcatty", version: "" });
@@ -94,10 +96,6 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
};
}, [getApplicationInfo]);
// Check if demo mode is enabled for development testing
const isUpdateDemoMode = typeof window !== 'undefined' &&
window.localStorage?.getItem('debug.updateDemo') === '1';
const handleCheckForUpdates = async () => {
// In demo mode, allow checking even for dev builds
if (!isUpdateDemoMode && (!appInfo.version || appInfo.version === '0.0.0')) {
@@ -150,7 +148,7 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
{/* Update badge - reflects auto-download state */}
{updateState.latestRelease && (updateState.hasUpdate || updateState.autoDownloadStatus === 'downloading' || updateState.autoDownloadStatus === 'ready') && (
<button
onClick={() => updateState.autoDownloadStatus === 'ready' ? installUpdate() : void openReleasePage()}
onClick={() => updateState.autoDownloadStatus === 'ready' ? installUpdate() : updateState.autoDownloadStatus === 'downloading' ? undefined : startDownload()}
className={cn(
"inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium",
updateState.autoDownloadStatus === 'ready'
@@ -177,7 +175,7 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
variant="secondary"
className="gap-2"
onClick={() => void handleCheckForUpdates()}
disabled={updateState.isChecking}
disabled={updateState.isChecking || updateState.manualCheckStatus === 'checking' || updateState.autoDownloadStatus === 'downloading' || updateState.autoDownloadStatus === 'ready'}
>
{updateState.isChecking ? (
<Loader2 size={16} className="animate-spin" />

View File

@@ -5,6 +5,7 @@
import { AppWindow, Cloud, FileType, HardDrive, Keyboard, Palette, Sparkles, TerminalSquare, X } from "lucide-react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useSettingsState } from "../application/state/useSettingsState";
import { useAvailableFonts } from "../application/state/fontStore";
import { usePortForwardingState } from "../application/state/usePortForwardingState";
import { useVaultState } from "../application/state/useVaultState";
import { useWindowControls } from "../application/state/useWindowControls";
@@ -19,7 +20,6 @@ import SettingsTerminalTab from "./settings/tabs/SettingsTerminalTab";
import SettingsSystemTab from "./settings/tabs/SettingsSystemTab";
const SettingsAITab = React.lazy(() => import("./settings/tabs/SettingsAITab"));
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
import type { TerminalFont } from "../infrastructure/config/fonts";
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
@@ -45,12 +45,65 @@ class AITabErrorBoundary extends React.Component<
}
}
type SettingsState = ReturnType<typeof useSettingsState> & {
availableFonts: TerminalFont[];
};
type SettingsState = ReturnType<typeof useSettingsState>;
const SettingsSyncTab = React.lazy(() => import("./settings/tabs/SettingsSyncTab"));
const SettingsTerminalTabContainer: React.FC<{ settings: SettingsState }> = ({ settings }) => {
const availableFonts = useAvailableFonts();
return (
<SettingsTerminalTab
terminalThemeId={settings.terminalThemeId}
setTerminalThemeId={settings.setTerminalThemeId}
terminalFontFamilyId={settings.terminalFontFamilyId}
setTerminalFontFamilyId={settings.setTerminalFontFamilyId}
terminalFontSize={settings.terminalFontSize}
setTerminalFontSize={settings.setTerminalFontSize}
terminalSettings={settings.terminalSettings}
updateTerminalSetting={settings.updateTerminalSetting}
availableFonts={availableFonts}
workspaceFocusStyle={settings.workspaceFocusStyle}
setWorkspaceFocusStyle={settings.setWorkspaceFocusStyle}
/>
);
};
const SettingsAITabContainer: React.FC = () => {
const aiState = useAIState();
return (
<AITabErrorBoundary>
<React.Suspense fallback={<div className="flex-1 px-6 py-5 text-sm text-muted-foreground">Loading AI settings...</div>}>
<SettingsAITab
providers={aiState.providers}
addProvider={aiState.addProvider}
updateProvider={aiState.updateProvider}
removeProvider={aiState.removeProvider}
activeProviderId={aiState.activeProviderId}
setActiveProviderId={aiState.setActiveProviderId}
activeModelId={aiState.activeModelId}
setActiveModelId={aiState.setActiveModelId}
globalPermissionMode={aiState.globalPermissionMode}
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
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,
@@ -98,10 +151,13 @@ const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = (
const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }) => {
const { t } = useI18n();
const { notifyRendererReady, closeSettingsWindow } = useWindowControls();
const { updateState, checkNow, installUpdate, openReleasePage } = useUpdateCheck({ autoUpdateEnabled: settings.autoUpdateEnabled });
const aiState = useAIState();
const { updateState, checkNow, installUpdate, openReleasePage, startDownload, isUpdateDemoMode } = useUpdateCheck({ autoUpdateEnabled: settings.autoUpdateEnabled });
const [activeTab, setActiveTab] = useState("application");
const [mountedTabs, setMountedTabs] = useState(() => new Set(["application"]));
const isImmersive = settings.immersiveMode;
const toggleImmersive = useCallback(() => {
settings.setImmersiveMode(!isImmersive);
}, [settings, isImmersive]);
useEffect(() => {
notifyRendererReady();
@@ -206,6 +262,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
checkNow={checkNow}
openReleasePage={openReleasePage}
installUpdate={installUpdate}
startDownload={startDownload}
isUpdateDemoMode={isUpdateDemoMode}
/>
)}
@@ -227,21 +285,13 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
setUiLanguage={settings.setUiLanguage}
customCSS={settings.customCSS}
setCustomCSS={settings.setCustomCSS}
isImmersive={isImmersive}
onToggleImmersive={toggleImmersive}
/>
)}
{mountedTabs.has("terminal") && (
<SettingsTerminalTab
terminalThemeId={settings.terminalThemeId}
setTerminalThemeId={settings.setTerminalThemeId}
terminalFontFamilyId={settings.terminalFontFamilyId}
setTerminalFontFamilyId={settings.setTerminalFontFamilyId}
terminalFontSize={settings.terminalFontSize}
setTerminalFontSize={settings.setTerminalFontSize}
terminalSettings={settings.terminalSettings}
updateTerminalSetting={settings.updateTerminalSetting}
availableFonts={settings.availableFonts}
/>
<SettingsTerminalTabContainer settings={settings} />
)}
{mountedTabs.has("shortcuts") && (
@@ -261,34 +311,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
)}
{mountedTabs.has("ai") && (
<AITabErrorBoundary>
<React.Suspense fallback={null}>
<SettingsAITab
providers={aiState.providers}
addProvider={aiState.addProvider}
updateProvider={aiState.updateProvider}
removeProvider={aiState.removeProvider}
activeProviderId={aiState.activeProviderId}
setActiveProviderId={aiState.setActiveProviderId}
activeModelId={aiState.activeModelId}
setActiveModelId={aiState.setActiveModelId}
globalPermissionMode={aiState.globalPermissionMode}
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
externalAgents={aiState.externalAgents}
setExternalAgents={aiState.setExternalAgents}
defaultAgentId={aiState.defaultAgentId}
setDefaultAgentId={aiState.setDefaultAgentId}
commandBlocklist={aiState.commandBlocklist}
setCommandBlocklist={aiState.setCommandBlocklist}
commandTimeout={aiState.commandTimeout}
setCommandTimeout={aiState.setCommandTimeout}
maxIterations={aiState.maxIterations}
setMaxIterations={aiState.setMaxIterations}
webSearchConfig={aiState.webSearchConfig}
setWebSearchConfig={aiState.setWebSearchConfig}
/>
</React.Suspense>
</AITabErrorBoundary>
<SettingsAITabContainer />
)}
{mountedTabs.has("sync") && (
@@ -318,6 +341,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
checkNow={checkNow}
installUpdate={installUpdate}
openReleasePage={openReleasePage}
startDownload={startDownload}
/>
)}
</div>

View File

@@ -10,7 +10,8 @@
* Used in TerminalLayer to provide SFTP alongside terminal sessions.
*/
import React, { memo, useCallback, useEffect, useMemo, useRef } from "react";
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } 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";
@@ -30,12 +31,17 @@ import { SftpTransferQueue } from "./sftp/SftpTransferQueue";
import { SftpContextProvider } from "./sftp";
import { useSftpViewPaneCallbacks } from "./sftp/hooks/useSftpViewPaneCallbacks";
import { useSftpViewTabs } from "./sftp/hooks/useSftpViewTabs";
import { useSftpKeyboardShortcuts } from "./sftp/hooks/useSftpKeyboardShortcuts";
import { sftpFocusStore } from "./sftp/hooks/useSftpFocusedPane";
import { keepOnlyPaneSelections } from "./sftp/hooks/selectionScope";
import { KeyBinding, HotkeyScheme } from "../domain/models";
interface SftpSidePanelProps {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
updateHosts: (hosts: Host[]) => void;
sftpDefaultViewMode: "list" | "tree";
/** The host to connect to (follows focused terminal) */
activeHost: Host | null;
initialLocation?: { hostId: string; path: string } | null;
@@ -54,6 +60,8 @@ interface SftpSidePanelProps {
sftpAutoSync: boolean;
sftpShowHiddenFiles: boolean;
sftpUseCompressedUpload: boolean;
hotkeyScheme: HotkeyScheme;
keyBindings: KeyBinding[];
editorWordWrap: boolean;
setEditorWordWrap: (value: boolean) => void;
onGetTerminalCwd?: () => Promise<string | null>;
@@ -64,6 +72,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
keys,
identities,
updateHosts,
sftpDefaultViewMode,
activeHost,
initialLocation,
showWorkspaceHostHeader = false,
@@ -75,6 +84,8 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
sftpAutoSync,
sftpShowHiddenFiles,
sftpUseCompressedUpload,
hotkeyScheme,
keyBindings,
editorWordWrap,
setEditorWordWrap,
onGetTerminalCwd,
@@ -101,7 +112,15 @@ 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,
listLocalDir,
} = useSftpBackend();
const sftpRef = useRef(sftp);
sftpRef.current = sftp;
@@ -111,6 +130,17 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
const autoSyncRef = useRef(sftpAutoSync);
autoSyncRef.current = sftpAutoSync;
const panelRootRef = useRef<HTMLDivElement>(null);
const dialogActionScopeIdRef = useRef(`sftp-side-panel:${crypto.randomUUID()}`);
const [hasPaneFocus, setHasPaneFocus] = useState(false);
useSftpKeyboardShortcuts({
keyBindings,
hotkeyScheme,
sftpRef,
dialogActionScopeId: dialogActionScopeIdRef.current,
isActive: isVisible && hasPaneFocus,
});
const { getOpenerForFile, setOpenerForExtension } = useSftpFileAssociations();
const getOpenerForFileRef = useRef(getOpenerForFile);
@@ -122,10 +152,60 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
sftpRef.current.setShowHiddenFiles("left", paneId, !pane.showHiddenFiles);
}, []);
const syncFocusedSelection = useCallback((tabId: string | null) => {
if (tabId) {
keepOnlyPaneSelections(sftpRef.current, { side: "left", tabId });
return;
}
keepOnlyPaneSelections(sftpRef.current, null);
}, []);
const handlePaneFocus = useCallback(() => {
sftpFocusStore.setFocusedSide("left");
setHasPaneFocus(true);
syncFocusedSelection(sftpRef.current.getActiveTabId("left"));
}, [syncFocusedSelection]);
// NOTE: We intentionally do NOT sync to activeTabStore here.
// activeTabStore is a global singleton shared with SftpView.
// Writing to it here would corrupt SftpView's left pane visibility.
useEffect(() => {
if (!isVisible) {
setHasPaneFocus(false);
syncFocusedSelection(null);
}
}, [isVisible, syncFocusedSelection]);
useEffect(() => {
if (!isVisible) return;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as Node | null;
const elementTarget = target instanceof Element ? target : null;
const isPortalInteraction = !!elementTarget?.closest(
'#netcatty-context-menu-root, [role="dialog"], [data-radix-popper-content-wrapper]',
);
if (isPortalInteraction) {
return;
}
if (panelRootRef.current?.contains(target)) {
sftpFocusStore.setFocusedSide("left");
setHasPaneFocus(true);
syncFocusedSelection(sftpRef.current.getActiveTabId("left"));
} else {
setHasPaneFocus(false);
syncFocusedSelection(null);
}
};
document.addEventListener("pointerdown", handlePointerDown, true);
return () => {
document.removeEventListener("pointerdown", handlePointerDown, true);
};
}, [isVisible, syncFocusedSelection]);
const {
leftCallbacks,
rightCallbacks,
@@ -153,9 +233,14 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
getOpenerForFileRef,
setOpenerForExtension,
t,
listSftp,
mkdirLocal,
deleteLocalFile,
showSaveDialog,
selectDirectory,
startStreamTransfer,
getSftpIdForConnection: sftp.getSftpIdForConnection,
listLocalFiles: listLocalDir,
});
const {
@@ -183,17 +268,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 +286,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 +376,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 +499,20 @@ 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 (t.parentTaskId) return false; // Child tasks rendered by SftpTransferQueue
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) => {
@@ -494,9 +578,11 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
rightCallbacks={rightCallbacks}
>
<div
ref={panelRootRef}
className="h-full flex flex-col bg-background overflow-hidden"
style={isVisible ? undefined : { display: "none" }}
aria-hidden={!isVisible}
onClick={handlePaneFocus}
>
{showWorkspaceHostHeader && displayHost && (
<div className="shrink-0 border-b border-border/50 bg-muted/20 px-3 py-1.5">
@@ -509,7 +595,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}
@@ -536,8 +622,12 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
<SftpPaneView
side="left"
pane={pane}
dialogActionScopeId={dialogActionScopeIdRef.current}
isPaneFocused={isVisible && hasPaneFocus}
sftpDefaultViewMode={sftpDefaultViewMode}
showHeader
showEmptyHeader
forceActive
onToggleShowHiddenFiles={() => handleToggleHiddenFiles(pane.id)}
onGoToTerminalCwd={onGetTerminalCwd ? handleGoToTerminalCwd : undefined}
/>
@@ -548,6 +638,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
<SftpTransferQueue
sftp={sftp}
visibleTransfers={visibleTransfers}
allTransfers={sftp.transfers}
canRevealTransferTarget={canRevealTransferTarget}
onRevealTransferTarget={handleRevealTransferTarget}
/>
@@ -598,6 +689,7 @@ const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps):
prev.keys === next.keys &&
prev.identities === next.identities &&
prev.updateHosts === next.updateHosts &&
prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
prev.activeHost === next.activeHost &&
prev.showWorkspaceHostHeader === next.showWorkspaceHostHeader &&
prev.isVisible === next.isVisible &&

View File

@@ -19,10 +19,11 @@ import { useI18n } from "../application/i18n/I18nProvider";
import { useIsSftpActive } from "../application/state/activeTabStore";
import { useSftpState } from "../application/state/useSftpState";
import { useSftpBackend } from "../application/state/useSftpBackend";
import { useSettingsState } from "../application/state/useSettingsState";
import { HotkeyScheme, KeyBinding } from "../domain/models";
import { logger } from "../lib/logger";
import { useRenderTracker } from "../lib/useRenderTracker";
import { cn } from "../lib/utils";
import { useInstantThemeSwitch } from "../lib/useInstantThemeSwitch";
import { Host, Identity, SSHKey } from "../types";
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
import { toast } from "./ui/toast";
@@ -39,6 +40,8 @@ import { useSftpViewPaneCallbacks } from "./sftp/hooks/useSftpViewPaneCallbacks"
import { useSftpViewTabs } from "./sftp/hooks/useSftpViewTabs";
import { useSftpKeyboardShortcuts } from "./sftp/hooks/useSftpKeyboardShortcuts";
import { sftpFocusStore, SftpFocusedSide, useSftpFocusedSide } from "./sftp/hooks/useSftpFocusedPane";
import { keepOnlyActivePaneSelections, keepOnlyPaneSelections } from "./sftp/hooks/selectionScope";
// Wrapper component that subscribes to activeTabId for CSS visibility
// This isolates the activeTabId subscription - only this component re-renders on tab switch
@@ -49,21 +52,38 @@ interface SftpViewProps {
keys: SSHKey[];
identities: Identity[];
updateHosts: (hosts: Host[]) => void;
sftpDefaultViewMode: "list" | "tree";
sftpDoubleClickBehavior: "open" | "transfer";
sftpAutoSync: boolean;
sftpShowHiddenFiles: boolean;
sftpUseCompressedUpload: boolean;
hotkeyScheme: HotkeyScheme;
keyBindings: KeyBinding[];
editorWordWrap: boolean;
setEditorWordWrap: (enabled: boolean) => void;
}
const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updateHosts }) => {
const SftpViewInner: React.FC<SftpViewProps> = ({
hosts,
keys,
identities,
updateHosts,
sftpDefaultViewMode,
sftpDoubleClickBehavior,
sftpAutoSync,
sftpShowHiddenFiles,
sftpUseCompressedUpload,
hotkeyScheme,
keyBindings,
editorWordWrap,
setEditorWordWrap,
}) => {
const { t } = useI18n();
const isActive = useIsSftpActive();
const {
sftpDoubleClickBehavior,
sftpAutoSync,
sftpShowHiddenFiles,
sftpUseCompressedUpload,
hotkeyScheme,
keyBindings,
editorWordWrap,
setEditorWordWrap,
} = useSettingsState();
const rootRef = useRef<HTMLDivElement>(null);
const dialogActionScopeIdRef = useRef("sftp-main-view");
useInstantThemeSwitch(rootRef);
// File watch event handlers (stable refs to avoid re-creating the useSftpState options)
const fileWatchHandlers = useMemo(() => ({
@@ -86,8 +106,16 @@ 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,
listLocalDir,
} = useSftpBackend();
// Store sftp in a ref so callbacks can access the latest instance
// without needing to re-create when sftp changes
@@ -107,6 +135,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
keyBindings,
hotkeyScheme,
sftpRef,
dialogActionScopeId: dialogActionScopeIdRef.current,
isActive,
});
@@ -114,8 +143,18 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
const focusedSide = useSftpFocusedSide();
// Handle pane focus when clicking on a pane container
const handlePaneFocus = useCallback((side: SftpFocusedSide) => {
// Clear the opposite side's selection so file operations only affect the focused pane
const handlePaneFocus = useCallback((side: SftpFocusedSide, targetTabId?: string) => {
const prevSide = sftpFocusStore.getFocusedSide();
sftpFocusStore.setFocusedSide(side);
if (prevSide !== side) {
if (targetTabId) {
keepOnlyPaneSelections(sftpRef.current, { side, tabId: targetTabId });
} else {
// Focus side changed — clear other panes but keep the newly focused pane intact.
keepOnlyActivePaneSelections(sftpRef.current, side);
}
}
}, []);
const handleToggleHiddenFiles = useCallback((side: "left" | "right", paneId: string) => {
@@ -176,13 +215,18 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
getOpenerForFileRef,
setOpenerForExtension,
t,
listSftp,
mkdirLocal,
deleteLocalFile,
showSaveDialog,
selectDirectory,
startStreamTransfer,
getSftpIdForConnection: sftp.getSftpIdForConnection,
listLocalFiles: listLocalDir,
});
const visibleTransfers = useMemo(
() => [...sftp.transfers].reverse().slice(0, 5),
() => [...sftp.transfers].filter((t) => !t.parentTaskId).reverse().slice(0, 5),
[sftp.transfers],
);
@@ -225,6 +269,26 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
handleHostSelectRight,
} = useSftpViewTabs({ sftp, sftpRef });
const handleAddTabLeftWithFocus = useCallback(() => {
const tabId = handleAddTabLeft();
handlePaneFocus("left", tabId);
}, [handleAddTabLeft, handlePaneFocus]);
const handleAddTabRightWithFocus = useCallback(() => {
const tabId = handleAddTabRight();
handlePaneFocus("right", tabId);
}, [handleAddTabRight, handlePaneFocus]);
const handleSelectTabLeftWithFocus = useCallback((tabId: string) => {
handleSelectTabLeft(tabId);
handlePaneFocus("left", tabId);
}, [handlePaneFocus, handleSelectTabLeft]);
const handleSelectTabRightWithFocus = useCallback((tabId: string) => {
handleSelectTabRight(tabId);
handlePaneFocus("right", tabId);
}, [handlePaneFocus, handleSelectTabRight]);
return (
<SftpContextProvider
hosts={hosts}
@@ -235,6 +299,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
rightCallbacks={rightCallbacks}
>
<div
ref={rootRef}
className={cn(
"absolute inset-0 min-h-0 flex flex-col",
isActive ? "z-20" : "",
@@ -264,9 +329,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
<SftpTabBar
tabs={leftTabsInfo}
side="left"
onSelectTab={handleSelectTabLeft}
onSelectTab={handleSelectTabLeftWithFocus}
onCloseTab={handleCloseTabLeft}
onAddTab={handleAddTabLeft}
onAddTab={handleAddTabLeftWithFocus}
onReorderTabs={handleReorderTabsLeft}
onMoveTabToOtherSide={handleMoveTabFromRightToLeft}
/>
@@ -282,6 +347,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
<SftpPaneView
side="left"
pane={pane}
dialogActionScopeId={dialogActionScopeIdRef.current}
isPaneFocused={focusedSide === "left"}
sftpDefaultViewMode={sftpDefaultViewMode}
showHeader
showEmptyHeader={false}
onToggleShowHiddenFiles={() => handleToggleHiddenFiles("left", pane.id)}
@@ -321,9 +389,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
<SftpTabBar
tabs={rightTabsInfo}
side="right"
onSelectTab={handleSelectTabRight}
onSelectTab={handleSelectTabRightWithFocus}
onCloseTab={handleCloseTabRight}
onAddTab={handleAddTabRight}
onAddTab={handleAddTabRightWithFocus}
onReorderTabs={handleReorderTabsRight}
onMoveTabToOtherSide={handleMoveTabFromLeftToRight}
/>
@@ -339,6 +407,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
<SftpPaneView
side="right"
pane={pane}
dialogActionScopeId={dialogActionScopeIdRef.current}
isPaneFocused={focusedSide === "right"}
sftpDefaultViewMode={sftpDefaultViewMode}
showHeader
showEmptyHeader={false}
onToggleShowHiddenFiles={() => handleToggleHiddenFiles("right", pane.id)}
@@ -397,7 +468,18 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
};
const sftpViewAreEqual = (prev: SftpViewProps, next: SftpViewProps): boolean =>
prev.hosts === next.hosts && prev.keys === next.keys && prev.identities === next.identities;
prev.hosts === next.hosts &&
prev.keys === next.keys &&
prev.identities === next.identities &&
prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
prev.sftpDoubleClickBehavior === next.sftpDoubleClickBehavior &&
prev.sftpAutoSync === next.sftpAutoSync &&
prev.sftpShowHiddenFiles === next.sftpShowHiddenFiles &&
prev.sftpUseCompressedUpload === next.sftpUseCompressedUpload &&
prev.hotkeyScheme === next.hotkeyScheme &&
prev.keyBindings === next.keyBindings &&
prev.editorWordWrap === next.editorWordWrap &&
prev.setEditorWordWrap === next.setEditorWordWrap;
export const SftpView = memo(SftpViewInner, sftpViewAreEqual);
SftpView.displayName = "SftpView";

View File

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

View File

@@ -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, useCallback, useEffect, useMemo, useRef, useState } from "react";
// flushSync removed - no longer needed
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import ReactDOM from "react-dom";
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,9 @@ import {
shouldEnableNativeUserInputAutoScroll,
shouldScrollOnTerminalInput,
} from "../domain/terminalScroll";
import {
resolveHostTerminalThemeId,
} from "../domain/terminalAppearance";
import { resolveHostAuth } from "../domain/sshAuth";
import { useTerminalBackend } from "../application/state/useTerminalBackend";
import KnownHostConfirmDialog, { HostKeyInfo } from "./KnownHostConfirmDialog";
@@ -41,6 +44,8 @@ import { TerminalToolbar } from "./terminal/TerminalToolbar";
import { TerminalComposeBar } from "./terminal/TerminalComposeBar";
import { TerminalContextMenu } from "./terminal/TerminalContextMenu";
import { TerminalSearchBar } from "./terminal/TerminalSearchBar";
import { ZmodemProgressIndicator } from "./terminal/ZmodemProgressIndicator";
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
import { createXTermRuntime, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
import { XTERM_PERFORMANCE_CONFIG } from "../infrastructure/config/xtermPerformance";
@@ -49,6 +54,7 @@ import { useTerminalContextActions } from "./terminal/hooks/useTerminalContextAc
import { useTerminalAuthState } from "./terminal/hooks/useTerminalAuthState";
import { useServerStats } from "./terminal/hooks/useServerStats";
import { extractDropEntries, getPathForFile, DropEntry } from "../lib/sftpFileUtils";
import { useTerminalAutocomplete, AutocompletePopup } from "./terminal/autocomplete";
/**
* Extract unique root paths from drop entries for local terminal path insertion.
@@ -105,7 +111,8 @@ interface TerminalProps {
keys: SSHKey[];
identities: Identity[];
snippets: Snippet[];
allHosts?: Host[];
chainHosts?: Host[];
themePreviewId?: string;
knownHosts?: KnownHost[];
isVisible: boolean;
inWorkspace?: boolean;
@@ -152,6 +159,10 @@ 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 };
}
@@ -174,7 +185,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
keys,
identities,
snippets,
allHosts = [],
chainHosts = [],
themePreviewId,
knownHosts: _knownHosts = [],
isVisible,
inWorkspace,
@@ -211,6 +223,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onToggleComposeBar,
isWorkspaceComposeBarOpen,
onBroadcastInput,
onSnippetExecutorChange,
sessionLog,
}) => {
// Timeout for connection - increased to 120s to allow time for keyboard-interactive (2FA) authentication
@@ -223,11 +236,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const serializeAddonRef = useRef<SerializeAddon | null>(null);
const searchAddonRef = useRef<SearchAddon | null>(null);
const xtermRuntimeRef = useRef<XTermRuntime | null>(null);
const knownCwdRef = useRef<string | undefined>(undefined);
const disposeDataRef = useRef<(() => void) | null>(null);
const disposeExitRef = useRef<(() => void) | null>(null);
const sessionRef = useRef<string | null>(null);
const hasConnectedRef = useRef(false);
const hasRunStartupCommandRef = useRef(false);
const terminalDataCapturedRef = useRef(false);
const onTerminalDataCaptureRef = useRef(onTerminalDataCapture);
const commandBufferRef = useRef<string>("");
const [hasMouseTracking, setHasMouseTracking] = useState(false);
const mouseTrackingRef = useRef(false);
@@ -235,6 +251,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const terminalSettingsRef = useRef(terminalSettings);
terminalSettingsRef.current = terminalSettings;
onTerminalDataCaptureRef.current = onTerminalDataCapture;
const isVisibleRef = useRef(isVisible);
isVisibleRef.current = isVisible;
const pendingOutputScrollRef = useRef(false);
@@ -286,6 +303,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const snippetsRef = useRef(snippets);
snippetsRef.current = snippets;
// Autocomplete handler refs (set after hook initialization)
const autocompleteKeyEventRef = useRef<((e: KeyboardEvent) => boolean) | undefined>(undefined);
const autocompleteInputRef = useRef<((data: string) => void) | undefined>(undefined);
const autocompleteRepositionRef = useRef<(() => void) | undefined>(undefined);
const terminalBackend = useTerminalBackend();
const { resizeSession, setSessionEncoding } = terminalBackend;
@@ -302,6 +324,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;
@@ -336,19 +359,170 @@ const TerminalComponent: React.FC<TerminalProps> = ({
handleCloseSearch,
} = terminalSearch;
// Terminal autocomplete — onAcceptText writes directly to session (no CustomEvent)
const autocompleteAcceptTextRef = useRef<((text: string) => void) | undefined>(undefined);
autocompleteAcceptTextRef.current = (text: string) => {
const id = sessionRef.current;
if (id && text) {
// Serial line mode: buffer text and handle local echo instead of direct send
if (host.protocol === "serial" && serialConfig?.lineMode) {
for (const ch of text) {
if (ch === "\r") {
const line = serialLineBufferRef.current + "\r";
terminalBackend.writeToSession(id, line);
serialLineBufferRef.current = "";
if (serialConfig?.localEcho) termRef.current?.write("\r\n");
} else if (ch === "\x15") {
if (serialConfig?.localEcho && serialLineBufferRef.current.length > 0) {
termRef.current?.write("\b \b".repeat(serialLineBufferRef.current.length));
}
serialLineBufferRef.current = "";
} else if (ch === "\b" || ch === "\x7f") {
if (serialLineBufferRef.current.length > 0) {
serialLineBufferRef.current = serialLineBufferRef.current.slice(0, -1);
if (serialConfig?.localEcho) termRef.current?.write("\b \b");
}
} else if (ch.charCodeAt(0) >= 32) {
serialLineBufferRef.current += ch;
if (serialConfig?.localEcho) termRef.current?.write(ch);
}
}
// Still update commandBuffer and broadcast for serial line mode
// (fall through to shared bookkeeping below — don't return early)
} else if (host.protocol === "serial" && serialConfig?.localEcho) {
// Serial character mode with local echo: echo accepted text locally
terminalBackend.writeToSession(id, text);
for (const ch of text) {
if (ch === "\r") {
termRef.current?.write("\r\n");
} else if (ch.charCodeAt(0) >= 32) {
termRef.current?.write(ch);
}
}
} else {
terminalBackend.writeToSession(id, text);
}
// Broadcast to other sessions if broadcast mode is enabled
if (isBroadcastEnabledRef.current && onBroadcastInputRef.current) {
onBroadcastInputRef.current(text, sessionId);
}
// Update command buffer for onCommandExecuted tracking
for (const ch of text) {
if (ch === "\r" || ch === "\n") {
const cmd = commandBufferRef.current.trim();
if (cmd && onCommandExecuted) onCommandExecuted(cmd, host.id, host.label, sessionId);
commandBufferRef.current = "";
} else if (ch === "\x15") {
// Ctrl+U: clear line — reset command buffer (fuzzy match sends this)
commandBufferRef.current = "";
} else if (ch === "\b" || ch === "\x7f") {
// Backspace: remove last character (Windows fuzzy replacement uses \b)
commandBufferRef.current = commandBufferRef.current.slice(0, -1);
} else if (ch.charCodeAt(0) >= 32) {
commandBufferRef.current += ch;
}
}
}
};
const autocomplete = useTerminalAutocomplete({
termRef,
sessionId,
hostId: host.id,
hostOs: host.os || (host.protocol === "local"
? (navigator.platform?.startsWith("Win") ? "windows" : navigator.platform?.startsWith("Mac") ? "macos" : "linux")
: "linux"),
settings: terminalSettings ? {
enabled: terminalSettings.autocompleteEnabled ?? true,
showGhostText: terminalSettings.autocompleteGhostText ?? true,
showPopupMenu: terminalSettings.autocompletePopupMenu ?? true,
debounceMs: terminalSettings.autocompleteDebounceMs ?? 100,
minChars: terminalSettings.autocompleteMinChars ?? 1,
maxSuggestions: terminalSettings.autocompleteMaxSuggestions ?? 8,
} : undefined,
onAcceptText: (text) => autocompleteAcceptTextRef.current?.(text),
protocol: host.protocol,
getCwd: () => knownCwdRef.current ?? xtermRuntimeRef.current?.currentCwd,
});
// Wire up autocomplete handler refs so createXTermRuntime can use them
autocompleteKeyEventRef.current = autocomplete.handleKeyEvent;
autocompleteInputRef.current = autocomplete.handleInput;
autocompleteRepositionRef.current = autocomplete.repositionPopup;
const autocompleteClosePopup = autocomplete.closePopup;
useEffect(() => {
knownCwdRef.current = undefined;
}, [sessionId, host.id]);
useEffect(() => {
if (host.protocol === "local" || host.protocol === "serial" || host.protocol === "telnet") {
return;
}
if (status !== "connected" || !sessionRef.current || knownCwdRef.current) return;
let cancelled = false;
const timer = setTimeout(async () => {
if (!sessionRef.current) return;
try {
const result = await terminalBackend.getSessionPwd(sessionRef.current);
if (!cancelled && result.success && result.cwd) {
knownCwdRef.current = result.cwd;
}
} catch {
// Best effort only.
}
}, 150);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [host.protocol, status, terminalBackend]);
useEffect(() => {
if (!isVisible) {
autocompleteClosePopup();
}
}, [isVisible, autocompleteClosePopup]);
// Check if this is a local or serial connection (doesn't need connection dialog during connecting)
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',
isVisible,
});
const zmodem = useZmodemTransfer(sessionId);
const zmodemToastedRef = useRef(false);
useEffect(() => {
if (zmodem.active) {
zmodemToastedRef.current = false;
return;
}
if (zmodemToastedRef.current) return;
if (zmodem.error) {
zmodemToastedRef.current = true;
toast.error(zmodem.error, 'ZMODEM');
} else if (zmodem.filename) {
zmodemToastedRef.current = true;
toast.success(
`${zmodem.transferType === 'upload' ? 'Uploaded' : 'Downloaded'}: ${zmodem.filename}`,
'ZMODEM',
);
}
}, [zmodem.active, zmodem.error, zmodem.filename, zmodem.transferType]);
useEffect(() => {
if (!error) {
lastToastedErrorRef.current = null;
@@ -400,26 +574,47 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// Subscribe to custom theme changes so editing triggers re-render
const customThemes = useCustomThemes();
const hasFontSizeOverride = host.fontSizeOverride === true || (host.fontSizeOverride === undefined && host.fontSize != null);
const hasFontFamilyOverride = host.fontFamilyOverride === true || (host.fontFamilyOverride === undefined && !!host.fontFamily);
const effectiveFontSize = useMemo(
() => (hasFontSizeOverride && host.fontSize != null ? host.fontSize : fontSize),
[fontSize, hasFontSizeOverride, host.fontSize],
);
const resolvedFontFamily = useMemo(() => {
const hostFontId = hasFontFamilyOverride && host.fontFamily
? host.fontFamily
: fontFamilyId;
const resolvedFontId = hostFontId || "menlo";
return (availableFonts.find((f) => f.id === resolvedFontId) || availableFonts[0]).family;
}, [availableFonts, fontFamilyId, hasFontFamilyOverride, host.fontFamily]);
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 = themePreviewId ?? resolveHostTerminalThemeId(
{ theme: host.theme, themeOverride: host.themeOverride } as Pick<Host, 'theme' | 'themeOverride'>,
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]);
}, [customThemes, host.theme, host.themeOverride, terminalTheme, themePreviewId]);
const resolvedChainHosts =
(host.hostChain?.hostIds
?.map((id) => allHosts.find((h) => h.id === id))
.filter(Boolean) as Host[]) || [];
chainHosts;
const updateStatus = (next: TerminalSession["status"]) => {
setStatus(next);
hasConnectedRef.current = next === "connected";
onStatusChange?.(sessionId, next);
};
const handleTerminalDataCaptureOnce = useCallback((capturedSessionId: string, data: string) => {
const captureHandler = onTerminalDataCaptureRef.current;
if (!captureHandler || terminalDataCapturedRef.current) return;
terminalDataCapturedRef.current = true;
captureHandler(capturedSessionId, data);
}, []);
const cleanupSession = () => {
disposeDataRef.current?.();
@@ -487,7 +682,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
},
onSessionExit,
onTerminalDataCapture,
onTerminalDataCapture: handleTerminalDataCaptureOnce,
onOsDetected,
onCommandExecuted,
sessionLog,
@@ -496,12 +691,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
useEffect(() => {
let disposed = false;
terminalDataCapturedRef.current = false;
setError(null);
hasConnectedRef.current = false;
pendingOutputScrollRef.current = false;
setProgressLogs([]);
setShowLogs(false);
setIsCancelling(false);
setIsDisconnectedDialogDismissed(false);
const boot = async () => {
try {
@@ -531,7 +728,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
serialLocalEcho: serialConfig?.localEcho,
serialLineMode: serialConfig?.lineMode,
serialLineBufferRef,
onCwdChange: (cwd: string) => {
knownCwdRef.current = cwd;
},
onOsc52ReadRequest: handleOsc52ReadRequest,
// Autocomplete integration
onAutocompleteKeyEvent: (e: KeyboardEvent) => autocompleteKeyEventRef.current?.(e) ?? true,
onAutocompleteInput: (data: string) => autocompleteInputRef.current?.(data),
});
xtermRuntimeRef.current = runtime;
@@ -606,11 +809,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return () => {
disposed = true;
if (onTerminalDataCapture && serializeAddonRef.current) {
if (!terminalDataCapturedRef.current && serializeAddonRef.current) {
try {
const terminalData = serializeAddonRef.current.serialize();
logger.info("[Terminal] Capturing data on unmount", { sessionId, dataLength: terminalData.length });
onTerminalDataCapture(sessionId, terminalData);
handleTerminalDataCaptureOnce(sessionId, terminalData);
} catch (err) {
logger.warn("Failed to serialize terminal data on unmount:", err);
}
@@ -618,7 +821,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
teardown();
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- Effect only runs on host.id/sessionId change, internal functions are stable
}, [host.id, sessionId]);
}, [handleTerminalDataCaptureOnce, host.id, sessionId]);
// Connection timeline and timeout visuals
useEffect(() => {
@@ -627,28 +830,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));
@@ -671,7 +852,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}, 200);
return () => {
if (stepTimer) clearInterval(stepTimer);
clearInterval(countdown);
clearTimeout(timeout);
clearInterval(prog);
@@ -679,6 +859,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;
@@ -700,6 +886,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (!options?.force) {
const lastSize = lastFittedSizeRef.current;
if (lastSize && lastSize.width === width && lastSize.height === height) {
autocompleteRepositionRef.current?.();
return;
}
}
@@ -708,6 +895,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
try {
lastFittedSizeRef.current = { width, height };
fitAddon.fit();
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => {
autocompleteRepositionRef.current?.();
});
} else {
autocompleteRepositionRef.current?.();
}
} catch (err) {
logger.warn("Fit failed", err);
}
@@ -723,15 +917,20 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
};
useEffect(() => {
// Sync xterm theme before browser paint so canvas + DOM CSS vars update in the same frame
useLayoutEffect(() => {
if (termRef.current) {
const effectiveFontSize = host.fontSize || fontSize;
termRef.current.options.fontSize = effectiveFontSize;
termRef.current.options.theme = {
...effectiveTheme.colors,
selectionBackground: effectiveTheme.colors.selection,
};
}
}, [effectiveTheme]);
useEffect(() => {
if (termRef.current) {
termRef.current.options.fontSize = effectiveFontSize;
termRef.current.options.fontFamily = resolvedFontFamily;
if (terminalSettings) {
termRef.current.options.cursorStyle = terminalSettings.cursorShape;
@@ -773,6 +972,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;
@@ -780,27 +983,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
termRef.current.options.ignoreBracketedPasteMode = terminalSettings.disableBracketedPaste ?? false;
}
setTimeout(() => safeFit({ force: true }), 50);
if (isVisibleRef.current) {
setTimeout(() => safeFit({ force: true, requireVisible: true }), 50);
} else {
lastFittedSizeRef.current = null;
}
}
}, [fontSize, effectiveTheme, terminalSettings, host.fontSize]);
useEffect(() => {
if (termRef.current) {
const effectiveFontSize = host.fontSize || fontSize;
termRef.current.options.fontSize = effectiveFontSize;
const hostFontId = host.fontFamily || fontFamilyId || "menlo";
const fontObj = availableFonts.find((f) => f.id === hostFontId) || availableFonts[0];
termRef.current.options.fontFamily = fontObj.family;
termRef.current.options.theme = {
...effectiveTheme.colors,
selectionBackground: effectiveTheme.colors.selection,
};
setTimeout(() => safeFit({ force: true }), 50);
}
}, [host.fontSize, host.fontFamily, host.theme, fontFamilyId, fontSize, effectiveTheme, availableFonts]);
}, [effectiveFontSize, resolvedFontFamily, terminalSettings]);
useEffect(() => {
if (!isVisible) return;
@@ -848,7 +1037,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (terminalSettings && termRef.current) {
const fontFamily = termRef.current.options?.fontFamily || "";
const effectiveFontSize = host.fontSize || fontSize;
if (typeof document !== "undefined" && document.fonts?.check) {
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${fontFamily}`;
const resolvedBold = document.fonts.check(weightSpec)
@@ -884,7 +1072,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return () => {
cancelled = true;
};
}, [host.id, host.fontFamily, host.fontSize, fontFamilyId, fontSize, resizeSession, sessionId, terminalSettings]);
}, [effectiveFontSize, resizeSession, terminalSettings]);
useEffect(() => {
if (!isVisible || !containerRef.current || !fitAddonRef.current) return;
@@ -927,6 +1115,26 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return () => clearTimeout(timer);
}, [inWorkspace, isVisible]);
// When search bar opens/closes, re-fit terminal and maintain scroll position
useEffect(() => {
const term = termRef.current;
if (!term || !fitAddonRef.current) return;
const buffer = term.buffer.active;
const wasAtBottom = buffer.viewportY >= buffer.baseY;
const prevViewportY = buffer.viewportY;
const timer = setTimeout(() => {
safeFit({ force: true, requireVisible: true });
requestAnimationFrame(() => {
if (wasAtBottom) {
term.scrollToBottom();
} else {
term.scrollToLine(prevViewportY);
}
});
}, 0);
return () => clearTimeout(timer);
}, [isSearchOpen]);
useEffect(() => {
const shouldAutoFocus = isVisible && termRef.current && (!inWorkspace || isFocusMode);
if (shouldAutoFocus) {
@@ -1022,6 +1230,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}, [sessionId]);
useEffect(() => {
if (!isVisible) return;
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
const handler = () => {
@@ -1039,7 +1249,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (resizeTimeout) clearTimeout(resizeTimeout);
window.removeEventListener("resize", handler);
};
}, []);
}, [isVisible]);
const disableBracketedPasteRef = useRef(terminalSettings?.disableBracketedPaste ?? false);
disableBracketedPasteRef.current = terminalSettings?.disableBracketedPaste ?? false;
@@ -1047,11 +1257,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,
@@ -1110,6 +1352,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onCloseSession?.(sessionId);
};
const handleDismissDisconnectedDialog = () => {
setIsDisconnectedDialogDismissed(true);
};
const handleCloseDisconnectedSession = () => {
onCloseSession?.(sessionId);
};
const handleHostKeyClose = () => {
setNeedsHostKeyVerification(false);
setPendingHostKeyInfo(null);
@@ -1149,18 +1399,31 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (!termRef.current) return;
cleanupSession();
auth.resetForRetry();
terminalDataCapturedRef.current = false;
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();
@@ -1270,6 +1533,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
: status === "connecting"
? "bg-amber-400"
: "bg-rose-500";
const terminalPreviewVars = useMemo(() => ({
['--terminal-ui-bg' as never]: `var(--terminal-preview-bg, ${effectiveTheme.colors.background})`,
['--terminal-ui-fg' as never]: `var(--terminal-preview-fg, ${effectiveTheme.colors.foreground})`,
['--terminal-ui-border' as never]: `var(--terminal-preview-border, color-mix(in srgb, ${effectiveTheme.colors.foreground} 8%, ${effectiveTheme.colors.background} 92%))`,
['--terminal-ui-toolbar-btn' as never]: `var(--terminal-preview-toolbar-btn, color-mix(in srgb, ${effectiveTheme.colors.background} 88%, ${effectiveTheme.colors.foreground} 12%))`,
['--terminal-ui-toolbar-btn-hover' as never]: `var(--terminal-preview-toolbar-btn-hover, color-mix(in srgb, ${effectiveTheme.colors.background} 78%, ${effectiveTheme.colors.foreground} 22%))`,
['--terminal-ui-toolbar-btn-active' as never]: `var(--terminal-preview-toolbar-btn-active, color-mix(in srgb, ${effectiveTheme.colors.background} 68%, ${effectiveTheme.colors.foreground} 32%))`,
}), [effectiveTheme.colors.background, effectiveTheme.colors.foreground]);
return (
<TerminalContextMenu
@@ -1292,6 +1563,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
"relative h-full w-full flex overflow-hidden bg-gradient-to-br from-[#050910] via-[#06101a] to-[#0b1220]",
isComposeBarOpen && !inWorkspace && "flex-col"
)}
style={terminalPreviewVars}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
@@ -1320,16 +1592,16 @@ const TerminalComponent: React.FC<TerminalProps> = ({
)}
<div className="absolute left-0 right-0 top-0 z-20 pointer-events-none">
<div
className="flex items-center gap-1 px-2 py-0.5 backdrop-blur-md pointer-events-auto min-w-0 border-b-[0.5px]"
className="flex items-center gap-1 px-2 py-0.5 backdrop-blur-md pointer-events-auto min-w-0"
style={{
backgroundColor: effectiveTheme.colors.background,
color: effectiveTheme.colors.foreground,
borderColor: `color-mix(in srgb, ${effectiveTheme.colors.foreground} 8%, ${effectiveTheme.colors.background} 92%)`,
['--terminal-toolbar-fg' as never]: effectiveTheme.colors.foreground,
['--terminal-toolbar-bg' as never]: effectiveTheme.colors.background,
['--terminal-toolbar-btn' as never]: `color-mix(in srgb, ${effectiveTheme.colors.background} 88%, ${effectiveTheme.colors.foreground} 12%)`,
['--terminal-toolbar-btn-hover' as never]: `color-mix(in srgb, ${effectiveTheme.colors.background} 78%, ${effectiveTheme.colors.foreground} 22%)`,
['--terminal-toolbar-btn-active' as never]: `color-mix(in srgb, ${effectiveTheme.colors.background} 68%, ${effectiveTheme.colors.foreground} 32%)`,
backgroundColor: 'var(--terminal-ui-bg)',
color: 'var(--terminal-ui-fg)',
borderColor: 'var(--terminal-ui-border)',
['--terminal-toolbar-fg' as never]: 'var(--terminal-ui-fg)',
['--terminal-toolbar-bg' as never]: 'var(--terminal-ui-bg)',
['--terminal-toolbar-btn' as never]: 'var(--terminal-ui-toolbar-btn)',
['--terminal-toolbar-btn-hover' as never]: 'var(--terminal-ui-toolbar-btn-hover)',
['--terminal-toolbar-btn-active' as never]: 'var(--terminal-ui-toolbar-btn-active)',
}}
>
<div className="flex items-center gap-1 text-[11px] font-semibold">
@@ -1341,8 +1613,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}>
@@ -1389,6 +1661,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>
)}
@@ -1686,7 +1976,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
<div
className="h-full flex-1 min-w-0 relative overflow-hidden pt-8"
style={{ backgroundColor: effectiveTheme.colors.background }}
style={{ backgroundColor: 'var(--terminal-ui-bg)' }}
>
<div
ref={containerRef}
@@ -1694,10 +1984,34 @@ const TerminalComponent: React.FC<TerminalProps> = ({
style={{
top: isSearchOpen ? "64px" : "30px",
paddingLeft: 6,
backgroundColor: effectiveTheme.colors.background,
backgroundColor: 'var(--terminal-ui-bg)',
}}
/>
{/* Autocomplete popup — rendered via Portal to escape overflow:hidden */}
{isVisible && autocomplete.state.popupVisible && autocomplete.state.suggestions.length > 0 &&
ReactDOM.createPortal(
<AutocompletePopup
suggestions={autocomplete.state.suggestions}
selectedIndex={autocomplete.state.selectedIndex}
position={autocomplete.state.popupPosition}
cursorLineTop={autocomplete.state.popupCursorLineTop}
cursorLineBottom={autocomplete.state.popupCursorLineBottom}
visible={autocomplete.state.popupVisible}
expandUpward={autocomplete.state.expandUpward}
themeColors={effectiveTheme.colors}
onSelect={autocomplete.selectSuggestion}
subDirPanels={autocomplete.state.subDirPanels}
subDirFocusLevel={autocomplete.state.subDirFocusLevel}
containerRef={containerRef}
onRequestReposition={autocomplete.repositionPopup}
searchBarOffset={isSearchOpen ? 64 : 30}
onDismiss={autocompleteClosePopup}
/>,
document.body,
)
}
{needsHostKeyVerification && pendingHostKeyInfo && (
<div className="absolute inset-0 z-30 bg-background">
<KnownHostConfirmDialog
@@ -1734,9 +2048,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
)}
{/* Connection dialog: skip for local/serial during connecting phase, but show on error */}
{status !== "connected" && !needsHostKeyVerification && !(
(isLocalConnection || isSerialConnection) && status === "connecting"
) && (
{shouldShowConnectionDialog && (
<TerminalConnectionDialog
host={host}
status={status}
@@ -1747,6 +2059,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
showLogs={showLogs}
_setShowLogs={setShowLogs}
keys={keys}
onDismissDisconnected={handleDismissDisconnectedDialog}
authProps={{
authMethod: auth.authMethod,
setAuthMethod: auth.setAuthMethod,
@@ -1772,11 +2085,28 @@ const TerminalComponent: React.FC<TerminalProps> = ({
timeLeft,
isCancelling,
progressLogs,
onCancel: handleCancelConnect,
onCancelConnect: handleCancelConnect,
onCloseSession: handleCloseDisconnectedSession,
onRetry: handleRetry,
}}
/>
)}
{/* ZMODEM transfer progress indicator */}
{zmodem.active && (
<div className="absolute bottom-4 right-4 z-[25] pointer-events-auto">
<ZmodemProgressIndicator
transferType={zmodem.transferType}
filename={zmodem.filename}
transferred={zmodem.transferred}
total={zmodem.total}
fileIndex={zmodem.fileIndex}
fileCount={zmodem.fileCount}
finalizing={zmodem.finalizing}
onCancel={zmodem.cancel}
/>
</div>
)}
</div>
{/* Compose Bar (solo sessions only; workspace uses TerminalLayer's global bar) */}

File diff suppressed because it is too large Load Diff

View File

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

124
components/ThemeList.tsx Normal file
View File

@@ -0,0 +1,124 @@
/**
* Shared theme list component used by both ThemeSelectPanel and ThemeSelectModal
*/
import React, { memo, useMemo } from 'react';
import { Check } from 'lucide-react';
import { useI18n } from '../application/i18n/I18nProvider';
import { TERMINAL_THEMES } from '../infrastructure/config/terminalThemes';
import { useCustomThemes } from '../application/state/customThemeStore';
import { cn } from '../lib/utils';
import { TerminalTheme } from '../types';
// Memoized theme item component
export const ThemeItem = memo(({
theme,
isSelected,
onSelect
}: {
theme: TerminalTheme;
isSelected: boolean;
onSelect: (id: string) => void;
}) => (
<button
onClick={() => onSelect(theme.id)}
className={cn(
'w-full flex items-center gap-3 px-3 py-2.5 text-left transition-all',
isSelected
? 'bg-primary/10'
: 'hover:bg-muted'
)}
>
{/* Color swatch preview */}
<div
className="w-12 h-8 rounded-[4px] flex-shrink-0 flex flex-col justify-center items-start pl-1.5 gap-0.5 border border-border/50"
style={{ backgroundColor: theme.colors.background }}
>
<div className="h-1 w-4 rounded-full" style={{ backgroundColor: theme.colors.green }} />
<div className="h-1 w-6 rounded-full" style={{ backgroundColor: theme.colors.blue }} />
<div className="h-1 w-3 rounded-full" style={{ backgroundColor: theme.colors.yellow }} />
</div>
<div className="flex-1 min-w-0">
<div className={cn('text-sm font-medium truncate', isSelected ? 'text-primary' : 'text-foreground')}>
{theme.name}
</div>
<div className="text-[10px] text-muted-foreground capitalize">{theme.type}</div>
</div>
{isSelected && (
<Check size={16} className="text-primary flex-shrink-0" />
)}
</button>
));
ThemeItem.displayName = 'ThemeItem';
interface ThemeListProps {
selectedThemeId: string;
onSelect: (themeId: string) => void;
}
export const ThemeList: React.FC<ThemeListProps> = ({ selectedThemeId, onSelect }) => {
const { t } = useI18n();
const customThemes = useCustomThemes();
const { darkThemes, lightThemes } = useMemo(() => {
const dark = TERMINAL_THEMES.filter(t => t.type === 'dark');
const light = TERMINAL_THEMES.filter(t => t.type === 'light');
return { darkThemes: dark, lightThemes: light };
}, []);
return (
<>
{/* Dark Themes Section */}
<div className="mb-4">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-3">
{t('settings.terminal.themeModal.darkThemes')}
</div>
<div className="space-y-1">
{darkThemes.map(theme => (
<ThemeItem
key={theme.id}
theme={theme}
isSelected={selectedThemeId === theme.id}
onSelect={onSelect}
/>
))}
</div>
</div>
{/* Light Themes Section */}
<div>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-3">
{t('settings.terminal.themeModal.lightThemes')}
</div>
<div className="space-y-1">
{lightThemes.map(theme => (
<ThemeItem
key={theme.id}
theme={theme}
isSelected={selectedThemeId === theme.id}
onSelect={onSelect}
/>
))}
</div>
</div>
{/* Custom Themes Section */}
{customThemes.length > 0 && (
<div className="mt-4">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-3">
{t('terminal.customTheme.section')}
</div>
<div className="space-y-1">
{customThemes.map(theme => (
<ThemeItem
key={theme.id}
theme={theme}
isSelected={selectedThemeId === theme.id}
onSelect={onSelect}
/>
))}
</div>
</div>
)}
</>
);
};

View File

@@ -1,13 +1,10 @@
import React, { useMemo, useState } from 'react';
import { TERMINAL_THEMES } from '../infrastructure/config/terminalThemes';
import { useCustomThemes } from '../application/state/customThemeStore';
import { cn } from '../lib/utils';
import { TerminalTheme } from '../types';
import React from 'react';
import {
AsidePanel,
AsidePanelContent,
} from './ui/aside-panel';
import { ScrollArea } from './ui/scroll-area';
import { ThemeList } from './ThemeList';
interface ThemeSelectPanelProps {
open: boolean;
@@ -18,40 +15,6 @@ interface ThemeSelectPanelProps {
showBackButton?: boolean;
}
// Mini terminal preview component
const TerminalPreview: React.FC<{ theme: TerminalTheme; isSelected: boolean }> = ({
theme,
isSelected
}) => {
return (
<div
className={cn(
"w-16 h-10 rounded-md overflow-hidden border-2 flex-shrink-0",
isSelected ? "border-primary" : "border-transparent"
)}
style={{ backgroundColor: theme.colors.background }}
>
<div className="p-1 text-[4px] font-mono leading-tight" style={{ color: theme.colors.foreground }}>
<div>
<span style={{ color: theme.colors.green }}>$</span>{' '}
<span style={{ color: theme.colors.cyan }}>ls</span>
</div>
<div className="flex gap-0.5 flex-wrap">
<span style={{ color: theme.colors.blue }}>dir/</span>
<span style={{ color: theme.colors.green }}>file</span>
</div>
<div>
<span style={{ color: theme.colors.green }}>$</span>{' '}
<span
className="inline-block w-1 h-1.5"
style={{ backgroundColor: theme.colors.cursor }}
/>
</div>
</div>
</div>
);
};
const ThemeSelectPanel: React.FC<ThemeSelectPanelProps> = ({
open,
selectedThemeId,
@@ -60,51 +23,6 @@ const ThemeSelectPanel: React.FC<ThemeSelectPanelProps> = ({
onBack,
showBackButton = true,
}) => {
// Reserved for future hover preview feature
const [_hoveredThemeId, setHoveredThemeId] = useState<string | null>(null);
const customThemes = useCustomThemes();
// All themes combined
const allThemes = useMemo(() => {
return [...TERMINAL_THEMES, ...customThemes];
}, [customThemes]);
const renderThemeItem = (theme: TerminalTheme) => {
const isSelected = theme.id === selectedThemeId;
return (
<button
key={theme.id}
className={cn(
"w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors text-left",
isSelected
? "bg-primary/10"
: "hover:bg-secondary/50"
)}
onClick={() => onSelect(theme.id)}
onMouseEnter={() => setHoveredThemeId(theme.id)}
onMouseLeave={() => setHoveredThemeId(null)}
>
<TerminalPreview theme={theme} isSelected={isSelected} />
<div className="flex-1 min-w-0">
<div className={cn(
"text-sm font-medium truncate",
isSelected && "text-primary"
)}>
{theme.name}
</div>
{theme.id === 'netcatty-dark' && (
<div className="text-xs text-muted-foreground">Default</div>
)}
{theme.id === 'netcatty-light' && (
<div className="text-xs text-muted-foreground">Light mode</div>
)}
</div>
</button>
);
};
return (
<AsidePanel
open={open}
@@ -116,8 +34,10 @@ const ThemeSelectPanel: React.FC<ThemeSelectPanelProps> = ({
<AsidePanelContent className="p-0">
<ScrollArea className="h-full">
<div className="py-2">
{/* All themes in a single list */}
{allThemes.map(renderThemeItem)}
<ThemeList
selectedThemeId={selectedThemeId || ''}
onSelect={onSelect}
/>
</div>
</ScrollArea>
</AsidePanelContent>

View File

@@ -1,10 +1,12 @@
import { Bell, Copy, FileText, Folder, LayoutGrid, Minus, Moon, MoreHorizontal, Plus, Server, Shield, Sparkles, Square, Sun, TerminalSquare, Usb, X } from 'lucide-react';
import { Bell, Copy, FileText, Folder, FolderLock, LayoutGrid, Minus, Moon, MoreHorizontal, Plus, Server, Sparkles, Square, Sun, TerminalSquare, Usb, X } from 'lucide-react';
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { activeTabStore, useActiveTabId } from '../application/state/activeTabStore';
import { buildWorkspaceActivityMap } from '../application/state/sessionActivity';
import { useSessionActivityMap } from '../application/state/sessionActivityStore';
import { LogView } from '../application/state/useSessionState';
import { useWindowControls } from '../application/state/useWindowControls';
import { useI18n } from '../application/i18n/I18nProvider';
import { normalizeDistroId } from '../domain/host';
import { getEffectiveHostDistro } from '../domain/host';
import { cn } from '../lib/utils';
import { Host, TerminalSession, Workspace } from '../types';
import { DISTRO_LOGOS, DISTRO_COLORS } from './DistroAvatar';
@@ -36,6 +38,7 @@ interface TopTabsProps {
onToggleTheme: () => void;
onOpenSettings: () => void;
onSyncNow?: () => Promise<void>;
isImmersiveActive?: boolean;
onStartSessionDrag: (sessionId: string) => void;
onEndSessionDrag: () => void;
onReorderTabs: (draggedId: string, targetId: string, position: 'before' | 'after') => void;
@@ -54,7 +57,7 @@ const localOsId = (() => {
const SessionTabIcon: React.FC<{ host: Host | undefined; isActive: boolean; protocol?: string }> = memo(({ host, isActive, protocol }) => {
const boxBase = "shrink-0 h-4 w-4 rounded flex items-center justify-center";
const iconSize = "h-2.5 w-2.5";
const fallbackIcon = cn(iconSize, isActive ? "text-accent" : "text-muted-foreground");
const fallbackStyle = { color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' };
// Serial protocol → USB icon
if (protocol === 'serial' || host?.protocol === 'serial') {
@@ -81,7 +84,7 @@ const SessionTabIcon: React.FC<{ host: Host | undefined; isActive: boolean; prot
);
}
return (
<div className={cn(boxBase, "bg-primary/15 text-primary")}>
<div className={boxBase} style={{ backgroundColor: 'color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 15%, transparent)', color: 'var(--top-tabs-accent, hsl(var(--accent)))' }}>
<TerminalSquare className={iconSize} />
</div>
);
@@ -89,7 +92,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 +100,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>
@@ -108,22 +111,33 @@ const SessionTabIcon: React.FC<{ host: Host | undefined; isActive: boolean; prot
// Fallback: generic server icon for remote, terminal for unknown
if (host && host.protocol !== 'local') {
return (
<div className={cn(boxBase, "bg-primary/15 text-primary")}>
<div className={boxBase} style={{ backgroundColor: 'color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 15%, transparent)', color: 'var(--top-tabs-accent, hsl(var(--accent)))' }}>
<Server className={iconSize} />
</div>
);
}
return <TerminalSquare className={fallbackIcon} />;
return <TerminalSquare className={iconSize} style={fallbackStyle} />;
});
SessionTabIcon.displayName = 'SessionTabIcon';
const sessionStatusDot = (status: TerminalSession['status']) => {
const sessionStatusDot = (status: TerminalSession['status'], hasActivity: boolean) => {
const tone = status === 'connected'
? "bg-emerald-400"
: status === 'connecting'
? "bg-amber-400"
: "bg-rose-500";
return <span className={cn("inline-block h-2 w-2 rounded-full ring-2 ring-background/60", tone)} />;
return (
<span className="relative inline-flex h-2 w-2 shrink-0 items-center justify-center">
<span
className={cn(
"relative inline-block h-2 w-2 rounded-full ring-2",
tone,
hasActivity && "session-activity-dot",
)}
style={{ boxShadow: '0 0 0 2px color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 60%, transparent)' }}
/>
</span>
);
};
// Custom window controls for Windows/Linux (frameless window)
@@ -167,14 +181,16 @@ const WindowControls: React.FC = memo(() => {
<div className="flex items-center app-drag h-full">
<button
onClick={handleMinimize}
className="h-full w-10 flex items-center justify-center text-muted-foreground hover:bg-foreground/10 hover:text-foreground transition-all duration-150 app-no-drag"
className="h-full w-10 flex items-center justify-center transition-all duration-150 app-no-drag"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
title="Minimize"
>
<Minus size={16} />
</button>
<button
onClick={handleMaximize}
className="h-full w-10 flex items-center justify-center text-muted-foreground hover:bg-foreground/10 hover:text-foreground transition-all duration-150 app-no-drag"
className="h-full w-10 flex items-center justify-center transition-all duration-150 app-no-drag"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
title={isMaximized ? "Restore" : "Maximize"}
>
{isMaximized ? (
@@ -217,6 +233,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onToggleTheme,
onOpenSettings,
onSyncNow,
isImmersiveActive,
onStartSessionDrag,
onEndSessionDrag,
onReorderTabs,
@@ -225,6 +242,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
// Subscribe to activeTabId from external store
const { maximize, isFullscreen, onFullscreenChanged } = useWindowControls();
const activeTabId = useActiveTabId();
const sessionActivityMap = useSessionActivityMap();
const isVaultActive = activeTabId === 'vault';
const isSftpActive = activeTabId === 'sftp';
const onSelectTab = activeTabStore.setActiveTabId;
@@ -328,6 +346,10 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
return map;
}, [hosts]);
const workspaceActivityMap = useMemo(() => {
return buildWorkspaceActivityMap(sessions, sessionActivityMap);
}, [sessionActivityMap, sessions]);
// Pre-compute session counts per workspace for O(1) access
const workspacePaneCounts = useMemo(() => {
const counts = new Map<string, number>();
@@ -451,6 +473,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
if (item.type === 'session') {
const session = item.session;
const hasActivity = !!sessionActivityMap[session.id];
const isBeingDragged = draggingSessionId === session.id;
const shiftStyle = tabShiftStyles[session.id] || {};
const showDropIndicatorBefore = dropIndicator?.tabId === session.id && dropIndicator.position === 'before';
@@ -470,30 +493,56 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onDrop={(e) => handleTabDrop(e, session.id)}
className={cn(
"relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
"transition-all duration-150",
activeTabId === session.id
? "bg-background text-foreground"
: "text-muted-foreground hover:bg-background/40 hover:text-foreground",
"transition-transform duration-150",
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : ""
)}
style={shiftStyle}
style={{
...shiftStyle,
backgroundColor: activeTabId === session.id
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
: 'transparent',
color: activeTabId === session.id
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
}}
onMouseEnter={(e) => {
if (activeTabId !== session.id) {
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
}
}}
onMouseLeave={(e) => {
if (activeTabId !== session.id) {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
}
}}
>
{/* Active tab top accent line */}
{activeTabId === session.id && (
<div className="absolute top-0 left-0 right-0 h-[2px] bg-accent" />
<div
className="absolute top-0 left-0 right-0 h-[2px]"
style={{ backgroundColor: 'hsl(var(--primary))' }}
/>
)}
{/* Drop indicator line - before */}
{showDropIndicatorBefore && isDraggingForReorder && (
<div className="absolute -left-0.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
<div
className="absolute -left-0.5 top-1 bottom-1 w-0.5 rounded-full animate-pulse"
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))', boxShadow: '0 0 8px 2px color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 50%, transparent)' }}
/>
)}
{/* Drop indicator line - after */}
{showDropIndicatorAfter && isDraggingForReorder && (
<div className="absolute -right-0.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
<div
className="absolute -right-0.5 top-1 bottom-1 w-0.5 rounded-full animate-pulse"
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))', boxShadow: '0 0 8px 2px color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 50%, transparent)' }}
/>
)}
<div className="flex items-center gap-2 min-w-0 flex-1">
<SessionTabIcon host={hostMap.get(session.hostId)} isActive={activeTabId === session.id} protocol={session.protocol} />
<span className="truncate">{session.hostLabel}</span>
<div className="flex-shrink-0">{sessionStatusDot(session.status)}</div>
<div className="flex-shrink-0">{sessionStatusDot(session.status, hasActivity)}</div>
</div>
<button
onClick={(e) => onCloseSession(session.id, e)}
@@ -522,6 +571,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
if (item.type === 'workspace') {
const workspace = item.workspace;
const paneCount = item.paneCount;
const hasActivity = !!workspaceActivityMap.get(workspace.id);
const isActive = activeTabId === workspace.id;
const isBeingDragged = draggingSessionId === workspace.id;
const shiftStyle = tabShiftStyles[workspace.id] || {};
@@ -542,32 +592,71 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onDrop={(e) => handleTabDrop(e, workspace.id)}
className={cn(
"relative h-7 pl-3 pr-2 min-w-[150px] max-w-[260px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
"transition-all duration-150",
isActive
? "bg-background text-foreground"
: "text-muted-foreground hover:bg-background/40 hover:text-foreground",
"transition-transform duration-150",
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : ""
)}
style={shiftStyle}
style={{
...shiftStyle,
backgroundColor: isActive
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
: 'transparent',
color: isActive
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
}}
onMouseEnter={(e) => {
if (!isActive) {
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
}
}}
onMouseLeave={(e) => {
if (!isActive) {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
}
}}
>
{/* Active tab top accent line */}
{isActive && (
<div className="absolute top-0 left-0 right-0 h-[2px] bg-accent" />
<div
className="absolute top-0 left-0 right-0 h-[2px]"
style={{ backgroundColor: 'hsl(var(--primary))' }}
/>
)}
{/* Drop indicator line - before */}
{showDropIndicatorBefore && isDraggingForReorder && (
<div className="absolute -left-0.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
<div
className="absolute -left-0.5 top-1 bottom-1 w-0.5 rounded-full animate-pulse"
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))', boxShadow: '0 0 8px 2px color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 50%, transparent)' }}
/>
)}
{/* Drop indicator line - after */}
{showDropIndicatorAfter && isDraggingForReorder && (
<div className="absolute -right-0.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
<div
className="absolute -right-0.5 top-1 bottom-1 w-0.5 rounded-full animate-pulse"
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))', boxShadow: '0 0 8px 2px color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 50%, transparent)' }}
/>
)}
<div className="flex items-center gap-2 truncate">
<LayoutGrid size={14} className={cn("shrink-0", isActive ? "text-primary" : "text-muted-foreground")} />
<LayoutGrid
size={14}
className="shrink-0"
style={{ color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
/>
<span className="truncate">{workspace.title}</span>
</div>
<div className="text-[10px] px-1.5 py-0.5 rounded-full border border-border/70 bg-background/60 min-w-[22px] text-center">
{paneCount}
<div className="flex items-center gap-1.5 shrink-0">
{hasActivity && sessionStatusDot('connected', true)}
<div
className="text-[10px] px-1.5 py-0.5 rounded-full min-w-[22px] text-center"
style={{
border: '1px solid color-mix(in srgb, var(--top-tabs-fg, hsl(var(--foreground))) 18%, transparent)',
backgroundColor: 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 60%, transparent)',
}}
>
{paneCount}
</div>
</div>
</div>
</ContextMenuTrigger>
@@ -595,18 +684,41 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onClick={() => onSelectTab(logView.id)}
className={cn(
"relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
"transition-colors duration-150",
isActive
? "bg-background text-foreground"
: "text-muted-foreground hover:bg-background/40 hover:text-foreground"
)}
style={{
backgroundColor: isActive
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
: 'transparent',
color: isActive
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
}}
onMouseEnter={(e) => {
if (!isActive) {
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
}
}}
onMouseLeave={(e) => {
if (!isActive) {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
}
}}
>
{/* Active tab top accent line */}
{isActive && (
<div className="absolute top-0 left-0 right-0 h-[2px] bg-accent" />
<div
className="absolute top-0 left-0 right-0 h-[2px]"
style={{ backgroundColor: 'var(--top-tabs-fg, hsl(var(--foreground)))' }}
/>
)}
<div className="flex items-center gap-2 min-w-0 flex-1">
<FileText size={14} className={cn("shrink-0", isActive ? "text-accent" : "text-muted-foreground")} />
<FileText
size={14}
className="shrink-0"
style={{ color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
/>
<span className="truncate">
{t('tabs.logPrefix')} {isLocal ? t('tabs.logLocal') : logView.log.hostname}
</span>
@@ -640,8 +752,13 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
return (
<div
data-top-tabs-root
className="relative w-full bg-secondary app-drag"
style={dragRegionNoSelect}
style={{
...dragRegionNoSelect,
backgroundColor: 'var(--top-tabs-bg, hsl(var(--secondary)))',
color: 'var(--top-tabs-fg, hsl(var(--foreground)))',
}}
onDoubleClick={handleTitleBarDoubleClick}
>
{/* Always-on drag stripe so the window can be moved even when tabs fill the bar */}
@@ -656,25 +773,62 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onClick={() => onSelectTab('vault')}
className={cn(
"relative h-7 px-3 rounded text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
"transition-colors duration-150",
isVaultActive
? "bg-foreground/10 text-foreground"
: "text-muted-foreground hover:bg-background/40 hover:text-foreground"
)}
style={{
backgroundColor: isVaultActive
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
: 'transparent',
color: isVaultActive
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
}}
onMouseEnter={(e) => {
if (!isVaultActive) {
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
}
}}
onMouseLeave={(e) => {
if (!isVaultActive) {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
}
}}
>
<Shield size={14} /> Vaults
<FolderLock size={14} /> Vaults
</div>
<div
onClick={() => onSelectTab('sftp')}
className={cn(
"relative h-7 px-3 rounded-none text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
"transition-colors duration-150",
isSftpActive
? "bg-background text-foreground"
: "text-muted-foreground hover:bg-background/40 hover:text-foreground"
)}
style={{
backgroundColor: isSftpActive
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
: 'transparent',
color: isSftpActive
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
}}
onMouseEnter={(e) => {
if (!isSftpActive) {
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
}
}}
onMouseLeave={(e) => {
if (!isSftpActive) {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
}
}}
>
{isSftpActive && <div className="absolute top-0 left-0 right-0 h-[2px] bg-accent" />}
{isSftpActive && (
<div
className="absolute top-0 left-0 right-0 h-[2px]"
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))' }}
/>
)}
<Folder size={14} /> SFTP
</div>
</div>
@@ -696,7 +850,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
{canScrollLeft && (
<div
className="absolute left-0 top-0 bottom-0 w-8 pointer-events-none z-10"
style={{ background: 'linear-gradient(to right, hsl(var(--secondary) / 0.9), transparent)' }}
style={{ background: 'linear-gradient(to right, var(--top-tabs-bg, hsl(var(--secondary))), transparent)' }}
/>
)}
@@ -713,6 +867,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
variant="ghost"
size="icon"
className="h-7 w-7 flex-shrink-0 app-no-drag mb-0 rounded-none"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
onClick={onOpenQuickSwitcher}
title="Open quick switcher"
>
@@ -727,7 +882,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
{canScrollRight && (
<div
className="absolute right-0 top-0 bottom-0 w-8 pointer-events-none z-10"
style={{ background: 'linear-gradient(to left, hsl(var(--secondary) / 0.9), transparent)' }}
style={{ background: 'linear-gradient(to left, var(--top-tabs-bg, hsl(var(--secondary))), transparent)' }}
/>
)}
</div>
@@ -738,6 +893,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
variant="ghost"
size="icon"
className="h-7 w-7 flex-shrink-0 app-no-drag self-end rounded-none"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
onClick={onOpenQuickSwitcher}
title="More tabs"
>
@@ -750,21 +906,24 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-foreground app-no-drag"
className="h-6 w-6 app-no-drag"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
title="AI Assistant"
onClick={() => window.dispatchEvent(new CustomEvent('netcatty:toggle-ai-panel'))}
>
<Sparkles size={16} />
</Button>
<Button variant="ghost" size="icon" className="h-6 w-6 text-muted-foreground hover:text-foreground app-no-drag">
<Button variant="ghost" size="icon" className="h-6 w-6 app-no-drag" style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}>
<Bell size={16} />
</Button>
<SyncStatusButton onOpenSettings={onOpenSettings} onSyncNow={onSyncNow} />
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-foreground app-no-drag"
className="h-6 w-6 app-no-drag"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
onClick={onToggleTheme}
disabled={isImmersiveActive}
title="Toggle theme"
>
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
@@ -788,10 +947,12 @@ const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean => {
prev.orphanSessions === next.orphanSessions &&
prev.workspaces === next.workspaces &&
prev.orderedTabs === next.orderedTabs &&
prev.logViews === next.logViews &&
prev.draggingSessionId === next.draggingSessionId &&
prev.isMacClient === next.isMacClient &&
prev.onOpenSettings === next.onOpenSettings &&
prev.onSyncNow === next.onSyncNow
prev.onSyncNow === next.onSyncNow &&
prev.isImmersiveActive === next.isImmersiveActive
);
};

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
import { cn } from '../../lib/utils';
import type { ComponentProps, HTMLAttributes, ReactNode } from 'react';
import type { ComponentProps } from 'react';
import React, { useCallback } from 'react';
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom';
import { ArrowDown } from 'lucide-react';
@@ -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}
@@ -25,41 +25,6 @@ export const ConversationContent = ({ className, ...props }: ConversationContent
/>
);
export interface ConversationEmptyStateProps extends HTMLAttributes<HTMLDivElement> {
title?: string;
description?: string;
icon?: ReactNode;
}
export const ConversationEmptyState = ({
className,
title,
description,
icon,
children,
...props
}: ConversationEmptyStateProps) => (
<div
className={cn(
'flex size-full flex-col items-center justify-center gap-3 p-8 text-center',
className,
)}
{...props}
>
{children ?? (
<>
{icon && <div className="text-muted-foreground">{icon}</div>}
<div className="space-y-1">
<h3 className="font-medium text-sm">{title}</h3>
{description && (
<p className="text-muted-foreground text-sm">{description}</p>
)}
</div>
</>
)}
</div>
);
export const ConversationScrollButton = ({ className, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
const { isAtBottom, scrollToBottom } = useStickToBottomContext();

View File

@@ -25,8 +25,8 @@ export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageContent = ({ children, className, ...props }: MessageContentProps) => (
<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-2.5 group-[.is-user]:py-2',
'flex w-fit min-w-0 max-w-full flex-col gap-1.5 text-[13px] leading-relaxed',
'group-[.is-user]:ml-auto group-[.is-user]:overflow-hidden 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

@@ -8,8 +8,6 @@
import { ArrowUp, Square, X } from 'lucide-react';
import type {
ComponentProps,
ComponentPropsWithoutRef,
ElementRef,
FormEvent,
HTMLAttributes,
KeyboardEvent,
@@ -17,13 +15,6 @@ import type {
} from 'react';
import { forwardRef, useCallback, useRef } from 'react';
import { cn } from '../../lib/utils';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../ui/select';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
import {
InputGroup,
@@ -254,30 +245,3 @@ export const PromptInputSubmit = forwardRef<HTMLButtonElement, PromptInputSubmit
);
PromptInputSubmit.displayName = 'PromptInputSubmit';
// ---------------------------------------------------------------------------
// PromptInputSelect (thin wrappers around the project's Select component)
// ---------------------------------------------------------------------------
export const PromptInputSelect = Select;
export const PromptInputSelectTrigger = forwardRef<
ElementRef<typeof SelectTrigger>,
ComponentPropsWithoutRef<typeof SelectTrigger>
>(({ className, ...props }, ref) => (
<SelectTrigger
ref={ref}
className={cn(
'h-7 min-w-0 w-auto gap-1 border-none bg-transparent px-2 text-[11px]',
'text-muted-foreground/40 hover:text-muted-foreground/70',
'focus:ring-0 focus:ring-offset-0',
'[&>svg]:h-3 [&>svg]:w-3 [&>svg]:opacity-40',
className,
)}
{...props}
/>
));
PromptInputSelectTrigger.displayName = 'PromptInputSelectTrigger';
export const PromptInputSelectContent = SelectContent;
export const PromptInputSelectItem = SelectItem;
export const PromptInputSelectValue = SelectValue;

View File

@@ -1,7 +1,42 @@
import { cn } from '../../lib/utils';
import { ChevronDown, ChevronRight, CheckCircle2, Loader2, XCircle, Slash } from 'lucide-react';
import type { HTMLAttributes } from 'react';
import { useState } from 'react';
import { Check, ChevronDown, ChevronRight, CheckCircle2, Loader2, ShieldAlert, X, XCircle, Slash } from 'lucide-react';
import React, { useCallback, useEffect, useRef, useState, type HTMLAttributes } from 'react';
import { Button } from '../ui/button';
import { Badge } from '../ui/badge';
import { useI18n } from '../../application/i18n/I18nProvider';
/**
* Format tool result for display. Extracts stdout/stderr from structured
* command results for terminal-like output.
*/
function formatToolResult(result: unknown): string {
let parsed = result;
if (typeof parsed === 'string') {
try {
const obj = JSON.parse(parsed);
if (obj && typeof obj === 'object') parsed = obj;
} catch {
return parsed;
}
}
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const obj = parsed as Record<string, unknown>;
if (typeof obj.stdout === 'string' || typeof obj.stderr === 'string') {
const parts: string[] = [];
if (typeof obj.stdout === 'string' && obj.stdout) parts.push(obj.stdout);
if (typeof obj.stderr === 'string' && obj.stderr) parts.push(obj.stderr);
if (typeof obj.exitCode === 'number' && obj.exitCode !== 0) {
parts.push(`exit code: ${obj.exitCode}`);
}
if (parts.length > 0) return parts.join('\n');
}
}
if (typeof parsed === 'string') return parsed;
return JSON.stringify(parsed, null, 2);
}
export interface ToolCallProps extends HTMLAttributes<HTMLDivElement> {
name: string;
@@ -10,23 +45,91 @@ export interface ToolCallProps extends HTMLAttributes<HTMLDivElement> {
isError?: boolean;
isLoading?: boolean;
isInterrupted?: boolean;
/** Approval state for this tool call (from the approval gate). */
approvalStatus?: 'pending' | 'approved' | 'denied';
/** Called when user approves this tool call. */
onApprove?: () => void;
/** Called when user rejects this tool call. */
onReject?: () => void;
}
export const ToolCall = ({ name, args, result, isError, isLoading, isInterrupted, className, ...props }: ToolCallProps) => {
export const ToolCall = ({
name, args, result, isError, isLoading, isInterrupted,
approvalStatus, onApprove, onReject,
className, ...props
}: ToolCallProps) => {
const { t } = useI18n();
const [expanded, setExpanded] = useState(false);
const cardRef = useRef<HTMLDivElement>(null);
const approveBtnRef = useRef<HTMLButtonElement>(null);
const [responded, setResponded] = useState(false);
const statusIcon = isLoading ? (
<Loader2 size={12} className="animate-spin text-blue-400/70" />
const isPendingApproval = approvalStatus === 'pending' && !responded;
const handleApprove = useCallback(() => {
if (!isPendingApproval) return;
setResponded(true);
onApprove?.();
}, [isPendingApproval, onApprove]);
const handleReject = useCallback(() => {
if (!isPendingApproval) return;
setResponded(true);
onReject?.();
}, [isPendingApproval, onReject]);
// Keyboard: Enter = approve, Escape = reject (when pending)
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (!isPendingApproval) return;
if (e.key === 'Enter') { e.preventDefault(); handleApprove(); }
else if (e.key === 'Escape') { e.preventDefault(); handleReject(); }
}, [isPendingApproval, handleApprove, handleReject]);
// Auto-focus and auto-scroll when approval is pending
useEffect(() => {
if (isPendingApproval && cardRef.current) {
cardRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' });
// Small delay to let the UI render, then expand and focus
setExpanded(true);
setTimeout(() => approveBtnRef.current?.focus(), 100);
}
}, [isPendingApproval]);
// Reset responded state when approvalStatus changes (e.g. new approval)
useEffect(() => {
if (approvalStatus === 'pending') setResponded(false);
}, [approvalStatus]);
// Border/bg color based on approval status
const borderClass = approvalStatus === 'pending'
? 'border-yellow-500/30 bg-yellow-500/[0.04]'
: approvalStatus === 'approved'
? 'border-green-500/20 bg-green-500/[0.03]'
: approvalStatus === 'denied'
? 'border-red-500/20 bg-red-500/[0.03]'
: 'border-border/25 bg-muted/10';
const statusIconClass = 'shrink-0';
const statusIcon = approvalStatus === 'pending' ? (
<ShieldAlert size={12} className={cn('text-yellow-500/70', statusIconClass)} />
) : isLoading ? (
<Loader2 size={12} className={cn('animate-spin text-blue-400/70', statusIconClass)} />
) : isInterrupted ? (
<Slash size={12} className="text-muted-foreground/55" />
<Slash size={12} className={cn('text-muted-foreground/55', statusIconClass)} />
) : isError ? (
<XCircle size={12} className="text-red-400/70" />
<XCircle size={12} className={cn('text-red-400/70', statusIconClass)} />
) : result !== undefined ? (
<CheckCircle2 size={12} className="text-green-400/70" />
<CheckCircle2 size={12} className={cn('text-green-400/70', statusIconClass)} />
) : null;
return (
<div className={cn('rounded-md border border-border/25 bg-muted/10 overflow-hidden text-[12px]', className)} {...props}>
<div
ref={cardRef}
tabIndex={isPendingApproval ? 0 : undefined}
onKeyDown={isPendingApproval ? handleKeyDown : undefined}
className={cn('rounded-md border overflow-hidden text-[12px] outline-none', borderClass, className)}
{...props}
>
<button
type="button"
onClick={() => setExpanded(e => !e)}
@@ -36,28 +139,78 @@ export const ToolCall = ({ name, args, result, isError, isLoading, isInterrupted
? <ChevronDown size={12} className="text-muted-foreground/40 shrink-0" />
: <ChevronRight size={12} className="text-muted-foreground/40 shrink-0" />
}
<span className="font-mono text-muted-foreground/70 truncate">{name}</span>
{name === 'terminal_execute' && args?.command ? (
<span className="font-mono text-muted-foreground/70 truncate" title={String(args.command)}>
<span className="text-muted-foreground/40">$ </span>{String(args.command)}
</span>
) : (
<span className="font-mono text-muted-foreground/70 truncate">{name}</span>
)}
<span className="flex-1" />
{/* Approval badge for resolved approvals */}
{approvalStatus === 'approved' && (
<Badge className="text-[10px] px-1.5 py-0 bg-green-600/20 text-green-400 border-green-600/30">
{t('ai.chat.toolApproved')}
</Badge>
)}
{approvalStatus === 'denied' && (
<Badge className="text-[10px] px-1.5 py-0 bg-red-600/20 text-red-400 border-red-600/30">
{t('ai.chat.toolDenied')}
</Badge>
)}
{statusIcon}
</button>
{expanded && (
<div className="border-t border-border/20">
{args && Object.keys(args).length > 0 && (
<div className="px-3 py-2">
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/30 mb-1">Arguments</div>
<pre className="text-[11px] font-mono text-muted-foreground/50 whitespace-pre-wrap break-all">
<pre className="max-h-64 overflow-auto text-[11px] font-mono text-muted-foreground/50 whitespace-pre [overflow-wrap:normal]">
{JSON.stringify(args, null, 2)}
</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>
<pre className={cn(
'text-[11px] font-mono whitespace-pre-wrap break-all',
'max-h-64 overflow-auto text-[11px] font-mono whitespace-pre [overflow-wrap:normal]',
isError ? 'text-red-400/60' : 'text-muted-foreground/50',
)}>
{typeof result === 'string' ? result : JSON.stringify(result, null, 2)}
{formatToolResult(result)}
</pre>
</div>
)}

View File

@@ -11,6 +11,7 @@ type AgentLike = {
type AgentIconKey =
| 'catty'
| 'copilot'
| 'openai'
| 'claude'
| 'anthropic'
@@ -20,7 +21,7 @@ type AgentIconKey =
| 'openrouter'
| 'zed'
| 'atom'
| 'terminal'
| 'terminal'
| 'plus';
type AgentIconVisual = {
@@ -35,6 +36,11 @@ const AGENT_ICON_VISUALS: Record<AgentIconKey, AgentIconVisual> = {
badgeClassName: 'border-violet-500/20 bg-violet-500/10',
imageClassName: 'object-contain dark:brightness-0 dark:invert opacity-90',
},
copilot: {
src: '/ai/agents/copilot.svg',
badgeClassName: 'border-zinc-300 bg-white',
imageClassName: 'object-contain brightness-0',
},
openai: {
src: '/ai/providers/openai.svg',
badgeClassName: 'border-emerald-500/22 bg-emerald-500/12',
@@ -115,6 +121,9 @@ function getAgentIconKey(agent: AgentLike | 'add-more'): AgentIconKey {
if (tokens.some((token) => token.includes('claude'))) {
return 'claude';
}
if (tokens.some((token) => token.includes('copilot'))) {
return 'copilot';
}
if (tokens.some((token) => token.includes('anthropic'))) {
return 'anthropic';
}
@@ -154,20 +163,14 @@ function getAgentIconKey(agent: AgentLike | 'add-more'): AgentIconKey {
return 'terminal';
}
export function getAgentCommandLabel(agent: AgentLike): string | undefined {
if (agent.type === 'builtin') {
return 'Built-in terminal assistant';
}
return agent.command ? `CLI: ${agent.command}` : 'External CLI agent';
}
export const AgentIconBadge: React.FC<{
agent: AgentLike | 'add-more';
size?: 'xs' | 'sm' | 'md' | 'lg';
variant?: 'plain' | 'badge';
className?: string;
}> = ({ agent, size = 'md', variant = 'badge', className }) => {
const visual = AGENT_ICON_VISUALS[getAgentIconKey(agent)];
const iconKey = getAgentIconKey(agent);
const visual = AGENT_ICON_VISUALS[iconKey];
const badgeSize =
size === 'xs'
? 'h-4 w-4 rounded-sm'
@@ -187,18 +190,27 @@ export const AgentIconBadge: React.FC<{
if (variant === 'plain') {
return (
<img
src={visual.src}
alt=""
<div
aria-hidden="true"
draggable={false}
className={cn('shrink-0', imageSize, visual.imageClassName, className)}
className={cn('shrink-0', imageSize, className)}
style={{
maskImage: `url(${visual.src})`,
WebkitMaskImage: `url(${visual.src})`,
maskSize: 'contain',
WebkitMaskSize: 'contain',
maskRepeat: 'no-repeat',
WebkitMaskRepeat: 'no-repeat',
maskPosition: 'center',
WebkitMaskPosition: 'center',
backgroundColor: 'currentColor',
}}
/>
);
}
return (
<div
data-agent-badge=""
className={cn(
'flex shrink-0 items-center justify-center overflow-hidden border',
badgeSize,

View File

@@ -9,6 +9,10 @@ import { ChevronDown, RefreshCw, Plus, Settings } from 'lucide-react';
import React, { useCallback, useMemo, useState } from 'react';
import { cn } from '../../lib/utils';
import { useI18n } from '../../application/i18n/I18nProvider';
import {
isSettingsManagedDiscoveredAgent,
matchesManagedAgentConfig,
} from '../../infrastructure/ai/managedAgents';
import type { AgentInfo, ExternalAgentConfig, DiscoveredAgent } from '../../infrastructure/ai/types';
import AgentIconBadge from './AgentIconBadge';
import {
@@ -140,7 +144,12 @@ const AgentSelector: React.FC<AgentSelectorProps> = ({
const unconfiguredDiscovered = useMemo(
() =>
discoveredAgents.filter(
(da) => !externalAgents.some((ea) => ea.command === da.command || ea.command === da.path),
(da) => {
if (isSettingsManagedDiscoveredAgent(da)) {
return !externalAgents.some((ea) => matchesManagedAgentConfig(ea, da.command));
}
return !externalAgents.some((ea) => ea.command === da.command || ea.command === da.path);
},
),
[discoveredAgents, externalAgents],
);
@@ -208,7 +217,7 @@ const AgentSelector: React.FC<AgentSelectorProps> = ({
<DropdownContent
align="start"
sideOffset={6}
className="w-[288px] rounded-2xl border border-border/50 bg-popover p-0 text-foreground shadow-lg supports-[backdrop-filter]:backdrop-blur-xl"
className="w-[288px] overflow-hidden rounded-2xl border border-border/50 bg-popover p-0 text-foreground shadow-lg supports-[backdrop-filter]:backdrop-blur-xl"
>
{BUILTIN_AGENTS.map((agent) => (
<AgentMenuRow

View File

@@ -229,7 +229,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
value={value}
onChange={(e) => handleInputChange(e.target.value)}
placeholder={placeholder || defaultPlaceholder}
disabled={disabled || isStreaming}
disabled={disabled}
className={expanded ? 'max-h-[220px]' : undefined}
/>
<button

View File

@@ -6,10 +6,11 @@
* No avatars. Thinking blocks are collapsible.
*/
import { AlertCircle, FileText } from 'lucide-react';
import React from 'react';
import { AlertCircle, FileText, RotateCcw, X, ZoomIn, ZoomOut } from 'lucide-react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import type { ChatMessage } from '../../infrastructure/ai/types';
import { Dialog, DialogContent, DialogTitle } from '../ui/dialog';
import {
Conversation,
ConversationContent,
@@ -17,17 +18,124 @@ import {
} from '../ai-elements/conversation';
import { Message, MessageContent, MessageResponse } from '../ai-elements/message';
import { ToolCall } from '../ai-elements/tool-call';
import { InlineApprovalCard } from './InlineApprovalCard';
import ThinkingBlock from './ThinkingBlock';
import {
onApprovalRequest,
onApprovalCleared,
replayPendingApprovals,
resolveApproval,
type ApprovalRequest,
} from '../../infrastructure/ai/shared/approvalGate';
interface ChatMessageListProps {
messages: ChatMessage[];
isStreaming?: boolean;
onApprove?: (messageId: string) => void;
onReject?: (messageId: string) => void;
/** Active chat session ID — used to filter standalone MCP approval blocks */
activeSessionId?: string | null;
}
const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming, onApprove, onReject }) => {
const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming, activeSessionId }) => {
// Track pending approvals from the approval gate
const [pendingApprovals, setPendingApprovals] = useState<Map<string, ApprovalRequest>>(new Map());
const [resolvedApprovals, setResolvedApprovals] = useState<Map<string, boolean>>(new Map());
// Subscribe to approval gate events (SDK + MCP tool calls)
useEffect(() => {
const handler = (request: ApprovalRequest) => {
setPendingApprovals(prev => new Map(prev).set(request.toolCallId, request));
};
const unsub = onApprovalRequest(handler);
// Replay any approvals that fired while this component was unmounted
replayPendingApprovals(handler);
return unsub;
}, []);
// Subscribe to approval cleared/removed events (fired on session stop or timeout)
useEffect(() => {
return onApprovalCleared((clearedIds) => {
setPendingApprovals(prev => {
const m = new Map(prev);
for (const id of clearedIds) m.delete(id);
return m;
});
});
}, []);
const handleApprove = useCallback((toolCallId: string) => {
resolveApproval(toolCallId, true);
setPendingApprovals(prev => { const m = new Map(prev); m.delete(toolCallId); return m; });
setResolvedApprovals(prev => new Map(prev).set(toolCallId, true));
}, []);
const handleReject = useCallback((toolCallId: string) => {
resolveApproval(toolCallId, false);
setPendingApprovals(prev => { const m = new Map(prev); m.delete(toolCallId); return m; });
setResolvedApprovals(prev => new Map(prev).set(toolCallId, false));
}, []);
const [preview, setPreview] = useState<{ src: string; name: string } | null>(null);
const [zoom, setZoom] = useState(100);
const [dragged, setDragged] = useState(false);
const imgRef = useRef<HTMLImageElement>(null);
const dragPos = useRef({ x: 0, y: 0 });
const dragStart = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
const applyTransform = useCallback((z: number, x: number, y: number, animate: boolean) => {
if (!imgRef.current) return;
imgRef.current.style.transition = animate ? 'transform 0.25s ease' : 'none';
imgRef.current.style.transform = `scale(${z / 100}) translate(${x / (z / 100)}px, ${y / (z / 100)}px)`;
}, []);
const zoomRef = useRef(100);
const setZoomAndRef = useCallback((fn: (z: number) => number) => {
setZoom(z => { const nz = fn(z); zoomRef.current = nz; return nz; });
}, []);
const zoomIn = useCallback(() => setZoomAndRef(z => { const nz = Math.min(z + 25, 200); applyTransform(nz, dragPos.current.x, dragPos.current.y, true); return nz; }), [applyTransform, setZoomAndRef]);
const zoomOut = useCallback(() => setZoomAndRef(z => { const nz = Math.max(z - 25, 25); applyTransform(nz, dragPos.current.x, dragPos.current.y, true); return nz; }), [applyTransform, setZoomAndRef]);
const onWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -10 : 10;
setZoomAndRef(z => {
const nz = Math.max(25, Math.min(200, z + delta));
applyTransform(nz, dragPos.current.x, dragPos.current.y, false);
return nz;
});
}, [applyTransform, setZoomAndRef]);
const openPreview = useCallback((src: string, name: string) => {
setZoom(100); zoomRef.current = 100;
setDragged(false);
dragPos.current = { x: 0, y: 0 };
setPreview({ src, name });
}, []);
const resetPreview = useCallback(() => {
setZoom(100); zoomRef.current = 100;
setDragged(false);
dragPos.current = { x: 0, y: 0 };
applyTransform(100, 0, 0, true);
}, [applyTransform]);
const onPointerDown = useCallback((e: React.PointerEvent) => {
e.preventDefault();
(e.target as HTMLElement).setPointerCapture(e.pointerId);
dragStart.current = { startX: e.clientX, startY: e.clientY, origX: dragPos.current.x, origY: dragPos.current.y };
}, []);
const onPointerMove = useCallback((e: React.PointerEvent) => {
if (!dragStart.current) return;
if ((e.buttons & 1) === 0) { dragStart.current = null; return; }
const x = dragStart.current.origX + (e.clientX - dragStart.current.startX);
const y = dragStart.current.origY + (e.clientY - dragStart.current.startY);
dragPos.current = { x, y };
applyTransform(zoomRef.current, x, y, false);
}, [applyTransform]);
const endDrag = useCallback(() => {
if (dragStart.current && (dragPos.current.x !== 0 || dragPos.current.y !== 0)) {
setDragged(true);
}
dragStart.current = null;
}, []);
const { t } = useI18n();
const visibleMessages = messages.filter(m => m.role !== 'system');
const resolvedToolCallIds = new Set(
@@ -36,12 +144,14 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
.flatMap((m) => m.toolResults?.map((tr) => tr.toolCallId) ?? []),
);
// Build a map from toolCallId → toolName for display
// 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);
}
}
}
@@ -59,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) => {
@@ -69,6 +180,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
<ToolCall
key={tr.toolCallId}
name={toolCallNames.get(tr.toolCallId) || tr.toolCallId}
args={toolCallArgs.get(tr.toolCallId)}
result={tr.content}
isError={tr.isError}
/>
@@ -102,7 +214,8 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
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"
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
@@ -125,27 +238,34 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
</MessageResponse>
)}
{/* Tool calls */}
{message.toolCalls?.map((tc) => (
<ToolCall
key={tc.id}
name={tc.name}
args={tc.arguments}
isLoading={isThisStreaming && message.executionStatus === 'running'}
isInterrupted={message.executionStatus === 'cancelled' && !resolvedToolCallIds.has(tc.id)}
/>
))}
{/* 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)}
/>
)}
{/* Pending tool calls from the *last* assistant message are rendered
after all tool-result messages (see below) for chronological order.
Unresolved tool calls from earlier or cancelled messages are shown
inline — as interrupted, or with approval controls if still pending. */}
{(message !== lastAssistantMessage || message.executionStatus === 'cancelled') && message.toolCalls?.filter((tc) =>
!resolvedToolCallIds.has(tc.id),
).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;
return (
<ToolCall
key={tc.id}
name={tc.name}
args={tc.arguments}
isInterrupted={!isPending}
approvalStatus={approvalStatus}
onApprove={() => handleApprove(tc.id)}
onReject={() => handleReject(tc.id)}
/>
);
})}
{/* Status text with shimmer */}
{message.statusText && (
@@ -173,6 +293,51 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
);
})}
{/* Pending tool calls from the last assistant message — rendered here
(after all tool-result messages) so they appear at the bottom. */}
{lastAssistantMessage?.toolCalls?.filter((tc) =>
!resolvedToolCallIds.has(tc.id) && lastAssistantMessage.executionStatus !== 'cancelled',
).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;
return (
<ToolCall
key={tc.id}
name={tc.name}
args={tc.arguments}
isLoading={isStreaming && lastAssistantMessage.executionStatus === 'running' && !isPending}
approvalStatus={approvalStatus}
onApprove={() => handleApprove(tc.id)}
onReject={() => handleReject(tc.id)}
/>
);
})}
{/* 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">
@@ -184,13 +349,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;
@@ -208,7 +449,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,169 +0,0 @@
/**
* ExecutionPlan - Renders a multi-step execution plan for AI agent tasks.
*
* Shows a numbered list of steps with status indicators, host badges,
* optional command previews, and action buttons.
*/
import {
CheckCircle2,
Circle,
Loader2,
SkipForward,
XCircle,
} from 'lucide-react';
import React from 'react';
import { cn } from '../../lib/utils';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
// -------------------------------------------------------------------
// Types
// -------------------------------------------------------------------
interface ExecutionPlanStep {
description: string;
host?: string;
command?: string;
status: 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
}
interface ExecutionPlanProps {
steps: ExecutionPlanStep[];
onApprove: () => void;
onModify: () => void;
onReject: () => void;
isExecuting: boolean;
}
// -------------------------------------------------------------------
// Status icon mapping
// -------------------------------------------------------------------
function StepStatusIcon({
status,
}: {
status: ExecutionPlanStep['status'];
}) {
switch (status) {
case 'pending':
return <Circle size={16} className="text-muted-foreground" />;
case 'running':
return (
<Loader2 size={16} className="text-blue-500 animate-spin" />
);
case 'completed':
return <CheckCircle2 size={16} className="text-green-500" />;
case 'failed':
return <XCircle size={16} className="text-destructive" />;
case 'skipped':
return (
<SkipForward size={16} className="text-muted-foreground/60" />
);
}
}
// -------------------------------------------------------------------
// Component
// -------------------------------------------------------------------
const ExecutionPlan: React.FC<ExecutionPlanProps> = ({
steps,
onApprove,
onModify,
onReject,
isExecuting,
}) => {
return (
<div className="rounded-lg border border-border bg-muted/30 overflow-hidden">
{/* Header */}
<div className="px-3 py-2 border-b border-border/60 bg-muted/50">
<span className="text-sm font-medium">
Execution Plan ({steps.length} step{steps.length !== 1 ? 's' : ''})
</span>
</div>
{/* Steps list */}
<div className="divide-y divide-border/30">
{steps.map((step, index) => (
<div
key={index}
className={cn(
'flex items-start gap-3 px-3 py-2.5 transition-colors',
step.status === 'running' && 'bg-blue-500/5',
step.status === 'completed' && 'bg-green-500/5',
step.status === 'failed' && 'bg-destructive/5',
step.status === 'skipped' && 'opacity-50',
)}
>
{/* Step number + status icon */}
<div className="flex items-center gap-2 shrink-0 pt-0.5">
<span className="text-xs text-muted-foreground font-mono w-4 text-right">
{index + 1}
</span>
<StepStatusIcon status={step.status} />
</div>
{/* Step content */}
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<span
className={cn(
'text-sm',
step.status === 'skipped' && 'line-through',
)}
>
{step.description}
</span>
{step.host && (
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0"
>
{step.host}
</Badge>
)}
</div>
{step.command && (
<code className="block text-xs font-mono bg-muted/80 px-2 py-1 rounded text-muted-foreground truncate">
{step.command}
</code>
)}
</div>
</div>
))}
</div>
{/* Action buttons */}
<div className="px-3 py-2.5 border-t border-border/60 flex items-center justify-end gap-2">
{isExecuting ? (
<Button
variant="destructive"
size="sm"
onClick={onReject}
>
Cancel
</Button>
) : (
<>
<Button variant="ghost" size="sm" onClick={onReject}>
Cancel
</Button>
<Button variant="outline" size="sm" onClick={onModify}>
Modify Plan
</Button>
<Button size="sm" onClick={onApprove}>
Approve
</Button>
</>
)}
</div>
</div>
);
};
ExecutionPlan.displayName = 'ExecutionPlan';
export default ExecutionPlan;
export { ExecutionPlan };
export type { ExecutionPlanProps, ExecutionPlanStep };

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

@@ -1,200 +0,0 @@
/**
* PermissionDialog - Modal for AI agent tool call permission requests.
*
* Shown when the agent needs user approval to execute a tool call.
* Displays tool name, arguments, recommendation, and approve/reject actions.
*/
import { ShieldAlert } from 'lucide-react';
import React, { useCallback } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '../ui/dialog';
// -------------------------------------------------------------------
// Types
// -------------------------------------------------------------------
interface PermissionDialogProps {
open: boolean;
toolCall: { name: string; arguments: Record<string, unknown> } | null;
recommendation: 'allow' | 'confirm' | 'deny';
onApprove: () => void;
onReject: () => void;
onDismiss: () => void;
}
// -------------------------------------------------------------------
// Component
// -------------------------------------------------------------------
const PermissionDialog: React.FC<PermissionDialogProps> = ({
open,
toolCall,
recommendation,
onApprove,
onReject,
onDismiss,
}) => {
const { t } = useI18n();
const isDenied = recommendation === 'deny';
// Keyboard shortcuts: Enter to approve, Escape to reject
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !isDenied) {
e.preventDefault();
onApprove();
} else if (e.key === 'Escape') {
e.preventDefault();
onReject();
}
},
[isDenied, onApprove, onReject],
);
// Format arguments as readable code block content
let formattedArgs = '';
if (toolCall) {
try {
formattedArgs = JSON.stringify(toolCall.arguments, null, 2);
} catch {
formattedArgs = String(toolCall.arguments);
}
}
// Extract host/session info from arguments if present
const sessionId =
toolCall?.arguments?.sessionId as string | undefined;
const sessionIds =
toolCall?.arguments?.sessionIds as string[] | undefined;
const recommendationBadge = () => {
switch (recommendation) {
case 'allow':
return (
<Badge className="bg-green-600/20 text-green-400 border-green-600/30">
{t('ai.chat.recommendAllow')}
</Badge>
);
case 'confirm':
return (
<Badge className="bg-yellow-600/20 text-yellow-400 border-yellow-600/30">
{t('ai.chat.recommendConfirm')}
</Badge>
);
case 'deny':
return <Badge variant="destructive">{t('ai.chat.recommendDeny')}</Badge>;
}
};
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onDismiss()}>
<DialogContent hideCloseButton onKeyDown={handleKeyDown}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ShieldAlert
size={20}
className={cn(
isDenied ? 'text-destructive' : 'text-yellow-500',
)}
/>
{t('ai.chat.permissionRequired')}
</DialogTitle>
<DialogDescription>
{t('ai.chat.permissionDescription')}
</DialogDescription>
</DialogHeader>
{toolCall && (
<div className="space-y-3">
{/* Tool name and recommendation */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">{t('ai.chat.toolLabel')}:</span>
<code className="text-sm font-mono bg-muted px-1.5 py-0.5 rounded">
{toolCall.name}
</code>
</div>
{recommendationBadge()}
</div>
{/* Target session(s) */}
{(sessionId || sessionIds) && (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">{t('ai.chat.targetLabel')}:</span>
{sessionId && (
<code className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded">
{sessionId}
</code>
)}
{sessionIds && (
<div className="flex flex-wrap gap-1">
{sessionIds.map((id) => (
<code
key={id}
className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded"
>
{id}
</code>
))}
</div>
)}
</div>
)}
{/* Arguments code block */}
<div className="rounded-md border border-border bg-muted/50 p-3 max-h-48 overflow-auto">
<pre className="text-xs font-mono whitespace-pre-wrap break-all text-foreground">
{formattedArgs}
</pre>
</div>
{/* Deny warning */}
{isDenied && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3">
<p className="text-sm text-destructive">
{t('ai.chat.commandBlocked')}
</p>
</div>
)}
</div>
)}
<DialogFooter>
{isDenied ? (
<Button variant="destructive" onClick={onReject} className="w-full">
{t('ai.chat.reject')}
</Button>
) : (
<>
<Button
variant="outline"
onClick={onReject}
className="border-destructive/30 text-destructive hover:bg-destructive/10"
>
{t('ai.chat.reject')}
</Button>
<Button onClick={onApprove}>{t('ai.chat.approve')}</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
};
PermissionDialog.displayName = 'PermissionDialog';
export default PermissionDialog;
export { PermissionDialog };
export type { PermissionDialogProps };

View File

@@ -18,6 +18,7 @@ import type {
ChatMessage,
ChatMessageAttachment,
ExternalAgentConfig,
ProviderAdvancedParams,
ProviderConfig,
WebSearchConfig,
} from '../../../infrastructure/ai/types';
@@ -64,16 +65,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. */
@@ -88,9 +103,8 @@ type StreamChunk =
| ReasoningChunk
| ToolCallChunk
| ToolResultChunk
| ToolApprovalRequestChunk
| ErrorChunk
| { type: 'reasoning-end' | 'text-start' | 'text-end' | 'start' | 'finish' | 'start-step' | 'finish-step' };
| { type: 'reasoning-end' | 'text-start' | 'text-end' | 'start' | 'finish' | 'start-step' | 'finish-step' | 'tool-approval-request' };
/** Shape of the netcatty bridge exposed on `window` (panel-specific subset). */
export interface PanelBridge extends NetcattyBridge {
@@ -98,6 +112,13 @@ export interface PanelBridge extends NetcattyBridge {
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>;
aiAcpListModels?: (
acpCommand: string,
acpArgs?: string[],
cwd?: string,
providerId?: string,
chatSessionId?: string,
) => Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string }>; currentModelId?: string | null; error?: string }>;
aiAcpCleanup?: (chatSessionId: string) => Promise<{ ok: boolean }>;
[key: string]: ((...args: unknown[]) => unknown) | undefined;
}
@@ -110,6 +131,9 @@ export interface TerminalSessionInfo {
label: string;
os?: string;
username?: string;
protocol?: string;
shellType?: string;
deviceType?: string;
connected: boolean;
}
@@ -119,27 +143,8 @@ export function getNetcattyBridge(): PanelBridge | undefined {
return (window as any).netcatty as PanelBridge | undefined;
}
/** Approval info returned by processCattyStream when a tool-approval-request is received. */
export interface ApprovalInfo {
approvalId: string;
toolCallId: string;
toolName: string;
toolArgs: Record<string, unknown>;
}
/** Pending approval context stored between approval request and user response. */
export interface PendingApprovalContext {
sessionId: string;
scopeKey: string;
sdkMessages: Array<ModelMessage>;
approvalInfo: ApprovalInfo;
model: ReturnType<typeof createModelFromConfig>;
systemPrompt: string;
tools: ReturnType<typeof createCattyTools>;
scopeType: 'terminal' | 'workspace';
scopeLabel?: string;
getExecutorContext: () => ExecutorContext;
}
// ApprovalInfo and PendingApprovalContext removed — approval is now handled
// inside the tool's execute function via the approvalGate module.
function generateId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
@@ -181,7 +186,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>,
@@ -190,7 +195,8 @@ export interface UseAIChatStreamingReturn {
sdkMessages: Array<ModelMessage>,
signal: AbortSignal,
currentAssistantMsgId: string,
) => Promise<ApprovalInfo | null>;
advancedParams?: ProviderAdvancedParams,
) => Promise<void>;
/** Send a message to the Catty agent (built-in). */
sendToCattyAgent: (
sessionId: string,
@@ -227,7 +233,6 @@ export interface SendToCattyContext {
terminalSessions: TerminalSessionInfo[];
webSearchConfig?: WebSearchConfig | null;
getExecutorContext?: () => ExecutorContext;
setPendingApproval: (ctx: PendingApprovalContext | null) => void;
autoTitleSession: (sessionId: string, text: string) => void;
}
@@ -325,7 +330,8 @@ export function useAIChatStreaming({
sdkMessages: Array<ModelMessage>,
signal: AbortSignal,
currentAssistantMsgId: string,
): Promise<ApprovalInfo | null> => {
advancedParams?: ProviderAdvancedParams,
): Promise<void> => {
const result = streamText({
model,
messages: sdkMessages,
@@ -333,13 +339,17 @@ export function useAIChatStreaming({
tools,
stopWhen: stepCountIs(maxIterations),
abortSignal: signal,
...(advancedParams?.maxTokens != null && { maxOutputTokens: advancedParams.maxTokens }),
...(advancedParams?.temperature != null && { temperature: advancedParams.temperature }),
...(advancedParams?.topP != null && { topP: advancedParams.topP }),
...(advancedParams?.frequencyPenalty != null && { frequencyPenalty: advancedParams.frequencyPenalty }),
...(advancedParams?.presencePenalty != null && { presencePenalty: advancedParams.presencePenalty }),
});
// Track the current assistant message ID so updates target the correct message
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 = '';
@@ -457,6 +467,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',
@@ -466,7 +477,7 @@ export function useAIChatStreaming({
content: typeof toolOutput === 'string'
? toolOutput
: JSON.stringify(toolOutput),
isError: false,
isError: toolError,
}],
timestamp: Date.now(),
executionStatus: 'completed',
@@ -474,25 +485,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();
@@ -524,7 +519,7 @@ export function useAIChatStreaming({
flushText();
reader.releaseLock();
}
return pendingApprovalInfo;
return;
}, [maxIterations, addMessageToSession, updateMessageById]);
// -------------------------------------------------------------------
@@ -613,9 +608,10 @@ export function useAIChatStreaming({
: 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;
@@ -689,13 +685,19 @@ export function useAIChatStreaming({
context.commandBlocklist,
context.globalPermissionMode,
context.webSearchConfig ?? undefined,
sessionId,
);
const systemPrompt = buildSystemPrompt({
scopeType: context.scopeType, scopeLabel: context.scopeLabel,
hosts: context.terminalSessions.map(s => ({
sessionId: s.sessionId, hostname: s.hostname, label: s.label,
os: s.os, username: s.username, connected: s.connected,
os: s.os,
username: s.username,
protocol: s.protocol,
shellType: s.shellType,
deviceType: s.deviceType,
connected: s.connected,
})),
permissionMode: context.globalPermissionMode,
webSearchEnabled: isWebSearchReady(context.webSearchConfig),
@@ -819,23 +821,7 @@ export function useAIChatStreaming({
sdkMessages.push({ role: 'user', content: trimmed });
}
const approvalInfo = await processCattyStream(sessionId, model, systemPrompt, tools, sdkMessages, abortController.signal, assistantMsgId);
if (approvalInfo) {
context.setPendingApproval({
sessionId,
scopeKey: sendScopeKey,
sdkMessages,
approvalInfo,
model,
systemPrompt,
tools,
scopeType: context.scopeType,
scopeLabel: context.scopeLabel,
getExecutorContext,
});
return; // Keep streaming flag — waiting for user approval
}
await processCattyStream(sessionId, model, systemPrompt, tools, sdkMessages, abortController.signal, assistantMsgId, context.activeProvider?.advancedParams);
} catch (err) {
console.error('[Catty] streamText error:', err);
reportStreamError(sessionId, abortController.signal, err);

View File

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

View File

@@ -2,14 +2,15 @@
* Host Chain Sub-Panel
* Panel for configuring SSH jump host chain
*/
import { ArrowDown,Plus,X } from 'lucide-react';
import React from 'react';
import { ArrowDown,Plus,Search,X } from 'lucide-react';
import React, { useMemo, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Host } from '../../types';
import { DistroAvatar } from '../DistroAvatar';
import { AsidePanel } from '../ui/aside-panel';
import { Button } from '../ui/button';
import { Card } from '../ui/card';
import { Input } from '../ui/input';
import { ScrollArea } from '../ui/scroll-area';
export interface ChainPanelProps {
@@ -38,6 +39,14 @@ export const ChainPanel: React.FC<ChainPanelProps> = ({
onCancel,
}) => {
const { t } = useI18n();
const [searchQuery, setSearchQuery] = useState('');
const filteredHosts = useMemo(() => {
if (!searchQuery.trim()) return availableHostsForChain;
const q = searchQuery.toLowerCase();
return availableHostsForChain.filter(
(host) => host.label.toLowerCase().includes(q) || host.hostname.toLowerCase().includes(q)
);
}, [availableHostsForChain, searchQuery]);
return (
<AsidePanel
open={true}
@@ -52,16 +61,7 @@ export const ChainPanel: React.FC<ChainPanelProps> = ({
}
>
<ScrollArea className="flex-1">
<div className="p-4 space-y-4">
<Card className="p-3 space-y-3 bg-card border-border/80">
<p className="text-xs text-muted-foreground">
{t('hostDetails.chain.desc', { host: formLabel || formHostname })}
</p>
<Button className="w-full h-10" onClick={() => { }}>
<Plus size={14} className="mr-2" /> {t('hostDetails.chain.addHost')}
</Button>
</Card>
<div className="p-4 space-y-4 w-0 min-w-full">
{/* Chain visualization */}
<div className="space-y-2">
{chainedHosts.map((host, index) => (
@@ -73,7 +73,7 @@ export const ChainPanel: React.FC<ChainPanelProps> = ({
)}
<div className="flex items-center gap-2 p-2 rounded-lg border border-border/60 bg-card">
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} className="h-8 w-8" />
<span className="text-sm font-medium flex-1">{host.label || host.hostname}</span>
<span className="text-sm font-medium flex-1 min-w-0 truncate">{host.label || host.hostname}</span>
<Button
variant="ghost"
size="icon"
@@ -110,11 +110,20 @@ export const ChainPanel: React.FC<ChainPanelProps> = ({
{availableHostsForChain.length > 0 && (
<Card className="p-3 bg-card border-border/80">
<p className="text-xs font-semibold text-muted-foreground mb-2">{t('hostDetails.chain.availableHosts')}</p>
<div className="relative mb-2">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('common.searchPlaceholder')}
className="h-8 pl-8 text-sm"
/>
</div>
<div className="space-y-1">
{availableHostsForChain.map((host) => (
{filteredHosts.map((host) => (
<button
key={host.id}
className="w-full flex items-center gap-2 p-2 rounded-md hover:bg-secondary transition-colors text-left"
className="w-full flex items-center gap-2 p-2 rounded-md hover:bg-secondary transition-colors text-left overflow-hidden"
onClick={() => onAddHost(host.id)}
>
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} className="h-8 w-8" />

View File

@@ -123,7 +123,7 @@ export const WizardContent: React.FC<WizardContentProps> = ({
>
{selectedHost ? (
<div className="flex items-center gap-2 w-full">
<DistroAvatar host={selectedHost} fallback={selectedHost.os[0].toUpperCase()} className="h-6 w-6" />
<DistroAvatar host={selectedHost} fallback={selectedHost.os[0].toUpperCase()} size="sm" />
<span>{selectedHost.label}</span>
<Check size={14} className="ml-auto" />
</div>
@@ -228,7 +228,7 @@ export const WizardContent: React.FC<WizardContentProps> = ({
>
{selectedHost ? (
<div className="flex items-center gap-2 w-full">
<DistroAvatar host={selectedHost} fallback={selectedHost.os[0].toUpperCase()} className="h-6 w-6" />
<DistroAvatar host={selectedHost} fallback={selectedHost.os[0].toUpperCase()} size="sm" />
<span>{selectedHost.label}</span>
<Check size={14} className="ml-auto" />
</div>

View File

@@ -3,55 +3,12 @@
* A modal dialog for selecting terminal themes in settings
*/
import React, { memo, useCallback, useMemo } from 'react';
import React, { useCallback } from 'react';
import { createPortal } from 'react-dom';
import { Check, Palette, X } from 'lucide-react';
import { Palette, X } from 'lucide-react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { TERMINAL_THEMES, TerminalThemeConfig } from '../../infrastructure/config/terminalThemes';
import { useCustomThemes } from '../../application/state/customThemeStore';
import { Button } from '../ui/button';
import { cn } from '../../lib/utils';
// Memoized theme item component to prevent unnecessary re-renders
const ThemeItem = memo(({
theme,
isSelected,
onSelect
}: {
theme: TerminalThemeConfig;
isSelected: boolean;
onSelect: (id: string) => void;
}) => (
<button
onClick={() => onSelect(theme.id)}
className={cn(
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-all',
isSelected
? 'bg-primary/15 ring-1 ring-primary'
: 'hover:bg-muted'
)}
>
{/* Color swatch preview */}
<div
className="w-12 h-8 rounded-md flex-shrink-0 flex flex-col justify-center items-start pl-1.5 gap-0.5 border border-border/50"
style={{ backgroundColor: theme.colors.background }}
>
<div className="h-1 w-4 rounded-full" style={{ backgroundColor: theme.colors.green }} />
<div className="h-1 w-6 rounded-full" style={{ backgroundColor: theme.colors.blue }} />
<div className="h-1 w-3 rounded-full" style={{ backgroundColor: theme.colors.yellow }} />
</div>
<div className="flex-1 min-w-0">
<div className={cn('text-sm font-medium truncate', isSelected ? 'text-primary' : 'text-foreground')}>
{theme.name}
</div>
<div className="text-[10px] text-muted-foreground capitalize">{theme.type}</div>
</div>
{isSelected && (
<Check size={16} className="text-primary flex-shrink-0" />
)}
</button>
));
ThemeItem.displayName = 'ThemeItem';
import { ThemeList } from '../ThemeList';
interface ThemeSelectModalProps {
open: boolean;
@@ -68,15 +25,6 @@ export const ThemeSelectModal: React.FC<ThemeSelectModalProps> = ({
}) => {
const { t } = useI18n();
// Group themes by type
const { darkThemes, lightThemes } = useMemo(() => {
const dark = TERMINAL_THEMES.filter(t => t.type === 'dark');
const light = TERMINAL_THEMES.filter(t => t.type === 'light');
return { darkThemes: dark, lightThemes: light };
}, []);
const customThemes = useCustomThemes();
// Handle theme selection - select and close
const handleThemeSelect = useCallback((themeId: string) => {
onSelect(themeId);
@@ -134,58 +82,10 @@ export const ThemeSelectModal: React.FC<ThemeSelectModalProps> = ({
{/* Theme List */}
<div className="flex-1 min-h-0 overflow-y-auto p-4">
{/* Dark Themes Section */}
<div className="mb-4">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-1">
{t('settings.terminal.themeModal.darkThemes')}
</div>
<div className="space-y-1">
{darkThemes.map(theme => (
<ThemeItem
key={theme.id}
theme={theme}
isSelected={selectedThemeId === theme.id}
onSelect={handleThemeSelect}
/>
))}
</div>
</div>
{/* Light Themes Section */}
<div>
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-1">
{t('settings.terminal.themeModal.lightThemes')}
</div>
<div className="space-y-1">
{lightThemes.map(theme => (
<ThemeItem
key={theme.id}
theme={theme}
isSelected={selectedThemeId === theme.id}
onSelect={handleThemeSelect}
/>
))}
</div>
</div>
{/* Custom Themes Section */}
{customThemes.length > 0 && (
<div className="mt-4">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-1">
{t('terminal.customTheme.section')}
</div>
<div className="space-y-1">
{customThemes.map(theme => (
<ThemeItem
key={theme.id}
theme={theme}
isSelected={selectedThemeId === theme.id}
onSelect={handleThemeSelect}
/>
))}
</div>
</div>
)}
<ThemeList
selectedThemeId={selectedThemeId}
onSelect={handleThemeSelect}
/>
</div>
{/* Footer */}

View File

@@ -8,7 +8,7 @@
* - SafetySettings
*/
import { Bot, Globe } from "lucide-react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type {
AIPermissionMode,
AIProviderId,
@@ -16,8 +16,12 @@ import type {
ProviderConfig,
WebSearchConfig,
} from "../../../infrastructure/ai/types";
import {
getManagedAgentStoredPath,
matchesManagedAgentConfig,
type ManagedAgentKey,
} from "../../../infrastructure/ai/managedAgents";
import { PROVIDER_PRESETS } from "../../../infrastructure/ai/types";
import { useAgentDiscovery } from "../../../application/state/useAgentDiscovery";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { TabsContent } from "../../ui/tabs";
import { Select, SettingRow } from "../settings-ui";
@@ -38,6 +42,7 @@ import { ProviderCard } from "./ai/ProviderCard";
import { AddProviderDropdown } from "./ai/AddProviderDropdown";
import { CodexConnectionCard } from "./ai/CodexConnectionCard";
import { ClaudeCodeCard } from "./ai/ClaudeCodeCard";
import { CopilotCliCard } from "./ai/CopilotCliCard";
import { SafetySettings } from "./ai/SafetySettings";
import { WebSearchSettings } from "./ai/WebSearchSettings";
@@ -70,6 +75,54 @@ interface SettingsAITabProps {
setWebSearchConfig: (config: WebSearchConfig | null) => void;
}
function areExternalAgentListsEqual(
left: ExternalAgentConfig[],
right: ExternalAgentConfig[],
): boolean {
if (left.length !== right.length) return false;
return left.every((agent, index) => JSON.stringify(agent) === JSON.stringify(right[index]));
}
function buildManagedAgentState(
prevAgents: ExternalAgentConfig[],
defaultAgentId: string,
agentKey: ManagedAgentKey,
pathInfo: AgentPathInfo | null,
): { agents: ExternalAgentConfig[]; defaultAgentId: string } {
const managedId = `discovered_${agentKey}`;
const managedAgents = prevAgents.filter((agent) => matchesManagedAgentConfig(agent, agentKey));
const otherAgents = prevAgents.filter((agent) => !matchesManagedAgentConfig(agent, agentKey));
const storedPath = getManagedAgentStoredPath(prevAgents, agentKey);
if (!pathInfo?.available || !pathInfo.path) {
return {
agents: storedPath ? prevAgents : otherAgents,
defaultAgentId: storedPath
? defaultAgentId
: managedAgents.some((agent) => agent.id === defaultAgentId)
? "catty"
: defaultAgentId,
};
}
const existingManaged = managedAgents.find((agent) => agent.id === managedId);
const defaults = AGENT_DEFAULTS[agentKey];
const nextManagedAgent: ExternalAgentConfig = {
...existingManaged,
...defaults,
id: managedId,
command: pathInfo.path,
enabled: managedAgents.length === 0 ? true : managedAgents.some((agent) => agent.enabled),
};
return {
agents: [...otherAgents, nextManagedAgent],
defaultAgentId: managedAgents.some((agent) => agent.id === defaultAgentId)
? managedId
: defaultAgentId,
};
}
// ---------------------------------------------------------------------------
// Main Tab Component
// ---------------------------------------------------------------------------
@@ -113,58 +166,44 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
const [claudePathInfo, setClaudePathInfo] = useState<AgentPathInfo | null>(null);
const [claudeCustomPath, setClaudeCustomPath] = useState("");
const [isResolvingClaude, setIsResolvingClaude] = useState(false);
const initialManagedPathsRef = useRef<{
codex: string;
claude: string;
copilot: string;
} | null>(null);
if (!initialManagedPathsRef.current) {
initialManagedPathsRef.current = {
codex: getManagedAgentStoredPath(externalAgents, "codex") ?? "",
claude: getManagedAgentStoredPath(externalAgents, "claude") ?? "",
copilot: getManagedAgentStoredPath(externalAgents, "copilot") ?? "",
};
}
const {
discoveredAgents,
isDiscovering,
enableAgent,
} = useAgentDiscovery(externalAgents, setExternalAgents);
const [copilotPathInfo, setCopilotPathInfo] = useState<AgentPathInfo | null>(null);
const [copilotCustomPath, setCopilotCustomPath] = useState("");
const [isResolvingCopilot, setIsResolvingCopilot] = useState(false);
// Derive path info from discovery results
useEffect(() => {
if (isDiscovering) return;
// Ref to read current defaultAgentId without adding it as a dependency.
const defaultAgentIdRef = useRef(defaultAgentId);
defaultAgentIdRef.current = defaultAgentId;
const codex = discoveredAgents.find((a) => a.command === "codex");
setCodexPathInfo(
codex
? { path: codex.path, version: codex.version, available: true }
: { path: null, version: null, available: false },
);
const claude = discoveredAgents.find((a) => a.command === "claude");
setClaudePathInfo(
claude
? { path: claude.path, version: claude.version, available: true }
: { path: null, version: null, available: false },
);
}, [isDiscovering, discoveredAgents]);
// Auto-register discovered agents in externalAgents
useEffect(() => {
if (isDiscovering || discoveredAgents.length === 0) return;
setExternalAgents((prev) => {
const agentsToRegister: ExternalAgentConfig[] = [];
for (const da of discoveredAgents) {
if (da.command !== "codex" && da.command !== "claude") continue;
const agentId = `discovered_${da.command}`;
if (prev.some((ea) => ea.id === agentId)) continue;
agentsToRegister.push(enableAgent(da));
}
return agentsToRegister.length > 0 ? [...prev, ...agentsToRegister] : prev;
});
}, [isDiscovering, discoveredAgents, enableAgent, setExternalAgents]);
// Validate a custom path for an agent
const handleCheckCustomPath = useCallback(async (agentKey: "codex" | "claude") => {
const resolveAgentPath = useCallback(async (
agentKey: ManagedAgentKey,
customPath = "",
) => {
const bridge = getBridge();
if (!bridge?.aiResolveCli) return;
if (!bridge?.aiResolveCli) return null;
const customPath = agentKey === "codex" ? codexCustomPath : claudeCustomPath;
const setInfo = agentKey === "codex" ? setCodexPathInfo : setClaudePathInfo;
const setResolving = agentKey === "codex" ? setIsResolvingCodex : setIsResolvingClaude;
const setInfo = agentKey === "codex"
? setCodexPathInfo
: agentKey === "claude"
? setClaudePathInfo
: setCopilotPathInfo;
const setResolving = agentKey === "codex"
? setIsResolvingCodex
: agentKey === "claude"
? setIsResolvingClaude
: setIsResolvingCopilot;
setResolving(true);
try {
@@ -174,32 +213,48 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
});
setInfo(result);
// Register/update in externalAgents if valid
if (result.available && result.path) {
const agentId = `discovered_${agentKey}`;
const defaults = AGENT_DEFAULTS[agentKey];
setExternalAgents((prev) => {
const idx = prev.findIndex((a) => a.id === agentId);
const config: ExternalAgentConfig = {
id: agentId,
command: result.path!,
enabled: true,
...defaults,
};
if (idx >= 0) {
const updated = [...prev];
updated[idx] = { ...updated[idx], command: result.path! };
return updated;
}
return [...prev, config];
});
// Consolidate managed agent entries using the callback form of
// setExternalAgents so we never depend on externalAgents directly.
// All three agents resolve concurrently on mount — React runs
// state updater callbacks sequentially, so updating the ref inside
// ensures later calls see earlier defaultAgentId changes.
let nextDefaultId: string | null = null;
setExternalAgents((prev) => {
const state = buildManagedAgentState(prev, defaultAgentIdRef.current, agentKey, result);
if (state.defaultAgentId !== defaultAgentIdRef.current) {
nextDefaultId = state.defaultAgentId;
defaultAgentIdRef.current = state.defaultAgentId;
}
return areExternalAgentListsEqual(prev, state.agents) ? prev : state.agents;
});
if (nextDefaultId !== null) {
setDefaultAgentId(nextDefaultId);
}
return result;
} catch (err) {
console.error("Path resolution failed:", err);
return null;
} finally {
setResolving(false);
}
}, [codexCustomPath, claudeCustomPath, setExternalAgents]);
}, [setExternalAgents, setDefaultAgentId]);
useEffect(() => {
void resolveAgentPath("codex", initialManagedPathsRef.current?.codex ?? "");
void resolveAgentPath("claude", initialManagedPathsRef.current?.claude ?? "");
void resolveAgentPath("copilot", initialManagedPathsRef.current?.copilot ?? "");
}, [resolveAgentPath]);
// Validate a custom path for an agent
const handleCheckCustomPath = useCallback(async (agentKey: ManagedAgentKey) => {
const customPath = agentKey === "codex"
? codexCustomPath
: agentKey === "claude"
? claudeCustomPath
: copilotCustomPath;
await resolveAgentPath(agentKey, customPath);
}, [claudeCustomPath, codexCustomPath, copilotCustomPath, resolveAgentPath]);
// Add a new provider from preset
const handleAddProvider = useCallback(
@@ -457,7 +512,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
<CodexConnectionCard
pathInfo={codexPathInfo}
isResolvingPath={isDiscovering || isResolvingCodex}
isResolvingPath={isResolvingCodex}
customPath={codexCustomPath}
onCustomPathChange={setCodexCustomPath}
onRecheckPath={() => void handleCheckCustomPath("codex")}
@@ -483,13 +538,29 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
<ClaudeCodeCard
pathInfo={claudePathInfo}
isResolvingPath={isDiscovering || isResolvingClaude}
isResolvingPath={isResolvingClaude}
customPath={claudeCustomPath}
onCustomPathChange={setClaudeCustomPath}
onRecheckPath={() => void handleCheckCustomPath("claude")}
/>
</div>
{/* -- GitHub Copilot CLI Section -- */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<ProviderIconBadge providerId="copilot" size="sm" />
<h3 className="text-base font-medium">{t('ai.copilot.title')}</h3>
</div>
<CopilotCliCard
pathInfo={copilotPathInfo}
isResolvingPath={isResolvingCopilot}
customPath={copilotCustomPath}
onCustomPathChange={setCopilotCustomPath}
onRecheckPath={() => void handleCheckCustomPath("copilot")}
/>
</div>
{/* -- Default Agent Section -- */}
{agentOptions.length > 1 && (
<div className="space-y-4">
@@ -507,7 +578,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
value={defaultAgentId}
options={agentOptions}
onChange={setDefaultAgentId}
className="w-48"
className="w-64"
/>
</SettingRow>
</div>

View File

@@ -25,6 +25,8 @@ export default function SettingsAppearanceTab(props: {
setUiLanguage: (language: string) => void;
customCSS: string;
setCustomCSS: (css: string) => void;
isImmersive?: boolean;
onToggleImmersive?: () => void;
}) {
const { t } = useI18n();
const availableUIFonts = useAvailableUIFonts();
@@ -45,6 +47,8 @@ export default function SettingsAppearanceTab(props: {
setUiLanguage,
customCSS,
setCustomCSS,
isImmersive,
onToggleImmersive,
} = props;
const getHslStyle = useCallback((hsl: string) => ({ backgroundColor: `hsl(${hsl})` }), []);

View File

@@ -28,13 +28,12 @@ const getOpenerLabel = (
export default function SettingsFileAssociationsTab() {
const { t } = useI18n();
const { getAllAssociations, removeAssociation, setOpenerForExtension } = useSftpFileAssociations();
const { sftpDoubleClickBehavior, setSftpDoubleClickBehavior, sftpAutoSync, setSftpAutoSync, sftpShowHiddenFiles, setSftpShowHiddenFiles, sftpUseCompressedUpload, setSftpUseCompressedUpload, sftpAutoOpenSidebar, setSftpAutoOpenSidebar } = useSettingsState();
const { getAllAssociations, removeAssociation, setOpenerForExtension, getDefaultOpener, setDefaultOpener, removeDefaultOpener } = useSftpFileAssociations();
const { sftpDoubleClickBehavior, setSftpDoubleClickBehavior, sftpAutoSync, setSftpAutoSync, sftpShowHiddenFiles, setSftpShowHiddenFiles, sftpUseCompressedUpload, setSftpUseCompressedUpload, sftpAutoOpenSidebar, setSftpAutoOpenSidebar, sftpDefaultViewMode, setSftpDefaultViewMode, sftpTransferConcurrency, setSftpTransferConcurrency } = useSettingsState();
const associations = getAllAssociations();
const defaultOpener = getDefaultOpener();
const [editingExtension, setEditingExtension] = useState<string | null>(null);
// Debug log for Settings page
console.log('[SettingsFileAssociationsTab] Rendering with', associations.length, 'associations:', associations);
const [isSelectingDefaultApp, setIsSelectingDefaultApp] = useState(false);
const handleRemove = useCallback((extension: string) => {
if (confirm(t('settings.sftpFileAssociations.removeConfirm', { ext: extension === 'file' ? t('sftp.opener.noExtension') : extension }))) {
@@ -42,6 +41,22 @@ export default function SettingsFileAssociationsTab() {
}
}, [removeAssociation, t]);
const handleSelectDefaultSystemApp = useCallback(async () => {
setIsSelectingDefaultApp(true);
try {
const bridge = netcattyBridge.get();
if (!bridge?.selectApplication) return;
const result = await bridge.selectApplication();
if (result) {
setDefaultOpener('system-app', { path: result.path, name: result.name });
}
} catch (e) {
console.error('Failed to select application:', e);
} finally {
setIsSelectingDefaultApp(false);
}
}, [setDefaultOpener]);
const handleEdit = useCallback(async (extension: string) => {
setEditingExtension(extension);
try {
@@ -133,6 +148,76 @@ export default function SettingsFileAssociationsTab() {
</div>
</div>
{/* Default view mode section */}
<div className="space-y-4">
<SectionHeader title={t('settings.sftp.defaultViewMode')} />
<p className="text-sm text-muted-foreground">
{t('settings.sftp.defaultViewMode.desc')}
</p>
<div className="space-y-3">
<button
onClick={() => setSftpDefaultViewMode('list')}
className={cn(
"w-full text-left p-4 rounded-lg border-2 transition-colors",
sftpDefaultViewMode === 'list'
? "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-full border-2 flex items-center justify-center mt-0.5 shrink-0",
sftpDefaultViewMode === 'list'
? "border-primary"
: "border-muted-foreground/30"
)}>
{sftpDefaultViewMode === 'list' && (
<div className="h-2.5 w-2.5 rounded-full bg-primary" />
)}
</div>
<div className="space-y-1">
<Label className="font-medium cursor-pointer">
{t('settings.sftp.defaultViewMode.list')}
</Label>
<p className="text-sm text-muted-foreground">
{t('settings.sftp.defaultViewMode.listDesc')}
</p>
</div>
</div>
</button>
<button
onClick={() => setSftpDefaultViewMode('tree')}
className={cn(
"w-full text-left p-4 rounded-lg border-2 transition-colors",
sftpDefaultViewMode === 'tree'
? "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-full border-2 flex items-center justify-center mt-0.5 shrink-0",
sftpDefaultViewMode === 'tree'
? "border-primary"
: "border-muted-foreground/30"
)}>
{sftpDefaultViewMode === 'tree' && (
<div className="h-2.5 w-2.5 rounded-full bg-primary" />
)}
</div>
<div className="space-y-1">
<Label className="font-medium cursor-pointer">
{t('settings.sftp.defaultViewMode.tree')}
</Label>
<p className="text-sm text-muted-foreground">
{t('settings.sftp.defaultViewMode.treeDesc')}
</p>
</div>
</div>
</button>
</div>
</div>
{/* Auto-sync section */}
<div className="space-y-4">
<SectionHeader title={t('settings.sftp.autoSync')} />
@@ -293,6 +378,117 @@ export default function SettingsFileAssociationsTab() {
</button>
</div>
{/* Transfer concurrency section */}
<div className="space-y-4">
<SectionHeader title={t('settings.sftp.transferConcurrency')} />
<p className="text-sm text-muted-foreground">
{t('settings.sftp.transferConcurrency.desc')}
</p>
<div className="flex items-center gap-3">
<input
type="range"
min={1}
max={16}
step={1}
value={sftpTransferConcurrency}
onChange={(e) => setSftpTransferConcurrency(Number(e.target.value))}
className="flex-1 accent-primary"
/>
<span className="text-sm font-mono w-6 text-center">{sftpTransferConcurrency}</span>
</div>
</div>
{/* Default opener section */}
<div className="space-y-4">
<SectionHeader title={t('settings.sftp.defaultOpener')} />
<p className="text-sm text-muted-foreground">
{t('settings.sftp.defaultOpener.desc')}
</p>
<div className="space-y-3">
<button
onClick={() => removeDefaultOpener()}
className={cn(
"w-full text-left p-4 rounded-lg border-2 transition-colors",
!defaultOpener
? "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-full border-2 flex items-center justify-center mt-0.5 shrink-0",
!defaultOpener ? "border-primary" : "border-muted-foreground/30"
)}>
{!defaultOpener && <div className="h-2.5 w-2.5 rounded-full bg-primary" />}
</div>
<div className="space-y-1">
<Label className="font-medium cursor-pointer">
{t('settings.sftp.defaultOpener.ask')}
</Label>
<p className="text-sm text-muted-foreground">
{t('settings.sftp.defaultOpener.askDesc')}
</p>
</div>
</div>
</button>
<button
onClick={() => setDefaultOpener('builtin-editor')}
className={cn(
"w-full text-left p-4 rounded-lg border-2 transition-colors",
defaultOpener?.openerType === 'builtin-editor'
? "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-full border-2 flex items-center justify-center mt-0.5 shrink-0",
defaultOpener?.openerType === 'builtin-editor' ? "border-primary" : "border-muted-foreground/30"
)}>
{defaultOpener?.openerType === 'builtin-editor' && <div className="h-2.5 w-2.5 rounded-full bg-primary" />}
</div>
<div className="space-y-1">
<Label className="font-medium cursor-pointer">
{t('sftp.opener.builtInEditor')}
</Label>
<p className="text-sm text-muted-foreground">
{t('settings.sftp.defaultOpener.builtInDesc')}
</p>
</div>
</div>
</button>
<button
onClick={handleSelectDefaultSystemApp}
disabled={isSelectingDefaultApp}
className={cn(
"w-full text-left p-4 rounded-lg border-2 transition-colors",
defaultOpener?.openerType === 'system-app'
? "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-full border-2 flex items-center justify-center mt-0.5 shrink-0",
defaultOpener?.openerType === 'system-app' ? "border-primary" : "border-muted-foreground/30"
)}>
{defaultOpener?.openerType === 'system-app' && <div className="h-2.5 w-2.5 rounded-full bg-primary" />}
</div>
<div className="space-y-1">
<Label className="font-medium cursor-pointer">
{defaultOpener?.openerType === 'system-app' && defaultOpener.systemApp
? defaultOpener.systemApp.name
: t('settings.sftp.defaultOpener.systemApp')}
</Label>
<p className="text-sm text-muted-foreground">
{t('settings.sftp.defaultOpener.systemAppDesc')}
</p>
</div>
</div>
</button>
</div>
</div>
{/* File associations section */}
<div className="space-y-4">
<SectionHeader title={t('settings.sftpFileAssociations.title')} />

View File

@@ -1,8 +1,8 @@
import React, { useCallback } from "react";
import type { PortForwardingRule } from "../../../domain/models";
import type { SyncPayload } from "../../../domain/sync";
import { buildSyncPayload, applySyncPayload } from "../../../domain/syncPayload";
import type { SyncableVaultData } from "../../../domain/syncPayload";
import { buildSyncPayload, applySyncPayload } from "../../../application/syncPayload";
import type { SyncableVaultData } from "../../../application/syncPayload";
import { STORAGE_KEY_PORT_FORWARDING } from "../../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
import { getEffectiveKnownHosts } from "../../../infrastructure/syncHelpers";

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;
@@ -64,6 +89,7 @@ interface SettingsSystemTabProps {
checkNow: () => Promise<unknown>;
installUpdate: () => void;
openReleasePage: () => void;
startDownload: () => void;
}
const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
@@ -86,6 +112,7 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
checkNow,
installUpdate,
openReleasePage,
startDownload,
}) => {
const { t } = useI18n();
const isMac = typeof navigator !== "undefined" && /Mac/i.test(navigator.platform);
@@ -98,6 +125,12 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
const [hotkeyError, setHotkeyError] = useState<string | null>(null);
const [credentialsAvailable, setCredentialsAvailable] = useState<boolean | null>(null);
const [isCheckingCredentials, setIsCheckingCredentials] = useState(false);
const [crashLogs, setCrashLogs] = useState<CrashLogFile[]>([]);
const [isLoadingCrashLogs, setIsLoadingCrashLogs] = useState(false);
const [expandedLog, setExpandedLog] = useState<string | null>(null);
const [logEntries, setLogEntries] = useState<CrashLogEntry[]>([]);
const [isClearingCrashLogs, setIsClearingCrashLogs] = useState(false);
const [crashLogClearResult, setCrashLogClearResult] = useState<{ deletedCount: number } | null>(null);
const [appVersion, setAppVersion] = useState('');
@@ -144,6 +177,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;
@@ -365,7 +465,16 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
</Button>
)}
{/* Open releases — shown when update found on unsupported platform, or on check error */}
{/* Download button — shown when update found and no download in progress */}
{updateState.autoDownloadStatus === 'idle' &&
updateState.manualCheckStatus === 'available' && (
<Button variant="outline" size="sm" onClick={startDownload}>
<Download size={14} className="mr-1.5" />
{t('update.downloadNow')}
</Button>
)}
{/* Open releases — fallback for unsupported platforms or check errors */}
{updateState.autoDownloadStatus === 'idle' &&
(updateState.manualCheckStatus === 'available' || updateState.manualCheckStatus === 'error' || (updateState.manualCheckStatus === 'idle' && updateState.hasUpdate)) && (
<Button variant="ghost" size="sm" onClick={openReleasePage}>
@@ -449,6 +558,148 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
</div>
</div>
{/* Crash Logs Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<AlertTriangle size={18} className="text-muted-foreground" />
<h3 className="text-base font-medium">{t("settings.system.crashLogs.title")}</h3>
</div>
<div className="bg-muted/30 rounded-lg p-4 space-y-3">
<p className="text-sm text-muted-foreground">
{t("settings.system.crashLogs.description")}
</p>
{crashLogs.length === 0 && !isLoadingCrashLogs && (
<p className="text-sm text-muted-foreground italic">
{t("settings.system.crashLogs.noLogs")}
</p>
)}
{crashLogs.length > 0 && (
<div className="space-y-2">
{crashLogs.map((log) => (
<div key={log.fileName} className="border border-border/60 rounded-md overflow-hidden">
<button
onClick={() => handleExpandCrashLog(log.fileName)}
className="w-full flex items-center justify-between px-3 py-2 text-sm hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-2">
{expandedLog === log.fileName ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<span className="font-mono">{log.date}</span>
<span className="text-muted-foreground">
({t("settings.system.crashLogs.entries").replace("{count}", String(log.entryCount))})
</span>
</div>
<span className="text-xs text-muted-foreground">{formatBytes(log.size)}</span>
</button>
{expandedLog === log.fileName && logEntries.length > 0 && (
<div className="border-t border-border/60 max-h-64 overflow-y-auto">
{logEntries.map((entry, idx) => (
<div key={idx} className="px-3 py-2 text-xs border-b border-border/30 last:border-b-0 space-y-1">
<div className="flex items-center gap-3 flex-wrap">
<span className="font-mono text-muted-foreground">
{new Date(entry.timestamp).toLocaleTimeString()}
</span>
<span className="px-1.5 py-0.5 rounded bg-destructive/10 text-destructive font-medium">
{entry.source}
</span>
</div>
<p className="font-mono break-all">{entry.message}</p>
{entry.errorMeta && Object.keys(entry.errorMeta).length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
{Object.entries(entry.errorMeta).map(([k, v]) => (
<span key={k} className="px-1.5 py-0.5 rounded bg-muted font-mono">
{k}={String(v)}
</span>
))}
</div>
)}
{entry.extra && Object.keys(entry.extra).length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
{Object.entries(entry.extra).map(([k, v]) => (
<span key={k} className="px-1.5 py-0.5 rounded bg-muted font-mono">
{k}={String(v)}
</span>
))}
</div>
)}
{(() => {
const parts: string[] = [];
if (entry.version) parts.push(`v${entry.version}`);
if (entry.electronVersion) parts.push(`Electron ${entry.electronVersion}`);
if (entry.platform) parts.push(`${entry.platform}/${entry.arch}`);
if (entry.osVersion) parts.push(`OS ${entry.osVersion}`);
if (entry.pid) parts.push(`PID ${entry.pid}`);
if (entry.activeSessionCount != null && entry.activeSessionCount >= 0) parts.push(`Sessions: ${entry.activeSessionCount}`);
if (entry.memoryMB) parts.push(`RAM: ${entry.memoryMB.rss}MB`);
if (entry.uptimeSeconds != null) parts.push(`Uptime: ${entry.uptimeSeconds}s`);
const text = parts.join(' ');
return text ? (
<div className="text-muted-foreground truncate" title={text}>
{text}
</div>
) : null;
})()}
{entry.stack && (
<pre className="mt-1 p-2 bg-muted rounded text-[11px] leading-relaxed overflow-x-auto whitespace-pre-wrap break-all text-muted-foreground">
{entry.stack}
</pre>
)}
</div>
))}
</div>
)}
</div>
))}
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2 pt-2">
<Button
variant="outline"
size="sm"
onClick={loadCrashLogs}
disabled={isLoadingCrashLogs}
className="gap-1.5"
>
<RefreshCw size={14} className={isLoadingCrashLogs ? "animate-spin" : ""} />
{t("settings.system.refresh")}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleClearCrashLogs}
disabled={isClearingCrashLogs || crashLogs.length === 0}
className="gap-1.5 text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 size={14} />
{t("settings.system.crashLogs.clear")}
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleOpenCrashLogsDir}
title={t("settings.system.openFolder")}
>
<FolderOpen size={16} />
</Button>
</div>
{crashLogClearResult && (
<p className="text-sm text-muted-foreground">
{t("settings.system.crashLogs.cleared").replace("{count}", String(crashLogClearResult.deletedCount))}
</p>
)}
</div>
<p className="text-xs text-muted-foreground">
{t("settings.system.crashLogs.hint")}
</p>
</div>
{/* Temp Directory Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">

View File

@@ -84,6 +84,8 @@ export default function SettingsTerminalTab(props: {
value: TerminalSettings[K],
) => void;
availableFonts: TerminalFont[];
workspaceFocusStyle: 'dim' | 'border';
setWorkspaceFocusStyle: (style: 'dim' | 'border') => void;
}) {
const {
terminalThemeId,
@@ -95,6 +97,8 @@ export default function SettingsTerminalTab(props: {
terminalSettings,
updateTerminalSetting,
availableFonts,
workspaceFocusStyle,
setWorkspaceFocusStyle,
} = props;
const { t } = useI18n();
@@ -114,23 +118,33 @@ export default function SettingsTerminalTab(props: {
|| TERMINAL_THEMES[0];
}, [terminalThemeId, customThemes]);
const handleAutocompleteGhostTextChange = useCallback((enabled: boolean) => {
updateTerminalSetting("autocompleteGhostText", enabled);
if (enabled) {
updateTerminalSetting("autocompletePopupMenu", false);
}
}, [updateTerminalSetting]);
const handleAutocompletePopupMenuChange = useCallback((enabled: boolean) => {
updateTerminalSetting("autocompletePopupMenu", enabled);
if (enabled) {
updateTerminalSetting("autocompleteGhostText", false);
}
}, [updateTerminalSetting]);
// Import .itermcolors file
const importFileRef = useRef<HTMLInputElement>(null);
const handleImportItermcolors = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) {
console.log('[Settings] No file selected');
return;
}
console.log('[Settings] File selected:', file.name, 'size:', file.size);
const name = file.name.replace(/\.(itermcolors|xml)$/i, '');
const reader = new FileReader();
reader.onload = () => {
const xml = reader.result as string;
console.log('[Settings] File read successfully, length:', xml.length);
const parsed = parseItermcolors(xml, name);
if (parsed) {
console.log('[Settings] Theme parsed successfully:', parsed.id, parsed.name);
customThemeStore.addTheme(parsed);
setTerminalThemeId(parsed.id);
} else {
@@ -620,6 +634,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")}
@@ -848,6 +869,56 @@ export default function SettingsTerminalTab(props: {
/>
</SettingRow>
</div>
{/* Autocomplete */}
<SectionHeader title={t("settings.terminal.section.workspaceFocus")} />
<div className="space-y-1">
<SettingRow
label={t("settings.terminal.workspaceFocus.style")}
description={t("settings.terminal.workspaceFocus.style.desc")}
>
<Select
value={workspaceFocusStyle}
onChange={(v) => setWorkspaceFocusStyle(v as 'dim' | 'border')}
options={[
{ value: 'dim', label: t("settings.terminal.workspaceFocus.dim") },
{ value: 'border', label: t("settings.terminal.workspaceFocus.border") },
]}
/>
</SettingRow>
</div>
<SectionHeader title={t("settings.terminal.section.autocomplete")} />
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
<SettingRow
label={t("settings.terminal.autocomplete.enabled")}
description={t("settings.terminal.autocomplete.enabled.desc")}
>
<Toggle
checked={terminalSettings.autocompleteEnabled}
onChange={(v) => updateTerminalSetting("autocompleteEnabled", v)}
/>
</SettingRow>
<SettingRow
label={t("settings.terminal.autocomplete.ghostText")}
description={t("settings.terminal.autocomplete.ghostText.desc")}
>
<Toggle
checked={terminalSettings.autocompleteGhostText}
onChange={handleAutocompleteGhostTextChange}
disabled={!terminalSettings.autocompleteEnabled}
/>
</SettingRow>
<SettingRow
label={t("settings.terminal.autocomplete.popupMenu")}
description={t("settings.terminal.autocomplete.popupMenu.desc")}
>
<Toggle
checked={terminalSettings.autocompletePopupMenu}
onChange={handleAutocompletePopupMenuChange}
disabled={!terminalSettings.autocompleteEnabled}
/>
</SettingRow>
</div>
</SettingsTabContent>
);
}

View File

@@ -0,0 +1,87 @@
import React from "react";
import { RefreshCw } from "lucide-react";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Button } from "../../../ui/button";
import { cn } from "../../../../lib/utils";
import type { AgentPathInfo } from "./types";
import { ProviderIconBadge } from "./ProviderIconBadge";
export const CopilotCliCard: React.FC<{
pathInfo: AgentPathInfo | null;
isResolvingPath: boolean;
customPath: string;
onCustomPathChange: (path: string) => void;
onRecheckPath: () => void;
}> = ({
pathInfo,
isResolvingPath,
customPath,
onCustomPathChange,
onRecheckPath,
}) => {
const { t } = useI18n();
const found = pathInfo?.available;
const statusText = isResolvingPath
? t('ai.copilot.detecting')
: found
? t('ai.copilot.detected')
: t('ai.copilot.notFound');
const statusClassName = isResolvingPath
? "text-muted-foreground"
: found
? "text-emerald-500"
: "text-amber-500";
return (
<div className="rounded-lg border border-border/60 bg-muted/20 p-4 space-y-3">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<ProviderIconBadge providerId="copilot" size="sm" />
<span className="text-sm font-medium">{t('ai.copilot.title')}</span>
</div>
<p className="text-xs text-muted-foreground mt-2 leading-5">
{t('ai.copilot.description')}
</p>
</div>
<div className={cn("text-xs font-medium shrink-0", statusClassName)}>
{statusText}
</div>
</div>
{found ? (
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">{t('ai.copilot.path')}</span>
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
{pathInfo.version && (
<>
<span className="text-muted-foreground">|</span>
<span className="text-muted-foreground">{pathInfo.version}</span>
</>
)}
</div>
) : !isResolvingPath ? (
<div className="space-y-2">
<p className="text-xs text-amber-500">
{t('ai.copilot.notFoundHint')}
</p>
<div className="flex items-center gap-2">
<input
type="text"
value={customPath}
onChange={(e) => onCustomPathChange(e.target.value)}
placeholder={t('ai.copilot.customPathPlaceholder')}
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!customPath.trim()}>
<RefreshCw size={14} className="mr-1.5" />
{t('ai.copilot.check')}
</Button>
</div>
</div>
) : null}
</div>
);
};

View File

@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useState } from "react";
import { Check, Eye, EyeOff } from "lucide-react";
import type { ProviderConfig } from "../../../../infrastructure/ai/types";
import { Check, ChevronDown, ChevronRight, Eye, EyeOff } from "lucide-react";
import type { ProviderConfig, ProviderAdvancedParams } from "../../../../infrastructure/ai/types";
import { PROVIDER_PRESETS } from "../../../../infrastructure/ai/types";
import { encryptField, decryptField } from "../../../../infrastructure/persistence/secureFieldAdapter";
import { useI18n } from "../../../../application/i18n/I18nProvider";
@@ -20,10 +20,12 @@ export const ProviderConfigForm: React.FC<{
baseURL: provider.baseURL ?? PROVIDER_PRESETS[provider.providerId]?.defaultBaseURL ?? "",
defaultModel: provider.defaultModel ?? "",
skipTLSVerify: provider.skipTLSVerify ?? false,
advancedParams: provider.advancedParams ?? {},
});
const isCustom = provider.providerId === "custom";
const [showApiKey, setShowApiKey] = useState(false);
const [isDecrypting, setIsDecrypting] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
const preset = PROVIDER_PRESETS[provider.providerId];
@@ -43,11 +45,37 @@ export const ProviderConfigForm: React.FC<{
}
}, [provider.apiKey]);
const [advancedParamRaw, setAdvancedParamRaw] = useState<Record<string, string>>({});
const handleAdvancedParam = useCallback((key: keyof ProviderAdvancedParams, raw: string) => {
setAdvancedParamRaw((prev) => ({ ...prev, [key]: raw }));
setForm((prev) => {
const next = { ...prev.advancedParams };
if (raw.trim() === "" || raw.trim() === "-") {
delete next[key];
} else {
const num = Number(raw);
if (!Number.isNaN(num)) {
next[key] = num;
}
}
return { ...prev, advancedParams: next };
});
}, []);
const handleSave = useCallback(async () => {
const cleanedParams: ProviderAdvancedParams = {};
const ap = form.advancedParams;
if (ap.maxTokens != null && Number.isFinite(ap.maxTokens) && ap.maxTokens > 0) cleanedParams.maxTokens = Math.max(1, Math.round(ap.maxTokens));
if (ap.temperature != null) cleanedParams.temperature = Math.min(2, Math.max(0, ap.temperature));
if (ap.topP != null) cleanedParams.topP = Math.min(1, Math.max(0, ap.topP));
if (ap.frequencyPenalty != null) cleanedParams.frequencyPenalty = Math.min(2, Math.max(-2, ap.frequencyPenalty));
if (ap.presencePenalty != null) cleanedParams.presencePenalty = Math.min(2, Math.max(-2, ap.presencePenalty));
const updates: Partial<ProviderConfig> = {
baseURL: form.baseURL || undefined,
defaultModel: form.defaultModel || undefined,
skipTLSVerify: form.skipTLSVerify || undefined,
advancedParams: Object.keys(cleanedParams).length > 0 ? cleanedParams : undefined,
...(isCustom && form.name.trim() ? { name: form.name.trim() } : {}),
};
@@ -137,6 +165,92 @@ export const ProviderConfigForm: React.FC<{
<span className="text-xs text-muted-foreground">{t('ai.providers.skipTLSVerify')}</span>
</label>
{/* Advanced Parameters */}
<div className="space-y-2">
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
>
{showAdvanced ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
{t('ai.providers.advancedParams')}
</button>
{showAdvanced && (
<div className="space-y-2.5 pl-1 border-l-2 border-border/40 ml-1">
<p className="text-[11px] text-muted-foreground/70 pl-3">{t('ai.providers.advancedParams.hint')}</p>
{/* max_tokens */}
<div className="space-y-1 pl-3">
<label className="text-xs text-muted-foreground">max_tokens</label>
<input
type="number"
min={1}
step={1}
value={advancedParamRaw.maxTokens ?? (form.advancedParams.maxTokens != null ? String(form.advancedParams.maxTokens) : "")}
onChange={(e) => handleAdvancedParam("maxTokens", e.target.value)}
placeholder={t('ai.providers.advancedParams.maxTokens.placeholder')}
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
{/* temperature */}
<div className="space-y-1 pl-3">
<label className="text-xs text-muted-foreground">temperature <span className="text-muted-foreground/50">(02)</span></label>
<input
type="number"
min={0}
max={2}
step={0.1}
value={advancedParamRaw.temperature ?? (form.advancedParams.temperature != null ? String(form.advancedParams.temperature) : "")}
onChange={(e) => handleAdvancedParam("temperature", e.target.value)}
placeholder={t('ai.providers.advancedParams.default')}
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
{/* top_p */}
<div className="space-y-1 pl-3">
<label className="text-xs text-muted-foreground">top_p <span className="text-muted-foreground/50">(01)</span></label>
<input
type="number"
min={0}
max={1}
step={0.05}
value={advancedParamRaw.topP ?? (form.advancedParams.topP != null ? String(form.advancedParams.topP) : "")}
onChange={(e) => handleAdvancedParam("topP", e.target.value)}
placeholder={t('ai.providers.advancedParams.default')}
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
{/* frequency_penalty */}
<div className="space-y-1 pl-3">
<label className="text-xs text-muted-foreground">frequency_penalty <span className="text-muted-foreground/50">(-22)</span></label>
<input
type="number"
min={-2}
max={2}
step={0.1}
value={advancedParamRaw.frequencyPenalty ?? (form.advancedParams.frequencyPenalty != null ? String(form.advancedParams.frequencyPenalty) : "")}
onChange={(e) => handleAdvancedParam("frequencyPenalty", e.target.value)}
placeholder={t('ai.providers.advancedParams.default')}
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
{/* presence_penalty */}
<div className="space-y-1 pl-3">
<label className="text-xs text-muted-foreground">presence_penalty <span className="text-muted-foreground/50">(-22)</span></label>
<input
type="number"
min={-2}
max={2}
step={0.1}
value={advancedParamRaw.presencePenalty ?? (form.advancedParams.presencePenalty != null ? String(form.advancedParams.presencePenalty) : "")}
onChange={(e) => handleAdvancedParam("presencePenalty", e.target.value)}
placeholder={t('ai.providers.advancedParams.default')}
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
</div>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-2 pt-1">
<Button variant="default" size="sm" onClick={() => void handleSave()}>

View File

@@ -20,7 +20,8 @@ export const ProviderIconBadge: React.FC<{
aria-hidden="true"
draggable={false}
className={cn(
"object-contain brightness-0 invert",
"object-contain",
providerId === "copilot" ? "brightness-0" : "brightness-0 invert",
size === "sm" ? "w-3 h-3" : "w-4 h-4",
)}
/>

View File

@@ -5,4 +5,5 @@ export { ProviderCard } from "./ProviderCard";
export { AddProviderDropdown } from "./AddProviderDropdown";
export { CodexConnectionCard } from "./CodexConnectionCard";
export { ClaudeCodeCard } from "./ClaudeCodeCard";
export { CopilotCliCard } from "./CopilotCliCard";
export { SafetySettings } from "./SafetySettings";

View File

@@ -4,6 +4,7 @@
import type {
AIProviderId,
ExternalAgentConfig,
ProviderAdvancedParams,
} from "../../../../infrastructure/ai/types";
export type CodexIntegrationState =
@@ -42,6 +43,7 @@ export interface ProviderFormState {
baseURL: string;
defaultModel: string;
skipTLSVerify: boolean;
advancedParams: ProviderAdvancedParams;
}
export interface FetchedModel {
@@ -77,9 +79,16 @@ 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: [],
},
copilot: {
name: "GitHub Copilot CLI",
args: ["-p", "{prompt}"],
icon: "copilot",
acpCommand: "copilot",
acpArgs: ["--acp", "--stdio"],
},
};
// ---------------------------------------------------------------------------
@@ -106,12 +115,13 @@ export function normalizeCodexBridgeError(error: unknown): string {
// Provider icon helper
// ---------------------------------------------------------------------------
export type SettingsIconId = AIProviderId | "claude";
export type SettingsIconId = AIProviderId | "claude" | "copilot";
export const SETTINGS_ICON_PATHS: Record<SettingsIconId, string> = {
openai: "/ai/providers/openai.svg",
anthropic: "/ai/providers/anthropic.svg",
claude: "/ai/agents/claude.svg",
copilot: "/ai/agents/copilot.svg",
google: "/ai/providers/google.svg",
ollama: "/ai/providers/ollama.svg",
openrouter: "/ai/providers/openrouter.svg",
@@ -122,6 +132,7 @@ export const SETTINGS_ICON_COLORS: Record<SettingsIconId, string> = {
openai: "bg-emerald-600",
anthropic: "bg-orange-600",
claude: "bg-orange-600",
copilot: "border border-zinc-300 bg-white",
google: "bg-blue-600",
ollama: "bg-purple-600",
openrouter: "bg-pink-600",

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,
};
};

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