Compare commits

...

67 Commits

Author SHA1 Message Date
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
60 changed files with 2918 additions and 336 deletions

View File

@@ -699,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',
@@ -1684,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',

View File

@@ -507,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': '上传失败',
@@ -1691,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',

View File

@@ -28,6 +28,7 @@ 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;
}
@@ -78,6 +79,7 @@ export const useSftpPaneActions = ({
listRemoteFiles,
handleSessionError,
isSessionError,
clearSelectionsExcept,
dirCacheTtlMs,
}: UseSftpPaneActionsParams): UseSftpPaneActionsResult => {
const normalizePathForCompare = useCallback((path: string): string => {
@@ -465,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)) {
@@ -475,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 !== "..") {
@@ -489,7 +499,7 @@ export const useSftpPaneActions = ({
updateActiveTab(side, (prev) => ({ ...prev, selectedFiles: newSelection }));
},
[updateActiveTab],
[updateActiveTab, clearSelectionsExcept, leftTabsRef, rightTabsRef],
);
const clearSelection = useCallback((side: "left" | "right") => {

View File

@@ -14,6 +14,7 @@ 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 @@ 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,

View File

@@ -22,6 +22,7 @@ interface UseSftpTransfersParams {
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
clearCacheForConnection: (connectionId: string) => void;
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
connectionCacheKeyMapRef: React.MutableRefObject<Map<string, string>>;
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
handleSessionError: (side: "left" | "right", error: Error) => void;
@@ -78,6 +79,7 @@ export const useSftpTransfers = ({
refresh,
clearCacheForConnection,
sftpSessionsRef,
connectionCacheKeyMapRef,
listLocalFiles,
listRemoteFiles,
handleSessionError,
@@ -209,6 +211,7 @@ export const useSftpTransfers = ({
sourceEncoding: SftpFilenameEncoding,
targetEncoding: SftpFilenameEncoding,
rootTaskId: string, // The original top-level task ID for cancellation checking
sameHost?: boolean,
onStreamProgress?: (transferred: number, total: number, speed: number) => void,
): Promise<void> => {
// Check if task or root task was cancelled before starting
@@ -228,6 +231,7 @@ export const useSftpTransfers = ({
totalBytes: task.totalBytes || undefined,
sourceEncoding: sourceIsLocal ? undefined : sourceEncoding,
targetEncoding: targetIsLocal ? undefined : targetEncoding,
sameHost: sameHost || undefined,
};
let lastProgressUpdate = 0;
@@ -343,6 +347,7 @@ export const useSftpTransfers = ({
sourceEncoding: SftpFilenameEncoding,
targetEncoding: SftpFilenameEncoding,
rootTaskId: string, // The original top-level task ID for cancellation checking
sameHost?: boolean,
symlinkDepth = 0,
followSymlinks = false, // Only true for downloadToLocal — uploads/copies treat symlinks as files
) => {
@@ -433,6 +438,7 @@ export const useSftpTransfers = ({
sourceEncoding,
targetEncoding,
rootTaskId,
sameHost,
isSymlink ? symlinkDepth + 1 : symlinkDepth,
followSymlinks,
);
@@ -496,6 +502,7 @@ export const useSftpTransfers = ({
sourceEncoding,
targetEncoding,
rootTaskId,
sameHost,
);
activeChildIdsRef.current.get(rootTaskId)?.delete(fileId);
@@ -571,6 +578,22 @@ export const useSftpTransfers = ({
? null
: sftpSessionsRef.current.get(targetPane.connection!.id);
// Detect same-host: both sides connected to the same remote endpoint.
// Use per-connection cache keys (hostname+port+protocol+sudo+username) instead of
// just hostId, because the same hostId can have different session-time overrides.
const sourceCacheKey = sourcePane.connection?.id
? connectionCacheKeyMapRef.current.get(sourcePane.connection.id)
: undefined;
const targetCacheKey = targetPane.connection?.id
? connectionCacheKeyMapRef.current.get(targetPane.connection.id)
: undefined;
const sameHost = !!(
sourceSftpId && targetSftpId &&
!sourcePane.connection?.isLocal && !targetPane.connection?.isLocal &&
sourceCacheKey && targetCacheKey &&
sourceCacheKey === targetCacheKey
);
if (!sourcePane.connection?.isLocal && !sourceSftpId) {
const sourceSide = targetSide === "left" ? "right" : "left";
handleSessionError(sourceSide, new Error("Source SFTP session lost"));
@@ -718,7 +741,34 @@ export const useSftpTransfers = ({
let dirPartialFailure = false;
if (task.isDirectory) {
// Same-host exec-based paths are only safe for UTF-8 compatible encodings.
// "auto" is allowed here — the backend resolves it to the actual encoding
// and skips exec if it resolved to non-UTF-8 (e.g. gb18030).
const encodingSafeForExec =
(!sourceEncoding || sourceEncoding === "utf-8" || sourceEncoding === "auto") &&
(!targetEncoding || targetEncoding === "utf-8" || targetEncoding === "auto");
// Try same-host directory optimization first; falls back to recursive transfer
// if remote cp is unavailable (e.g. Windows SSH servers).
let dirHandledBySameHost = false;
if (task.isDirectory && sameHost && encodingSafeForExec && sourceSftpId) {
if (cancelledTasksRef.current.has(task.id)) {
throw new Error("Transfer cancelled");
}
const result = await netcattyBridge.require().sameHostCopyDirectory!(
sourceSftpId,
task.sourcePath,
task.targetPath,
sourceEncoding,
task.id,
);
if (cancelledTasksRef.current.has(task.id)) {
throw new Error("Transfer cancelled");
}
dirHandledBySameHost = result.success;
}
if (task.isDirectory && !dirHandledBySameHost) {
// For directory transfers, parent task uses:
// totalBytes = total file count (discovered async)
// transferredBytes = completed file count (incremented by child completions)
@@ -746,12 +796,13 @@ export const useSftpTransfers = ({
sourceEncoding,
targetEncoding,
task.id, // rootTaskId - this is the top-level task
sameHost,
);
if (dirErrors > 0) {
dirPartialFailure = true;
}
} else {
} else if (!task.isDirectory) {
await transferFile(
task,
sourceSftpId,
@@ -761,6 +812,7 @@ export const useSftpTransfers = ({
sourceEncoding,
targetEncoding,
task.id, // rootTaskId - this is the top-level task
sameHost,
);
}
@@ -1247,6 +1299,7 @@ export const useSftpTransfers = ({
sourceEncoding,
"auto", // targetEncoding
task.id,
false, // sameHost
0, // symlinkDepth
true, // followSymlinks — download should expand symlink dirs
);

View File

@@ -81,6 +81,7 @@ export interface CloudSyncHook {
code: string,
redirectUri: string
) => Promise<void>;
cancelOAuthConnect: () => void;
disconnectProvider: (provider: CloudProvider) => Promise<void>;
resetProviderStatus: (provider: CloudProvider) => void;
@@ -265,34 +266,30 @@ export const useCloudSync = (): CloudSyncHook => {
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 — omit noopener/noreferrer so we can track the popup
let popup: Window | null = null;
let popupPollTimer: ReturnType<typeof setInterval> | null = null;
const openTimer = setTimeout(() => {
popup = window.open(data.url, "_blank", "width=600,height=700");
// Poll for popup closure — if user closes it, cancel the OAuth flow
if (popup) {
popupPollTimer = setInterval(() => {
if (popup?.closed) {
if (popupPollTimer) clearInterval(popupPollTimer);
bridge?.cancelOAuthCallback?.();
}
}, 500);
}
}, 100);
// 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 {
// Wait for callback
const { code } = await callbackPromise;
const { code } = await Promise.race([callbackPromise, browserPromise]);
// Complete auth with the received code
await manager.completePKCEAuth('google', code, data.redirectUri);
} finally {
clearTimeout(openTimer);
if (popupPollTimer) clearInterval(popupPollTimer);
if (openTimer) clearTimeout(openTimer);
}
}
@@ -314,34 +311,29 @@ export const useCloudSync = (): CloudSyncHook => {
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 — omit noopener/noreferrer so we can track the popup
let popup: Window | null = null;
let popupPollTimer: ReturnType<typeof setInterval> | null = null;
const openTimer = setTimeout(() => {
popup = window.open(data.url, "_blank", "width=600,height=700");
// Poll for popup closure — if user closes it, cancel the OAuth flow
if (popup) {
popupPollTimer = setInterval(() => {
if (popup?.closed) {
if (popupPollTimer) clearInterval(popupPollTimer);
bridge?.cancelOAuthCallback?.();
}
}, 500);
}
}, 100);
// 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 {
// Wait for callback
const { code } = await callbackPromise;
const { code } = await Promise.race([callbackPromise, browserPromise]);
// Complete auth with the received code
await manager.completePKCEAuth('onedrive', code, data.redirectUri);
} finally {
clearTimeout(openTimer);
if (popupPollTimer) clearInterval(popupPollTimer);
if (openTimer) clearTimeout(openTimer);
}
}
@@ -372,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) => {
@@ -469,6 +466,7 @@ export const useCloudSync = (): CloudSyncHook => {
connectWebDAV,
connectS3,
completePKCEAuth,
cancelOAuthConnect,
disconnectProvider,
resetProviderStatus,

View File

@@ -57,6 +57,7 @@ export const useSftpState = (
getActivePane,
updateTab,
updateActiveTab,
clearSelectionsExcept,
setTabShowHiddenFiles,
addTab,
closeTab,
@@ -235,6 +236,7 @@ export const useSftpState = (
listRemoteFiles,
handleSessionError,
isSessionError,
clearSelectionsExcept,
dirCacheTtlMs: DIR_CACHE_TTL_MS,
});
@@ -289,6 +291,7 @@ export const useSftpState = (
refresh,
clearCacheForConnection,
sftpSessionsRef,
connectionCacheKeyMapRef,
listLocalFiles,
listRemoteFiles,
handleSessionError,
@@ -338,6 +341,7 @@ export const useSftpState = (
toggleSelection,
rangeSelect,
clearSelection,
clearSelectionsExcept,
selectAll,
setFilter,
setFilenameEncoding,
@@ -391,6 +395,7 @@ export const useSftpState = (
toggleSelection,
rangeSelect,
clearSelection,
clearSelectionsExcept,
selectAll,
setFilter,
setFilenameEncoding,
@@ -447,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>) =>

View File

@@ -21,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,
@@ -43,6 +44,20 @@ import { clearAllPendingApprovals } from '../infrastructure/ai/shared/approvalGa
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
// -------------------------------------------------------------------
@@ -425,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
@@ -593,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,
});

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,6 +415,16 @@ 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"
@@ -1088,6 +1105,7 @@ 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')}
/>
@@ -1104,6 +1122,7 @@ 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')}
/>

View File

@@ -65,8 +65,8 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
// 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 = {
@@ -98,7 +98,7 @@ 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,
)}

View File

@@ -739,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">
@@ -984,9 +984,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
{!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">
<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 flex-1 truncate font-mono" title={keyPath}>
<span className="text-xs w-0 flex-1 truncate font-mono" title={keyPath}>
{keyPath}
</span>
<Button
@@ -1179,10 +1179,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
selectedCredentialType === "localKeyFile" &&
!form.identityFileId && (
<div className="space-y-1.5">
<div className="flex items-center gap-1">
<div className="flex items-center gap-1 min-w-0">
<input
type="text"
className="flex-1 h-8 px-2 text-xs font-mono bg-background border border-border/60 rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
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)}

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

@@ -33,6 +33,7 @@ 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 {
@@ -130,12 +131,14 @@ 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,
});
@@ -149,10 +152,19 @@ 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.
@@ -161,19 +173,30 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
useEffect(() => {
if (!isVisible) {
setHasPaneFocus(false);
syncFocusedSelection(null);
}
}, [isVisible]);
}, [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);
}
};
@@ -181,7 +204,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
return () => {
document.removeEventListener("pointerdown", handlePointerDown, true);
};
}, [isVisible]);
}, [isVisible, syncFocusedSelection]);
const {
leftCallbacks,
@@ -599,10 +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}
/>

View File

@@ -40,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
@@ -79,6 +81,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
const { t } = useI18n();
const isActive = useIsSftpActive();
const rootRef = useRef<HTMLDivElement>(null);
const dialogActionScopeIdRef = useRef("sftp-main-view");
useInstantThemeSwitch(rootRef);
@@ -132,6 +135,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
keyBindings,
hotkeyScheme,
sftpRef,
dialogActionScopeId: dialogActionScopeIdRef.current,
isActive,
});
@@ -139,8 +143,18 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
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) => {
@@ -255,6 +269,26 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
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}
@@ -295,9 +329,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
<SftpTabBar
tabs={leftTabsInfo}
side="left"
onSelectTab={handleSelectTabLeft}
onSelectTab={handleSelectTabLeftWithFocus}
onCloseTab={handleCloseTabLeft}
onAddTab={handleAddTabLeft}
onAddTab={handleAddTabLeftWithFocus}
onReorderTabs={handleReorderTabsLeft}
onMoveTabToOtherSide={handleMoveTabFromRightToLeft}
/>
@@ -313,6 +347,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
<SftpPaneView
side="left"
pane={pane}
dialogActionScopeId={dialogActionScopeIdRef.current}
isPaneFocused={focusedSide === "left"}
sftpDefaultViewMode={sftpDefaultViewMode}
showHeader
@@ -354,9 +389,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
<SftpTabBar
tabs={rightTabsInfo}
side="right"
onSelectTab={handleSelectTabRight}
onSelectTab={handleSelectTabRightWithFocus}
onCloseTab={handleCloseTabRight}
onAddTab={handleAddTabRight}
onAddTab={handleAddTabRightWithFocus}
onReorderTabs={handleReorderTabsRight}
onMoveTabToOtherSide={handleMoveTabFromLeftToRight}
/>
@@ -372,6 +407,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
<SftpPaneView
side="right"
pane={pane}
dialogActionScopeId={dialogActionScopeIdRef.current}
isPaneFocused={focusedSide === "right"}
sftpDefaultViewMode={sftpDefaultViewMode}
showHeader

View File

@@ -44,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";
@@ -500,6 +502,27 @@ const TerminalComponent: React.FC<TerminalProps> = ({
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;
@@ -1963,6 +1986,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
containerRef={containerRef}
onRequestReposition={autocomplete.repositionPopup}
searchBarOffset={isSearchOpen ? 64 : 30}
onDismiss={autocompleteClosePopup}
/>,
document.body,
)
@@ -2047,6 +2071,22 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}}
/>
)}
{/* 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) */}

View File

@@ -826,7 +826,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
{isSftpActive && (
<div
className="absolute top-0 left-0 right-0 h-[2px]"
style={{ backgroundColor: 'var(--top-tabs-fg, hsl(var(--foreground)))' }}
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))' }}
/>
)}
<Folder size={14} /> SFTP

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';
}
@@ -160,7 +169,8 @@ export const AgentIconBadge: React.FC<{
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'

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],
);

View File

@@ -238,8 +238,13 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
</MessageResponse>
)}
{/* Tool calls */}
{message.toolCalls?.map((tc) => {
{/* 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
@@ -249,14 +254,12 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
: resolved === false
? 'denied' as const
: undefined;
return (
<ToolCall
key={tc.id}
name={tc.name}
args={tc.arguments}
isLoading={isThisStreaming && message.executionStatus === 'running' && !isPending}
isInterrupted={message.executionStatus === 'cancelled' && !resolvedToolCallIds.has(tc.id)}
isInterrupted={!isPending}
approvalStatus={approvalStatus}
onApprove={() => handleApprove(tc.id)}
onReject={() => handleReject(tc.id)}
@@ -290,6 +293,33 @@ 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))

View File

@@ -112,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;
}

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

@@ -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

@@ -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

@@ -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

@@ -82,6 +82,13 @@ export const AGENT_DEFAULTS: Record<string, Omit<ExternalAgentConfig, "id" | "co
acpCommand: "claude-agent-acp",
acpArgs: [],
},
copilot: {
name: "GitHub Copilot CLI",
args: ["-p", "{prompt}"],
icon: "copilot",
acpCommand: "copilot",
acpArgs: ["--acp", "--stdio"],
},
};
// ---------------------------------------------------------------------------
@@ -108,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",
@@ -124,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

@@ -21,6 +21,7 @@ export interface SftpTransferSource {
export interface SftpPaneCallbacks {
onConnect: (host: Host | "local") => void;
onDisconnect: () => void;
onPrepareSelection: () => void;
onNavigateTo: (path: string) => void;
onNavigateUp: () => void;
onRefresh: () => void;

View File

@@ -76,8 +76,10 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
onClick={handleSelect}
onDoubleClick={handleOpen}
className={cn(
"px-4 py-2 items-center cursor-pointer text-sm hover:bg-accent/50",
isSelectionVisible && "bg-accent text-accent-foreground",
"px-4 py-2 items-center cursor-pointer text-sm",
isSelectionVisible
? "bg-accent text-accent-foreground hover:bg-accent"
: "hover:bg-accent/50",
isDragOver && isNavDir && "bg-primary/25 ring-1 ring-primary/50"
)}
style={{ display: 'grid', gridTemplateColumns: buildSftpColumnTemplate(columnWidths) }}
@@ -130,6 +132,8 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
const areEqual = (prev: SftpFileRowProps, next: SftpFileRowProps): boolean => {
if (prev.index !== next.index) return false;
if (prev.isSelected !== next.isSelected) return false;
// Only re-render for showSelectionHighlight changes when the row is actually selected
if (prev.isSelected && prev.showSelectionHighlight !== next.showSelectionHighlight) return false;
if (prev.isDragOver !== next.isDragOver) return false;
if (prev.columnWidths.name !== next.columnWidths.name) return false;
if (prev.columnWidths.modified !== next.columnWidths.modified) return false;

View File

@@ -11,11 +11,14 @@ import {
} from "../ui/dialog";
import { Input } from "../ui/input";
import { Label } from "../ui/label";
import { getFileName, getParentPath } from "../../application/state/sftp/utils";
import { SftpHostPicker } from "./index";
import type { Host } from "../../types";
interface SftpPaneDialogsProps {
t: (key: string, params?: Record<string, unknown>) => string;
hostLabel?: string;
currentPath?: string;
// New folder
showNewFolderDialog: boolean;
setShowNewFolderDialog: (open: boolean) => void;
@@ -61,8 +64,15 @@ interface SftpPaneDialogsProps {
onDisconnect: () => void;
}
const HostHint: React.FC<{ label?: string }> = ({ label }) =>
label ? (
<div className="text-xs text-muted-foreground truncate mb-1">{label}</div>
) : null;
export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
t,
hostLabel,
currentPath,
showNewFolderDialog,
setShowNewFolderDialog,
newFolderName,
@@ -100,12 +110,36 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
setHostSearch,
onConnect,
onDisconnect,
}) => (
}) => {
const isSingleDeleteTarget = deleteTargets.length === 1;
const deletePath = (() => {
if (isSingleDeleteTarget) {
return deleteTargets[0];
}
const uniquePaths = Array.from(new Set(deleteTargets.map((target) => getParentPath(target)).filter(Boolean)));
if (uniquePaths.length === 1) return uniquePaths[0];
if (uniquePaths.length > 1) return "Multiple locations";
return currentPath;
})();
const showDeleteList = deleteTargets.length > 1;
const deleteListItems = (() => {
if (!showDeleteList) return [];
const uniquePaths = Array.from(new Set(deleteTargets.map((target) => getParentPath(target)).filter(Boolean)));
if (uniquePaths.length === 1) {
return deleteTargets.map((target) => getFileName(target) || target);
}
return deleteTargets;
})();
return (
<>
{/* Dialogs */}
<Dialog open={showNewFolderDialog} onOpenChange={setShowNewFolderDialog}>
<DialogContent className="max-w-sm">
<DialogHeader>
<HostHint label={hostLabel} />
<DialogTitle>{t("sftp.newFolder")}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
@@ -148,6 +182,7 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
}}>
<DialogContent className="max-w-sm">
<DialogHeader>
<HostHint label={hostLabel} />
<DialogTitle>{t("sftp.newFile")}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
@@ -192,6 +227,7 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
<Dialog open={showOverwriteConfirm} onOpenChange={setShowOverwriteConfirm}>
<DialogContent className="max-w-sm">
<DialogHeader>
<HostHint label={hostLabel} />
<DialogTitle>{t("sftp.overwrite.title")}</DialogTitle>
<DialogDescription>
{t("sftp.overwrite.desc", { name: overwriteTarget || "" })}
@@ -217,6 +253,7 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
<Dialog open={showRenameDialog} onOpenChange={setShowRenameDialog}>
<DialogContent className="max-w-sm">
<DialogHeader>
<HostHint label={hostLabel} />
<DialogTitle>{t("sftp.rename.title")}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
@@ -258,19 +295,39 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
{t("sftp.deleteConfirm.title", { count: deleteTargets.length })}
</DialogTitle>
<DialogDescription>
{t("sftp.deleteConfirm.desc")}
{t(showDeleteList ? "sftp.deleteConfirm.desc" : "sftp.deleteConfirm.descSingle")}
</DialogDescription>
</DialogHeader>
<div className="max-h-32 overflow-auto text-sm space-y-1">
{deleteTargets.map((name) => (
<div
key={name}
className="flex items-center gap-2 text-muted-foreground"
>
<Trash2 size={12} />
<span className="truncate">{name}</span>
<div className="space-y-3">
{hostLabel || deletePath ? (
<div className="text-xs text-muted-foreground space-y-1.5">
{hostLabel ? (
<div className="flex items-start gap-2">
<span className="font-medium text-foreground/80 shrink-0">{t("sftp.deleteConfirm.host")}:</span>
<span className="break-all">{hostLabel}</span>
</div>
) : null}
{deletePath ? (
<div className="flex items-start gap-2">
<span className="font-medium text-foreground/80 shrink-0">{t("sftp.deleteConfirm.path")}:</span>
<span className="break-all">{deletePath}</span>
</div>
) : null}
</div>
))}
) : null}
{showDeleteList ? (
<div className="max-h-32 overflow-auto text-sm space-y-1">
{deleteListItems.map((name) => (
<div
key={name}
className="flex items-center gap-2 text-muted-foreground"
>
<Trash2 size={12} />
<span className="truncate">{name}</span>
</div>
))}
</div>
) : null}
</div>
<DialogFooter>
<Button
@@ -310,4 +367,5 @@ export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
}}
/>
</>
);
);
};

View File

@@ -44,7 +44,7 @@ import { getParentPath, joinPath } from '../../application/state/sftp/utils';
import { buildSftpColumnTemplate, filterHiddenFiles, formatBytes, formatDate, getFileIcon, isNavigableDirectory, sortSftpEntries, type ColumnWidths, type SortField, type SortOrder } from './utils';
import type { SftpTransferSource } from './SftpContext';
import { sftpTreeSelectionStore, useSftpTreeSelectionState } from './hooks/useSftpTreeSelectionStore';
import { sftpTreeEnterStore } from './hooks/useSftpKeyboardShortcuts';
import { sftpKeyboardSelectionStore, sftpTreeEnterStore } from './hooks/useSftpKeyboardShortcuts';
import { useI18n } from '../../application/i18n/I18nProvider';
import { isKnownBinaryFile } from '../../lib/sftpFileUtils';
@@ -55,6 +55,7 @@ type NodeDescriptor =
interface SftpPaneTreeViewProps {
pane: SftpPane;
side: 'left' | 'right';
onPrepareSelection: () => void;
onLoadChildren: (path: string) => Promise<SftpFileEntry[]>;
onMoveEntriesToPath: (sourcePaths: string[], targetPath: string) => Promise<void>;
onNavigateUp: () => void;
@@ -126,8 +127,10 @@ const TreeNode = React.memo<TreeNodeProps>(({
return (
<div
className={cn(
'grid items-center gap-x-1 px-2 cursor-pointer select-none hover:bg-accent/50 text-sm',
isSelected && 'bg-accent text-accent-foreground',
'grid items-center gap-x-1 px-2 cursor-pointer select-none text-sm',
isSelected
? 'bg-accent text-accent-foreground hover:bg-accent'
: 'hover:bg-accent/50',
isDragOver && 'ring-2 ring-primary/50 ring-inset bg-primary/10',
)}
style={{ gridTemplateColumns: columnTemplate, height: TREE_ROW_HEIGHT }}
@@ -257,6 +260,7 @@ interface ContextTarget {
export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
pane,
side,
onPrepareSelection,
onLoadChildren,
onMoveEntriesToPath,
onNavigateUp,
@@ -368,12 +372,21 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
const [rootEntries, setRootEntries] = useState<SftpFileEntry[]>(pane.files ?? []);
const [resolvedRootPath, setResolvedRootPath] = useState(pane.connection?.currentPath ?? '');
useEffect(() => {
if (selectedPaths.size === 0) {
lastClickedPathRef.current = null;
sftpKeyboardSelectionStore.clear(pane.id);
}
}, [pane.id, selectedPaths.size]);
const onOpenEntryRef = useRef(onOpenEntry);
onOpenEntryRef.current = onOpenEntry;
const onNavigateUpRef = useRef(onNavigateUp);
onNavigateUpRef.current = onNavigateUp;
const onNavigateToRef = useRef(onNavigateTo);
onNavigateToRef.current = onNavigateTo;
const onPrepareSelectionRef = useRef(onPrepareSelection);
onPrepareSelectionRef.current = onPrepareSelection;
const onMoveEntriesToPathRef = useRef(onMoveEntriesToPath);
onMoveEntriesToPathRef.current = onMoveEntriesToPath;
const onDragStartRef = useRef(onDragStart);
@@ -508,6 +521,7 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
invalidateTreeCache();
dispatchTreePaths({ type: 'RESET' });
sftpTreeSelectionStore.clearSelection(pane.id);
sftpKeyboardSelectionStore.clear(pane.id);
lastClickedPathRef.current = null;
}
}, [pane.connection?.currentPath, pane.connection?.id, pane.id, invalidateTreeCache]);
@@ -556,11 +570,11 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
focusTreeContainer();
const state = treeSelectionStateRef.current;
const currentIdx = state.visibleIndexByPath.get(entryPath) ?? -1;
const nextSelection: string[] = (() => {
if (e.shiftKey && lastClickedPathRef.current) {
const items = state.visibleItems;
const lastIdx = state.visibleIndexByPath.get(lastClickedPathRef.current) ?? -1;
const currentIdx = state.visibleIndexByPath.get(entryPath) ?? -1;
if (lastIdx !== -1 && currentIdx !== -1) {
const parentPath = getParentPath(entryPath);
const start = Math.min(lastIdx, currentIdx);
@@ -582,7 +596,16 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
return [entryPath];
})();
onPrepareSelectionRef.current();
sftpTreeSelectionStore.setSelection(pane.id, nextSelection);
if (currentIdx !== -1) {
if (e.shiftKey && lastClickedPathRef.current) {
const anchorIdx = state.visibleIndexByPath.get(lastClickedPathRef.current) ?? currentIdx;
sftpKeyboardSelectionStore.set(pane.id, anchorIdx, currentIdx);
} else {
sftpKeyboardSelectionStore.set(pane.id, currentIdx, currentIdx);
}
}
lastClickedPathRef.current = entryPath;
}, [focusTreeContainer, pane.id]);
@@ -610,23 +633,33 @@ export const SftpPaneTreeView = React.memo<SftpPaneTreeViewProps>(({
const delta = e.key === 'ArrowDown' ? 1 : -1;
const currentSelected = [...selectedPathsRef.current];
let currentIdx = -1;
if (currentSelected.length === 1) {
currentIdx = state.visibleIndexByPath.get(currentSelected[0]) ?? -1;
let { anchor: anchorIdx, focus: focusIdx } = sftpKeyboardSelectionStore.get(pane.id);
if (currentSelected.length === 0) {
anchorIdx = e.shiftKey ? 0 : -1;
focusIdx = -1;
} else {
const focusPath = items[focusIdx]?.path;
if (!focusPath || !state.selectedPaths.has(focusPath)) {
focusIdx = state.visibleIndexByPath.get(currentSelected[currentSelected.length - 1]) ?? 0;
anchorIdx = focusIdx;
sftpKeyboardSelectionStore.set(pane.id, anchorIdx, focusIdx);
}
}
let nextIdx = currentIdx + delta;
let nextIdx = focusIdx + delta;
if (nextIdx < 0) nextIdx = 0;
if (nextIdx >= items.length) nextIdx = items.length - 1;
onPrepareSelectionRef.current();
if (e.shiftKey && currentSelected.length > 0) {
const anchorIdx = currentIdx >= 0 ? currentIdx : 0;
const start = Math.min(anchorIdx, nextIdx);
const end = Math.max(anchorIdx, nextIdx);
const paths = items.slice(start, end + 1).map((item) => item.path);
sftpTreeSelectionStore.setSelection(pane.id, paths);
sftpKeyboardSelectionStore.set(pane.id, anchorIdx, nextIdx);
} else {
sftpTreeSelectionStore.setSelection(pane.id, [items[nextIdx].path]);
sftpKeyboardSelectionStore.set(pane.id, nextIdx, nextIdx);
}
lastClickedPathRef.current = items[nextIdx].path;

View File

@@ -67,25 +67,31 @@ SftpPaneWrapper.displayName = "SftpPaneWrapper";
interface SftpPaneViewProps {
side: "left" | "right";
pane: SftpPane;
dialogActionScopeId: string;
isPaneFocused: boolean;
sftpDefaultViewMode: 'list' | 'tree';
showHeader?: boolean;
showEmptyHeader?: boolean;
onToggleShowHiddenFiles?: () => void;
onGoToTerminalCwd?: () => void;
/** When true, treat this pane as always active (used by SftpSidePanel which manages visibility itself) */
forceActive?: boolean;
}
const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
side,
pane,
dialogActionScopeId,
isPaneFocused,
sftpDefaultViewMode,
showHeader = true,
showEmptyHeader = true,
onToggleShowHiddenFiles,
onGoToTerminalCwd,
forceActive,
}) => {
const isActive = true;
const activeTabId = useActiveTabId(side);
const isActive = forceActive || (activeTabId ? pane.id === activeTabId : true);
const callbacks = useSftpPaneCallbacks(side);
const { draggedFiles, onDragStart, onDragEnd } = useSftpDrag();
@@ -354,7 +360,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
],
);
useSftpDialogActionHandler(side, dialogActionHandlers);
useSftpDialogActionHandler(side, dialogActionScopeId, dialogActionHandlers, isActive);
const handleSortWithTransition = (field: typeof sortField) => {
startTransition(() => handleSort(field));
@@ -495,6 +501,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
<SftpPaneTreeView
pane={pane}
side={side}
onPrepareSelection={callbacks.onPrepareSelection}
onLoadChildren={callbacks.onListDirectory}
onMoveEntriesToPath={handleMoveEntriesToPath}
onNavigateUp={callbacks.onNavigateUp}
@@ -573,6 +580,8 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
<SftpPaneDialogs
t={t}
hostLabel={pane.connection?.hostLabel}
currentPath={pane.connection?.currentPath}
showNewFolderDialog={showNewFolderDialog}
setShowNewFolderDialog={setShowNewFolderDialog}
newFolderName={newFolderName}
@@ -621,6 +630,7 @@ const sftpPaneViewAreEqual = (
): boolean => {
if (prev.pane !== next.pane) return false;
if (prev.side !== next.side) return false;
if (prev.dialogActionScopeId !== next.dialogActionScopeId) return false;
if (prev.isPaneFocused !== next.isPaneFocused) return false;
if (prev.showHeader !== next.showHeader) return false;
if (prev.showEmptyHeader !== next.showEmptyHeader) return false;

View File

@@ -214,6 +214,22 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
[onCloseTab],
);
const handleSelectTabClick = useCallback(
(e: React.MouseEvent, tabId: string) => {
e.stopPropagation();
onSelectTab(tabId);
},
[onSelectTab],
);
const handleAddTabClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onAddTab();
},
[onAddTab],
);
// Cross-pane drag handlers
const handleCrossPaneDragOver = useCallback(
(e: React.DragEvent) => {
@@ -302,7 +318,7 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
<div
key={tab.id}
data-tab-id={tab.id}
onClick={() => onSelectTab(tab.id)}
onClick={(e) => handleSelectTabClick(e, tab.id)}
draggable
onDragStart={(e) => handleTabDragStart(e, tab.id)}
onDragEnd={handleTabDragEnd}
@@ -379,7 +395,7 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
{/* Add tab button */}
<button
className="px-2 flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-[linear-gradient(135deg,_hsl(var(--accent)_/_0.18),_hsl(var(--primary)_/_0.18))] transition-all duration-150 border-l border-border/40 cursor-pointer"
onClick={onAddTab}
onClick={handleAddTabClick}
title={t("sftp.tabs.addTab")}
>
<Plus size={14} />
@@ -418,4 +434,3 @@ const sftpTabBarAreEqual = (
export const SftpTabBar = memo(SftpTabBarInner, sftpTabBarAreEqual);
SftpTabBar.displayName = "SftpTabBar";

View File

@@ -0,0 +1,37 @@
import type { SftpStateApi } from "../../../application/state/useSftpState";
import { sftpTreeSelectionStore } from "./useSftpTreeSelectionStore";
export interface SftpSelectionTarget {
side: "left" | "right";
tabId: string;
}
export const keepOnlyPaneSelections = (
sftp: SftpStateApi,
target: SftpSelectionTarget | null,
) => {
sftp.clearSelectionsExcept(target);
const paneIds = [
...sftp.leftTabs.tabs.map((tab) => tab.id),
...sftp.rightTabs.tabs.map((tab) => tab.id),
];
for (const paneId of paneIds) {
if (target?.tabId === paneId) continue;
sftpTreeSelectionStore.clearSelection(paneId);
}
};
export const keepOnlyActivePaneSelections = (
sftp: SftpStateApi,
side: "left" | "right",
): SftpSelectionTarget | null => {
const tabId = sftp.getActiveTabId(side);
if (!tabId) {
keepOnlyPaneSelections(sftp, null);
return null;
}
const target = { side, tabId } as const;
keepOnlyPaneSelections(sftp, target);
return target;
};

View File

@@ -13,6 +13,7 @@ type SftpDialogActionType = "rename" | "delete" | "newFolder" | "newFile" | null
interface SftpDialogAction {
type: SftpDialogActionType;
targetSide: SftpFocusedSide;
targetScopeId: string;
targetFiles?: string[]; // For rename (single file) or delete (multiple files)
timestamp: number; // To distinguish different triggers of the same action
}
@@ -37,13 +38,14 @@ export const sftpDialogActionStore = {
/**
* Trigger a dialog action
*/
trigger: (type: SftpDialogActionType, targetFiles?: string[]) => {
trigger: (type: SftpDialogActionType, targetScopeId: string, targetFiles?: string[]) => {
if (!type) {
dialogAction = null;
} else {
dialogAction = {
type,
targetSide: sftpFocusStore.getFocusedSide(),
targetScopeId,
targetFiles,
timestamp: Date.now(),
};
@@ -82,17 +84,19 @@ export const useSftpDialogAction = (): SftpDialogAction | null => {
*/
export const useSftpDialogActionHandler = (
side: SftpFocusedSide,
scopeId: string,
handlers: {
onRename?: (fileName: string) => void;
onDelete?: (fileNames: string[]) => void;
onNewFolder?: () => void;
onNewFile?: () => void;
}
},
isActive = true
) => {
const action = useSftpDialogAction();
useEffect(() => {
if (!action || action.targetSide !== side) return;
if (!action || action.targetSide !== side || action.targetScopeId !== scopeId || !isActive) return;
// Handle the action and clear it
switch (action.type) {
@@ -116,5 +120,5 @@ export const useSftpDialogActionHandler = (
// Clear the action after handling
sftpDialogActionStore.clear();
}, [action, side, handlers]);
}, [action, side, scopeId, handlers, isActive]);
};

View File

@@ -14,6 +14,7 @@ import { sftpFocusStore } from "./useSftpFocusedPane";
import { sftpDialogActionStore } from "./useSftpDialogAction";
import { sftpTreeSelectionStore } from "./useSftpTreeSelectionStore";
import { sftpListOrderStore } from "./useSftpListOrderStore";
import { keepOnlyPaneSelections } from "./selectionScope";
import type { SftpStateApi } from "../../../application/state/useSftpState";
import { filterHiddenFiles, isNavigableDirectory } from "../index";
import type { SftpFileEntry } from "../../../types";
@@ -72,13 +73,15 @@ export const sftpTreeEnterStore = {
// indices per pane so Shift+Arrow extends correctly.
const _kbSelectionState = new Map<string, { anchor: number; focus: number }>();
function getKbSelection(paneId: string) {
return _kbSelectionState.get(paneId) ?? { anchor: 0, focus: 0 };
}
function setKbSelection(paneId: string, anchor: number, focus: number) {
_kbSelectionState.set(paneId, { anchor, focus });
}
export const sftpKeyboardSelectionStore = {
get: (paneId: string) => _kbSelectionState.get(paneId) ?? { anchor: 0, focus: 0 },
set: (paneId: string, anchor: number, focus: number) => {
_kbSelectionState.set(paneId, { anchor, focus });
},
clear: (paneId: string) => {
_kbSelectionState.delete(paneId);
},
};
// Basic navigation keys that work even when custom hotkeys are disabled.
const BASIC_NAV_KEYS: Record<string, string> = {
@@ -90,6 +93,7 @@ interface UseSftpKeyboardShortcutsParams {
keyBindings: KeyBinding[];
hotkeyScheme: "disabled" | "mac" | "pc";
sftpRef: MutableRefObject<SftpStateApi>;
dialogActionScopeId: string;
isActive: boolean;
}
@@ -115,6 +119,7 @@ export const useSftpKeyboardShortcuts = ({
keyBindings,
hotkeyScheme,
sftpRef,
dialogActionScopeId,
isActive,
}: UseSftpKeyboardShortcutsParams) => {
const handleKeyDown = useCallback(
@@ -134,6 +139,12 @@ export const useSftpKeyboardShortcuts = ({
return;
}
// Skip when a dialog or overlay is open to prevent SFTP shortcuts from
// firing while interacting with unrelated dialogs (e.g. settings, confirm).
if (document.querySelector('[role="dialog"][data-state="open"]')) {
return;
}
// ── Arrow Up/Down: move selection ────────────────────────────────
if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && !e.ctrlKey && !e.metaKey && !e.altKey) {
const sftp = sftpRef.current;
@@ -155,29 +166,35 @@ export const useSftpKeyboardShortcuts = ({
// Resolve current focus position from tracked state, falling back
// to the actual selection when out of sync (e.g. after mouse click).
let { anchor: anchorIdx, focus: focusIdx } = getKbSelection(pane.id);
let { anchor: anchorIdx, focus: focusIdx } = sftpKeyboardSelectionStore.get(pane.id);
const currentSelected = Array.from(pane.selectedFiles) as string[];
// If the tracked focus doesn't match the actual selection, re-sync
if (currentSelected.length >= 1 && !currentSelected.includes(listItems[focusIdx])) {
if (currentSelected.length === 0) {
// No selection: start from before the list so the first arrow press lands on item 0.
// For Shift+Arrow, anchor at 0 so range selection starts from the first item.
anchorIdx = e.shiftKey ? 0 : -1;
focusIdx = -1;
} else if (!currentSelected.includes(listItems[focusIdx])) {
// Tracked focus doesn't match actual selection, re-sync
focusIdx = listItems.indexOf(currentSelected[currentSelected.length - 1]);
if (focusIdx < 0) focusIdx = 0;
anchorIdx = focusIdx;
setKbSelection(pane.id, anchorIdx, focusIdx);
sftpKeyboardSelectionStore.set(pane.id, anchorIdx, focusIdx);
}
let nextIdx = focusIdx + delta;
if (nextIdx < 0) nextIdx = 0;
if (nextIdx >= listItems.length) nextIdx = listItems.length - 1;
keepOnlyPaneSelections(sftp, { side: focusedSide, tabId: pane.id });
if (e.shiftKey) {
// Shift+Arrow: extend range from anchor to new focus
const start = Math.min(anchorIdx, nextIdx);
const end = Math.max(anchorIdx, nextIdx);
sftp.rangeSelect(focusedSide, listItems.slice(start, end + 1));
setKbSelection(pane.id, anchorIdx, nextIdx);
sftpKeyboardSelectionStore.set(pane.id, anchorIdx, nextIdx);
} else {
sftp.rangeSelect(focusedSide, [listItems[nextIdx]]);
setKbSelection(pane.id, nextIdx, nextIdx);
sftpKeyboardSelectionStore.set(pane.id, nextIdx, nextIdx);
}
return;
}
@@ -191,26 +208,35 @@ export const useSftpKeyboardShortcuts = ({
const currentSelected = [...treeState.selectedPaths];
// Use tracked state, re-sync if needed
let { anchor: anchorIdx, focus: focusIdx } = getKbSelection(pane.id);
if (currentSelected.length >= 1 && items[focusIdx]?.path !== currentSelected[currentSelected.length - 1]) {
focusIdx = treeState.visibleIndexByPath.get(currentSelected[currentSelected.length - 1]) ?? 0;
anchorIdx = focusIdx;
setKbSelection(pane.id, anchorIdx, focusIdx);
let { anchor: anchorIdx, focus: focusIdx } = sftpKeyboardSelectionStore.get(pane.id);
if (currentSelected.length === 0) {
// No selection: start from before the list so the first arrow press lands on item 0.
// For Shift+Arrow, anchor at 0 so range selection starts from the first item.
anchorIdx = e.shiftKey ? 0 : -1;
focusIdx = -1;
} else {
const focusPath = items[focusIdx]?.path;
if (!focusPath || !treeState.selectedPaths.has(focusPath)) {
focusIdx = treeState.visibleIndexByPath.get(currentSelected[currentSelected.length - 1]) ?? 0;
anchorIdx = focusIdx;
sftpKeyboardSelectionStore.set(pane.id, anchorIdx, focusIdx);
}
}
let nextIdx = focusIdx + delta;
if (nextIdx < 0) nextIdx = 0;
if (nextIdx >= items.length) nextIdx = items.length - 1;
keepOnlyPaneSelections(sftp, { side: focusedSide, tabId: pane.id });
if (e.shiftKey) {
const start = Math.min(anchorIdx, nextIdx);
const end = Math.max(anchorIdx, nextIdx);
const paths = items.slice(start, end + 1).map(item => item.path);
sftpTreeSelectionStore.setSelection(pane.id, paths);
setKbSelection(pane.id, anchorIdx, nextIdx);
sftpKeyboardSelectionStore.set(pane.id, anchorIdx, nextIdx);
} else {
sftpTreeSelectionStore.setSelection(pane.id, [items[nextIdx].path]);
setKbSelection(pane.id, nextIdx, nextIdx);
sftpKeyboardSelectionStore.set(pane.id, nextIdx, nextIdx);
}
return;
}
@@ -350,8 +376,10 @@ export const useSftpKeyboardShortcuts = ({
if (!clipboard || clipboard.files.length === 0) return;
// Use startTransfer to paste files from source to current pane
// The transfer direction is determined by clipboard sourceSide and current focusedSide
if (clipboard.sourceSide !== focusedSide) {
// Allow paste when source and target are different connections, even on the same side
const isSameConnection = clipboard.sourceSide === focusedSide
&& clipboard.sourceConnectionId === pane.connection.id;
if (!isSameConnection) {
const sourceTabs = clipboard.sourceSide === "left" ? sftp.leftTabs.tabs : sftp.rightTabs.tabs;
const sourcePane = sourceTabs.find((tab) => tab.connection?.id === clipboard.sourceConnectionId);
@@ -439,6 +467,7 @@ export const useSftpKeyboardShortcuts = ({
case "sftpSelectAll": {
if (treeSelectionState.visibleItems.length > 0) {
keepOnlyPaneSelections(sftp, { side: focusedSide, tabId: pane.id });
sftpTreeSelectionStore.selectAllVisible(pane.id);
break;
}
@@ -458,33 +487,38 @@ export const useSftpKeyboardShortcuts = ({
const allFileNames = visibleFiles
.filter((f) => f.name !== "..")
.map((f) => f.name);
keepOnlyPaneSelections(sftp, { side: focusedSide, tabId: pane.id });
sftp.rangeSelect(focusedSide, allFileNames);
break;
}
case "sftpRename": {
if (treeActionSelection.length === 1) {
sftpDialogActionStore.trigger("rename", [treeActionSelection[0].path]);
sftpDialogActionStore.trigger("rename", dialogActionScopeId, [treeActionSelection[0].path]);
break;
}
// Trigger rename for the first selected file
const selectedFiles = Array.from(pane.selectedFiles) as string[];
if (selectedFiles.length !== 1) return;
sftpDialogActionStore.trigger("rename", selectedFiles);
sftpDialogActionStore.trigger("rename", dialogActionScopeId, selectedFiles);
break;
}
case "sftpDelete": {
if (treeActionSelection.length > 0) {
sftpDialogActionStore.trigger("delete", treeActionSelection.map((entry) => entry.path));
sftpDialogActionStore.trigger(
"delete",
dialogActionScopeId,
treeActionSelection.map((entry) => entry.path),
);
break;
}
// Delete selected files
const selectedFiles = Array.from(pane.selectedFiles) as string[];
if (selectedFiles.length === 0) return;
sftpDialogActionStore.trigger("delete", selectedFiles);
sftpDialogActionStore.trigger("delete", dialogActionScopeId, selectedFiles);
break;
}
@@ -496,7 +530,7 @@ export const useSftpKeyboardShortcuts = ({
case "sftpNewFolder": {
// Create new folder
sftpDialogActionStore.trigger("newFolder");
sftpDialogActionStore.trigger("newFolder", dialogActionScopeId);
break;
}
@@ -559,7 +593,7 @@ export const useSftpKeyboardShortcuts = ({
}
}
},
[hotkeyScheme, isActive, keyBindings, sftpRef]
[dialogActionScopeId, hotkeyScheme, isActive, keyBindings, sftpRef]
);
useEffect(() => {

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useRef, useState } from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import type { SftpFileEntry } from "../../../types";
import type { SftpPaneCallbacks, SftpDragCallbacks, SftpTransferSource } from "../SftpContext";
import { isNavigableDirectory } from "../index";
@@ -67,6 +67,12 @@ export const useSftpPaneDragAndSelect = ({
const onUploadRef = useRef(onUploadExternalFiles);
onUploadRef.current = onUploadExternalFiles;
useEffect(() => {
if (pane.selectedFiles.size === 0) {
lastSelectedIndexRef.current = null;
}
}, [pane.selectedFiles.size]);
const getSamePaneDragPaths = useCallback((): string[] | null => {
const dragged = draggedFilesRef.current;
if (!dragged || dragged.length === 0) return null;

View File

@@ -99,6 +99,17 @@ export const sftpTreeSelectionStore = {
setPaneState(paneId, (state) => ({ ...state, selectedPaths: EMPTY_PATHS }));
},
clearAllExcept: (paneIdsToKeep?: Iterable<string>) => {
const keep = new Set(paneIdsToKeep ?? []);
Array.from(paneStates.keys()).forEach((paneId) => {
if (keep.has(paneId)) return;
setPaneState(paneId, (state) => {
if (state.selectedPaths.size === 0) return state;
return { ...state, selectedPaths: EMPTY_PATHS };
});
});
},
selectAllVisible: (paneId: string) => {
setPaneState(paneId, (state) => ({
...state,

View File

@@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from "react";
import type { MutableRefObject } from "react";
import type { SftpStateApi } from "../../../application/state/useSftpState";
import type { SftpDragCallbacks, SftpTransferSource } from "../SftpContext";
import { keepOnlyActivePaneSelections } from "./selectionScope";
interface UseSftpViewPaneActionsParams {
sftpRef: MutableRefObject<SftpStateApi>;
@@ -14,6 +15,8 @@ interface UseSftpViewPaneActionsResult {
onConnectRight: (host: Parameters<SftpStateApi["connect"]>[1]) => void;
onDisconnectLeft: () => void;
onDisconnectRight: () => void;
onPrepareSelectionLeft: () => void;
onPrepareSelectionRight: () => void;
onNavigateToLeft: (path: string) => void;
onNavigateToRight: (path: string) => void;
onNavigateUpLeft: () => void;
@@ -126,6 +129,12 @@ export const useSftpViewPaneActions = ({
);
const onDisconnectLeft = useCallback(() => sftpRef.current.disconnect("left"), [sftpRef]);
const onDisconnectRight = useCallback(() => sftpRef.current.disconnect("right"), [sftpRef]);
const onPrepareSelectionLeft = useCallback(() => {
keepOnlyActivePaneSelections(sftpRef.current, "left");
}, [sftpRef]);
const onPrepareSelectionRight = useCallback(() => {
keepOnlyActivePaneSelections(sftpRef.current, "right");
}, [sftpRef]);
const onNavigateToLeft = useCallback(
(path: string) => sftpRef.current.navigateTo("left", path),
[sftpRef],
@@ -151,20 +160,32 @@ export const useSftpViewPaneActions = ({
[sftpRef],
);
const onToggleSelectionLeft = useCallback(
(name: string, multi: boolean) => sftpRef.current.toggleSelection("left", name, multi),
[sftpRef],
(name: string, multi: boolean) => {
onPrepareSelectionLeft();
sftpRef.current.toggleSelection("left", name, multi);
},
[onPrepareSelectionLeft, sftpRef],
);
const onToggleSelectionRight = useCallback(
(name: string, multi: boolean) => sftpRef.current.toggleSelection("right", name, multi),
[sftpRef],
(name: string, multi: boolean) => {
onPrepareSelectionRight();
sftpRef.current.toggleSelection("right", name, multi);
},
[onPrepareSelectionRight, sftpRef],
);
const onRangeSelectLeft = useCallback(
(fileNames: string[]) => sftpRef.current.rangeSelect("left", fileNames),
[sftpRef],
(fileNames: string[]) => {
onPrepareSelectionLeft();
sftpRef.current.rangeSelect("left", fileNames);
},
[onPrepareSelectionLeft, sftpRef],
);
const onRangeSelectRight = useCallback(
(fileNames: string[]) => sftpRef.current.rangeSelect("right", fileNames),
[sftpRef],
(fileNames: string[]) => {
onPrepareSelectionRight();
sftpRef.current.rangeSelect("right", fileNames);
},
[onPrepareSelectionRight, sftpRef],
);
const onClearSelectionLeft = useCallback(() => sftpRef.current.clearSelection("left"), [sftpRef]);
const onClearSelectionRight = useCallback(() => sftpRef.current.clearSelection("right"), [sftpRef]);
@@ -266,6 +287,8 @@ export const useSftpViewPaneActions = ({
onConnectRight,
onDisconnectLeft,
onDisconnectRight,
onPrepareSelectionLeft,
onPrepareSelectionRight,
onNavigateToLeft,
onNavigateToRight,
onNavigateUpLeft,

View File

@@ -140,6 +140,7 @@ export const useSftpViewPaneCallbacks = ({
() => ({
onConnect: paneActions.onConnectLeft,
onDisconnect: paneActions.onDisconnectLeft,
onPrepareSelection: paneActions.onPrepareSelectionLeft,
onNavigateTo: paneActions.onNavigateToLeft,
onNavigateUp: paneActions.onNavigateUpLeft,
onRefresh: paneActions.onRefreshLeft,
@@ -176,6 +177,7 @@ export const useSftpViewPaneCallbacks = ({
() => ({
onConnect: paneActions.onConnectRight,
onDisconnect: paneActions.onDisconnectRight,
onPrepareSelection: paneActions.onPrepareSelectionRight,
onNavigateTo: paneActions.onNavigateToRight,
onNavigateUp: paneActions.onNavigateUpRight,
onRefresh: paneActions.onRefreshRight,

View File

@@ -21,8 +21,8 @@ interface UseSftpViewTabsResult {
setShowHostPickerRight: React.Dispatch<React.SetStateAction<boolean>>;
setHostSearchLeft: React.Dispatch<React.SetStateAction<string>>;
setHostSearchRight: React.Dispatch<React.SetStateAction<string>>;
handleAddTabLeft: () => void;
handleAddTabRight: () => void;
handleAddTabLeft: () => string;
handleAddTabRight: () => string;
handleCloseTabLeft: (tabId: string) => void;
handleCloseTabRight: (tabId: string) => void;
handleSelectTabLeft: (tabId: string) => void;
@@ -42,13 +42,15 @@ export const useSftpViewTabs = ({ sftp, sftpRef }: UseSftpViewTabsParams): UseSf
const [hostSearchRight, setHostSearchRight] = useState("");
const handleAddTabLeft = useCallback(() => {
sftpRef.current.addTab("left");
const tabId = sftpRef.current.addTab("left");
setShowHostPickerLeft(true);
return tabId;
}, [sftpRef]);
const handleAddTabRight = useCallback(() => {
sftpRef.current.addTab("right");
const tabId = sftpRef.current.addTab("right");
setShowHostPickerRight(true);
return tabId;
}, [sftpRef]);
const handleCloseTabLeft = useCallback((tabId: string) => {

View File

@@ -0,0 +1,79 @@
import { ArrowDownToLine, ArrowUpFromLine, X } from 'lucide-react';
import React from 'react';
interface ZmodemProgressIndicatorProps {
transferType: 'upload' | 'download' | null;
filename: string | null;
transferred: number;
total: number;
fileIndex: number;
fileCount: number;
finalizing: boolean;
onCancel: () => void;
}
function formatBytes(bytes: number): string {
if (bytes <= 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
}
export const ZmodemProgressIndicator: React.FC<ZmodemProgressIndicatorProps> = ({
transferType,
filename,
transferred,
total,
fileIndex,
fileCount,
finalizing,
onCancel,
}) => {
const percent = total > 0 ? Math.min(100, Math.round((transferred / total) * 100)) : 0;
const Icon = transferType === 'upload' ? ArrowUpFromLine : ArrowDownToLine;
const label = finalizing ? 'Waiting for remote...' : transferType === 'upload' ? 'Uploading' : 'Downloading';
const fileInfo = fileCount > 0 ? ` (${fileIndex + 1}/${fileCount})` : '';
return (
<div
className="flex items-center gap-2.5 px-3 py-2 rounded-lg shadow-lg backdrop-blur-sm min-w-[240px] max-w-[360px]"
style={{
backgroundColor: 'color-mix(in srgb, var(--terminal-ui-bg, #000000) 90%, transparent)',
border: '1px solid color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 15%, var(--terminal-ui-bg, #000000))',
color: 'var(--terminal-ui-fg, #ffffff)',
}}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<Icon className="h-4 w-4 flex-shrink-0 opacity-60" />
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2 mb-1">
<span className="text-xs font-medium truncate">
{filename || label}{fileInfo}
</span>
<span className="text-[10px] opacity-60 flex-shrink-0">{percent}%</span>
</div>
<div className="w-full h-1 rounded-full overflow-hidden" style={{ backgroundColor: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 10%, transparent)' }}>
<div
className="h-full rounded-full transition-all duration-150"
style={{
width: `${percent}%`,
backgroundColor: transferType === 'upload' ? '#3b82f6' : '#22c55e',
}}
/>
</div>
<div className="text-[10px] opacity-50 mt-0.5">
{formatBytes(transferred)} / {formatBytes(total)}
</div>
</div>
<button
onClick={onCancel}
className="flex-shrink-0 p-1 rounded transition-colors hover:bg-white/10"
title="Cancel transfer (Ctrl+C)"
>
<X className="h-3.5 w-3.5 opacity-60" />
</button>
</div>
);
};

View File

@@ -48,6 +48,8 @@ interface AutocompletePopupProps {
onRequestReposition?: () => void;
/** Offset from top of container to terminal content area (toolbar + search bar) */
searchBarOffset?: number;
/** Called when user clicks outside the popup to dismiss it */
onDismiss?: () => void;
}
const SOURCE_LABELS: Record<SuggestionSource, { label: string; fullLabel: string; fallbackColor: string }> = {
@@ -105,7 +107,9 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
containerRef,
onRequestReposition,
searchBarOffset: _searchBarOffset = 30,
onDismiss,
}) => {
const wrapperRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const selectedRef = useRef<HTMLDivElement>(null);
const [hoveredIndex, setHoveredIndex] = useState(-1);
@@ -148,6 +152,18 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
};
}, [containerRef, onRequestReposition, visible]);
// Dismiss popup when clicking outside
useEffect(() => {
if (!visible || !onDismiss) return;
const handlePointerDown = (e: PointerEvent) => {
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
onDismiss();
}
};
document.addEventListener("pointerdown", handlePointerDown);
return () => document.removeEventListener("pointerdown", handlePointerDown);
}, [visible, onDismiss]);
if (!visible || suggestions.length === 0) return null;
const bg = themeColors?.background ?? "#1e1e2e";
@@ -217,6 +233,7 @@ const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
return (
<div
ref={wrapperRef}
style={{
position: "fixed",
left: `${clampedLeft}px`,

View File

@@ -0,0 +1,102 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../../infrastructure/services/netcattyBridge';
export interface ZmodemTransferState {
active: boolean;
transferType: 'upload' | 'download' | null;
filename: string | null;
transferred: number;
total: number;
fileIndex: number;
fileCount: number;
finalizing: boolean;
error: string | null;
}
const initialState: ZmodemTransferState = {
active: false,
transferType: null,
filename: null,
transferred: 0,
total: 0,
fileIndex: 0,
fileCount: 0,
finalizing: false,
error: null,
};
export function useZmodemTransfer(sessionId: string | null) {
const [state, setState] = useState<ZmodemTransferState>(initialState);
const disposeRef = useRef<(() => void) | null>(null);
const disposeExitRef = useRef<(() => void) | null>(null);
useEffect(() => {
if (!sessionId) return;
const bridge = netcattyBridge.get();
if (!bridge?.onZmodemEvent) return;
disposeRef.current = bridge.onZmodemEvent(sessionId, (event) => {
switch (event.type) {
case 'detect':
setState({
active: true,
transferType: event.transferType ?? null,
filename: null,
transferred: 0,
total: 0,
fileIndex: 0,
fileCount: 0,
error: null,
});
break;
case 'progress':
setState((prev) => ({
...prev,
active: true,
transferType: event.transferType ?? prev.transferType,
filename: event.filename ?? prev.filename,
transferred: event.transferred ?? prev.transferred,
total: event.total ?? prev.total,
fileIndex: event.fileIndex ?? prev.fileIndex,
fileCount: event.fileCount ?? prev.fileCount,
finalizing: !!((event as Record<string, unknown>).finalizing),
}));
break;
case 'complete':
setState((prev) => ({ ...prev, active: false }));
break;
case 'error':
setState((prev) => ({
...prev,
active: false,
error: event.error ?? 'Unknown error',
}));
break;
}
});
// If the session exits mid-transfer (disconnect, shell exit, etc.),
// reset state so the progress indicator doesn't stay stuck.
disposeExitRef.current = bridge.onSessionExit(sessionId, () => {
setState(initialState);
});
return () => {
disposeRef.current?.();
disposeRef.current = null;
disposeExitRef.current?.();
disposeExitRef.current = null;
setState(initialState);
};
}, [sessionId]);
const cancel = useCallback(() => {
if (!sessionId) return;
const bridge = netcattyBridge.get();
bridge?.cancelZmodem?.(sessionId);
}, [sessionId]);
return { ...state, cancel };
}

View File

@@ -218,7 +218,7 @@ export const AsidePanelStack: React.FC<AsidePanelStackProps> = ({
return (
<AsidePanelContext.Provider value={{ push, pop, replace, clear, canGoBack, currentItem }}>
<div className={cn(
"absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag",
"absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden",
width,
className
)}>
@@ -253,7 +253,7 @@ export const AsidePanel: React.FC<AsidePanelProps> = ({
return (
<div className={cn(
"absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag",
"absolute right-0 top-0 bottom-0 max-w-full border-l border-border/60 bg-background z-30 flex flex-col app-no-drag overflow-hidden",
width,
className
)}>

View File

@@ -7,7 +7,7 @@
"use strict";
const { execFileSync } = require("node:child_process");
const { existsSync } = require("node:fs");
const { existsSync, statSync } = require("node:fs");
const path = require("node:path");
// ── ANSI / URL regexes ──
@@ -93,7 +93,11 @@ function normalizeCliPathForPlatform(filePath) {
if (!normalized) return null;
if (process.platform !== "win32") {
return existsSync(normalized) ? normalized : null;
// Reject directories (e.g. /Applications/Codex.app) — must be a file
try {
if (existsSync(normalized) && statSync(normalized).isFile()) return normalized;
} catch { /* stat failed */ }
return null;
}
const ext = path.extname(normalized).toLowerCase();

View File

@@ -7,9 +7,11 @@
const https = require("node:https");
const http = require("node:http");
const path = require("node:path");
const { URL } = require("node:url");
const { spawn, execFileSync } = require("node:child_process");
const { existsSync } = require("node:fs");
const fs = require("node:fs");
const { existsSync } = fs;
const mcpServerBridge = require("./mcpServerBridge.cjs");
@@ -60,7 +62,6 @@ const acpProviders = new Map();
const acpActiveStreams = new Map();
const acpRequestSessions = new Map();
const acpPendingCancelRequests = new Set();
const acpForceProviderReset = new Set();
const acpChatRuns = new Map();
// ── Provider registry (synced from renderer, keys stay encrypted) ──
@@ -141,21 +142,39 @@ function injectApiKeyIntoRequest(url, headers, providerId) {
}
function cleanupAcpProvider(chatSessionId) {
// Clean up temporary COPILOT_HOME directory regardless of whether a
// provider entry exists — prepareCopilotHome may have succeeded before
// provider creation failed.
try {
const tempDirBridge = require("./tempDirBridge.cjs");
const tempCopilotHome = path.join(tempDirBridge.getTempDir(), `copilot-home-${chatSessionId}`);
if (existsSync(tempCopilotHome)) {
fs.rmSync(tempCopilotHome, { recursive: true, force: true });
}
} catch {
// Best-effort cleanup
}
const entry = acpProviders.get(chatSessionId);
if (!entry) return;
const rootPid = entry.provider?.model?.agentProcess?.pid;
cleanupAcpProviderInstance(entry.provider, chatSessionId);
acpProviders.delete(chatSessionId);
}
function cleanupAcpProviderInstance(provider, chatSessionId = "transient") {
if (!provider) return;
const rootPid = provider?.model?.agentProcess?.pid;
const childPids = getChildProcessTreePids(rootPid);
try {
if (typeof entry.provider.forceCleanup === "function") {
entry.provider.forceCleanup();
} else if (typeof entry.provider.cleanup === "function") {
entry.provider.cleanup();
if (typeof provider.forceCleanup === "function") {
provider.forceCleanup();
} else if (typeof provider.cleanup === "function") {
provider.cleanup();
}
} catch (err) {
console.warn("[ACP] Provider cleanup failed for session", chatSessionId, err?.message || err);
}
killTrackedProcessTree(rootPid, childPids);
acpProviders.delete(chatSessionId);
}
function isActiveAcpRun(chatSessionId, requestId) {
@@ -163,9 +182,10 @@ function isActiveAcpRun(chatSessionId, requestId) {
return Boolean(activeRun && activeRun.requestId === requestId);
}
function isUnsupportedLoadSessionError(err) {
function shouldRetryFreshSession(err) {
const message = String(err?.message || err || "").toLowerCase();
return message.includes("method not found") && message.includes("session/load");
return (message.includes("method not found") && message.includes("session/load"))
|| (message.includes("resource not found") && message.includes("session") && message.includes("not found"));
}
function getChildProcessTreePids(rootPid) {
@@ -302,6 +322,127 @@ function _validateSenderImpl(event, allowSettings) {
}
}
function summarizeMcpServersForDebug(mcpServers) {
if (!Array.isArray(mcpServers)) return [];
return mcpServers.map((server) => ({
name: server?.name || "",
type: server?.type || "",
command: server?.command || "",
args: Array.isArray(server?.args) ? server.args : [],
hasEnv: Array.isArray(server?.env) ? server.env.length > 0 : false,
url: server?.url || "",
}));
}
function logAcpDebug(agentLabel, message, details) {
const prefix = `[ACP DEBUG][${agentLabel}]`;
if (details === undefined) {
console.log(prefix, message);
return;
}
try {
console.log(prefix, message, JSON.stringify(details));
} catch {
console.log(prefix, message, details);
}
}
function normalizeAgentCommandName(command) {
if (typeof command !== "string" || !command) return "";
return path.basename(command).toLowerCase().replace(/\.(exe|cmd|bat|ps1)$/i, "");
}
function matchesAgentCommand(command, expectedName) {
if (typeof command !== "string" || typeof expectedName !== "string") return false;
if (command.toLowerCase() === expectedName.toLowerCase()) return true;
return normalizeAgentCommandName(command) === normalizeAgentCommandName(expectedName);
}
function envPairsToObject(entries) {
if (!Array.isArray(entries)) return {};
const result = {};
for (const entry of entries) {
if (!entry || typeof entry.name !== "string") continue;
result[entry.name] = entry.value == null ? "" : String(entry.value);
}
return result;
}
function mapMcpServerToCopilotConfig(server) {
if (!server || typeof server !== "object" || !server.name) return null;
if (server.type === "stdio" || server.type === "local") {
return {
type: "local",
command: server.command || "",
args: Array.isArray(server.args) ? server.args : [],
env: envPairsToObject(server.env),
tools: ["*"],
};
}
if (server.type === "http" || server.type === "sse") {
return {
type: server.type,
url: server.url || "",
headers: envPairsToObject(server.headers),
tools: ["*"],
};
}
return null;
}
function safeReadJson(filePath) {
try {
if (!existsSync(filePath)) return null;
return JSON.parse(fs.readFileSync(filePath, "utf8"));
} catch {
return null;
}
}
function prepareCopilotHome(shellEnv, mcpServers, chatSessionId) {
const tempDirBridge = require("./tempDirBridge.cjs");
const homeDir = shellEnv.HOME || process.env.HOME || process.env.USERPROFILE || "";
const realCopilotHome = shellEnv.COPILOT_HOME || path.join(homeDir, ".copilot");
const tempCopilotHome = path.join(tempDirBridge.getTempDir(), `copilot-home-${chatSessionId}`);
try {
fs.rmSync(tempCopilotHome, { recursive: true, force: true });
} catch {
// Ignore cleanup failures; mkdir/copy below will surface real issues if any.
}
fs.mkdirSync(tempCopilotHome, { recursive: true });
if (realCopilotHome && existsSync(realCopilotHome)) {
fs.cpSync(realCopilotHome, tempCopilotHome, { recursive: true });
}
const configPath = path.join(tempCopilotHome, "mcp-config.json");
const baseConfig = safeReadJson(configPath) || { mcpServers: {} };
const mergedServers = { ...(baseConfig.mcpServers || {}) };
for (const server of Array.isArray(mcpServers) ? mcpServers : []) {
const mapped = mapMcpServerToCopilotConfig(server);
if (!mapped) continue;
mergedServers[server.name] = mapped;
}
fs.writeFileSync(
configPath,
JSON.stringify({ ...baseConfig, mcpServers: mergedServers }, null, 2),
{ mode: 0o600 },
);
return {
copilotHome: tempCopilotHome,
configPath,
serverNames: Object.keys(mergedServers),
};
}
/**
* Make a streaming HTTP request and forward SSE events back to renderer
*/
@@ -1253,6 +1394,15 @@ function registerHandlers(ipcMain) {
args: ["exec", "--full-auto", "--json", "{prompt}"],
resolveAcp: resolveCodexAcpBinaryPath,
},
{
command: "copilot",
name: "GitHub Copilot CLI",
icon: "copilot",
description: "GitHub's coding agent CLI",
acpCommand: "copilot",
acpArgs: ["--acp", "--stdio"],
args: ["-p", "{prompt}"],
},
];
const shellEnv = await getShellEnv();
@@ -1303,12 +1453,16 @@ function registerHandlers(ipcMain) {
const result = await runCommand(probeCmd, probeArgs, { env: shellEnv });
version = (result.stdout || result.stderr || "").trim().split("\n")[0];
} catch {
version = "";
// --version failed: not a valid CLI executable (e.g. .app bundle)
continue;
}
if (!version) continue;
const { resolveAcp: _unused, ...agentInfo } = agent;
agents.push({
...agentInfo,
acpCommand: agent.command === "copilot" ? resolvedPath : agentInfo.acpCommand,
path: resolvedPath,
version,
available: true,
@@ -1327,7 +1481,9 @@ function registerHandlers(ipcMain) {
if (customPath) {
// Normalize Windows shim paths like `codex` -> `codex.cmd` when present.
resolvedPath = normalizeCliPathForPlatform(customPath);
// Fall back to PATH search if the stored path no longer exists
// (e.g. CLI reinstalled to a different location).
resolvedPath = normalizeCliPathForPlatform(customPath) || resolveCliFromPath(command, shellEnv);
} else {
resolvedPath = resolveCliFromPath(command, shellEnv);
}
@@ -1341,7 +1497,12 @@ function registerHandlers(ipcMain) {
const result = await runCommand(resolvedPath, ["--version"], { env: shellEnv });
version = (result.stdout || result.stderr || "").trim().split("\n")[0];
} catch {
version = "";
// --version failed: not a valid CLI executable
return { path: resolvedPath, version: null, available: false };
}
if (!version) {
return { path: resolvedPath, version: null, available: false };
}
return { path: resolvedPath, version, available: true };
@@ -1521,6 +1682,7 @@ function registerHandlers(ipcMain) {
const ALLOWED_AGENT_COMMANDS = new Set([
"claude", "claude-agent-acp",
"codex", "codex-acp",
"copilot",
]);
// Spawn an external agent process
@@ -1730,6 +1892,102 @@ function registerHandlers(ipcMain) {
// ── ACP (Agent Client Protocol) streaming ──
ipcMain.handle("netcatty:ai:acp:list-models", async (event, { acpCommand, acpArgs, cwd, providerId, chatSessionId }) => {
if (!validateSender(event)) {
return { ok: false, error: "Unauthorized IPC sender" };
}
let provider = null;
let copilotConfigInfo = null;
try {
const { createACPProvider } = require("@mcpc-tech/acp-ai-provider");
const shellEnv = await getShellEnv();
const sessionCwd = cwd || process.cwd();
const isCodexAgent = matchesAgentCommand(acpCommand, "codex-acp");
const isClaudeAgent = matchesAgentCommand(acpCommand, "claude-agent-acp");
const isCopilotAgent = matchesAgentCommand(acpCommand, "copilot");
const agentLabel = isCodexAgent ? "codex" : isClaudeAgent ? "claude" : isCopilotAgent ? "copilot" : acpCommand;
const resolvedProvider = providerId ? resolveProviderApiKey(providerId) : null;
const apiKey = resolvedProvider?.apiKey || undefined;
const agentEnv = { ...shellEnv };
if (apiKey) {
agentEnv.CODEX_API_KEY = apiKey;
}
if (isCopilotAgent) {
copilotConfigInfo = prepareCopilotHome(shellEnv, [], chatSessionId || `models_${Date.now()}`);
agentEnv.COPILOT_HOME = copilotConfigInfo.copilotHome;
}
const claudeAcp = isClaudeAgent ? resolveClaudeAcpBinaryPath(shellEnv, electronModule) : null;
const resolvedCommand = isCodexAgent
? resolveCodexAcpBinaryPath(shellEnv, electronModule)
: claudeAcp
? claudeAcp.command
: acpCommand;
const resolvedArgs = claudeAcp
? [...claudeAcp.prependArgs, ...(acpArgs || [])]
: acpArgs || [];
provider = createACPProvider({
command: resolvedCommand,
args: resolvedArgs,
env: agentEnv,
session: {
cwd: sessionCwd,
mcpServers: [],
},
...(isCodexAgent
? { authMethodId: apiKey ? "codex-api-key" : "chatgpt" }
: isCopilotAgent
? { authMethodId: "copilot-login" }
: {}),
});
const sessionInfo = await provider.initSession();
const availableModels = Array.isArray(sessionInfo?.models?.availableModels)
? sessionInfo.models.availableModels
: [];
if (isCopilotAgent) {
logAcpDebug(agentLabel, "Fetched session models", {
chatSessionId: chatSessionId || null,
currentModelId: sessionInfo?.models?.currentModelId || null,
availableModelIds: availableModels.map((modelInfo) => modelInfo?.modelId).filter(Boolean),
copilotHome: copilotConfigInfo?.copilotHome || null,
copilotMcpConfigPath: copilotConfigInfo?.configPath || null,
});
}
return {
ok: true,
currentModelId: sessionInfo?.models?.currentModelId || null,
models: availableModels.map((modelInfo) => ({
id: modelInfo?.modelId,
name: modelInfo?.name || modelInfo?.displayName || modelInfo?.modelId,
description: modelInfo?.description || undefined,
})).filter((modelInfo) => Boolean(modelInfo.id)),
};
} catch (err) {
console.error("[ACP] Failed to list models:", err?.message || err);
return { ok: false, error: err?.message || String(err) };
} finally {
try {
cleanupAcpProviderInstance(provider, chatSessionId || "transient-model-list");
} catch {
// Ignore cleanup failures for transient model-discovery providers.
}
// Clean up transient COPILOT_HOME created for model listing
if (copilotConfigInfo?.copilotHome) {
try {
fs.rmSync(copilotConfigInfo.copilotHome, { recursive: true, force: true });
} catch { /* best-effort */ }
}
}
});
ipcMain.handle("netcatty:ai:acp:stream", async (event, { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images }) => {
// Validate IPC sender (Issue #17)
if (!validateSender(event)) {
@@ -1771,8 +2029,10 @@ function registerHandlers(ipcMain) {
const shellEnv = await getShellEnv();
if (shouldAbortStartup()) return { ok: true };
const sessionCwd = cwd || process.cwd();
const isCodexAgent = acpCommand === "codex-acp";
const isClaudeAgent = acpCommand === "claude-agent-acp";
const isCodexAgent = matchesAgentCommand(acpCommand, "codex-acp");
const isClaudeAgent = matchesAgentCommand(acpCommand, "claude-agent-acp");
const isCopilotAgent = matchesAgentCommand(acpCommand, "copilot");
const agentLabel = isCodexAgent ? "codex" : isClaudeAgent ? "claude" : isCopilotAgent ? "copilot" : acpCommand;
// Resolve API key from providerId (decrypted in main process only)
const resolvedProvider = providerId ? resolveProviderApiKey(providerId) : null;
@@ -1811,6 +2071,13 @@ function registerHandlers(ipcMain) {
const scopedIds = mcpServerBridge.getScopedSessionIds(chatSessionId);
const netcattyMcpConfig = mcpServerBridge.buildMcpServerConfig(mcpPort, scopedIds, chatSessionId);
mcpSnapshot.mcpServers.push(netcattyMcpConfig);
if (isCopilotAgent) {
logAcpDebug(agentLabel, "Injected Netcatty MCP server into session", {
chatSessionId,
scopedIds,
injectedServer: summarizeMcpServersForDebug([netcattyMcpConfig])[0],
});
}
} catch (err) {
console.error("[ACP] Failed to inject Netcatty MCP server:", err?.message || err);
}
@@ -1821,9 +2088,7 @@ function registerHandlers(ipcMain) {
const currentPermissionMode = mcpServerBridge.getPermissionMode();
let providerEntry = acpProviders.get(chatSessionId);
const shouldForceProviderReset = acpForceProviderReset.has(chatSessionId);
const shouldReuseProvider = Boolean(
!shouldForceProviderReset &&
providerEntry &&
providerEntry.acpCommand === acpCommand &&
providerEntry.cwd === sessionCwd &&
@@ -1840,6 +2105,11 @@ function registerHandlers(ipcMain) {
if (apiKey) {
agentEnv.CODEX_API_KEY = apiKey;
}
let copilotConfigInfo = null;
if (isCopilotAgent) {
copilotConfigInfo = prepareCopilotHome(shellEnv, mcpSnapshot.mcpServers, chatSessionId);
agentEnv.COPILOT_HOME = copilotConfigInfo.copilotHome;
}
const claudeAcp = isClaudeAgent ? resolveClaudeAcpBinaryPath(shellEnv, electronModule) : null;
const resolvedCommand = isCodexAgent
@@ -1850,6 +2120,7 @@ function registerHandlers(ipcMain) {
const resolvedArgs = claudeAcp
? [...claudeAcp.prependArgs, ...(acpArgs || [])]
: acpArgs || [];
const sessionMcpServers = isCopilotAgent ? [] : mcpSnapshot.mcpServers;
const provider = createACPProvider({
command: resolvedCommand,
@@ -1857,15 +2128,31 @@ function registerHandlers(ipcMain) {
env: agentEnv,
session: {
cwd: sessionCwd,
mcpServers: mcpSnapshot.mcpServers,
mcpServers: sessionMcpServers,
},
...(resumeSessionId ? { existingSessionId: resumeSessionId } : {}),
...(isCodexAgent
? { authMethodId: apiKey ? "codex-api-key" : "chatgpt" }
: isCopilotAgent
? { authMethodId: "copilot-login" }
: {}),
persistSession: true,
});
if (isCopilotAgent) {
logAcpDebug(agentLabel, "Creating ACP provider", {
requestId,
chatSessionId,
cwd: sessionCwd,
resolvedCommand,
resolvedArgs,
sessionMcpServers: summarizeMcpServersForDebug(sessionMcpServers),
copilotHome: copilotConfigInfo?.copilotHome || null,
copilotMcpConfigPath: copilotConfigInfo?.configPath || null,
copilotMcpServerNames: copilotConfigInfo?.serverNames || [],
});
}
providerEntry = {
provider,
acpCommand,
@@ -1877,15 +2164,21 @@ function registerHandlers(ipcMain) {
};
acpProviders.set(chatSessionId, providerEntry);
}
acpForceProviderReset.delete(chatSessionId);
let modelInstance = providerEntry.provider.languageModel(model || undefined);
try {
await providerEntry.provider.initSession(providerEntry.provider.tools);
if (isCopilotAgent) {
logAcpDebug(agentLabel, "ACP session initialized", {
requestId,
chatSessionId,
providerSessionId: providerEntry.provider.getSessionId?.() || null,
toolNames: Object.keys(providerEntry.provider.tools || {}),
});
}
if (shouldAbortStartup()) return { ok: true };
} catch (err) {
const attemptedResumeSessionId = providerEntry.provider?.getSessionId?.() || existingSessionId;
if (!attemptedResumeSessionId || !isUnsupportedLoadSessionError(err)) {
if (!attemptedResumeSessionId || !shouldRetryFreshSession(err)) {
throw err;
}
@@ -1901,13 +2194,22 @@ function registerHandlers(ipcMain) {
args: fallbackClaudeAcp
? [...fallbackClaudeAcp.prependArgs, ...(acpArgs || [])]
: acpArgs || [],
env: apiKey ? { ...shellEnv, CODEX_API_KEY: apiKey } : { ...shellEnv },
env: (() => {
const fallbackEnv = apiKey ? { ...shellEnv, CODEX_API_KEY: apiKey } : { ...shellEnv };
if (isCopilotAgent) {
const fallbackCopilotConfig = prepareCopilotHome(shellEnv, mcpSnapshot.mcpServers, chatSessionId);
fallbackEnv.COPILOT_HOME = fallbackCopilotConfig.copilotHome;
}
return fallbackEnv;
})(),
session: {
cwd: sessionCwd,
mcpServers: mcpSnapshot.mcpServers,
mcpServers: isCopilotAgent ? [] : mcpSnapshot.mcpServers,
},
...(isCodexAgent
? { authMethodId: apiKey ? "codex-api-key" : "chatgpt" }
: isCopilotAgent
? { authMethodId: "copilot-login" }
: {}),
persistSession: true,
});
@@ -1924,6 +2226,14 @@ function registerHandlers(ipcMain) {
acpProviders.set(chatSessionId, providerEntry);
modelInstance = providerEntry.provider.languageModel(model || undefined);
await providerEntry.provider.initSession(providerEntry.provider.tools);
if (isCopilotAgent) {
logAcpDebug(agentLabel, "ACP session initialized after fallback", {
requestId,
chatSessionId,
providerSessionId: providerEntry.provider.getSessionId?.() || null,
toolNames: Object.keys(providerEntry.provider.tools || {}),
});
}
if (shouldAbortStartup()) return { ok: true };
}
const activeProviderSessionId = providerEntry.provider.getSessionId?.() || null;
@@ -2042,6 +2352,9 @@ function registerHandlers(ipcMain) {
if (serialized.type === "text-delta" || serialized.type === "reasoning-delta" || serialized.type === "tool-call") {
hasContent = true;
}
if (isCopilotAgent && (serialized.type === "tool-call" || serialized.type === "tool-result" || serialized.type === "error" || serialized.type === "status")) {
logAcpDebug(agentLabel, `Stream event: ${serialized.type}`, serialized);
}
safeSend(event.sender, "netcatty:ai:acp:event", {
requestId,
event: serialized,
@@ -2057,6 +2370,13 @@ function registerHandlers(ipcMain) {
// If stream completed with zero content, likely an auth or connection issue
if (!hasContent && !abortController.signal.aborted) {
if (isCopilotAgent) {
logAcpDebug(agentLabel, "Stream completed with no content", {
requestId,
chatSessionId,
providerSessionId: providerEntry.provider.getSessionId?.() || null,
});
}
if (!isActiveAcpRun(chatSessionId, requestId)) {
return { ok: true };
}
@@ -2095,9 +2415,6 @@ function registerHandlers(ipcMain) {
acpPendingCancelRequests.delete(requestId);
const activeRun = acpChatRuns.get(chatSessionId);
if (activeRun?.requestId === requestId) {
if (abortController?.signal?.aborted || activeRun.cancelRequested) {
cleanupAcpProvider(chatSessionId);
}
acpChatRuns.delete(chatSessionId);
}
}
@@ -2127,10 +2444,6 @@ function registerHandlers(ipcMain) {
acpPendingCancelRequests.add(effectiveRequestId);
cancelled = true;
}
if (effectiveChatSessionId) {
acpForceProviderReset.add(effectiveChatSessionId);
cleanupAcpProvider(effectiveChatSessionId);
}
// Preserve the ACP provider session on stop so the next user message can
// continue within the same persisted conversation context. Full provider
// cleanup is handled by netcatty:ai:acp:cleanup when the chat is deleted.
@@ -2143,7 +2456,6 @@ function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:ai:acp:cleanup", async (event, { chatSessionId }) => {
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
mcpServerBridge.setChatSessionCancelled?.(chatSessionId, true);
acpForceProviderReset.delete(chatSessionId);
cleanupAcpProvider(chatSessionId);
mcpServerBridge.cleanupScopedMetadata(chatSessionId);
return { ok: true };

View File

@@ -380,6 +380,7 @@ async function handleMessage(socket, line) {
if (!socket.destroyed) socket.write(response);
return;
}
console.warn("[MCP Bridge] auth/verify failed or unexpected first method", method);
// Wrong token or wrong method — reject and close
const response = JSON.stringify({
jsonrpc: "2.0",
@@ -629,6 +630,22 @@ function handleExec(params) {
// ── MCP Server Config Builder ──
function resolveMcpServerRuntimeCommand() {
const runtimeCommand = process.execPath;
const runtimeEnv = [];
if (runtimeCommand && existsSync(runtimeCommand)) {
const basename = path.basename(runtimeCommand).toLowerCase();
const isNodeBinary = basename === "node" || basename.startsWith("node.");
if (!isNodeBinary) {
runtimeEnv.push({ name: "ELECTRON_RUN_AS_NODE", value: "1" });
}
return { command: runtimeCommand, env: runtimeEnv };
}
return { command: "node", env: runtimeEnv };
}
function buildMcpServerConfig(port, scopedSessionIds, chatSessionId) {
// Use provided scoped IDs, or resolve from chatSessionId, or fall back
const effectiveIds = (scopedSessionIds && scopedSessionIds.length > 0)
@@ -638,8 +655,10 @@ function buildMcpServerConfig(port, scopedSessionIds, chatSessionId) {
const runtimePath = toUnpackedAsarPath(
path.join(__dirname, "..", "mcp", "netcatty-mcp-server.cjs"),
);
const runtime = resolveMcpServerRuntimeCommand();
const env = [
...runtime.env,
{ name: "NETCATTY_MCP_PORT", value: String(port) },
];
@@ -664,7 +683,7 @@ function buildMcpServerConfig(port, scopedSessionIds, chatSessionId) {
return {
name: "netcatty-remote-hosts",
type: "stdio",
command: "node",
command: runtime.command,
args: [runtimePath],
env,
};

View File

@@ -105,7 +105,6 @@ const renderOAuthPage = ({ title, message, detail, status, autoClose }) => {
.logo {
width: 36px;
height: 36px;
color: hsl(var(--accent));
}
.brand {
font-size: 16px;
@@ -162,14 +161,17 @@ const renderOAuthPage = ({ title, message, detail, status, autoClose }) => {
<body>
<div class="shell">
<div class="header">
<svg class="logo" viewBox="0 0 48 48" fill="none" aria-hidden="true">
<rect width="48" height="48" rx="12" fill="currentColor" fill-opacity="0.12" />
<path
d="M14 16C14 14.8954 14.8954 14 16 14H32C33.1046 14 34 14.8954 34 16V32C34 33.1046 33.1046 34 32 34H16C14.8954 34 14 33.1046 14 32V16Z"
stroke="currentColor" stroke-width="2" />
<path d="M18 22L22 26L18 30" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M26 30H30" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
<svg class="logo" viewBox="0 0 56 56" aria-hidden="true">
<rect x="0" y="0" width="56" height="56" rx="12" fill="#2F7BFF"/>
<rect x="10" y="13" width="36" height="24" rx="4" fill="#FFFFFF" stroke="#1D4FCF" stroke-opacity="0.12"/>
<rect x="10" y="13" width="36" height="5" rx="4" fill="#E6EEFF"/>
<circle cx="14" cy="15.5" r="1" fill="#1E4FD1"/>
<circle cx="18" cy="15.5" r="1" fill="#1E4FD1" opacity="0.7"/>
<circle cx="22" cy="15.5" r="1" fill="#1E4FD1" opacity="0.5"/>
<path d="M16 28 L20 26 L16 24" stroke="#1E4FD1" fill="none" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M24 30 H30" stroke="#1E4FD1" stroke-width="1.6" stroke-linecap="round"/>
<path d="M36 33 C40 36,42 38,42 42 C42 45,40 47,37 47" stroke="white" fill="none" stroke-width="3.2" stroke-linecap="round"/>
<rect x="34" y="44" width="6" height="5" rx="1" fill="white" stroke="#1E4FD1"/>
</svg>
<div>
<div class="brand">Netcatty</div>
@@ -279,9 +281,8 @@ function startOAuthCallback(expectedState) {
res.end(
renderOAuthPage({
title: "Authorization Complete",
message: "You are signed in and ready to sync.",
message: "You are signed in and ready to sync. You can close this tab now.",
status: "success",
autoClose: true,
})
);

View File

@@ -1858,4 +1858,5 @@ module.exports = {
renameSftp,
statSftp,
chmodSftp,
resolveEncodingForRequest,
};

View File

@@ -24,6 +24,7 @@ const {
} = require("./sshAuthHelper.cjs");
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
const { trackSessionIdlePrompt } = require("./ai/shellUtils.cjs");
const { createZmodemSentry } = require("./zmodemHelper.cjs");
// Default SSH key names in priority order (preferred keys tried first)
const PREFERRED_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
@@ -1246,15 +1247,36 @@ async function startSSHSession(event, options) {
}
};
const sshZmodemSentry = createZmodemSentry({
sessionId,
onData(buf) {
const decoder = getSessionDecoder(sessionId, "stdout");
const decoded = decoder.write(buf);
trackSessionIdlePrompt(session, decoded);
bufferData(decoded);
sessionLogStreamManager.appendData(sessionId, decoded);
},
writeToRemote(buf) {
try { return stream.write(buf); } catch { return true; /* ignore */ }
},
interruptRemote() {
try { stream.signal?.("INT"); } catch { /* ignore */ }
},
getWebContents() {
return event.sender;
},
label: "SSH",
});
session.zmodemSentry = sshZmodemSentry;
stream.on("data", (data) => {
const decoder = getSessionDecoder(sessionId, "stdout");
const decoded = decoder.write(data);
trackSessionIdlePrompt(session, decoded);
bufferData(decoded);
sessionLogStreamManager.appendData(sessionId, decoded);
// data is Buffer from ssh2 — feed raw bytes to ZMODEM sentry.
// In normal mode, sentry's onData callback handles decoding and buffering.
sshZmodemSentry.consume(data);
});
stream.stderr?.on("data", (data) => {
// stderr is not used for ZMODEM — decode normally
const decoder = getSessionDecoder(sessionId, "stderr");
const decoded = decoder.write(data);
bufferData(decoded);
@@ -1294,6 +1316,7 @@ async function startSSHSession(event, options) {
} else {
safeSend(contents, "netcatty:exit", { sessionId, exitCode: streamExitCode, reason: streamExited ? "exited" : "closed" });
}
sessions.get(sessionId)?.zmodemSentry?.cancel();
sessions.delete(sessionId);
sessionEncodings.delete(sessionId);
sessionDecoders.delete(sessionId);
@@ -1362,6 +1385,7 @@ async function startSSHSession(event, options) {
sendProgress(totalHops, totalHops, options.hostname, 'error', err.message);
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "error" });
sessionLogStreamManager.stopStream(sessionId);
sessions.get(sessionId)?.zmodemSentry?.cancel();
sessions.delete(sessionId);
sessionEncodings.delete(sessionId);
sessionDecoders.delete(sessionId);
@@ -1382,6 +1406,7 @@ async function startSSHSession(event, options) {
sendProgress(totalHops, totalHops, options.hostname, 'error', err.message);
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "timeout" });
sessionLogStreamManager.stopStream(sessionId);
sessions.get(sessionId)?.zmodemSentry?.cancel();
sessions.delete(sessionId);
sessionEncodings.delete(sessionId);
sessionDecoders.delete(sessionId);
@@ -1412,6 +1437,7 @@ async function startSSHSession(event, options) {
}
}
sessionLogStreamManager.stopStream(sessionId);
sessions.get(sessionId)?.zmodemSentry?.cancel();
sessions.delete(sessionId);
sessionEncodings.delete(sessionId);
sessionDecoders.delete(sessionId);

View File

@@ -14,6 +14,7 @@ const { SerialPort } = require("serialport");
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
const { detectShellKind } = require("./ai/ptyExec.cjs");
const { trackSessionIdlePrompt } = require("./ai/shellUtils.cjs");
const { createZmodemSentry } = require("./zmodemHelper.cjs");
// Shared references
let sessions = null;
@@ -286,6 +287,7 @@ function startLocalSession(event, payload) {
rows: payload?.rows || 24,
env,
cwd,
encoding: null, // Return Buffer for ZMODEM binary support
});
const session = {
@@ -329,11 +331,40 @@ function startLocalSession(event, payload) {
});
session.flushPendingData = flushLocal;
proc.onData((data) => {
trackSessionIdlePrompt(session, data);
bufferLocalData(data);
sessionLogStreamManager.appendData(sessionId, data);
});
// On Windows, node-pty ignores encoding: null and still emits UTF-8
// strings, making raw-byte ZMODEM impossible for local PTY sessions.
// Only wire up the sentry on platforms where encoding: null works.
if (process.platform !== "win32") {
const localDecoder = new StringDecoder("utf8");
const zmodemSentry = createZmodemSentry({
sessionId,
onData(buf) {
const str = localDecoder.write(buf);
if (!str) return;
trackSessionIdlePrompt(session, str);
bufferLocalData(str);
sessionLogStreamManager.appendData(sessionId, str);
},
writeToRemote(buf) {
try { return proc.write(buf); } catch { return true; }
},
getWebContents() {
return electronModule.webContents.fromId(session.webContentsId);
},
label: "Local",
});
session.zmodemSentry = zmodemSentry;
proc.onData((data) => {
zmodemSentry.consume(data);
});
} else {
proc.onData((data) => {
trackSessionIdlePrompt(session, data);
bufferLocalData(data);
sessionLogStreamManager.appendData(sessionId, data);
});
}
proc.onExit((evt) => {
flushLocal();
@@ -535,19 +566,57 @@ async function startTelnetSession(event, options) {
contents?.send("netcatty:data", { sessionId, data });
});
const telnetZmodemSentry = createZmodemSentry({
sessionId,
onData(buf) {
const decoded = telnetDecoder.write(buf);
if (!decoded) return;
const session = sessions.get(sessionId);
if (session) trackSessionIdlePrompt(session, decoded);
bufferTelnetData(decoded);
sessionLogStreamManager.appendData(sessionId, decoded);
},
writeToRemote(buf) {
// Escape 0xFF bytes as 0xFF 0xFF per Telnet spec so binary
// ZMODEM data passes through without being treated as IAC.
try {
let hasFF = false;
for (let i = 0; i < buf.length; i++) {
if (buf[i] === 0xff) { hasFF = true; break; }
}
if (hasFF) {
const escaped = [];
for (let i = 0; i < buf.length; i++) {
escaped.push(buf[i]);
if (buf[i] === 0xff) escaped.push(0xff);
}
return socket.write(Buffer.from(escaped));
} else {
return socket.write(buf);
}
} catch { return true; }
},
getWebContents() {
return electronModule.webContents.fromId(telnetWebContentsId);
},
label: "Telnet",
});
// Attach sentry to session once created (connect callback runs after this)
const attachTelnetSentry = () => {
const session = sessions.get(sessionId);
if (session) session.zmodemSentry = telnetZmodemSentry;
};
socket.once('connect', attachTelnetSentry);
socket.on('data', (data) => {
const session = sessions.get(sessionId);
if (!session) return;
// Always run Telnet negotiation — even during ZMODEM, the Telnet
// layer still escapes 0xFF as IAC IAC and sends control sequences.
const cleanData = handleTelnetNegotiation(data);
if (cleanData.length > 0) {
const decoded = telnetDecoder.write(cleanData);
if (decoded) {
trackSessionIdlePrompt(session, decoded);
bufferTelnetData(decoded);
sessionLogStreamManager.appendData(sessionId, decoded);
}
telnetZmodemSentry.consume(cleanData);
}
});
@@ -562,6 +631,7 @@ async function startTelnetSession(event, options) {
sessionLogStreamManager.stopStream(sessionId);
const session = sessions.get(sessionId);
if (session) {
session.zmodemSentry?.cancel();
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "error" });
}
@@ -577,6 +647,7 @@ async function startTelnetSession(event, options) {
sessionLogStreamManager.stopStream(sessionId);
const session = sessions.get(sessionId);
if (session) {
session.zmodemSentry?.cancel();
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:exit", { sessionId, exitCode: hadError ? 1 : 0, reason: hadError ? "error" : "closed" });
}
@@ -645,6 +716,7 @@ async function startMoshSession(event, options) {
rows,
env,
cwd: os.homedir(),
encoding: null, // Return Buffer for ZMODEM binary support
});
const session = {
@@ -682,11 +754,37 @@ async function startMoshSession(event, options) {
});
session.flushPendingData = flushMosh;
proc.onData((data) => {
trackSessionIdlePrompt(session, data);
bufferMoshData(data);
sessionLogStreamManager.appendData(sessionId, data);
});
if (process.platform !== "win32") {
const moshDecoder = new StringDecoder("utf8");
const moshZmodemSentry = createZmodemSentry({
sessionId,
onData(buf) {
const str = moshDecoder.write(buf);
if (!str) return;
trackSessionIdlePrompt(session, str);
bufferMoshData(str);
sessionLogStreamManager.appendData(sessionId, str);
},
writeToRemote(buf) {
try { return proc.write(buf); } catch { return true; }
},
getWebContents() {
return electronModule.webContents.fromId(session.webContentsId);
},
label: "Mosh",
});
session.zmodemSentry = moshZmodemSentry;
proc.onData((data) => {
moshZmodemSentry.consume(data);
});
} else {
proc.onData((data) => {
trackSessionIdlePrompt(session, data);
bufferMoshData(data);
sessionLogStreamManager.appendData(sessionId, data);
});
}
proc.onExit((evt) => {
flushMosh();
@@ -790,17 +888,33 @@ async function startSerialSession(event, options) {
});
}
serialPort.on('data', (data) => {
const decoded = serialDecoder.write(data);
if (decoded) {
const serialZmodemSentry = createZmodemSentry({
sessionId,
onData(buf) {
const decoded = serialDecoder.write(buf);
if (!decoded) return;
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:data", { sessionId, data: decoded });
sessionLogStreamManager.appendData(sessionId, decoded);
}
},
writeToRemote(buf) {
try { return serialPort.write(buf); } catch { return true; }
},
getWebContents() {
return electronModule.webContents.fromId(session.webContentsId);
},
label: "Serial",
});
session.zmodemSentry = serialZmodemSentry;
serialPort.on('data', (data) => {
// data is already Buffer from serialport — feed to sentry
serialZmodemSentry.consume(data);
});
serialPort.on('error', (err) => {
console.error(`[Serial] Port error: ${err.message}`);
session.zmodemSentry?.cancel();
sessionLogStreamManager.stopStream(sessionId);
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "error" });
@@ -809,6 +923,7 @@ async function startSerialSession(event, options) {
serialPort.on('close', () => {
console.log(`[Serial] Port closed`);
session.zmodemSentry?.cancel();
sessionLogStreamManager.stopStream(sessionId);
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:exit", { sessionId, exitCode: 0, reason: "closed" });
@@ -830,7 +945,15 @@ async function startSerialSession(event, options) {
function writeToSession(event, payload) {
const session = sessions.get(payload.sessionId);
if (!session) return;
// During ZMODEM transfer, block terminal input (Ctrl+C cancels the transfer)
if (session.zmodemSentry?.isActive()) {
if (payload.data === '\x03') {
session.zmodemSentry.cancel();
}
return;
}
try {
if (session.stream) {
session.stream.write(payload.data);
@@ -887,6 +1010,7 @@ function closeSession(event, payload) {
if (!session) return;
try {
session.zmodemSentry?.cancel();
session.flushPendingData?.();
if (session.stream) {
session.stream.close();
@@ -999,6 +1123,7 @@ function cleanupAllSessions() {
console.log(`[Terminal] Cleaning up ${sessions.size} sessions before quit`);
for (const [sessionId, session] of sessions) {
try {
session.zmodemSentry?.cancel();
if (session.stream) {
session.stream.close();
session.conn?.end();

View File

@@ -6,7 +6,7 @@
const fs = require("node:fs");
const path = require("node:path");
const os = require("node:os");
const { encodePathForSession, ensureRemoteDirForSession, requireSftpChannel } = require("./sftpBridge.cjs");
const { encodePathForSession, ensureRemoteDirForSession, requireSftpChannel, resolveEncodingForRequest } = require("./sftpBridge.cjs");
/**
* Safely ensure a local directory exists.
@@ -50,6 +50,9 @@ let sftpClients = null;
// Active transfers storage
const activeTransfers = new Map();
const isolatedDownloadChannelPools = new WeakMap();
// Cache sftpIds where remote cp is known to be unavailable, so we skip
// repeated failed exec attempts for each file in a multi-file transfer.
const cpUnavailableSet = new Set();
/**
* Initialize the transfer bridge with dependencies
@@ -58,6 +61,46 @@ function init(deps) {
sftpClients = deps.sftpClients;
}
/**
* Execute an SSH command with cancellation support.
* Registers an abort hook on the transfer object that closes the exec stream,
* which sends SIGHUP to the remote process.
*/
function execSshCommandCancellable(sshClient, command, transfer) {
return new Promise((resolve, reject) => {
if (transfer.cancelled) return reject(new Error('Transfer cancelled'));
sshClient.exec(command, (err, stream) => {
if (err) return reject(err);
// If cancelled between exec() call and callback, kill immediately
if (transfer.cancelled) {
try { stream.close(); } catch { }
return reject(new Error('Transfer cancelled'));
}
let stdout = '';
let stderr = '';
// Wire abort: closing the stream kills the remote process
const prevAbort = transfer.abort;
transfer.abort = () => {
try { stream.close(); } catch { }
if (typeof prevAbort === 'function') prevAbort();
};
stream.on('close', (code) => {
transfer.abort = prevAbort; // restore
if (transfer.cancelled) return reject(new Error('Transfer cancelled'));
resolve({ stdout, stderr, code });
});
stream.on('data', (data) => { stdout += data.toString(); });
stream.stderr.on('data', (data) => { stderr += data.toString(); });
});
});
}
async function openIsolatedSftpChannel(client) {
const sshClient = client?.client;
if (!sshClient || typeof sshClient.sftp !== "function") return null;
@@ -475,6 +518,7 @@ async function startTransfer(event, payload, onProgress) {
totalBytes,
sourceEncoding,
targetEncoding,
sameHost,
} = payload;
const sender = event.sender;
@@ -674,34 +718,73 @@ async function startTransfer(event, payload, onProgress) {
});
} else if (sourceType === 'sftp' && targetType === 'sftp') {
const tempPath = path.join(os.tmpdir(), `netcatty-transfer-${transferId}`);
// Try same-host optimization first: remote cp via SSH exec.
// Falls back to download+upload if cp is unavailable (e.g. Windows SSH servers).
let sameHostDone = false;
const resolvedSourceEnc = sourceSftpId ? resolveEncodingForRequest(sourceSftpId, sourceEncoding) : sourceEncoding;
const resolvedTargetEnc = targetSftpId ? resolveEncodingForRequest(targetSftpId, targetEncoding) : targetEncoding;
if (sameHost
&& (!resolvedSourceEnc || resolvedSourceEnc === 'utf-8')
&& (!resolvedTargetEnc || resolvedTargetEnc === 'utf-8')
&& !cpUnavailableSet.has(sourceSftpId)) {
const srcClient = sftpClients.get(sourceSftpId);
const sshClient = srcClient?.client;
if (sshClient && typeof sshClient.exec === 'function') {
try {
const dir = path.dirname(targetPath).replace(/\\/g, '/');
try { await ensureRemoteDirForSession(sourceSftpId, dir, targetEncoding || sourceEncoding); } catch { }
const sourceClient = sftpClients.get(sourceSftpId);
const targetClient = sftpClients.get(targetSftpId);
if (!sourceClient) throw new Error("Source SFTP session not found");
if (!targetClient) throw new Error("Target SFTP session not found");
const escapedSource = sourcePath.replace(/'/g, "'\\''");
const escapedTarget = targetPath.replace(/'/g, "'\\''");
const command = `cp -a '${escapedSource}' '${escapedTarget}'`;
const encodedSourcePath = encodePathForSession(sourceSftpId, sourcePath, sourceEncoding);
const downloadProgress = (transferred) => {
sendProgress(Math.floor(transferred / 2), fileSize);
};
await downloadFile(encodedSourcePath, tempPath, sourceClient, fileSize, transfer, downloadProgress);
if (transfer.cancelled) {
try { await fs.promises.unlink(tempPath); } catch { }
throw new Error('Transfer cancelled');
const result = await execSshCommandCancellable(sshClient, command, transfer);
if (result.code === 0) {
sendProgress(fileSize, fileSize);
sameHostDone = true;
} else if (result.code === 127) {
// Exit 127 = command not found — cache to skip future attempts
cpUnavailableSet.add(sourceSftpId);
}
// Other non-zero exits (permission denied, disk full, etc.)
// fall through to download+upload without caching
} catch (cpErr) {
// If cancelled, re-throw; otherwise fall back to download+upload
if (transfer.cancelled) throw cpErr;
}
}
}
const dir = path.dirname(targetPath).replace(/\\/g, '/');
try { await ensureRemoteDirForSession(targetSftpId, dir, targetEncoding); } catch { }
if (!sameHostDone) {
const tempPath = path.join(os.tmpdir(), `netcatty-transfer-${transferId}`);
const encodedTargetPath = encodePathForSession(targetSftpId, targetPath, targetEncoding);
const uploadProgress = (transferred) => {
sendProgress(Math.floor(fileSize / 2) + Math.floor(transferred / 2), fileSize);
};
await uploadFile(tempPath, encodedTargetPath, targetClient, fileSize, transfer, uploadProgress);
const sourceClient = sftpClients.get(sourceSftpId);
const targetClient = sftpClients.get(targetSftpId);
if (!sourceClient) throw new Error("Source SFTP session not found");
if (!targetClient) throw new Error("Target SFTP session not found");
try { await fs.promises.unlink(tempPath); } catch { }
const encodedSourcePath = encodePathForSession(sourceSftpId, sourcePath, sourceEncoding);
const downloadProgress = (transferred) => {
sendProgress(Math.floor(transferred / 2), fileSize);
};
await downloadFile(encodedSourcePath, tempPath, sourceClient, fileSize, transfer, downloadProgress);
if (transfer.cancelled) {
try { await fs.promises.unlink(tempPath); } catch { }
throw new Error('Transfer cancelled');
}
const dir = path.dirname(targetPath).replace(/\\/g, '/');
try { await ensureRemoteDirForSession(targetSftpId, dir, targetEncoding); } catch { }
const encodedTargetPath = encodePathForSession(targetSftpId, targetPath, targetEncoding);
const uploadProgress = (transferred) => {
sendProgress(Math.floor(fileSize / 2) + Math.floor(transferred / 2), fileSize);
};
await uploadFile(tempPath, encodedTargetPath, targetClient, fileSize, transfer, uploadProgress);
try { await fs.promises.unlink(tempPath); } catch { }
}
} else {
throw new Error("Invalid transfer configuration");
@@ -749,12 +832,73 @@ async function cancelTransfer(event, payload) {
return { success: true };
}
/**
* Same-host directory copy: uses a single `cp -ra` command on the remote server
* instead of recursively transferring files one by one.
*/
async function sameHostCopyDirectory(event, payload) {
const { sftpId, sourcePath, targetPath, encoding, transferId } = payload;
// Register in activeTransfers so cancelTransfer can flag it
const transfer = { cancelled: false };
if (transferId) {
activeTransfers.set(transferId, transfer);
}
try {
if (cpUnavailableSet.has(sftpId)) return { success: false };
const client = sftpClients.get(sftpId);
if (!client) return { success: false };
const sshClient = client.client;
if (!sshClient || typeof sshClient.exec !== 'function') {
return { success: false };
}
if (transfer.cancelled) throw new Error("Transfer cancelled");
// Ensure target directory itself exists (not just its parent),
// so cp copies contents into it rather than creating a nested subdirectory.
const targetDir = targetPath.replace(/\\/g, '/');
try { await ensureRemoteDirForSession(sftpId, targetDir, encoding); } catch { }
// Use "source/." to copy directory *contents* into target, preserving merge
// semantics consistent with the recursive per-file transfer path.
// Without "/.", `cp -ra source target` would create target/source/ when target exists.
const escapedSource = sourcePath.replace(/'/g, "'\\''");
const escapedTarget = targetPath.replace(/'/g, "'\\''");
const command = `cp -ra '${escapedSource}/.' '${escapedTarget}/'`;
try {
const result = await execSshCommandCancellable(sshClient, command, transfer);
if (result.code === 127) {
cpUnavailableSet.add(sftpId);
return { success: false };
}
if (result.code !== 0) {
return { success: false };
}
} catch (cpErr) {
if (transfer.cancelled) throw cpErr;
return { success: false };
}
return { success: true };
} finally {
if (transferId) {
activeTransfers.delete(transferId);
}
}
}
/**
* Register IPC handlers for transfer operations
*/
function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:transfer:start", startTransfer);
ipcMain.handle("netcatty:transfer:cancel", cancelTransfer);
ipcMain.handle("netcatty:transfer:same-host-copy-dir", sameHostCopyDirectory);
}
module.exports = {
@@ -762,4 +906,5 @@ module.exports = {
registerHandlers,
startTransfer,
cancelTransfer,
sameHostCopyDirectory,
};

View File

@@ -0,0 +1,794 @@
/**
* ZMODEM Helper - Provides ZMODEM file transfer support for terminal sessions.
*
* Architecture: ZMODEM detection and transfer runs entirely in the main process.
* The Sentry wraps the raw data stream and routes data either to the normal
* string-based terminal pipeline (via `to_terminal`) or to the ZMODEM protocol
* handler. This avoids any changes to the IPC / preload / renderer data path.
*
* The renderer is only notified for progress display via lightweight IPC events.
*/
const Zmodem = require("zmodem.js");
const fs = require("node:fs");
const path = require("node:path");
// Lazy-load electron to avoid issues when requiring from non-electron contexts
let _electron = null;
function getElectron() {
if (!_electron) _electron = require("electron");
return _electron;
}
/**
* Create a ZMODEM sentry that wraps a session's data stream.
*
* All raw data from the PTY / SSH stream / socket should be fed into
* `consume()`. The sentry transparently calls `onData(str)` for normal
* terminal output and handles ZMODEM transfers internally.
*
* @param {object} opts
* @param {string} opts.sessionId
* @param {(data: Buffer) => void} opts.onData
* Called with raw bytes during normal (non-ZMODEM) operation.
* The caller is responsible for charset-aware decoding (UTF-8, iconv, etc.).
* @param {(buf: Buffer) => void} opts.writeToRemote
* Write raw bytes back to the remote side (PTY / SSH stream / socket).
* @param {() => import('electron').WebContents | null} opts.getWebContents
* Returns the Electron WebContents for sending progress IPC events.
* @param {string} [opts.label]
* Human-readable label for log messages (e.g. "Local", "SSH").
* @returns {ZmodemSentryWrapper}
*/
function createZmodemSentry(opts) {
const {
sessionId,
onData,
writeToRemote,
getWebContents,
interruptRemote,
label = "Session",
} = opts;
let active = false;
let currentZSession = null;
let _needsDrain = false;
const pendingEchoes = [];
let pendingTerminalSuppression = null;
let cancelInterruptTimer = null;
let ignoreDetectionUntil = 0;
// After aborting, suppress incoming data briefly so residual ZMODEM
// protocol bytes from the remote don't flood the terminal as garbage.
let cooldownUntil = 0;
const COOLDOWN_MS = 2000;
const ECHO_TTL_MS = 1500;
const ECHO_MAX_BYTES = 256;
function prunePendingEchoes(now = Date.now()) {
while (pendingEchoes.length && pendingEchoes[0].expiresAt <= now) {
pendingEchoes.shift();
}
}
function rememberOutgoingEcho(octets) {
const buf = Buffer.from(octets);
if (!buf.length || buf.length > ECHO_MAX_BYTES) return;
prunePendingEchoes();
pendingEchoes.push({
buf,
expiresAt: Date.now() + ECHO_TTL_MS,
});
}
function stripEchoedOutgoingData(data) {
if (!pendingEchoes.length) return data;
prunePendingEchoes();
let buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
let mutated = false;
while (pendingEchoes.length && buf.length) {
const nextEcho = pendingEchoes[0].buf;
if (buf.length < nextEcho.length) break;
if (!buf.subarray(0, nextEcho.length).equals(nextEcho)) break;
mutated = true;
buf = buf.subarray(nextEcho.length);
pendingEchoes.shift();
}
return mutated ? buf : data;
}
function stripPendingTerminalSuppression(data) {
if (!pendingTerminalSuppression?.length) return data;
let buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
const fullMatchAt = buf.indexOf(pendingTerminalSuppression);
if (fullMatchAt !== -1) {
buf = Buffer.concat([
buf.subarray(0, fullMatchAt),
buf.subarray(fullMatchAt + pendingTerminalSuppression.length),
]);
pendingTerminalSuppression = null;
return buf;
}
const maxMatch = Math.min(pendingTerminalSuppression.length, buf.length);
let matchLen = 0;
while (matchLen < maxMatch && buf[matchLen] === pendingTerminalSuppression[matchLen]) {
matchLen += 1;
}
if (!matchLen) return buf;
buf = buf.subarray(matchLen);
pendingTerminalSuppression = matchLen === pendingTerminalSuppression.length
? null
: pendingTerminalSuppression.subarray(matchLen);
return buf;
}
function stripVisibleZmodemHeaders(data) {
let buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
let searchFrom = 0;
while (searchFrom < buf.length) {
const prefixAt = buf.indexOf(Buffer.from([0x2a, 0x2a, 0x18, 0x42]), searchFrom);
if (prefixAt === -1) break;
const minHeaderLength = 20;
if (buf.length - prefixAt < minHeaderLength) break;
let isHexHeader = true;
for (let i = 0; i < 14; i += 1) {
const byte = buf[prefixAt + 4 + i];
const isHexDigit =
(byte >= 0x30 && byte <= 0x39) ||
(byte >= 0x41 && byte <= 0x46) ||
(byte >= 0x61 && byte <= 0x66);
if (!isHexDigit) {
isHexHeader = false;
break;
}
}
if (!isHexHeader) {
searchFrom = prefixAt + 1;
continue;
}
let headerLength = 18;
if (buf[prefixAt + 18] === 0x0d && buf[prefixAt + 19] === 0x0a) {
headerLength = 20;
if (buf[prefixAt + 20] === 0x11) {
headerLength = 21;
}
}
buf = Buffer.concat([
buf.subarray(0, prefixAt),
buf.subarray(prefixAt + headerLength),
]);
searchFrom = prefixAt;
}
return buf;
}
function looksLikeResidualZmodemData(data) {
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
if (!buf.length) return true;
for (const byte of buf) {
const isResidualControl =
byte === 0x18 || // CAN / ZDLE
byte === 0x08 || // backspace from abort sequence
byte === 0x11 || // XON
byte === 0x13 || // XOFF
byte === 0x0d ||
byte === 0x0a;
if (isResidualControl) continue;
return false;
}
return true;
}
function sendExtraAbortBytes() {
try {
writeToRemote(Buffer.from([0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18]));
} catch {
/* ignore */
}
}
function scheduleRemoteInterruptAfterCancel(transferRole) {
if (cancelInterruptTimer) {
clearTimeout(cancelInterruptTimer);
cancelInterruptTimer = null;
}
if (transferRole !== "send") return;
ignoreDetectionUntil = Date.now() + 300;
try { interruptRemote?.(); } catch { /* ignore */ }
// Some rz builds (notably Debian's lrzsz) can stay attached to the tty
// after a protocol cancel. Follow up with Ctrl+C so the remote shell
// reliably regains control. If rz is already gone, this just refreshes
// the prompt like a normal interactive interrupt.
cancelInterruptTimer = setTimeout(() => {
cancelInterruptTimer = null;
try { interruptRemote?.(); } catch { /* ignore */ }
try { writeToRemote(Buffer.from("\x03")); } catch { /* ignore */ }
}, 120);
}
function isIgnorableSendKeepaliveError(errMsg) {
return Boolean(
active &&
currentZSession?.type === "send" &&
!currentZSession?._sending_file &&
errMsg.includes("Unhandled header: ZRINIT")
);
}
function isIgnorableSendResumePingError(errMsg) {
return Boolean(
active &&
currentZSession?.type === "send" &&
!currentZSession?._sending_file &&
currentZSession?._next_header_handler?.ZRINIT &&
errMsg.includes("Unhandled header: ZRPOS")
);
}
const sentry = new Zmodem.Sentry({
to_terminal(octets) {
// Normal data pass raw bytes to the caller for charset-aware decoding.
let sanitizedOctets = stripPendingTerminalSuppression(Buffer.from(octets));
sanitizedOctets = stripVisibleZmodemHeaders(sanitizedOctets);
if (!sanitizedOctets.length) return;
onData(sanitizedOctets);
},
sender(octets) {
// ZMODEM protocol bytes send raw to remote.
rememberOutgoingEcho(octets);
const ok = writeToRemote(Buffer.from(octets));
// Track backpressure: if stream.write() returned false, the
// kernel TCP buffer is full. The upload loop should pause.
if (ok === false) _needsDrain = true;
},
on_detect(detection) {
if (active) {
console.warn(`[ZMODEM][${label}] Detection while transfer active; denying`);
detection.deny();
return;
}
if (Date.now() < ignoreDetectionUntil) {
console.log(`[ZMODEM][${label}] Ignoring stray detection during cancel grace window`);
detection.deny();
return;
}
active = true;
const zsession = detection.confirm();
currentZSession = zsession;
pendingTerminalSuppression = zsession.type === "receive"
? Buffer.from(Zmodem.Header.build("ZRQINIT").to_hex())
: zsession._last_ZRINIT?.to_hex
? Buffer.from(zsession._last_ZRINIT.to_hex())
: null;
const contents = getWebContents();
const transferType = zsession.type === "send" ? "upload" : "download";
console.log(`[ZMODEM][${label}] Detected ${transferType} for session ${sessionId}`);
safeSend(contents, "netcatty:zmodem:detect", {
sessionId,
transferType,
});
// Provide a drain helper so the upload loop can pause when the
// underlying transport's write buffer is full.
const transferOpts = {
...opts,
waitForDrain: () => {
if (!_needsDrain) return Promise.resolve();
_needsDrain = false;
// Yield to the event loop so Node can flush buffered writes to
// the kernel. Using setImmediate (not setTimeout) avoids any
// fixed delay — we resume as soon as the I/O phase completes.
return new Promise((resolve) => setImmediate(resolve));
},
};
handleTransfer(zsession, transferType, transferOpts)
.then(() => {
// Only act if this is still the active session (not replaced by a new one)
if (currentZSession !== zsession) return;
console.log(`[ZMODEM][${label}] Transfer completed for session ${sessionId}`);
safeSend(contents, "netcatty:zmodem:complete", { sessionId });
})
.catch((err) => {
if (currentZSession !== zsession) return;
console.error(`[ZMODEM][${label}] Transfer error:`, err.message || err);
try { zsession.abort(); } catch { /* ignore */ }
safeSend(contents, "netcatty:zmodem:error", {
sessionId,
error: String(err.message || err),
});
})
.finally(() => {
// Only clear state if this is still the active session
if (currentZSession === zsession) {
active = false;
currentZSession = null;
}
});
},
on_retract() {
// False positive sentry automatically resumes passthrough.
},
});
return {
/**
* Feed raw bytes from the session into the sentry.
* @param {Buffer|Uint8Array} data
*/
consume(data) {
// During cooldown after abort, unconditionally suppress all incoming
// data. sz can stream large amounts of file data that's still in
// SSH/TCP buffers after we send CAN; checking content doesn't help
// because the residual data contains arbitrary printable bytes.
if (cooldownUntil) {
const now = Date.now();
if (now < cooldownUntil) {
// Keep sending CAN in case earlier ones were lost in the flood
if (now - (cooldownUntil - COOLDOWN_MS) > 200) {
sendExtraAbortBytes();
}
return; // drop everything during cooldown
}
cooldownUntil = 0;
// After cooldown, let this chunk through — it's likely the shell prompt
}
try {
const sanitizedData = stripEchoedOutgoingData(data);
if (!sanitizedData.length) return;
sentry.consume(sanitizedData);
} catch (err) {
const errMsg = String(err.message || err);
console.error(`[ZMODEM][${label}] Sentry consume error:`, errMsg);
const wasActive = active;
// lrzsz's `rz` may resend ZRINIT while we're waiting for the user
// to choose files. zmodem.js doesn't model that pre-offer keepalive,
// but the repeated header is harmless, so ignore it and keep waiting.
if (isIgnorableSendKeepaliveError(errMsg)) {
console.log(`[ZMODEM][${label}] Ignoring repeated pre-offer ZRINIT`);
return;
}
// Some receivers emit a final ZRPOS ping right before they send the
// post-file ZRINIT. If that ping is processed a beat late, zmodem.js
// complains even though the transfer can continue normally.
if (isIgnorableSendResumePingError(errMsg)) {
console.log(`[ZMODEM][${label}] Ignoring late post-file ZRPOS`);
return;
}
// ZFIN/OO mismatch: the file transfer completed (ZFIN exchanged)
// but the shell prompt arrived before the "OO" end marker. This
// is common over SSH because sz exits and the shell resumes before
// the "OO" acknowledgement is sent. Treat as successful transfer.
// Do NOT abort() here — that sends CAN bytes to the remote shell.
// Instead, manually clean up the sentry's internal session state.
if (wasActive && errMsg.includes("ZFIN") && errMsg.includes("OO")) {
console.log(`[ZMODEM][${label}] ZFIN/OO mismatch — treating as success`);
if (currentZSession) {
try { currentZSession._on_session_end(); } catch { /* ignore */ }
}
active = false;
currentZSession = null;
safeSend(getWebContents(), "netcatty:zmodem:complete", { sessionId });
try { sentry.consume(data); } catch { /* ignore */ }
return;
}
// For all other errors, abort and send extra CAN sequences to
// ensure the remote rz/sz process stops transmitting.
if (currentZSession) {
try { currentZSession.abort(); } catch { /* ignore */ }
}
sendExtraAbortBytes();
// Follow up with Ctrl+C after a short delay to kill rz/sz on
// Debian and other systems where it stays attached after CAN.
setTimeout(() => {
try { writeToRemote(Buffer.from("\x03")); } catch { /* ignore */ }
}, 150);
active = false;
currentZSession = null;
// Enter cooldown: discard incoming data briefly while the remote
// processes our CAN sequence and stops sending ZMODEM frames.
cooldownUntil = Date.now() + COOLDOWN_MS;
if (wasActive) {
safeSend(getWebContents(), "netcatty:zmodem:error", {
sessionId,
error: errMsg,
});
}
}
},
/** Whether a ZMODEM transfer is currently in progress. */
isActive() {
return active;
},
/** Cancel the current ZMODEM transfer. */
cancel() {
if (currentZSession) {
const transferRole = currentZSession.type;
console.log(`[ZMODEM][${label}] Cancelling transfer for session ${sessionId}`);
try { currentZSession.abort(); } catch { /* ignore */ }
sendExtraAbortBytes();
active = false;
currentZSession = null;
cooldownUntil = Date.now() + COOLDOWN_MS;
scheduleRemoteInterruptAfterCancel(transferRole);
safeSend(getWebContents(), "netcatty:zmodem:error", {
sessionId,
error: "Transfer cancelled",
});
}
},
};
}
// ---------------------------------------------------------------------------
// Shared helpers (module-level, usable from handleUpload / handleDownload)
// ---------------------------------------------------------------------------
/**
* Race a promise against a timeout. If the promise doesn't settle within
* `ms`, resolve with undefined instead of hanging forever. This prevents
* zmodem.js internal promises (xfer.end, zsession.close) from blocking
* indefinitely after cancel/abort.
*/
function withTimeout(promise, ms) {
let timer;
return Promise.race([
promise,
new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error("ZMODEM handshake timeout")), ms);
}),
]).finally(() => clearTimeout(timer));
}
/**
* Send CAN bytes + delayed Ctrl-C to kill the remote rz/sz process.
* Used from dialog-cancel paths that run outside the sentry closure.
*/
function abortRemoteProcess(writeToRemote) {
try { writeToRemote(Buffer.from([0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18])); } catch { /* ignore */ }
setTimeout(() => {
try { writeToRemote(Buffer.from("\x03")); } catch { /* ignore */ }
}, 150);
}
// ---------------------------------------------------------------------------
// Transfer handlers
// ---------------------------------------------------------------------------
async function handleTransfer(zsession, transferType, opts) {
if (transferType === "upload") {
await handleUpload(zsession, opts);
} else {
await handleDownload(zsession, opts);
}
}
/**
* Upload files to the remote (remote executed `rz`).
*/
async function handleUpload(zsession, opts) {
const { sessionId, getWebContents } = opts;
const contents = getWebContents();
const { BrowserWindow, dialog } = getElectron();
const yieldToIO = () => new Promise((resolve) => setImmediate(resolve));
const win = contents ? BrowserWindow.fromWebContents(contents) : null;
const result = await dialog.showOpenDialog(win || undefined, {
properties: ["openFile", "multiSelections"],
title: "Select files to upload (ZMODEM)",
});
if (result.canceled || !result.filePaths.length) {
try { zsession.abort(); } catch { /* ignore */ }
abortRemoteProcess(opts.writeToRemote);
throw new Error("Transfer cancelled");
}
const filePaths = result.filePaths;
const fileStats = filePaths.map((fp) => fs.statSync(fp));
for (let i = 0; i < filePaths.length; i++) {
const filePath = filePaths[i];
const stat = fileStats[i];
const name = path.basename(filePath);
safeSend(contents, "netcatty:zmodem:progress", {
sessionId,
filename: name,
transferred: 0,
total: stat.size,
fileIndex: i,
fileCount: filePaths.length,
transferType: "upload",
});
let bytesRemaining = 0;
for (let j = i; j < fileStats.length; j++) bytesRemaining += fileStats[j].size;
const xfer = await zsession.send_offer({
name,
size: stat.size,
mtime: new Date(stat.mtimeMs),
files_remaining: filePaths.length - i,
bytes_remaining: bytesRemaining,
});
if (!xfer) {
// Receiver skipped this file
continue;
}
// Read and send in chunks
const CHUNK_SIZE = 64 * 1024; // Leave room for inbound ZMODEM control frames
const fd = fs.openSync(filePath, "r");
const buf = Buffer.alloc(CHUNK_SIZE);
let sent = 0;
try {
while (true) {
const bytesRead = fs.readSync(fd, buf, 0, CHUNK_SIZE);
if (bytesRead === 0) break;
// zmodem.js send() is synchronous and triggers writeToRemote via
// the sentry's sender callback. Yield after each chunk so the
// event loop can flush buffered writes and process inbound control
// frames, preventing unbounded memory growth on slow links.
xfer.send(new Uint8Array(buf.buffer, buf.byteOffset, bytesRead));
sent += bytesRead;
safeSend(contents, "netcatty:zmodem:progress", {
sessionId,
filename: name,
transferred: sent,
total: stat.size,
fileIndex: i,
fileCount: filePaths.length,
transferType: "upload",
});
// Wait for transport to drain if its buffer is full, then yield
// so inbound ZMODEM control frames can be processed.
if (opts.waitForDrain) await opts.waitForDrain();
await yieldToIO();
}
// All data written to Node.js buffer — but TCP may still be
// flushing to the remote. Show "finalizing" state while we
// wait for the remote to acknowledge.
safeSend(contents, "netcatty:zmodem:progress", {
sessionId,
filename: name,
transferred: stat.size,
total: stat.size,
fileIndex: i,
fileCount: filePaths.length,
transferType: "upload",
finalizing: true,
});
await withTimeout(xfer.end(), 120000);
} finally {
fs.closeSync(fd);
}
}
await withTimeout(zsession.close(), 120000);
}
/**
* Download files from the remote (remote executed `sz <file>`).
*/
async function handleDownload(zsession, opts) {
const { sessionId, getWebContents } = opts;
const contents = getWebContents();
const { BrowserWindow, dialog } = getElectron();
const win = contents ? BrowserWindow.fromWebContents(contents) : null;
let fileIndex = 0;
const pendingStreams = [];
const pendingOffers = [];
let lastProgressTime = 0;
let downloadDir = null;
let rejectSession = () => {};
const processOffer = (xfer, reject) => {
if (!downloadDir) {
pendingOffers.push(xfer);
return;
}
const detail = xfer.get_details();
// Sanitize filename to prevent path traversal attacks
const rawName = detail.name || `untitled_${Date.now()}`;
const name = path.basename(rawName);
const size = detail.size || 0;
const savePath = path.join(downloadDir, name);
const currentIndex = fileIndex++;
safeSend(contents, "netcatty:zmodem:progress", {
sessionId,
filename: name,
transferred: 0,
total: size,
fileIndex: currentIndex,
fileCount: -1, // unknown total until session ends
transferType: "download",
});
// Avoid overwriting existing files — append (1), (2), etc.
let finalPath = savePath;
if (fs.existsSync(savePath)) {
const ext = path.extname(name);
const base = path.basename(name, ext);
let n = 1;
do {
finalPath = path.join(downloadDir, `${base} (${n})${ext}`);
n++;
} while (fs.existsSync(finalPath));
}
const ws = fs.createWriteStream(finalPath);
let received = 0;
let writeAborted = false;
// Track pending write streams (and paths) for cleanup at session end
pendingStreams.push({ stream: ws, path: finalPath, completed: false });
ws.on("error", (err) => {
writeAborted = true;
console.error(`[ZMODEM] Write stream error for ${name}:`, err.message);
ws.destroy();
reject(err);
});
xfer.accept({
on_input(payload) {
if (writeAborted) return;
const chunk = Buffer.from(payload);
ws.write(chunk);
received += chunk.length;
// Throttle progress IPC to ~10 updates/sec to avoid
// overwhelming the renderer on fast links.
const now = Date.now();
if (now - lastProgressTime >= 100) {
lastProgressTime = now;
safeSend(contents, "netcatty:zmodem:progress", {
sessionId,
filename: name,
transferred: received,
total: size,
fileIndex: currentIndex,
fileCount: -1,
transferType: "download",
});
}
},
}).catch((err) => {
ws.destroy();
reject(err);
});
xfer.on("complete", () => {
const entry = pendingStreams.find((e) => e.stream === ws);
if (entry) entry.completed = true;
ws.end();
});
};
const sessionPromise = new Promise((resolve, reject) => {
rejectSession = reject;
zsession.on("offer", (xfer) => {
try {
processOffer(xfer, reject);
} catch (err) {
reject(err);
}
});
// Wait for all write streams to finish flushing before resolving.
// If a stream never received end() (e.g. transfer was cancelled),
// destroy it so the fd is released and finish/close can fire.
zsession.on("session_end", async () => {
try {
await Promise.all(
pendingStreams.map((entry) => {
const { stream: s, path: filePath, completed } = entry;
if (s.writableFinished) {
// Delete partial files that never completed
if (!completed) {
try { fs.unlinkSync(filePath); } catch { /* ignore */ }
}
return Promise.resolve();
}
if (!s.writableEnded) s.destroy();
return new Promise((r) => {
s.on("close", () => {
// Clean up partial downloads
if (!completed) {
try { fs.unlinkSync(filePath); } catch { /* ignore */ }
}
r();
});
});
})
);
} catch { /* ignore — error handler already called reject */ }
resolve();
});
});
// Start the session BEFORE showing the dialog so lrzsz doesn't
// time out waiting for ZRINIT while the user browses for a folder.
zsession.start();
const result = await dialog.showOpenDialog(win || undefined, {
properties: ["openDirectory", "createDirectory"],
title: "Select download directory (ZMODEM)",
});
if (result.canceled || !result.filePaths.length) {
try { zsession.abort(); } catch { /* ignore */ }
abortRemoteProcess(opts.writeToRemote);
void sessionPromise.catch(() => {});
throw new Error("Transfer cancelled");
}
downloadDir = result.filePaths[0];
while (pendingOffers.length) {
processOffer(pendingOffers.shift(), rejectSession);
}
await sessionPromise;
}
// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
function safeSend(contents, channel, data) {
try {
if (contents && !contents.isDestroyed()) {
contents.send(channel, data);
}
} catch {
// WebContents may have been destroyed between the check and the send
}
}
module.exports = { createZmodemSentry };

View File

@@ -505,6 +505,14 @@ const registerBridges = (win) => {
aiBridge.registerHandlers(ipcMain);
crashLogBridge.registerHandlers(ipcMain);
// ZMODEM cancel handler
ipcMain.on("netcatty:zmodem:cancel", (_event, payload) => {
const session = sessions.get(payload.sessionId);
if (session?.zmodemSentry) {
session.zmodemSentry.cancel();
}
});
// Fig autocomplete spec loader — uses dynamic import() since @withfig/autocomplete is ESM
ipcMain.handle("netcatty:figspec:list", async () => {
try {

View File

@@ -8,6 +8,7 @@ const transferCompleteListeners = new Map();
const transferErrorListeners = new Map();
const transferCancelledListeners = new Map();
const chainProgressListeners = new Map();
const zmodemListeners = new Map();
const sftpConnectionProgressListeners = new Set();
const authFailedListeners = new Map();
const languageChangeListeners = new Set();
@@ -109,6 +110,28 @@ function _deliverToListeners(sessionId, data) {
});
}
// ZMODEM file transfer events
ipcRenderer.on("netcatty:zmodem:detect", (_event, payload) => {
const set = zmodemListeners.get(payload.sessionId);
if (!set) return;
set.forEach((cb) => { try { cb({ type: "detect", ...payload }); } catch {} });
});
ipcRenderer.on("netcatty:zmodem:progress", (_event, payload) => {
const set = zmodemListeners.get(payload.sessionId);
if (!set) return;
set.forEach((cb) => { try { cb({ type: "progress", ...payload }); } catch {} });
});
ipcRenderer.on("netcatty:zmodem:complete", (_event, payload) => {
const set = zmodemListeners.get(payload.sessionId);
if (!set) return;
set.forEach((cb) => { try { cb({ type: "complete", ...payload }); } catch {} });
});
ipcRenderer.on("netcatty:zmodem:error", (_event, payload) => {
const set = zmodemListeners.get(payload.sessionId);
if (!set) return;
set.forEach((cb) => { try { cb({ type: "error", ...payload }); } catch {} });
});
ipcRenderer.on("netcatty:data", (_event, payload) => {
const set = dataListeners.get(payload.sessionId);
if (!set) return;
@@ -153,6 +176,7 @@ ipcRenderer.on("netcatty:exit", (_event, payload) => {
}
dataListeners.delete(payload.sessionId);
exitListeners.delete(payload.sessionId);
zmodemListeners.delete(payload.sessionId);
const pendingTimer = _mcpFlushTimers.get(payload.sessionId);
if (pendingTimer) {
clearTimeout(pendingTimer);
@@ -569,6 +593,14 @@ const api = {
},
setSessionEncoding: (sessionId, encoding) =>
ipcRenderer.invoke("netcatty:ssh:setEncoding", { sessionId, encoding }),
onZmodemEvent: (sessionId, cb) => {
if (!zmodemListeners.has(sessionId)) zmodemListeners.set(sessionId, new Set());
zmodemListeners.get(sessionId).add(cb);
return () => zmodemListeners.get(sessionId)?.delete(cb);
},
cancelZmodem: (sessionId) => {
ipcRenderer.send("netcatty:zmodem:cancel", { sessionId });
},
onSessionData: (sessionId, cb) => {
if (!dataListeners.has(sessionId)) dataListeners.set(sessionId, new Set());
dataListeners.get(sessionId).add(cb);
@@ -741,6 +773,9 @@ const api = {
cleanupTransferListeners(transferId);
return ipcRenderer.invoke("netcatty:transfer:cancel", { transferId });
},
sameHostCopyDirectory: async (sftpId, sourcePath, targetPath, encoding, transferId) => {
return ipcRenderer.invoke("netcatty:transfer:same-host-copy-dir", { sftpId, sourcePath, targetPath, encoding, transferId });
},
// Compressed folder upload
startCompressedUpload: async (options, onProgress, onComplete, onError) => {
const { compressionId } = options;
@@ -1204,6 +1239,9 @@ const api = {
aiAcpStream: async (requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images) => {
return ipcRenderer.invoke("netcatty:ai:acp:stream", { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images });
},
aiAcpListModels: async (acpCommand, acpArgs, cwd, providerId, chatSessionId) => {
return ipcRenderer.invoke("netcatty:ai:acp:list-models", { acpCommand, acpArgs, cwd, providerId, chatSessionId });
},
aiAcpCancel: async (requestId, chatSessionId) => {
return ipcRenderer.invoke("netcatty:ai:acp:cancel", { requestId, chatSessionId });
},

19
global.d.ts vendored
View File

@@ -263,6 +263,23 @@ declare global {
writeToSession(sessionId: string, data: string): void;
resizeSession(sessionId: string, cols: number, rows: number): void;
closeSession(sessionId: string): void;
// ZMODEM file transfer
onZmodemEvent?(
sessionId: string,
cb: (event: {
type: 'detect' | 'progress' | 'complete' | 'error';
sessionId: string;
transferType?: 'upload' | 'download';
filename?: string;
transferred?: number;
total?: number;
fileIndex?: number;
fileCount?: number;
finalizing?: boolean;
error?: string;
}) => void
): () => void;
cancelZmodem?(sessionId: string): void;
onSessionData(sessionId: string, cb: (data: string) => void): () => void;
onSessionExit(
sessionId: string,
@@ -346,6 +363,7 @@ declare global {
uploadFile?(sftpId: string, localPath: string, remotePath: string, transferId: string): Promise<void>;
downloadFile?(sftpId: string, remotePath: string, localPath: string, transferId: string): Promise<void>;
cancelTransfer?(transferId: string): Promise<void>;
sameHostCopyDirectory?(sftpId: string, sourcePath: string, targetPath: string, encoding?: SftpFilenameEncoding, transferId?: string): Promise<{ success: boolean }>;
// Compressed folder upload
startCompressedUpload?(
@@ -383,6 +401,7 @@ declare global {
totalBytes?: number;
sourceEncoding?: SftpFilenameEncoding;
targetEncoding?: SftpFilenameEncoding;
sameHost?: boolean;
},
onProgress?: (transferred: number, total: number, speed: number) => void,
onComplete?: () => void,

View File

@@ -0,0 +1,69 @@
import type { DiscoveredAgent, ExternalAgentConfig } from './types';
export type ManagedAgentKey = 'codex' | 'claude' | 'copilot';
const MANAGED_AGENT_META: Record<ManagedAgentKey, { commandNames: string[]; acpCommand: string }> = {
codex: { commandNames: ['codex', 'codex-acp'], acpCommand: 'codex-acp' },
claude: { commandNames: ['claude', 'claude-agent-acp'], acpCommand: 'claude-agent-acp' },
copilot: { commandNames: ['copilot'], acpCommand: 'copilot' },
};
function getCommandBasename(command: string | undefined): string {
const normalized = String(command || '').trim();
if (!normalized) return '';
const parts = normalized.split(/[\\/]/);
return (parts.pop() || '').toLowerCase();
}
function isPathLikeCommand(command: string | undefined): boolean {
const normalized = String(command || '').trim();
return normalized.includes('/') || normalized.includes('\\');
}
function matchesPrimaryCliBasename(command: string | undefined, agentKey: ManagedAgentKey): boolean {
const basename = getCommandBasename(command);
return basename === agentKey || basename.startsWith(`${agentKey}.`);
}
export function isSettingsManagedDiscoveredAgent(
agent: Pick<DiscoveredAgent, 'command'>,
): agent is Pick<DiscoveredAgent, 'command'> & { command: ManagedAgentKey } {
return agent.command === 'codex' || agent.command === 'claude' || agent.command === 'copilot';
}
export function matchesManagedAgentConfig(
agent: Pick<ExternalAgentConfig, 'id' | 'command' | 'acpCommand'>,
agentKey: ManagedAgentKey,
): boolean {
const meta = MANAGED_AGENT_META[agentKey];
const basename = getCommandBasename(agent.command);
return (
agent.id === `discovered_${agentKey}` ||
agent.acpCommand === meta.acpCommand ||
meta.commandNames.some((commandName) => basename === commandName || basename.startsWith(`${commandName}.`))
);
}
export function getManagedAgentStoredPath(
agents: ExternalAgentConfig[],
agentKey: ManagedAgentKey,
): string | null {
const managedId = `discovered_${agentKey}`;
const preferredAgent = agents.find(
(agent) =>
agent.id === managedId &&
isPathLikeCommand(agent.command) &&
matchesPrimaryCliBasename(agent.command, agentKey),
);
if (preferredAgent) {
return preferredAgent.command;
}
const fallbackAgent = agents.find(
(agent) =>
matchesManagedAgentConfig(agent, agentKey) &&
isPathLikeCommand(agent.command) &&
matchesPrimaryCliBasename(agent.command, agentKey),
);
return fallbackAgent?.command ?? null;
}

22
package-lock.json generated
View File

@@ -57,6 +57,7 @@
"use-stick-to-bottom": "^1.1.3",
"uuid": "^13.0.0",
"webdav": "^5.8.0",
"zmodem.js": "^0.1.10",
"zod": "^4.3.6"
},
"devDependencies": {
@@ -8282,6 +8283,18 @@
"buffer": "^5.1.0"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"license": "Apache-2.0",
"bin": {
"crc32": "bin/crc32.njs"
},
"engines": {
"node": ">=0.8"
}
},
"node_modules/cross-dirname": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz",
@@ -16276,6 +16289,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zmodem.js": {
"version": "0.1.10",
"resolved": "https://registry.npmjs.org/zmodem.js/-/zmodem.js-0.1.10.tgz",
"integrity": "sha512-Z1DWngunZ/j3BmIzSJpFZVNV73iHkj89rxXX4IciJdU9ga3nZ7rJ5LkfjV/QDsKhc7bazDWTTJCLJ+iRXD82dw==",
"license": "Apache-2.0",
"dependencies": {
"crc-32": "^1.1.1"
}
},
"node_modules/zod": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",

View File

@@ -75,6 +75,7 @@
"use-stick-to-bottom": "^1.1.3",
"uuid": "^13.0.0",
"webdav": "^5.8.0",
"zmodem.js": "^0.1.10",
"zod": "^4.3.6"
},
"devDependencies": {

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96" width="96" height="96"><path d="M95.667 67.954C92.225 73.933 72.24 88.04 47.997 88.04 23.754 88.04 3.769 73.933.328 67.954c-.216-.375-.307-.796-.328-1.226V55.661c.019-.371.089-.736.226-1.081 1.489-3.738 5.386-9.166 10.417-10.623.667-1.712 1.655-4.215 2.576-6.062-.154-1.414-.208-2.872-.208-4.345 0-5.322 1.128-9.99 4.527-13.466 1.587-1.623 3.557-2.869 5.893-3.805 5.595-4.545 13.563-8.369 24.48-8.369s19.057 3.824 24.652 8.369c2.337.936 4.306 2.182 5.894 3.805 3.399 3.476 4.527 8.144 4.527 13.466 0 1.473-.054 2.931-.208 4.345.921 1.847 1.909 4.35 2.576 6.062 5.03 1.457 8.928 6.885 10.417 10.623.163.41.231.848.231 1.289v10.644c0 .504-.081 1.004-.333 1.441ZM48.686 43.993l-.3.001-1.077-.001c-.423.709-.894 1.39-1.418 2.035-3.078 3.787-7.672 5.964-14.026 5.964-6.897 0-11.952-1.435-15.123-5.032a7.886 7.886 0 0 1-.342-.419l-.39.419v26.326c5.737 3.118 18.05 8.713 31.987 8.713 13.938 0 26.251-5.595 31.988-8.713V46.96l-.39-.419s-.132.181-.342.419c-3.171 3.597-8.226 5.032-15.123 5.032-6.354 0-10.949-2.177-14.026-5.964a17.178 17.178 0 0 1-1.418-2.034h-.066l.066-.001Zm-3.94-11.733c.17-1.326.251-2.513.253-3.573v-.084c-.005-3.077-.678-5.079-1.752-6.308-1.365-1.562-4.184-2.758-10.127-2.115-6.021.652-9.386 2.146-11.294 4.098-1.847 1.889-2.818 4.715-2.818 9.272 0 4.842.698 7.703 2.232 9.443 1.459 1.655 4.332 3.001 10.625 3.001 4.837 0 7.603-1.573 9.371-3.749 1.899-2.336 2.967-5.759 3.51-9.985Zm6.503 0c.543 4.226 1.611 7.649 3.51 9.985 1.768 2.176 4.533 3.749 9.371 3.749 6.292 0 9.165-1.346 10.624-3.001 1.535-1.74 2.232-4.601 2.232-9.443 0-4.557-.97-7.383-2.817-9.272-1.908-1.952-5.274-3.446-11.294-4.098-5.943-.643-8.763.553-10.127 2.115-1.074 1.229-1.747 3.231-1.752 6.308v.084c.002 1.06.083 2.247.253 3.573Zm-2.563 11.734h.066l-.066-.001v.001Z"></path><path d="M38.5 55.75a3.5 3.5 0 0 1 3.5 3.5v8.5a3.5 3.5 0 1 1-7 0v-8.5a3.5 3.5 0 0 1 3.5-3.5Zm19 0a3.5 3.5 0 0 1 3.5 3.5v8.5a3.5 3.5 0 1 1-7 0v-8.5a3.5 3.5 0 0 1 3.5-3.5Z"></path></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB