Compare commits

...

140 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
陈大猫
9397a781b5 refactor: unify directory download with upload transfer system (#560)
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
* refactor: unify directory download with upload transfer system

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

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

Net reduction of ~260 lines.

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

* chore: remove dead code from directory download refactor

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

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

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

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

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

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

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

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

* fix: add symlink cycle detection and propagate child failures

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: add symlink depth guard to countDirectoryFiles and estimateDirectoryBytes

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

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

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

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

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

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

* fix: add ancestor path cycle detection for symlink directories

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* refactor: remove redundant completed state update for directory transfers

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

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

* fix: route partial directory failures through shared completion path

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

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

* fix: restrict symlink directory recursion to downloadToLocal only

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 01:27:46 +08:00
陈大猫
255a4730e7 feat: make SFTP folder transfer concurrency configurable (#558)
* feat: make SFTP folder transfer concurrency configurable

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

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

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

* fix: sync transfer concurrency setting across windows

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

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

* fix: move setSftpTransferConcurrency after notifySettingsChanged

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

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 22:04:48 +08:00
陈大猫
de0d1e1912 perf: use fallback viewport height for transfer child list virtualization (#559)
When the transfer child list crosses the virtualization threshold (80
items), viewportHeight may be 0 if the layout hasn't been measured yet.
Previously this caused all children to render on the first frame,
creating a lag spike when clicking "show details" on large transfers.

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

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

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

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

Closes #556

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

* fix: sync workspace focus style across windows

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

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 21:31:15 +08:00
bincxz
e57376c461 fix: remove popd from FOLDER_ONLY and resolve score collision
- Remove popd from FOLDER_ONLY_COMMANDS since it does not accept
  path arguments (it pops from the directory stack)
- Change recent-history score from 700 to 720 to avoid collision
  with spec option suggestions (also 700), giving recent history
  a clear rank: path (750) > recent history (720) > options (700)

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

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

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

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

Also add popd to FOLDER_ONLY_COMMANDS.

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

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

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

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

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

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

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

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

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

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

Closes #551

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

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

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

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

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

Closes #550

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

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

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

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

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

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

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

* refactor: store default opener in separate localStorage key

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

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 19:30:54 +08:00
陈大猫
aeacd913f5 feat: sync global SFTP bookmarks via cloud sync (#553)
* feat: sync global SFTP bookmarks via cloud sync

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

Local bookmarks remain device-specific by design.

Closes #548

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

* fix: deduplicate global SFTP bookmarks by path during merge

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

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

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

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

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 18:59:58 +08:00
陈大猫
67b78abfce fix: sort directory symlinks with directories in SFTP file list (#552)
Symlinks pointing to directories (DirLinks) were sorted with regular
files instead of being grouped with directories. Reuse the existing
isNavigableDirectory() helper so these entries sort alongside real
directories.

Closes #549

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

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

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

* feat(sftp): add tree view i18n keys

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

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

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

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

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

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

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

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

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

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

* perf(sftp): optimize SftpPaneTreeView render pipeline

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

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

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

* fix: harden SFTP tree view actions and selection

* fix: support tree selection shortcuts and nested create targets

* fix: keep SFTP tree view sorting in sync

* Improve SFTP tree view interactions and refresh behavior

* Optimize SFTP tree refresh and pane state usage

* Reduce remaining SFTP tree performance overhead

* Fix nested SFTP drop target routing

* Restore keyboard access to parent tree entry

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

This reverts commit 6d19413025.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

This reverts commit f739e81e8d7691eb33965f6c431623a257fd8b4b.

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

* fix: invalidate target cache after transfers

* fix: reload tree root after create mutations

* fix: use receive callback for tree drop targets

* fix: trigger pane refresh after transfer completion

* fix: handle transfer refresh tokens only once

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

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

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

* feat: improve list view keyboard and folder drops

* fix: strengthen list view keyboard selection feedback

* style: make list view selection more obvious

* fix: keep list selection visible during keyboard navigation

* fix: rerender list rows when selection changes

* fix: sync list selection highlight updates

* style: align list selection with tree view

* style: hide list selection highlight when pane is unfocused

* feat: clear list selection when clicking empty space

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

* perf: make transfer size discovery asynchronous

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

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

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

* refine sftp transfer panel ux

* fix sftp sidebar and upload task flow

* polish sftp transfer interactions

---------

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

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

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

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

Closes #542

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

* fix: use resetProviderStatus instead of disconnectProvider on auth failure

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

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

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

* fix: add resetProviderStatus to CloudSyncHook interface

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 19:24:06 +08:00
陈大猫
262bc57a21 feat: enable Unicode 11 for improved Nerd Fonts rendering (#545)
Load @xterm/addon-unicode11 and set activeVersion to '11' for better
character width handling of Nerd Fonts, Powerline glyphs, and CJK
characters. This matches the approach used by tabby terminal.

Closes #543 (Nerd Fonts portion)

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

Closes #543 (Nerd Fonts portion)

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

* fix: retarget restored AI sessions on reconnect

* feat: format tool call results with proper line breaks

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

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

* fix: restrict unescape to stdout/stderr fields only

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

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

* fix: address review findings for AI chat reconnect

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

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

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

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

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

* fix: address round 2 review findings

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

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

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

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

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

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

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

* fix: address round 3 review findings

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: restore MCP sync for hidden panels

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

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

* chore: remove unused deletedIds variable

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

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 18:47:32 +08:00
陈大猫
c3224d30c6 feat: network device mode for SSH + serial charset encoding support (#540)
* feat: add deviceType field to Host model for network device support

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

* feat: pass deviceType through session metadata pipeline

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: suppress deviceType metadata for Mosh sessions

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: move serialEncoding declaration before session object creation

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

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

* fix: tighten isNetworkDevice logic and clean up edge cases

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

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

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

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

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

* fix: include deviceType in get_environment and guard execViaChannel fallback

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

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

* feat: add charset setting to serial host configuration UI

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

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

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

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

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

* fix: constrain serial connect modal height with scrollable content

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

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

* fix: propagate charset through all serial session creation paths

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

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

* fix: propagate charset in remaining session creation paths

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

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 18:33:16 +08:00
陈大猫
40d80fe535 perf: comprehensive UI and state management optimization (#539)
* perf: comprehensive performance optimization across UI and state management

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

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

* fix: flush final resize value before canceling RAF

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

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

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

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

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

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

* fix: revert useDeferredValue for QuickSwitcher search

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

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

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

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

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

* fix: defer initialConnectDoneRef flag until auto-connect executes

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

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

* fix: capture resizingRef fields before setState updater

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

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

* fix: remove activeTabId from activityTrackedSessions to stabilize subscriptions

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: reset fetchInFlightRef on disconnect to unblock reconnect stats

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

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

* fix: clear fetchInFlightRef when tab becomes hidden

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

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

* fix: use generation counter to invalidate stale stats responses

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

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

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

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

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

---------

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

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

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

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

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

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

---------

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

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

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

* fix: buffer incomplete escape sequences across data chunks

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

Also fix missing trailing newline in sessionActivity.ts.

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: reset download state when downloadUpdate() rejects

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: clamp max_tokens to minimum of 1 after rounding

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

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

* fix: reject non-finite max_tokens before persisting

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* chore: 清理全部调试日志

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

* fix: toggle bookmark correctly removes global-only bookmarks

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

Closes #520

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

* fix: review 问题全量修复

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

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

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

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

* fix: 第二轮 review 问题修复

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

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

* fix: Codex review 问题修复

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

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

* fix: Codex 第二轮 review 修复

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

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

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

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

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

* fix: noResponseTimer 增加无输出提示

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

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

* fix: Codex 第 5 轮 review 修复

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

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 12:04:17 +08:00
165 changed files with 16424 additions and 4613 deletions

410
App.tsx
View File

@@ -1,4 +1,4 @@
import React, { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { Suspense, lazy, useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react';
import { activeTabStore, useActiveTabId, useIsSftpActive, useIsTerminalLayerVisible, useIsVaultActive } from './application/state/activeTabStore';
import { useAutoSync } from './application/state/useAutoSync';
import { useImmersiveMode } from './application/state/useImmersiveMode';
@@ -19,10 +19,11 @@ import { resolveHostTerminalThemeId } from './domain/terminalAppearance';
import { collectSessionIds } from './domain/workspace';
import { TERMINAL_THEMES } from './infrastructure/config/terminalThemes';
import { useCustomThemes } from './application/state/customThemeStore';
import { applySyncPayload } from './domain/syncPayload';
import { applySyncPayload } from './application/syncPayload';
import { getCredentialProtectionAvailability } from './infrastructure/services/credentialProtection';
import { netcattyBridge } from './infrastructure/services/netcattyBridge';
import { localStorageAdapter } from './infrastructure/persistence/localStorageAdapter';
import { STORAGE_KEY_DEBUG_HOTKEYS } from './infrastructure/config/storageKeys';
import { TopTabs } from './components/TopTabs';
import { Button } from './components/ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from './components/ui/dialog';
@@ -103,8 +104,7 @@ const LazyCreateWorkspaceDialog = lazy(() =>
const IS_DEV = import.meta.env.DEV;
const HOTKEY_DEBUG =
IS_DEV &&
typeof window !== "undefined" &&
window.localStorage?.getItem("debug.hotkeys") === "1";
localStorageAdapter.readString(STORAGE_KEY_DEBUG_HOTKEYS) === "1";
const LazySftpView = lazy(() =>
import('./components/SftpView').then((m) => ({ default: m.SftpView })),
@@ -177,6 +177,7 @@ function App({ settings }: { settings: SettingsState }) {
const {
setTheme,
resolvedTheme,
terminalThemeId,
setTerminalThemeId,
currentTerminalTheme,
terminalFontFamilyId,
@@ -192,6 +193,7 @@ function App({ settings }: { settings: SettingsState }) {
sftpShowHiddenFiles,
sftpUseCompressedUpload,
sftpAutoOpenSidebar,
sftpDefaultViewMode,
editorWordWrap,
setEditorWordWrap,
sessionLogsEnabled,
@@ -199,8 +201,18 @@ function App({ settings }: { settings: SettingsState }) {
sessionLogsFormat,
reapplyCurrentTheme,
immersiveMode,
workspaceFocusStyle,
} = settings;
// Sync workspace focus indicator style to DOM for CSS targeting
useEffect(() => {
if (workspaceFocusStyle === 'border') {
document.documentElement.setAttribute('data-workspace-focus', 'border');
} else {
document.documentElement.removeAttribute('data-workspace-focus');
}
}, [workspaceFocusStyle]);
const {
hosts,
keys,
@@ -285,30 +297,48 @@ function App({ settings }: { settings: SettingsState }) {
const customThemes = useCustomThemes();
// Resolve the effective TerminalTheme for the currently focused terminal tab
const hostById = useMemo(
() => new Map(hosts.map((host) => [host.id, host])),
[hosts],
);
const sessionById = useMemo(
() => new Map(sessions.map((session) => [session.id, session])),
[sessions],
);
const workspaceById = useMemo(
() => new Map(workspaces.map((workspace) => [workspace.id, workspace])),
[workspaces],
);
const themeById = useMemo(
() => new Map([...customThemes, ...TERMINAL_THEMES].map((theme) => [theme.id, theme])),
[customThemes],
);
const activeTerminalTheme = useMemo<TerminalTheme | null>(() => {
if (activeTabId === 'vault' || activeTabId === 'sftp') return null;
const resolveTheme = (s: TerminalSession): TerminalTheme => {
const host = hosts.find(h => h.id === s.hostId) ?? null;
const host = hostById.get(s.hostId) ?? null;
const themeId = resolveHostTerminalThemeId(host, currentTerminalTheme.id);
return TERMINAL_THEMES.find(t => t.id === themeId)
|| customThemes.find(t => t.id === themeId)
|| currentTerminalTheme;
return themeById.get(themeId) || currentTerminalTheme;
};
// Workspace
const workspace = workspaces.find(w => w.id === activeTabId);
const workspace = workspaceById.get(activeTabId);
if (workspace) {
// Focus mode: use the focused (or first remaining) session's theme
if (workspace.viewMode === 'focus') {
const wsSessionIds = collectSessionIds(workspace.root);
const focused = sessions.find(s => s.id === workspace.focusedSessionId)
?? sessions.find(s => wsSessionIds.includes(s.id));
const focused = (workspace.focusedSessionId
? sessionById.get(workspace.focusedSessionId)
: null)
?? wsSessionIds.map((id) => sessionById.get(id)).find(Boolean);
return focused ? resolveTheme(focused) : null;
}
// Split mode: require all sessions to share the same theme
const sessionIds = collectSessionIds(workspace.root);
const wsSessions = sessionIds.map(id => sessions.find(s => s.id === id)).filter(Boolean) as TerminalSession[];
const wsSessions = sessionIds
.map((id) => sessionById.get(id))
.filter(Boolean) as TerminalSession[];
if (wsSessions.length === 0) return null;
const firstTheme = resolveTheme(wsSessions[0]);
const allSame = wsSessions.every(s => resolveTheme(s).id === firstTheme.id);
@@ -316,10 +346,10 @@ function App({ settings }: { settings: SettingsState }) {
}
// Single session tab
const session = sessions.find(s => s.id === activeTabId);
const session = sessionById.get(activeTabId);
if (!session) return null;
return resolveTheme(session);
}, [activeTabId, sessions, workspaces, hosts, currentTerminalTheme, customThemes]);
}, [activeTabId, currentTerminalTheme, hostById, sessionById, themeById, workspaceById]);
useImmersiveMode({
isImmersive: immersiveMode,
@@ -373,10 +403,148 @@ function App({ settings }: { settings: SettingsState }) {
}, [handleSyncNow]);
// Update check hook - checks for new versions on startup
const { updateState, dismissUpdate, openReleasePage, installUpdate } = useUpdateCheck();
const { updateState, dismissUpdate, installUpdate } = useUpdateCheck();
// Window controls - must be before update toast effect which uses openSettingsWindow
const { openSettingsWindow } = useWindowControls();
const _handleTrayJumpToSession = useEffectEvent((sessionId: string) => {
const session = sessions.find((item) => item.id === sessionId);
if (session?.workspaceId) {
setActiveTabId(session.workspaceId);
setWorkspaceFocusedSession(session.workspaceId, sessionId);
return;
}
setActiveTabId(sessionId);
});
const _handleTrayTogglePortForward = useEffectEvent((ruleId: string, start: boolean) => {
const rule = portForwardingRules.find((item) => item.id === ruleId);
if (!rule) return;
const host = rule.hostId ? hosts.find((item) => item.id === rule.hostId) : undefined;
if (!host) {
toast.error(t("pf.error.hostNotFound"));
return;
}
if (start) {
void startTunnel(rule, host, hosts, keys, identities, (status, error) => {
if (status === "error" && error) toast.error(error);
}, rule.autoStart);
return;
}
void stopTunnel(ruleId);
});
const _handleTrayPanelConnect = useEffectEvent((hostId: string) => {
const host = hosts.find((item) => item.id === hostId);
if (!host) {
toast.error(t("pf.error.hostNotFound"));
return;
}
const { username, hostname: localHost } = systemInfoRef.current;
if (host.protocol === 'serial') {
const portName = host.hostname.split('/').pop() || host.hostname;
const sessionId = connectToHost(host);
addConnectionLog({
sessionId,
hostId: host.id,
hostLabel: host.label || `Serial: ${portName}`,
hostname: host.hostname,
username,
protocol: 'serial',
startTime: Date.now(),
localUsername: username,
localHostname: localHost,
saved: false,
});
return;
}
const protocol = host.moshEnabled ? 'mosh' : (host.protocol || 'ssh');
const resolvedAuth = resolveHostAuth({ host, keys, identities });
const sessionId = connectToHost(host);
addConnectionLog({
sessionId,
hostId: host.id,
hostLabel: host.label,
hostname: host.hostname,
username: resolvedAuth.username || 'root',
protocol: protocol as 'ssh' | 'telnet' | 'local' | 'mosh',
startTime: Date.now(),
localUsername: username,
localHostname: localHost,
saved: false,
});
});
const _handleGlobalHotkeyKeyDown = useEffectEvent((e: KeyboardEvent) => {
const isMac = hotkeyScheme === 'mac';
const target = e.target as HTMLElement;
const isFormElement = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
const isMonacoElement =
target instanceof HTMLElement &&
!!target.closest?.('.monaco-editor, .monaco-diff-editor, .monaco-inputbox');
const isXtermInput =
target instanceof HTMLElement &&
!!target.closest?.(".xterm, .xterm-helper-textarea, .xterm-screen, .xterm-viewport");
if ((isFormElement || isMonacoElement) && !isXtermInput && e.key !== 'Escape') {
return;
}
const isTerminalElement =
target instanceof HTMLElement &&
!!target.closest?.(".xterm, .xterm-helper-textarea, .xterm-screen, .xterm-viewport");
const isTerminalInPath = Boolean(
e.composedPath?.().some(
(node) =>
node instanceof HTMLElement &&
(node.classList.contains("xterm") ||
node.classList.contains("xterm-helper-textarea") ||
node.classList.contains("xterm-screen") ||
node.classList.contains("xterm-viewport") ||
node.hasAttribute("data-session-id")),
),
);
for (const binding of keyBindings) {
const keyStr = isMac ? binding.mac : binding.pc;
if (!matchesKeyBinding(e, keyStr, isMac)) continue;
if (HOTKEY_DEBUG) console.log('[Hotkeys] Matched binding:', binding.action, keyStr);
if (binding.category === 'sftp') {
continue;
}
const terminalActions = ['copy', 'paste', 'selectAll', 'clearBuffer', 'searchTerminal'];
if (terminalActions.includes(binding.action)) {
if (isTerminalElement) {
return;
}
continue;
}
e.preventDefault();
e.stopPropagation();
if (HOTKEY_DEBUG) {
console.log('[Hotkeys] Global handle', {
action: binding.action,
key: e.key,
meta: e.metaKey,
ctrl: e.ctrlKey,
alt: e.altKey,
shift: e.shiftKey,
targetTag: target?.tagName,
isTerminalElement,
isTerminalInPath,
});
}
executeHotkeyAction(binding.action, e);
return;
}
});
const _handleEscapeKeyDown = useEffectEvent((e: KeyboardEvent) => {
if (e.key === 'Escape' && isQuickSwitcherOpen) {
setIsQuickSwitcherOpen(false);
}
});
// Show toast notification when update is available (only when auto-download is idle)
useEffect(() => {
@@ -408,7 +576,7 @@ function App({ settings }: { settings: SettingsState }) {
}, [updateState.hasUpdate, updateState.latestRelease, updateState.autoDownloadStatus, t, openSettingsWindow, dismissUpdate]);
// Track previous autoDownloadStatus so toast effects fire only on actual transitions,
// not when unrelated deps (openReleasePage, installUpdate) change their reference.
// not when unrelated deps (installUpdate, openSettingsWindow) change their reference.
const prevAutoDownloadStatusRef = useRef(updateState.autoDownloadStatus);
useEffect(() => {
const prev = prevAutoDownloadStatusRef.current;
@@ -431,12 +599,12 @@ function App({ settings }: { settings: SettingsState }) {
t('update.downloadFailed.message'),
{
title: t('update.downloadFailed.title'),
actionLabel: t('update.openReleases'),
onClick: () => openReleasePage(),
actionLabel: t('update.viewInSettings'),
onClick: () => void openSettingsWindow(),
}
);
}
}, [updateState.autoDownloadStatus, updateState.latestRelease?.version, t, installUpdate, openReleasePage]);
}, [updateState.autoDownloadStatus, updateState.latestRelease?.version, t, installUpdate, openSettingsWindow]);
// Auto-start port forwarding rules on app launch
usePortForwardingAutoStart({
@@ -483,110 +651,34 @@ function App({ settings }: { settings: SettingsState }) {
if (!bridge?.onTrayFocusSession || !bridge?.onTrayTogglePortForward) return;
const unsubscribeFocus = bridge.onTrayFocusSession((sessionId) => {
// Find the session to check if it belongs to a workspace
const session = sessions.find((s) => s.id === sessionId);
if (session?.workspaceId) {
// Session is in a workspace - navigate to workspace and focus the session
setActiveTabId(session.workspaceId);
setWorkspaceFocusedSession(session.workspaceId, sessionId);
} else {
// Standalone session or session not found - just set tab
setActiveTabId(sessionId);
}
_handleTrayJumpToSession(sessionId);
});
const unsubscribeToggle = bridge.onTrayTogglePortForward((ruleId, start) => {
const rule = portForwardingRules.find((r) => r.id === ruleId);
if (!rule) return;
const host = rule.hostId ? hosts.find((h) => h.id === rule.hostId) : undefined;
if (!host) {
toast.error(t("pf.error.hostNotFound"));
return;
}
if (start) {
void startTunnel(rule, host, hosts, keys, identities, (status, error) => {
if (status === "error" && error) toast.error(error);
}, rule.autoStart);
} else {
void stopTunnel(ruleId);
}
_handleTrayTogglePortForward(ruleId, start);
});
return () => {
unsubscribeFocus?.();
unsubscribeToggle?.();
};
}, [hosts, identities, keys, portForwardingRules, sessions, setActiveTabId, setWorkspaceFocusedSession, startTunnel, stopTunnel, t]);
}, []);
// Tray panel actions (from main process)
useEffect(() => {
const handlerJump = (sessionId: string) => {
// Find the session to check if it belongs to a workspace
const session = sessions.find((s) => s.id === sessionId);
if (session?.workspaceId) {
// Session is in a workspace - navigate to workspace and focus the session
setActiveTabId(session.workspaceId);
setWorkspaceFocusedSession(session.workspaceId, sessionId);
} else {
// Standalone session or session not found - just set tab
setActiveTabId(sessionId);
}
};
const handlerConnect = (hostId: string) => {
const host = hosts.find((h) => h.id === hostId);
if (!host) {
toast.error(t("pf.error.hostNotFound"));
return;
}
const { username, hostname: localHost } = systemInfoRef.current;
if (host.protocol === 'serial') {
const portName = host.hostname.split('/').pop() || host.hostname;
const sessionId = connectToHost(host);
addConnectionLog({
sessionId,
hostId: host.id,
hostLabel: host.label || `Serial: ${portName}`,
hostname: host.hostname,
username,
protocol: 'serial',
startTime: Date.now(),
localUsername: username,
localHostname: localHost,
saved: false,
});
return;
}
const protocol = host.moshEnabled ? 'mosh' : (host.protocol || 'ssh');
const resolvedAuth = resolveHostAuth({ host, keys, identities });
const sessionId = connectToHost(host);
addConnectionLog({
sessionId,
hostId: host.id,
hostLabel: host.label,
hostname: host.hostname,
username: resolvedAuth.username || 'root',
protocol: protocol as 'ssh' | 'telnet' | 'local' | 'mosh',
startTime: Date.now(),
localUsername: username,
localHostname: localHost,
saved: false,
});
};
const bridge = netcattyBridge.get();
if (!bridge?.onTrayPanelJumpToSession || !bridge?.onTrayPanelConnectToHost) return;
const unsubscribeJump = bridge.onTrayPanelJumpToSession(handlerJump);
const unsubscribeConnect = bridge.onTrayPanelConnectToHost(handlerConnect);
const unsubscribeJump = bridge.onTrayPanelJumpToSession((sessionId) => {
_handleTrayJumpToSession(sessionId);
});
const unsubscribeConnect = bridge.onTrayPanelConnectToHost((hostId) => {
_handleTrayPanelConnect(hostId);
});
return () => {
unsubscribeJump?.();
unsubscribeConnect?.();
};
}, [addConnectionLog, connectToHost, hosts, identities, keys, sessions, setActiveTabId, setWorkspaceFocusedSession, t]);
}, []);
// Keyboard-interactive authentication (2FA/MFA) event listener
useEffect(() => {
@@ -903,96 +995,21 @@ function App({ settings }: { settings: SettingsState }) {
useEffect(() => {
if (hotkeyScheme === 'disabled' || isHotkeyRecording) return;
const isMac = hotkeyScheme === 'mac';
if (HOTKEY_DEBUG) {
console.log('[Hotkeys] Registering global hotkey handler, scheme:', hotkeyScheme, 'bindings count:', keyBindings.length);
}
const handleGlobalKeyDown = (e: KeyboardEvent) => {
// Don't handle if we're in an input or textarea (except for Escape)
// Note: xterm terminal handles its own key interception via attachCustomKeyEventHandler
const target = e.target as HTMLElement;
const isFormElement = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
const isMonacoElement =
target instanceof HTMLElement &&
!!target.closest?.('.monaco-editor, .monaco-diff-editor, .monaco-inputbox');
const isXtermInput =
target instanceof HTMLElement &&
!!target.closest?.(".xterm, .xterm-helper-textarea, .xterm-screen, .xterm-viewport");
// Monaco is not always contentEditable/input, so treat it as an editor surface.
if ((isFormElement || isMonacoElement) && !isXtermInput && e.key !== 'Escape') {
return;
}
const isTerminalElement =
target instanceof HTMLElement &&
!!target.closest?.(".xterm, .xterm-helper-textarea, .xterm-screen, .xterm-viewport");
const isTerminalInPath = Boolean(
e.composedPath?.().some(
(node) =>
node instanceof HTMLElement &&
(node.classList.contains("xterm") ||
node.classList.contains("xterm-helper-textarea") ||
node.classList.contains("xterm-screen") ||
node.classList.contains("xterm-viewport") ||
node.hasAttribute("data-session-id")),
),
);
// Check each key binding
for (const binding of keyBindings) {
const keyStr = isMac ? binding.mac : binding.pc;
if (matchesKeyBinding(e, keyStr, isMac)) {
if (HOTKEY_DEBUG) console.log('[Hotkeys] Matched binding:', binding.action, keyStr);
// SFTP shortcuts are handled by SFTP-specific hooks.
if (binding.category === 'sftp') {
continue;
}
// Terminal-specific actions should be handled by the terminal
// Don't handle them at app level
const terminalActions = ['copy', 'paste', 'selectAll', 'clearBuffer', 'searchTerminal'];
if (terminalActions.includes(binding.action)) {
if (isTerminalElement) {
return; // Let terminal handle it
}
continue; // Ignore terminal actions outside terminal
}
e.preventDefault();
e.stopPropagation();
if (HOTKEY_DEBUG) {
console.log('[Hotkeys] Global handle', {
action: binding.action,
key: e.key,
meta: e.metaKey,
ctrl: e.ctrlKey,
alt: e.altKey,
shift: e.shiftKey,
targetTag: target?.tagName,
isTerminalElement,
isTerminalInPath,
});
}
executeHotkeyAction(binding.action, e);
return;
}
}
_handleGlobalHotkeyKeyDown(e);
};
window.addEventListener('keydown', handleGlobalKeyDown, true);
return () => window.removeEventListener('keydown', handleGlobalKeyDown, true);
}, [hotkeyScheme, keyBindings, isHotkeyRecording, executeHotkeyAction]);
}, [hotkeyScheme, isHotkeyRecording]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isQuickSwitcherOpen) {
setIsQuickSwitcherOpen(false);
}
_handleEscapeKeyDown(e);
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [isQuickSwitcherOpen]);
}, []);
const quickResults = useMemo(() => {
if (!isQuickSwitcherOpen) return [];
@@ -1005,7 +1022,7 @@ function App({ settings }: { settings: SettingsState }) {
)
: hosts;
return filtered;
}, [hosts, quickSearch, isQuickSwitcherOpen]);
}, [quickSearch, hosts, isQuickSwitcherOpen]);
const handleDeleteHost = useCallback((hostId: string) => {
const target = hosts.find(h => h.id === hostId);
@@ -1094,10 +1111,10 @@ function App({ settings }: { settings: SettingsState }) {
}, [addConnectionLog, connectToHost, identities, keys]);
// Wrapper to create serial session with logging
const handleConnectSerial = useCallback((config: SerialConfig) => {
const handleConnectSerial = useCallback((config: SerialConfig, options?: { charset?: string }) => {
const { username, hostname } = systemInfoRef.current;
const portName = config.path.split('/').pop() || config.path;
const sessionId = createSerialSession(config);
const sessionId = createSerialSession(config, options);
addConnectionLog({
sessionId,
hostId: '',
@@ -1304,6 +1321,8 @@ function App({ settings }: { settings: SettingsState }) {
sessions={sessions}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
terminalThemeId={terminalThemeId}
terminalFontSize={terminalFontSize}
onOpenSettings={handleOpenSettings}
onOpenQuickSwitcher={handleOpenQuickSwitcher}
onCreateLocalTerminal={handleCreateLocalTerminal}
@@ -1332,7 +1351,21 @@ function App({ settings }: { settings: SettingsState }) {
/>
</VaultViewContainer>
<SftpViewMount hosts={hosts} keys={keys} identities={identities} updateHosts={updateHosts} />
<SftpViewMount
hosts={hosts}
keys={keys}
identities={identities}
updateHosts={updateHosts}
sftpDefaultViewMode={sftpDefaultViewMode}
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
sftpAutoSync={sftpAutoSync}
sftpShowHiddenFiles={sftpShowHiddenFiles}
sftpUseCompressedUpload={sftpUseCompressedUpload}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
editorWordWrap={editorWordWrap}
setEditorWordWrap={setEditorWordWrap}
/>
<TerminalLayerMount
hosts={hosts}
@@ -1373,6 +1406,7 @@ function App({ settings }: { settings: SettingsState }) {
isBroadcastEnabled={isBroadcastEnabled}
onToggleBroadcast={toggleBroadcast}
updateHosts={updateHosts}
sftpDefaultViewMode={sftpDefaultViewMode}
sftpDoubleClickBehavior={sftpDoubleClickBehavior}
sftpAutoSync={sftpAutoSync}
sftpShowHiddenFiles={sftpShowHiddenFiles}

View File

@@ -355,6 +355,22 @@ const en: Messages = {
'settings.terminal.rendering.renderer.desc': 'Choose the terminal rendering technology. Auto will use Canvas on low-memory devices. Changes take effect on new terminal sessions.',
'settings.terminal.rendering.auto': 'Auto',
// Settings > Terminal > Workspace Focus Indicator
'settings.terminal.section.workspaceFocus': 'Workspace Focus Indicator',
'settings.terminal.workspaceFocus.style': 'Focus indicator style',
'settings.terminal.workspaceFocus.style.desc': 'How to indicate which pane is focused in split view.',
'settings.terminal.workspaceFocus.dim': 'Dim unfocused panes',
'settings.terminal.workspaceFocus.border': 'Border on focused pane',
// Settings > Terminal > Autocomplete
'settings.terminal.section.autocomplete': 'Autocomplete',
'settings.terminal.autocomplete.enabled': 'Enable autocomplete',
'settings.terminal.autocomplete.enabled.desc': 'Show command suggestions based on history and command specs as you type.',
'settings.terminal.autocomplete.ghostText': 'Ghost text',
'settings.terminal.autocomplete.ghostText.desc': 'Show inline gray suggestion text after the cursor (like fish shell).',
'settings.terminal.autocomplete.popupMenu': 'Popup menu',
'settings.terminal.autocomplete.popupMenu.desc': 'Show a floating list of multiple suggestions.',
// Settings > Shortcuts
'settings.shortcuts.section.scheme': 'Hotkey Scheme',
'settings.shortcuts.scheme.label': 'Keyboard shortcuts',
@@ -604,6 +620,8 @@ const en: Messages = {
'sftp.filter.placeholder': 'Filter by filename...',
'sftp.bookmark.add': 'Bookmark this path',
'sftp.bookmark.remove': 'Remove bookmark',
'sftp.bookmark.addGlobal': '+Global',
'sftp.bookmark.addGlobalTooltip': 'Save as global bookmark (shared across all hosts)',
'sftp.bookmark.empty': 'No bookmarks yet',
'sftp.columns.name': 'Name',
'sftp.columns.modified': 'Modified',
@@ -620,8 +638,21 @@ const en: Messages = {
'sftp.dragDropToUpload': 'Drag and drop files here to upload',
'sftp.retry': 'Retry',
'sftp.context.open': 'Open',
'sftp.context.navigateTo': 'Navigate to',
'sftp.context.moveTo': 'Move to...',
'sftp.context.moveToParent': 'Move to parent directory',
'sftp.moveTo.title': 'Move to directory',
'sftp.moveTo.placeholder': 'Enter target directory path',
'sftp.moveTo.confirm': 'Move',
'sftp.moveTo.pathNotFound': 'Directory not found or inaccessible',
'sftp.context.download': 'Download',
'sftp.context.copyToOtherPane': 'Copy to other pane',
'sftp.viewMode.label': 'View mode',
'sftp.viewMode.list': 'List view',
'sftp.viewMode.tree': 'Tree view',
'sftp.tree.loadError': 'Failed to load directory',
'sftp.tree.loading': 'Loading...',
'sftp.kind.folder': 'Folder',
'sftp.context.rename': 'Rename',
'sftp.context.permissions': 'Permissions',
'sftp.context.delete': 'Delete',
@@ -642,6 +673,13 @@ const en: Messages = {
'sftp.transfers.active': '{count} active',
'sftp.transfers.clearCompleted': 'Clear completed',
'sftp.transfers.calculatingTotal': 'Calculating total size...',
'sftp.transfers.filesCount': '{count} files',
'sftp.transfers.filesProgress': '{current}/{total} files',
'sftp.transfers.expandChildren': 'Show files',
'sftp.transfers.collapseChildren': 'Hide files',
'sftp.transfers.expandChildList': 'Show detail',
'sftp.transfers.collapseChildList': 'Hide',
'sftp.transfers.dragToResize': 'Drag to resize',
'sftp.goUp': 'Go up',
'sftp.goToTerminalCwd': 'Go to terminal directory',
'sftp.encoding.label': 'Filename Encoding',
@@ -661,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',
@@ -752,6 +793,15 @@ const en: Messages = {
// Settings > SFTP File Associations
'settings.tab.sftpFileAssociations': 'SFTP',
'settings.sftp.transferConcurrency': 'Transfer Concurrency',
'settings.sftp.transferConcurrency.desc': 'Number of files to transfer in parallel when uploading or downloading folders. Higher values may improve speed but can overwhelm some servers.',
'settings.sftp.defaultOpener': 'Default File Opener',
'settings.sftp.defaultOpener.desc': 'Choose the default application for opening files without a specific file association',
'settings.sftp.defaultOpener.ask': 'Always ask',
'settings.sftp.defaultOpener.askDesc': 'Show a dialog to choose an application each time',
'settings.sftp.defaultOpener.builtInDesc': 'Open text files in the built-in editor by default',
'settings.sftp.defaultOpener.systemApp': 'Choose Application...',
'settings.sftp.defaultOpener.systemAppDesc': 'Open files with a specific application by default',
'settings.sftpFileAssociations.title': 'SFTP File Associations',
'settings.sftpFileAssociations.desc': 'Configure default applications for opening files by extension',
'settings.sftpFileAssociations.extension': 'Extension',
@@ -780,6 +830,13 @@ const en: Messages = {
'settings.sftp.autoOpenSidebar.enable': 'Enable auto-open sidebar',
'settings.sftp.autoOpenSidebar.enableDesc': 'The SFTP sidebar will open automatically when a terminal session connects to a remote host',
'settings.sftp.defaultViewMode': 'Default View Mode',
'settings.sftp.defaultViewMode.desc': 'Choose the default view mode when opening a new SFTP tab. Per-host preferences override this setting.',
'settings.sftp.defaultViewMode.list': 'List View',
'settings.sftp.defaultViewMode.listDesc': 'Display files in a flat list for the current directory',
'settings.sftp.defaultViewMode.tree': 'Tree View',
'settings.sftp.defaultViewMode.treeDesc': 'Display files in a hierarchical tree structure',
'sftp.autoSync.success': 'File synced to remote: {fileName}',
'sftp.autoSync.error': 'Failed to sync file: {error}',
@@ -914,6 +971,10 @@ const en: Messages = {
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent is not available',
'hostDetails.agentForwarding.agentNotRunningHint': 'No SSH agent detected. Enable OpenSSH Authentication Agent in Windows Services, or use a compatible agent such as Bitwarden, 1Password, or gpg-agent.',
'hostDetails.section.agentForwarding': 'SSH Agent',
'hostDetails.section.deviceType': 'Device Type',
'hostDetails.deviceType': 'Network Device Mode',
'hostDetails.deviceType.desc': 'Enable for network equipment (switches, routers, firewalls) connected via SSH. Commands are sent as-is without shell wrapping, compatible with vendor CLIs like Huawei VRP and Cisco IOS.',
'hostDetails.deviceType.warning': 'AI agent commands will be sent directly without exit code tracking. Only enable for devices that do not run a standard shell.',
'hostDetails.section.legacyAlgorithms': 'Legacy Algorithms',
'hostDetails.legacyAlgorithms': 'Allow Legacy Algorithms',
'hostDetails.legacyAlgorithms.desc': 'Enable deprecated SSH algorithms (diffie-hellman-group1, ssh-dss, 3des-cbc, etc.) for connecting to older network equipment.',
@@ -1522,6 +1583,7 @@ const en: Messages = {
'serial.field.localEchoDesc': 'Echo typed characters locally (for devices without remote echo)',
'serial.field.lineMode': 'Line Mode',
'serial.field.lineModeDesc': 'Buffer input and send on Enter (instead of character-by-character)',
'serial.field.charset': 'Charset',
'serial.connectionError': 'Failed to connect to serial port',
'serial.field.baudRatePlaceholder': 'Select or enter baud rate...',
'serial.field.baudRateEmpty': 'Enter a custom baud rate',
@@ -1588,6 +1650,10 @@ const en: Messages = {
'ai.providers.noMatchingModels': 'No matching models',
'ai.providers.clickToLoadModels': 'Click to load models',
'ai.providers.showingModels': 'Showing first 100 of {count} models. Type to filter.',
'ai.providers.advancedParams': 'Advanced Parameters',
'ai.providers.advancedParams.hint': 'Leave blank to use provider defaults.',
'ai.providers.advancedParams.maxTokens.placeholder': 'e.g. 4096',
'ai.providers.advancedParams.default': 'Provider default',
// AI Codex
'ai.codex': 'Codex',
@@ -1621,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

@@ -428,6 +428,8 @@ const zhCN: Messages = {
'sftp.filter.placeholder': '按文件名筛选...',
'sftp.bookmark.add': '收藏此路径',
'sftp.bookmark.remove': '取消收藏',
'sftp.bookmark.addGlobal': '+全局',
'sftp.bookmark.addGlobalTooltip': '保存为全局收藏(所有主机共享)',
'sftp.bookmark.empty': '暂无收藏路径',
'sftp.columns.name': '名称',
'sftp.columns.modified': '修改时间',
@@ -444,8 +446,21 @@ const zhCN: Messages = {
'sftp.dragDropToUpload': '拖拽文件到这里上传',
'sftp.retry': '重试',
'sftp.context.open': '打开',
'sftp.context.navigateTo': '跳转到这里',
'sftp.context.moveTo': '移动到...',
'sftp.context.moveToParent': '移动到上级目录',
'sftp.moveTo.title': '移动到目录',
'sftp.moveTo.placeholder': '输入目标目录路径',
'sftp.moveTo.confirm': '移动',
'sftp.moveTo.pathNotFound': '目录不存在或无法访问',
'sftp.context.download': '下载',
'sftp.context.copyToOtherPane': '复制到另一侧',
'sftp.viewMode.label': '视图模式',
'sftp.viewMode.list': '列表视图',
'sftp.viewMode.tree': '树形视图',
'sftp.tree.loadError': '加载目录失败',
'sftp.tree.loading': '加载中...',
'sftp.kind.folder': '文件夹',
'sftp.context.rename': '重命名',
'sftp.context.permissions': '权限',
'sftp.context.delete': '删除',
@@ -466,6 +481,13 @@ const zhCN: Messages = {
'sftp.transfers.active': '{count} 个进行中',
'sftp.transfers.clearCompleted': '清除已完成',
'sftp.transfers.calculatingTotal': '正在统计总大小...',
'sftp.transfers.filesCount': '{count} 个文件',
'sftp.transfers.filesProgress': '{current}/{total} 个文件',
'sftp.transfers.expandChildren': '展开文件',
'sftp.transfers.collapseChildren': '收起文件',
'sftp.transfers.expandChildList': '展开详情',
'sftp.transfers.collapseChildList': '收起',
'sftp.transfers.dragToResize': '拖拽调整高度',
'sftp.goUp': '上一级',
'sftp.goToTerminalCwd': '定位到终端当前目录',
'sftp.encoding.label': '文件名编码',
@@ -485,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': '上传失败',
@@ -602,6 +627,10 @@ const zhCN: Messages = {
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent 不可用',
'hostDetails.agentForwarding.agentNotRunningHint': '未检测到 SSH Agent。请启用 Windows OpenSSH Authentication Agent 服务,或使用兼容的 Agent如 Bitwarden、1Password、gpg-agent。',
'hostDetails.section.agentForwarding': 'SSH 代理',
'hostDetails.section.deviceType': '设备类型',
'hostDetails.deviceType': '网络设备模式',
'hostDetails.deviceType.desc': '适用于通过 SSH 连接的网络设备(交换机、路由器、防火墙)。命令将原样发送,不进行 Shell 包装,兼容华为 VRP、Cisco IOS 等厂商 CLI。',
'hostDetails.deviceType.warning': 'AI 代理命令将直接发送,无法获取退出码。仅建议在设备不运行标准 Shell 时启用。',
'hostDetails.section.legacyAlgorithms': '旧版算法',
'hostDetails.legacyAlgorithms': '允许旧版算法',
'hostDetails.legacyAlgorithms.desc': '启用已弃用的 SSH 算法diffie-hellman-group1、ssh-dss、3des-cbc 等)以连接老旧网络设备。',
@@ -1093,6 +1122,15 @@ const zhCN: Messages = {
// Settings > SFTP File Associations
'settings.tab.sftpFileAssociations': 'SFTP',
'settings.sftp.transferConcurrency': '传输并发数',
'settings.sftp.transferConcurrency.desc': '上传或下载文件夹时并行传输的文件数量。较高的值可能提高速度,但可能导致某些服务器过载。',
'settings.sftp.defaultOpener': '默认文件打开方式',
'settings.sftp.defaultOpener.desc': '选择没有特定文件关联时的默认打开方式',
'settings.sftp.defaultOpener.ask': '每次询问',
'settings.sftp.defaultOpener.askDesc': '每次打开文件时弹出选择对话框',
'settings.sftp.defaultOpener.builtInDesc': '默认使用内置编辑器打开文本文件',
'settings.sftp.defaultOpener.systemApp': '选择应用程序...',
'settings.sftp.defaultOpener.systemAppDesc': '默认使用指定的外部应用程序打开文件',
'settings.sftpFileAssociations.title': 'SFTP 文件关联',
'settings.sftpFileAssociations.desc': '配置按扩展名打开文件的默认应用程序',
'settings.sftpFileAssociations.extension': '扩展名',
@@ -1121,6 +1159,13 @@ const zhCN: Messages = {
'settings.sftp.autoOpenSidebar.enable': '启用自动打开侧栏',
'settings.sftp.autoOpenSidebar.enableDesc': '当终端会话连接到远程主机时SFTP 侧栏将自动打开',
'settings.sftp.defaultViewMode': '默认视图模式',
'settings.sftp.defaultViewMode.desc': '选择打开新 SFTP 标签页时的默认视图模式。每个主机的偏好设置会覆盖此全局设置。',
'settings.sftp.defaultViewMode.list': '列表视图',
'settings.sftp.defaultViewMode.listDesc': '以平面列表显示当前目录的文件',
'settings.sftp.defaultViewMode.tree': '树形视图',
'settings.sftp.defaultViewMode.treeDesc': '以层级树形结构显示文件',
'sftp.autoSync.success': '文件已同步到远程:{fileName}',
'sftp.autoSync.error': '同步文件失败:{error}',
@@ -1266,6 +1311,15 @@ const zhCN: Messages = {
'settings.terminal.rendering.renderer.desc': '选择终端渲染技术。自动模式会在低内存设备上使用 Canvas。更改将在新终端会话中生效。',
'settings.terminal.rendering.auto': '自动',
// Settings > Terminal > Autocomplete
'settings.terminal.section.autocomplete': '自动补全',
'settings.terminal.autocomplete.enabled': '启用自动补全',
'settings.terminal.autocomplete.enabled.desc': '输入时根据历史命令和命令规范显示补全建议。',
'settings.terminal.autocomplete.ghostText': '行内建议',
'settings.terminal.autocomplete.ghostText.desc': '在光标后显示灰色的建议文本(类似 fish shell。',
'settings.terminal.autocomplete.popupMenu': '弹出菜单',
'settings.terminal.autocomplete.popupMenu.desc': '显示包含多个建议的浮动列表。',
// Settings > Shortcuts
'settings.shortcuts.section.scheme': '快捷键方案',
'settings.shortcuts.scheme.label': '键盘快捷键',
@@ -1536,6 +1590,7 @@ const zhCN: Messages = {
'serial.field.localEchoDesc': '本地回显输入字符(用于没有远程回显的设备)',
'serial.field.lineMode': '行模式',
'serial.field.lineModeDesc': '缓冲输入,按回车后发送(而不是逐字符发送)',
'serial.field.charset': '字符编码',
'serial.connectionError': '连接串口失败',
'serial.field.baudRatePlaceholder': '选择或输入波特率...',
'serial.field.baudRateEmpty': '输入自定义波特率',
@@ -1602,6 +1657,10 @@ const zhCN: Messages = {
'ai.providers.noMatchingModels': '没有匹配的模型',
'ai.providers.clickToLoadModels': '点击加载模型',
'ai.providers.showingModels': '显示前 100 个,共 {count} 个模型。输入以筛选。',
'ai.providers.advancedParams': '高级参数',
'ai.providers.advancedParams.hint': '留空则使用提供商默认值。',
'ai.providers.advancedParams.maxTokens.placeholder': '例如 4096',
'ai.providers.advancedParams.default': '提供商默认',
// AI Codex
'ai.codex': 'Codex',
@@ -1635,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

@@ -0,0 +1,38 @@
/**
* Application-layer notification port.
*
* UI layers (e.g. toast) register their implementation via `setNotify`.
* Application code calls `notify.*` without importing any UI module.
*/
export interface NotifyOptions {
title?: string;
duration?: number;
onClick?: () => void;
actionLabel?: string;
}
type NotifyFn = (message: string, titleOrOptions?: string | NotifyOptions) => void;
interface Notify {
success: NotifyFn;
error: NotifyFn;
warning: NotifyFn;
info: NotifyFn;
}
const noop: NotifyFn = () => {};
let _impl: Notify = { success: noop, error: noop, warning: noop, info: noop };
/** Called once by the UI layer to wire up the real implementation. */
export function setNotify(impl: Notify): void {
_impl = impl;
}
export const notify: Notify = {
success: (...args) => _impl.success(...args),
error: (...args) => _impl.error(...args),
warning: (...args) => _impl.warning(...args),
info: (...args) => _impl.info(...args),
};

View File

@@ -6,6 +6,7 @@ type Listener = () => void;
class ActiveTabStore {
private activeTabId: string = 'vault';
private listeners = new Set<Listener>();
private pendingNotify = false;
getActiveTabId = () => this.activeTabId;
@@ -13,7 +14,10 @@ class ActiveTabStore {
if (this.activeTabId !== id) {
this.activeTabId = id;
// Defer listener notification to avoid "setState during render" if called from a render phase
if (this.pendingNotify) return;
this.pendingNotify = true;
Promise.resolve().then(() => {
this.pendingNotify = false;
this.listeners.forEach(listener => listener());
});
}

View File

@@ -0,0 +1,46 @@
import { TerminalSession } from '../../types';
type SessionActivityMap = Record<string, boolean>;
export const getValidSessionActivityIds = (sessions: TerminalSession[]): Set<string> => {
return new Set(sessions.map((session) => session.id));
};
export const shouldMarkSessionActivity = (
activeTabId: string | null,
session: Pick<TerminalSession, 'id' | 'workspaceId'>,
): boolean => {
return activeTabId !== session.id && activeTabId !== session.workspaceId;
};
export const getSessionActivityIdsToClear = (
activeTabId: string | null,
sessions: TerminalSession[],
): string[] => {
if (!activeTabId || activeTabId === 'vault' || activeTabId === 'sftp') {
return [];
}
const activeSession = sessions.find((session) => session.id === activeTabId);
if (activeSession) {
return [activeSession.id];
}
return sessions
.filter((session) => session.workspaceId === activeTabId)
.map((session) => session.id);
};
export const buildWorkspaceActivityMap = (
sessions: TerminalSession[],
sessionActivityMap: SessionActivityMap,
): Map<string, boolean> => {
const workspaceActivityMap = new Map<string, boolean>();
for (const session of sessions) {
if (!session.workspaceId || !sessionActivityMap[session.id]) continue;
workspaceActivityMap.set(session.workspaceId, true);
}
return workspaceActivityMap;
};

View File

@@ -0,0 +1,78 @@
import { useSyncExternalStore } from 'react';
type Listener = () => void;
class SessionActivityStore {
private snapshot: Record<string, boolean> = {};
private listeners = new Set<Listener>();
getSnapshot = () => this.snapshot;
subscribe = (listener: Listener) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
private emit() {
this.listeners.forEach((listener) => listener());
}
setTabActive = (tabId: string, hasActivity: boolean) => {
const alreadyActive = !!this.snapshot[tabId];
if (alreadyActive === hasActivity) return;
if (hasActivity) {
this.snapshot = { ...this.snapshot, [tabId]: true };
} else {
const { [tabId]: _removed, ...rest } = this.snapshot;
this.snapshot = rest;
}
this.emit();
};
clearTab = (tabId: string) => {
this.setTabActive(tabId, false);
};
clearTabs = (tabIds: Iterable<string>) => {
let changed = false;
const next = { ...this.snapshot };
for (const tabId of tabIds) {
if (!next[tabId]) continue;
delete next[tabId];
changed = true;
}
if (!changed) return;
this.snapshot = next;
this.emit();
};
prune = (validTabIds: Set<string>) => {
let changed = false;
const next: Record<string, boolean> = {};
for (const tabId of Object.keys(this.snapshot)) {
if (validTabIds.has(tabId)) {
next[tabId] = true;
} else {
changed = true;
}
}
if (!changed) return;
this.snapshot = next;
this.emit();
};
}
export const sessionActivityStore = new SessionActivityStore();
export const useSessionActivityMap = () => {
return useSyncExternalStore(
sessionActivityStore.subscribe,
sessionActivityStore.getSnapshot,
);
};

View File

@@ -12,6 +12,7 @@ export interface SftpPane {
filter: string;
filenameEncoding: SftpFilenameEncoding;
showHiddenFiles: boolean;
transferMutationToken: number;
}
// Multi-tab state for left and right sides
@@ -39,6 +40,7 @@ export const createEmptyPane = (
filter: "",
filenameEncoding: "auto",
showHiddenFiles,
transferMutationToken: 0,
});
// File watch event types

View File

@@ -88,6 +88,8 @@ export const useSftpConnections = ({
if (!activeTabId) return;
const isReconnectAttempt = reconnectingRef.current[side];
// Notify caller of the tab ID synchronously, before any async work.
// This allows callers to map metadata (e.g. connection keys) to the tab
// immediately, avoiding race conditions with deferred effects.
@@ -466,7 +468,11 @@ export const useSftpConnections = ({
error: err instanceof Error ? err.message : "Connection failed",
}
: null,
error: err instanceof Error ? err.message : "Connection failed",
files: isReconnectAttempt ? [] : prev.files,
selectedFiles: isReconnectAttempt ? new Set<string>() : prev.selectedFiles,
error: isReconnectAttempt
? "sftp.error.reconnectFailed"
: (err instanceof Error ? err.message : "Connection failed"),
loading: false,
reconnecting: false,
}));
@@ -496,32 +502,39 @@ export const useSftpConnections = ({
!initialConnectDoneRef.current &&
leftTabs.tabs.length === 0
) {
initialConnectDoneRef.current = true;
setTimeout(() => {
const timer = window.setTimeout(() => {
initialConnectDoneRef.current = true;
connect("left", "local");
}, 0);
return () => window.clearTimeout(timer);
}
}, [autoConnectLocalOnMount, connect, leftTabs.tabs.length]);
useEffect(() => {
const attemptReconnect = async (side: "left" | "right") => {
const reconnectTimers: number[] = [];
const scheduleReconnect = (side: "left" | "right") => {
const lastHost = lastConnectedHostRef.current[side];
if (lastHost && reconnectingRef.current[side]) {
await new Promise((resolve) => setTimeout(resolve, 1000));
if (reconnectingRef.current[side]) {
connect(side, lastHost);
}
}
if (!lastHost || !reconnectingRef.current[side]) return;
const timer = window.setTimeout(() => {
if (!reconnectingRef.current[side]) return;
void connect(side, lastHost);
}, 1000);
reconnectTimers.push(timer);
};
if (leftPane.reconnecting && reconnectingRef.current.left) {
attemptReconnect("left");
scheduleReconnect("left");
}
if (rightPane.reconnecting && reconnectingRef.current.right) {
attemptReconnect("right");
scheduleReconnect("right");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [leftPane.reconnecting, rightPane.reconnecting, connect]);
return () => {
reconnectTimers.forEach((timer) => window.clearTimeout(timer));
};
}, [leftPane.reconnecting, rightPane.reconnecting, connect, lastConnectedHostRef, reconnectingRef]);
const disconnect = useCallback(
async (side: "left" | "right") => {

View File

@@ -45,7 +45,8 @@ interface SftpExternalOperationsResult {
activeFileWatchCountRef: React.MutableRefObject<number>;
uploadExternalFiles: (
side: "left" | "right",
dataTransfer: DataTransfer
dataTransfer: DataTransfer,
targetPath?: string
) => Promise<UploadResult[]>;
uploadExternalEntries: (
side: "left" | "right",
@@ -377,6 +378,7 @@ export const useSftpExternalOperations = (
speed: 0,
startTime: Date.now(),
isDirectory: true,
progressMode: "bytes",
};
addExternalUpload(scanningTask);
}
@@ -404,6 +406,8 @@ export const useSftpExternalOperations = (
speed: 0,
startTime: Date.now(),
isDirectory: task.isDirectory,
progressMode: task.progressMode ?? "bytes",
parentTaskId: task.parentTaskId,
};
addExternalUpload(transferTask);
}
@@ -505,7 +509,7 @@ export const useSftpExternalOperations = (
}, []);
const uploadExternalFiles = useCallback(
async (side: "left" | "right", dataTransfer: DataTransfer): Promise<UploadResult[]> => {
async (side: "left" | "right", dataTransfer: DataTransfer, targetPath?: string): Promise<UploadResult[]> => {
const pane = getActivePane(side);
if (!pane?.connection) {
throw new Error("No active connection");
@@ -525,13 +529,14 @@ export const useSftpExternalOperations = (
}
const uploadPaneId = pane.id;
const uploadTargetPath = targetPath || pane.connection.currentPath;
// Create a new upload controller for this upload
const controller = new UploadController();
uploadControllerRef.current = controller;
const callbacks = createUploadCallbacks(
pane.connection.id,
pane.connection.currentPath,
uploadTargetPath,
pane.connection.isLocal ? undefined : pane.connection.hostId,
pane.connection.isLocal ? undefined : connectionCacheKeyMapRef.current.get(pane.connection.id),
);
@@ -540,7 +545,7 @@ export const useSftpExternalOperations = (
const results = await uploadFromDataTransfer(
dataTransfer,
{
targetPath: pane.connection.currentPath,
targetPath: uploadTargetPath,
sftpId,
isLocal: pane.connection.isLocal,
bridge: createUploadBridge,
@@ -551,7 +556,14 @@ export const useSftpExternalOperations = (
controller
);
await refresh(side, { tabId: uploadPaneId });
// Invalidate cache for the upload target so returning to that path
// triggers a fresh listing.
if (clearDirCacheEntry && targetPath) {
clearDirCacheEntry(pane.connection.id, uploadTargetPath);
}
if (uploadTargetPath === pane.connection.currentPath) {
await refresh(side, { tabId: uploadPaneId });
}
return results;
} catch (error) {
logger.error("[SFTP] Upload failed:", error);
@@ -561,6 +573,7 @@ export const useSftpExternalOperations = (
}
},
[
clearDirCacheEntry,
connectionCacheKeyMapRef,
getActivePane,
refresh,
@@ -634,7 +647,9 @@ export const useSftpExternalOperations = (
if (clearDirCacheEntry) {
clearDirCacheEntry(pane.connection.id, uploadTargetPath);
}
await refresh(side, { tabId: uploadPaneId });
if (uploadTargetPath === pane.connection.currentPath) {
await refresh(side, { tabId: uploadPaneId });
}
return results;
} catch (error) {
logger.error("[SFTP] Upload failed:", error);

View File

@@ -3,9 +3,12 @@ import type { Host, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { logger } from "../../../lib/logger";
import { SftpPane } from "./types";
import { getParentPath, isNavigableDirectory, isWindowsRoot, joinPath } from "./utils";
import { getFileName, getParentPath, isNavigableDirectory, isWindowsRoot, joinPath } from "./utils";
import { buildCacheKey, setSharedRemoteHostCache } from "./sharedRemoteHostCache";
/** Shared empty set for navigation resets — never mutate this. */
const EMPTY_SET = new Set<string>();
interface UseSftpPaneActionsParams {
hosts: Host[];
getActivePane: (side: "left" | "right") => SftpPane | null;
@@ -25,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;
}
@@ -40,7 +44,9 @@ interface UseSftpPaneActionsResult {
setFilter: (side: "left" | "right", filter: string) => void;
getFilteredFiles: (pane: SftpPane) => SftpFileEntry[];
createDirectory: (side: "left" | "right", name: string) => Promise<void>;
createDirectoryAtPath: (side: "left" | "right", path: string, name: string) => Promise<void>;
createFile: (side: "left" | "right", name: string) => Promise<void>;
createFileAtPath: (side: "left" | "right", path: string, name: string) => Promise<void>;
deleteFiles: (side: "left" | "right", fileNames: string[]) => Promise<void>;
deleteFilesAtPath: (
side: "left" | "right",
@@ -49,6 +55,8 @@ interface UseSftpPaneActionsResult {
fileNames: string[],
) => Promise<void>;
renameFile: (side: "left" | "right", oldName: string, newName: string) => Promise<void>;
renameFileAtPath: (side: "left" | "right", oldPath: string, newName: string) => Promise<void>;
moveEntriesToPath: (side: "left" | "right", sourcePaths: string[], targetPath: string) => Promise<void>;
changePermissions: (side: "left" | "right", filePath: string, mode: string) => Promise<void>;
}
@@ -71,8 +79,39 @@ export const useSftpPaneActions = ({
listRemoteFiles,
handleSessionError,
isSessionError,
clearSelectionsExcept,
dirCacheTtlMs,
}: UseSftpPaneActionsParams): UseSftpPaneActionsResult => {
const normalizePathForCompare = useCallback((path: string): string => {
if (isWindowsRoot(path)) return path.replace(/\//g, "\\").toLowerCase();
if (/^[A-Za-z]:/.test(path)) {
return path.replace(/\//g, "\\").replace(/[\\]+$/, "").toLowerCase();
}
if (path === "/") return "/";
return path.replace(/\/+$/, "");
}, []);
const isSamePath = useCallback((a: string, b: string): boolean => {
return normalizePathForCompare(a) === normalizePathForCompare(b);
}, [normalizePathForCompare]);
const isDescendantPath = useCallback((candidate: string, parent: string): boolean => {
const normalizedCandidate = normalizePathForCompare(candidate);
const normalizedParent = normalizePathForCompare(parent);
if (normalizedCandidate === normalizedParent) return false;
if (/^[a-z]:\\$/.test(normalizedParent)) {
return normalizedCandidate.startsWith(normalizedParent);
}
if (normalizedParent === "/") {
return normalizedCandidate.startsWith("/");
}
const separator = normalizedParent.includes("\\") ? "\\" : "/";
return normalizedCandidate.startsWith(`${normalizedParent}${separator}`);
}, [normalizePathForCompare]);
// Build the shared cache key for the active pane. Prefer the last connected
// host (which includes session-time overrides), fall back to the vault hosts list.
const hostsRef = useRef(hosts);
@@ -146,7 +185,7 @@ export const useSftpPaneActions = ({
connectionId,
path,
files: cached.files,
selectedFiles: new Set(),
selectedFiles: EMPTY_SET,
});
updateTab(side, targetTabId, (prev) => ({
...prev,
@@ -156,7 +195,7 @@ export const useSftpPaneActions = ({
files: cached.files,
loading: false,
error: null,
selectedFiles: new Set(),
selectedFiles: EMPTY_SET,
}));
if (!pane.connection.isLocal) {
// Use hostId as the shared cache key — this is safe because the
@@ -200,7 +239,7 @@ export const useSftpPaneActions = ({
connection: prev.connection
? { ...prev.connection, currentPath: path }
: null,
selectedFiles: new Set(),
selectedFiles: EMPTY_SET,
loading: true,
error: null,
}));
@@ -270,7 +309,7 @@ export const useSftpPaneActions = ({
connectionId,
path,
files,
selectedFiles: new Set(),
selectedFiles: EMPTY_SET,
});
updateTab(side, targetTabId, (prev) => ({
@@ -280,7 +319,7 @@ export const useSftpPaneActions = ({
: null,
files,
loading: false,
selectedFiles: new Set(),
selectedFiles: EMPTY_SET,
}));
if (!pane.connection.isLocal) {
setSharedRemoteHostCache(getActivePaneCacheKey(side, pane.connection.hostId, pane.connection.id), {
@@ -340,6 +379,25 @@ export const useSftpPaneActions = ({
? sideTabs.tabs.find((t) => t.id === options.tabId) ?? null
: getActivePane(side);
if (pane?.connection) {
const hasRemoteSession = pane.connection.isLocal || sftpSessionsRef.current.has(pane.connection.id);
if (!hasRemoteSession) {
if (options?.tabId) return;
const lastHost = lastConnectedHostRef.current[side];
if (lastHost && !reconnectingRef.current[side]) {
reconnectingRef.current[side] = true;
updateActiveTab(side, (prev) => ({
...prev,
reconnecting: true,
error: "sftp.reconnecting.title",
}));
} else if (!lastHost) {
updateActiveTab(side, (prev) => ({
...prev,
error: "sftp.error.connectionLostManual",
}));
}
return;
}
await navigateTo(side, pane.connection.currentPath, { force: true, tabId: options?.tabId });
} else if (!pane?.connection && pane?.error) {
// For background tabs, don't trigger reconnection (it operates on
@@ -362,7 +420,7 @@ export const useSftpPaneActions = ({
}
}
},
[getActivePane, leftTabsRef, rightTabsRef, navigateTo, updateActiveTab, lastConnectedHostRef, reconnectingRef],
[getActivePane, leftTabsRef, rightTabsRef, navigateTo, updateActiveTab, lastConnectedHostRef, reconnectingRef, sftpSessionsRef],
);
const navigateUp = useCallback(
@@ -409,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)) {
@@ -419,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 !== "..") {
@@ -433,11 +499,11 @@ export const useSftpPaneActions = ({
updateActiveTab(side, (prev) => ({ ...prev, selectedFiles: newSelection }));
},
[updateActiveTab],
[updateActiveTab, clearSelectionsExcept, leftTabsRef, rightTabsRef],
);
const clearSelection = useCallback((side: "left" | "right") => {
updateActiveTab(side, (prev) => ({ ...prev, selectedFiles: new Set() }));
updateActiveTab(side, (prev) => ({ ...prev, selectedFiles: EMPTY_SET }));
}, [updateActiveTab]);
const selectAll = useCallback(
@@ -467,12 +533,12 @@ export const useSftpPaneActions = ({
);
}, []);
const createDirectory = useCallback(
async (side: "left" | "right", name: string) => {
const createDirectoryAtPath = useCallback(
async (side: "left" | "right", path: string, name: string) => {
const pane = getActivePane(side);
if (!pane?.connection) return;
const fullPath = joinPath(pane.connection.currentPath, name);
const fullPath = joinPath(path, name);
try {
if (pane.connection.isLocal) {
@@ -485,7 +551,9 @@ export const useSftpPaneActions = ({
}
await netcattyBridge.get()?.mkdirSftp(sftpId, fullPath, pane.filenameEncoding);
}
await refresh(side);
if (pane.connection.currentPath === path) {
await refresh(side);
}
} catch (err) {
if (isSessionError(err)) {
handleSessionError(side, err as Error);
@@ -497,12 +565,21 @@ export const useSftpPaneActions = ({
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
);
const createFile = useCallback(
const createDirectory = useCallback(
async (side: "left" | "right", name: string) => {
const pane = getActivePane(side);
if (!pane?.connection) return;
await createDirectoryAtPath(side, pane.connection.currentPath, name);
},
[createDirectoryAtPath, getActivePane],
);
const fullPath = joinPath(pane.connection.currentPath, name);
const createFileAtPath = useCallback(
async (side: "left" | "right", path: string, name: string) => {
const pane = getActivePane(side);
if (!pane?.connection) return;
const fullPath = joinPath(path, name);
try {
if (pane.connection.isLocal) {
@@ -529,7 +606,9 @@ export const useSftpPaneActions = ({
throw new Error("No write method available");
}
}
await refresh(side);
if (pane.connection.currentPath === path) {
await refresh(side);
}
} catch (err) {
if (isSessionError(err)) {
handleSessionError(side, err as Error);
@@ -541,6 +620,15 @@ export const useSftpPaneActions = ({
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
);
const createFile = useCallback(
async (side: "left" | "right", name: string) => {
const pane = getActivePane(side);
if (!pane?.connection) return;
await createFileAtPath(side, pane.connection.currentPath, name);
},
[createFileAtPath, getActivePane],
);
const deleteFiles = useCallback(
async (side: "left" | "right", fileNames: string[]) => {
const pane = getActivePane(side);
@@ -686,6 +774,139 @@ export const useSftpPaneActions = ({
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
);
// Rename using a full source path (for tree view where entryPath is already absolute).
// newName is still a basename; the new path is built as joinPath(parent, newName).
const renameFileAtPath = useCallback(
async (side: "left" | "right", oldPath: string, newName: string) => {
const pane = getActivePane(side);
if (!pane?.connection) return;
const parentPath = getParentPath(oldPath);
const newPath = joinPath(parentPath, newName);
try {
if (pane.connection.isLocal) {
await netcattyBridge.get()?.renameLocalFile?.(oldPath, newPath);
} else {
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
handleSessionError(side, new Error("SFTP session not found"));
return;
}
await netcattyBridge.get()?.renameSftp?.(sftpId, oldPath, newPath, pane.filenameEncoding);
}
if (pane.connection.currentPath === parentPath) {
await refresh(side);
}
} catch (err) {
if (isSessionError(err)) {
handleSessionError(side, err as Error);
return;
}
throw err;
}
},
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
);
const moveEntriesToPath = useCallback(
async (side: "left" | "right", sourcePaths: string[], targetPath: string) => {
const pane = getActivePane(side);
if (!pane?.connection || sourcePaths.length === 0) return;
const uniqueSources = Array.from(new Set(sourcePaths.filter(Boolean)));
const filteredSources = uniqueSources
.sort((a, b) => a.length - b.length)
.filter((path, index, arr) =>
!arr.slice(0, index).some((otherPath) => isSamePath(path, otherPath) || isDescendantPath(path, otherPath)),
);
const movableSources = filteredSources.filter((sourcePath) => {
if (isSamePath(sourcePath, targetPath)) return false;
if (isDescendantPath(targetPath, sourcePath)) return false;
const destinationPath = joinPath(targetPath, getFileName(sourcePath));
return !isSamePath(destinationPath, sourcePath);
});
if (movableSources.length === 0) return;
const sourceParentNames = new Map<string, string[]>();
for (const sourcePath of movableSources) {
const parentPath = getParentPath(sourcePath);
const names = sourceParentNames.get(parentPath) ?? [];
names.push(getFileName(sourcePath));
sourceParentNames.set(parentPath, names);
}
try {
if (pane.connection.isLocal) {
const renameLocalFile = netcattyBridge.get()?.renameLocalFile;
if (!renameLocalFile) {
throw new Error("Local rename unavailable");
}
for (const sourcePath of movableSources) {
const destinationPath = joinPath(targetPath, getFileName(sourcePath));
await renameLocalFile(sourcePath, destinationPath);
}
} else {
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
handleSessionError(side, new Error("SFTP session not found"));
return;
}
const renameSftp = netcattyBridge.get()?.renameSftp;
if (!renameSftp) {
throw new Error("SFTP rename unavailable");
}
for (const sourcePath of movableSources) {
const destinationPath = joinPath(targetPath, getFileName(sourcePath));
await renameSftp(sftpId, sourcePath, destinationPath, pane.filenameEncoding);
}
}
clearCacheForConnection(pane.connection.id);
const currentPath = pane.connection.currentPath;
const sourceParents = Array.from(sourceParentNames.keys());
const currentPathAffected =
sourceParents.some((path) => isSamePath(path, currentPath)) ||
isSamePath(targetPath, currentPath);
if (currentPathAffected) {
await refresh(side);
} else {
updateActiveTab(side, (prev) => {
if (!prev.connection || prev.connection.id !== pane.connection?.id) {
return prev;
}
const namesInCurrentPath = sourceParentNames.get(prev.connection.currentPath);
if (!namesInCurrentPath || namesInCurrentPath.length === 0) {
return prev;
}
const removeSet = new Set(namesInCurrentPath);
const nextSelection = new Set(prev.selectedFiles);
for (const name of removeSet) {
nextSelection.delete(name);
}
return {
...prev,
files: prev.files.filter((file) => !removeSet.has(file.name)),
selectedFiles: nextSelection,
};
});
}
} catch (err) {
if (isSessionError(err)) {
handleSessionError(side, err as Error);
return;
}
throw err;
}
},
[clearCacheForConnection, getActivePane, handleSessionError, isDescendantPath, isSamePath, isSessionError, refresh, sftpSessionsRef, updateActiveTab],
);
const changePermissions = useCallback(
async (
side: "left" | "right",
@@ -730,10 +951,14 @@ export const useSftpPaneActions = ({
setFilter,
getFilteredFiles,
createDirectory,
createDirectoryAtPath,
createFile,
createFileAtPath,
deleteFiles,
deleteFilesAtPath,
renameFile,
renameFileAtPath,
moveEntriesToPath,
changePermissions,
};
};

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,

File diff suppressed because it is too large Load Diff

View File

@@ -47,27 +47,63 @@ function cleanupAcpSessions(sessionIds: string[]) {
}
}
function isScopeKeyActive(scopeKey: string, activeTargetIds: Set<string>) {
const separatorIndex = scopeKey.indexOf(':');
if (separatorIndex === -1) return true;
const targetId = scopeKey.slice(separatorIndex + 1);
if (!targetId) return true;
return activeTargetIds.has(targetId);
}
export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
const currentSessions = latestAISessionsSnapshot
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
?? [];
const removedSessionIds = currentSessions
const orphanedSessionIds = currentSessions
.filter((session) => session.scope.targetId && !activeTargetIds.has(session.scope.targetId))
.map((session) => session.id);
if (removedSessionIds.length === 0) return;
if (orphanedSessionIds.length > 0) {
const orphanedSessionIdSet = new Set(orphanedSessionIds);
cleanupAcpSessions(removedSessionIds);
// Determine which sessions can be restored via host-based matching
const preservedIds = new Set<string>();
for (const session of currentSessions) {
if (!orphanedSessionIdSet.has(session.id)) continue;
// Only preserve remote terminal sessions with real hostIds
const isRestorable = session.scope.type === 'terminal'
&& session.scope.hostIds?.length
&& session.scope.hostIds.some((id) => !id.startsWith('local-') && !id.startsWith('serial-'));
if (isRestorable) {
preservedIds.add(session.id);
}
}
const removedSessionIdSet = new Set(removedSessionIds);
// Cleanup ACP sessions for all orphans (both deleted and preserved).
// Preserved sessions will get a new externalSessionId on next use,
// so cleaning the old one is safe and prevents subprocess leaks.
cleanupAcpSessions(orphanedSessionIds);
const nextSessions = currentSessions.filter((session) => {
if (!session.scope.targetId) return true;
return activeTargetIds.has(session.scope.targetId);
});
setLatestAISessionsSnapshot(nextSessions);
localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, pruneSessionsForStorage(nextSessions));
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
const nextSessions = currentSessions
.filter((session) => !orphanedSessionIdSet.has(session.id) || preservedIds.has(session.id))
.map((session) => {
if (!preservedIds.has(session.id) || !session.externalSessionId) {
return session;
}
// Drop transient ACP session handles so the next turn starts cleanly.
return { ...session, externalSessionId: undefined };
});
const sessionsChanged = nextSessions.length !== currentSessions.length
|| nextSessions.some((session, index) => session !== currentSessions[index]);
if (sessionsChanged) {
setLatestAISessionsSnapshot(nextSessions);
localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, pruneSessionsForStorage(nextSessions));
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
}
}
const activeSessionIdMap = latestAIActiveSessionMapSnapshot
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
@@ -75,11 +111,10 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
let activeSessionMapChanged = false;
const nextActiveSessionIdMap = { ...activeSessionIdMap };
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
if (sessionId && removedSessionIdSet.has(sessionId)) {
nextActiveSessionIdMap[scopeKey] = null;
activeSessionMapChanged = true;
}
for (const scopeKey of Object.keys(activeSessionIdMap)) {
if (isScopeKeyActive(scopeKey, activeTargetIds)) continue;
delete nextActiveSessionIdMap[scopeKey];
activeSessionMapChanged = true;
}
if (activeSessionMapChanged) {
@@ -126,6 +161,19 @@ function setLatestAIActiveSessionMapSnapshot(activeSessionIdMap: Record<string,
latestAIActiveSessionMapSnapshot = activeSessionIdMap;
}
function buildScopeKey(scope: AISessionScope) {
return `${scope.type}:${scope.targetId ?? ''}`;
}
function areHostIdsEqual(left?: string[], right?: string[]) {
const leftIds = left ?? [];
const rightIds = right ?? [];
if (leftIds.length !== rightIds.length) return false;
const rightSet = new Set(rightIds);
return leftIds.every((hostId) => rightSet.has(hostId));
}
export function useAIState() {
// ── Provider Config ──
const [providers, setProvidersRaw] = useState<ProviderConfig[]>(() =>
@@ -598,6 +646,61 @@ export function useAIState() {
});
}, [debouncedPersistSessions]);
const retargetSessionScope = useCallback((sessionId: string, scope: AISessionScope) => {
const currentSession = sessionsRef.current.find((session) => session.id === sessionId);
if (!currentSession) return;
const currentScope = currentSession.scope;
const scopeChanged =
currentScope.type !== scope.type
|| currentScope.targetId !== scope.targetId
|| !areHostIdsEqual(currentScope.hostIds, scope.hostIds);
const nextScopeKey = buildScopeKey(scope);
const currentScopeKey = buildScopeKey(currentScope);
if (scopeChanged) {
setSessionsRaw((prev) => {
let changed = false;
const next = prev.map((session) => {
if (session.id !== sessionId) return session;
changed = true;
// Clear stale ACP handle — retarget may run before orphan cleanup
return { ...session, scope, externalSessionId: undefined };
});
if (!changed) return prev;
sessionsRef.current = next;
setLatestAISessionsSnapshot(next);
persistSessions(next);
return next;
});
}
setActiveSessionIdMapRaw((prev) => {
let changed = false;
const next = { ...prev };
if (currentScopeKey !== nextScopeKey && next[currentScopeKey] === sessionId) {
delete next[currentScopeKey];
changed = true;
}
if (next[nextScopeKey] !== sessionId) {
next[nextScopeKey] = sessionId;
changed = true;
}
if (!changed) return prev;
setLatestAIActiveSessionMapSnapshot(next);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
return next;
});
}, [persistSessions]);
// Maximum messages per session to prevent unbounded memory growth
const MAX_MESSAGES_PER_SESSION = 500;
@@ -750,6 +853,7 @@ export function useAIState() {
deleteSessionsByTarget,
updateSessionTitle,
updateSessionExternalSessionId,
retargetSessionScope,
addMessageToSession,
updateLastMessage,
updateMessageById,

View File

@@ -7,7 +7,7 @@
* - Debounced sync to avoid too frequent API calls
*/
import { useCallback, useEffect, useRef } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCloudSync } from './useCloudSync';
import { useI18n } from '../i18n/I18nProvider';
import { getCloudSyncManager } from '../../infrastructure/services/CloudSyncManager';
@@ -16,11 +16,11 @@ import {
findSyncPayloadEncryptedCredentialPaths,
} from '../../domain/credentials';
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
import { collectSyncableSettings } from '../../domain/syncPayload';
import { collectSyncableSettings } from '../syncPayload';
import { STORAGE_KEY_PORT_FORWARDING } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { getEffectiveKnownHosts } from '../../infrastructure/syncHelpers';
import { toast } from '../../components/ui/toast';
import { notify } from '../notification';
interface AutoSyncConfig {
// Data to sync
@@ -60,6 +60,14 @@ export const useAutoSync = (config: AutoSyncConfig) => {
const isSyncRunningRef = useRef(false);
const skipNextSyncRef = useRef(false);
// Listen for SFTP bookmark changes to trigger auto-sync
const [bookmarksVersion, setBookmarksVersion] = useState(0);
useEffect(() => {
const handler = () => setBookmarksVersion((v) => v + 1);
window.addEventListener('sftp-bookmarks-changed', handler);
return () => window.removeEventListener('sftp-bookmarks-changed', handler);
}, []);
const getSyncSnapshot = useCallback(() => {
let effectivePFRules = config.portForwardingRules;
if (!effectivePFRules || effectivePFRules.length === 0) {
@@ -189,7 +197,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
throw error;
}
console.error('[AutoSync] Sync failed:', error);
toast.error(
notify.error(
error instanceof Error ? error.message : t('common.unknownError'),
t('sync.autoSync.failedTitle'),
);
@@ -231,7 +239,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// Don't save base or skip auto-sync — let the data-change effect
// naturally trigger an upload of the merged payload (which will
// go through syncAllProviders and save base on success).
toast.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle'));
notify.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle'));
}
} catch (error) {
console.error('[AutoSync] Failed to check remote version:', error);
@@ -288,7 +296,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
clearTimeout(syncTimeoutRef.current);
}
};
}, [sync.hasAnyConnectedProvider, sync.autoSyncEnabled, sync.isUnlocked, sync.isSyncing, getDataHash, syncNow, config.settingsVersion]);
}, [sync.hasAnyConnectedProvider, sync.autoSyncEnabled, sync.isUnlocked, sync.isSyncing, getDataHash, syncNow, config.settingsVersion, bookmarksVersion]);
// Check remote version on startup/unlock
useEffect(() => {

View File

@@ -6,7 +6,7 @@
* Uses useSyncExternalStore for real-time state synchronization across all components.
*/
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
import {
type CloudProvider,
type SecurityState,
@@ -24,7 +24,6 @@ import {
isProviderReadyForSync,
} from '../../domain/sync';
import {
CloudSyncManager,
getCloudSyncManager,
type SyncManagerState,
} from '../../infrastructure/services/CloudSyncManager';
@@ -82,8 +81,10 @@ export interface CloudSyncHook {
code: string,
redirectUri: string
) => Promise<void>;
cancelOAuthConnect: () => void;
disconnectProvider: (provider: CloudProvider) => Promise<void>;
resetProviderStatus: (provider: CloudProvider) => void;
// Sync Actions
syncNow: (payload: SyncPayload) => Promise<Map<CloudProvider, SyncResult>>;
syncToProvider: (provider: CloudProvider, payload: SyncPayload) => Promise<SyncResult>;
@@ -103,12 +104,6 @@ export interface CloudSyncHook {
refresh: () => void;
}
export interface GitHubAuthState {
isAuthenticating: boolean;
deviceFlowState: DeviceFlowState | null;
error: string | null;
}
// ============================================================================
// Hook Implementation
// ============================================================================
@@ -127,17 +122,6 @@ const getSnapshot = (): SyncManagerState => {
};
export const useCloudSync = (): CloudSyncHook => {
// Force update mechanism to ensure React re-renders
const [, forceUpdate] = useState(0);
// Subscribe to state changes and force update
useEffect(() => {
const unsubscribe = manager.subscribeToStateChanges(() => {
forceUpdate(n => n + 1);
});
return unsubscribe;
}, []);
// Use useSyncExternalStore for real-time state sync across all components
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
@@ -273,7 +257,7 @@ export const useCloudSync = (): CloudSyncHook => {
throw new Error('Unexpected auth type');
}
const data = result.data as { url: string; redirectUri: string };
// Start OAuth callback server in Electron and wait for authorization
const bridge = netcattyBridge.get();
const startCallback = bridge?.startOAuthCallback;
@@ -281,32 +265,44 @@ export const useCloudSync = (): CloudSyncHook => {
// Get state from adapter for CSRF protection
const adapter = manager.getAdapter('google') as { getPKCEState?: () => string | null } | undefined;
const expectedState = adapter?.getPKCEState?.() || undefined;
// Start callback server and open browser
// Start callback server and open system browser
const callbackPromise = startCallback(expectedState);
// Open browser after starting server
setTimeout(() => {
window.open(data.url, "_blank", "width=600,height=700,noopener,noreferrer");
}, 100);
// Wait for callback
const { code } = await callbackPromise;
// Complete auth with the received code
await manager.completePKCEAuth('google', code, data.redirectUri);
// Use system browser to avoid white-screen issues in popup windows (#563)
// Race: if browser launch fails, surface the error immediately
let openTimer: ReturnType<typeof setTimeout> | null = null;
const browserPromise = new Promise<never>((_resolve, reject) => {
openTimer = setTimeout(async () => {
try {
await bridge?.openExternal(data.url);
} catch (err) {
bridge?.cancelOAuthCallback?.();
reject(err instanceof Error ? err : new Error('Failed to open browser for authentication'));
}
}, 100);
});
try {
const { code } = await Promise.race([callbackPromise, browserPromise]);
// Complete auth with the received code
await manager.completePKCEAuth('google', code, data.redirectUri);
} finally {
if (openTimer) clearTimeout(openTimer);
}
}
return data.url;
}, []);
const connectOneDrive = useCallback(async (): Promise<string> => {
const result = await manager.startProviderAuth('onedrive');
if (result.type !== 'url') {
throw new Error('Unexpected auth type');
}
const data = result.data as { url: string; redirectUri: string };
// Start OAuth callback server in Electron and wait for authorization
const bridge = netcattyBridge.get();
const startCallback = bridge?.startOAuthCallback;
@@ -314,22 +310,33 @@ export const useCloudSync = (): CloudSyncHook => {
// Get state from adapter for CSRF protection
const adapter = manager.getAdapter('onedrive') as { getPKCEState?: () => string | null } | undefined;
const expectedState = adapter?.getPKCEState?.() || undefined;
// Start callback server and open browser
// Start callback server and open system browser
const callbackPromise = startCallback(expectedState);
// Open browser after starting server
setTimeout(() => {
window.open(data.url, "_blank", "width=600,height=700,noopener,noreferrer");
}, 100);
// Wait for callback
const { code } = await callbackPromise;
// Complete auth with the received code
await manager.completePKCEAuth('onedrive', code, data.redirectUri);
// Use system browser to avoid white-screen issues in popup windows (#563)
let openTimer: ReturnType<typeof setTimeout> | null = null;
const browserPromise = new Promise<never>((_resolve, reject) => {
openTimer = setTimeout(async () => {
try {
await bridge?.openExternal(data.url);
} catch (err) {
bridge?.cancelOAuthCallback?.();
reject(err instanceof Error ? err : new Error('Failed to open browser for authentication'));
}
}, 100);
});
try {
const { code } = await Promise.race([callbackPromise, browserPromise]);
// Complete auth with the received code
await manager.completePKCEAuth('onedrive', code, data.redirectUri);
} finally {
if (openTimer) clearTimeout(openTimer);
}
}
return data.url;
}, []);
@@ -345,6 +352,10 @@ export const useCloudSync = (): CloudSyncHook => {
await manager.disconnectProvider(provider);
}, []);
const resetProviderStatus = useCallback((provider: CloudProvider): void => {
manager.resetProviderStatus(provider);
}, []);
const connectWebDAV = useCallback(async (config: WebDAVConfig): Promise<void> => {
await manager.connectConfigProvider('webdav', config);
}, []);
@@ -353,6 +364,11 @@ export const useCloudSync = (): CloudSyncHook => {
await manager.connectConfigProvider('s3', config);
}, []);
const cancelOAuthConnect = useCallback(() => {
const bridge = netcattyBridge.get();
bridge?.cancelOAuthCallback?.();
}, []);
// ========== Settings ==========
const setAutoSync = useCallback((enabled: boolean, intervalMinutes?: number) => {
@@ -450,8 +466,10 @@ export const useCloudSync = (): CloudSyncHook => {
connectWebDAV,
connectS3,
completePKCEAuth,
cancelOAuthConnect,
disconnectProvider,
resetProviderStatus,
// Sync Actions
syncNow: syncNowWithUnlock,
syncToProvider: syncToProviderWithUnlock,
@@ -472,60 +490,4 @@ export const useCloudSync = (): CloudSyncHook => {
};
};
// ============================================================================
// Convenience Hooks
// ============================================================================
/**
* Hook for just the security state (lighter weight)
*/
export const useSecurityState = () => {
const [manager] = useState<CloudSyncManager>(() => getCloudSyncManager());
const [securityState, setSecurityState] = useState<SecurityState>(
() => manager.getSecurityState()
);
useEffect(() => {
const unsubscribe = manager.subscribe((event) => {
if (event.type === 'SECURITY_STATE_CHANGED') {
setSecurityState(event.state);
}
});
return unsubscribe;
}, [manager]);
return {
securityState,
isUnlocked: securityState === 'UNLOCKED',
isLocked: securityState === 'LOCKED',
hasNoKey: securityState === 'NO_KEY',
};
};
/**
* Hook for provider status indicators
*/
export const useProviderStatus = (provider: CloudProvider) => {
const [manager] = useState<CloudSyncManager>(() => getCloudSyncManager());
const [connection, setConnection] = useState<ProviderConnection>(
() => manager.getProviderConnection(provider)
);
useEffect(() => {
const unsubscribe = manager.subscribe(() => {
setConnection(manager.getProviderConnection(provider));
});
return unsubscribe;
}, [manager, provider]);
return {
...connection,
isConnected: isProviderReadyForSync(connection),
isSyncing: connection.status === 'syncing',
hasError: connection.status === 'error',
dotColor: getSyncDotColor(connection.status),
lastSyncFormatted: formatLastSync(connection.lastSync),
};
};
export default useCloudSync;

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef } from 'react';
import { KeyBinding, matchesKeyBinding } from '../../domain/models';
export interface HotkeyActions {
interface HotkeyActions {
// Tab management
switchToTab: (tabIndex: number) => void;
nextTab: () => void;

View File

@@ -58,7 +58,7 @@ export const useSessionState = () => {
return sessionId;
}, [setActiveTabId]);
const createSerialSession = useCallback((config: SerialConfig) => {
const createSerialSession = useCallback((config: SerialConfig, options?: { charset?: string }) => {
const sessionId = crypto.randomUUID();
const serialHostId = `serial-${sessionId}`;
const portName = config.path.split('/').pop() || config.path;
@@ -71,6 +71,7 @@ export const useSessionState = () => {
status: 'connecting',
protocol: 'serial',
serialConfig: config,
charset: options?.charset,
};
setSessions(prev => [...prev, newSession]);
setActiveTabId(sessionId);
@@ -103,6 +104,7 @@ export const useSessionState = () => {
status: 'connecting',
protocol: 'serial',
serialConfig: serialConfig,
charset: host.charset,
};
setSessions(prev => [...prev, newSession]);
setActiveTabId(sessionId);
@@ -120,6 +122,7 @@ export const useSessionState = () => {
protocol: host.protocol,
port: host.port,
moshEnabled: host.moshEnabled,
charset: host.charset,
};
setSessions(prev => [...prev, newSession]);
setActiveTabId(newSession.id);
@@ -321,6 +324,7 @@ export const useSessionState = () => {
status: 'connecting',
protocol: 'serial',
serialConfig: serialConfig,
charset: host.charset,
};
}
@@ -334,6 +338,7 @@ export const useSessionState = () => {
protocol: host.protocol,
port: host.port,
moshEnabled: host.moshEnabled,
charset: host.charset,
};
});
@@ -445,8 +450,9 @@ export const useSessionState = () => {
port: session.port,
moshEnabled: session.moshEnabled,
shellType: nextShellType,
charset: session.charset,
};
// Add pane to existing workspace
const hint: SplitHint = {
direction,
@@ -476,13 +482,14 @@ export const useSessionState = () => {
port: session.port,
moshEnabled: session.moshEnabled,
shellType: nextShellType,
charset: session.charset,
};
const hint: SplitHint = {
direction,
position: direction === 'horizontal' ? 'bottom' : 'right',
};
const newWorkspace = createWorkspaceEntity(sessionId, newSession.id, hint);
setWorkspaces(prev => [...prev, newWorkspace]);
setActiveTabId(newWorkspace.id);
@@ -563,6 +570,7 @@ export const useSessionState = () => {
hostname: host.hostname,
username: host.username,
status: 'connecting' as const,
charset: host.charset,
// workspaceId will be set after workspace is created
}));
@@ -649,6 +657,7 @@ export const useSessionState = () => {
port: session.port,
moshEnabled: session.moshEnabled,
shellType: nextShellType,
charset: session.charset,
serialConfig: session.serialConfig,
};
@@ -682,9 +691,11 @@ export const useSessionState = () => {
...workspaces.map(w => w.id),
...logViews.map(lv => lv.id),
];
const allTabIdSet = new Set(allTabIds);
// Filter tabOrder to only include existing tabs, then add any new tabs at the end
const orderedIds = tabOrder.filter(id => allTabIds.includes(id));
const newIds = allTabIds.filter(id => !orderedIds.includes(id));
const orderedIds = tabOrder.filter(id => allTabIdSet.has(id));
const orderedIdSet = new Set(orderedIds);
const newIds = allTabIds.filter(id => !orderedIdSet.has(id));
return [...orderedIds, ...newIds];
}, [orphanSessions, workspaces, logViews, tabOrder]);
@@ -698,10 +709,12 @@ export const useSessionState = () => {
...workspaces.map(w => w.id),
...logViews.map(lv => lv.id),
];
const allTabIdSet = new Set(allTabIds);
// Build current effective order: existing order + new tabs at end
const orderedIds = prevTabOrder.filter(id => allTabIds.includes(id));
const newIds = allTabIds.filter(id => !orderedIds.includes(id));
const orderedIds = prevTabOrder.filter(id => allTabIdSet.has(id));
const orderedIdSet = new Set(orderedIds);
const newIds = allTabIds.filter(id => !orderedIdSet.has(id));
const currentOrder = [...orderedIds, ...newIds];
const draggedIndex = currentOrder.indexOf(draggedId);

View File

@@ -22,6 +22,8 @@ import {
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY,
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
STORAGE_KEY_EDITOR_WORD_WRAP,
STORAGE_KEY_SESSION_LOGS_ENABLED,
STORAGE_KEY_SESSION_LOGS_DIR,
@@ -30,6 +32,7 @@ import {
STORAGE_KEY_CLOSE_TO_TRAY,
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
STORAGE_KEY_AUTO_UPDATE_ENABLED,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_IMMERSIVE_MODE,
} from '../../infrastructure/config/storageKeys';
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
@@ -65,6 +68,7 @@ const DEFAULT_SFTP_AUTO_SYNC = false;
const DEFAULT_SFTP_SHOW_HIDDEN_FILES = false;
const DEFAULT_SFTP_USE_COMPRESSED_UPLOAD = true;
const DEFAULT_SFTP_AUTO_OPEN_SIDEBAR = false;
const DEFAULT_SFTP_DEFAULT_VIEW_MODE: 'list' | 'tree' = 'list';
// Editor defaults
const DEFAULT_EDITOR_WORD_WRAP = false;
@@ -239,6 +243,14 @@ export const useSettingsState = () => {
const stored = readStoredString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
return stored === 'true' ? true : DEFAULT_SFTP_AUTO_OPEN_SIDEBAR;
});
const [sftpDefaultViewMode, setSftpDefaultViewMode] = useState<'list' | 'tree'>(() => {
const stored = readStoredString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE);
return (stored === 'list' || stored === 'tree') ? stored : DEFAULT_SFTP_DEFAULT_VIEW_MODE;
});
const [sftpTransferConcurrency, setSftpTransferConcurrencyState] = useState<number>(() => {
const stored = localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY);
return stored != null && stored >= 1 && stored <= 16 ? stored : 4;
});
// Editor Settings
const [editorWordWrap, setEditorWordWrapState] = useState<boolean>(() => {
@@ -343,6 +355,23 @@ export const useSettingsState = () => {
notifySettingsChanged(STORAGE_KEY_IMMERSIVE_MODE, enabled);
}, [notifySettingsChanged]);
const setSftpTransferConcurrency = useCallback((value: number) => {
const clamped = Math.max(1, Math.min(16, Math.round(value)));
setSftpTransferConcurrencyState(clamped);
localStorageAdapter.writeString(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY, String(clamped));
notifySettingsChanged(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY, clamped);
}, [notifySettingsChanged]);
const [workspaceFocusStyle, setWorkspaceFocusStyleState] = useState<'dim' | 'border'>(() => {
const stored = localStorageAdapter.readString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
return stored === 'border' ? 'border' : 'dim';
});
const setWorkspaceFocusStyle = useCallback((style: 'dim' | 'border') => {
setWorkspaceFocusStyleState(style);
localStorageAdapter.writeString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE, style);
notifySettingsChanged(STORAGE_KEY_WORKSPACE_FOCUS_STYLE, style);
}, [notifySettingsChanged]);
const syncAppearanceFromStorage = useCallback(() => {
const storedTheme = readStoredString(STORAGE_KEY_THEME);
const nextTheme = storedTheme && isValidTheme(storedTheme) ? storedTheme : theme;
@@ -433,6 +462,8 @@ export const useSettingsState = () => {
if (storedCompress === 'true' || storedCompress === 'false') setSftpUseCompressedUpload(storedCompress === 'true');
const storedAutoOpenSidebar = readStoredString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
if (storedAutoOpenSidebar === 'true' || storedAutoOpenSidebar === 'false') setSftpAutoOpenSidebar(storedAutoOpenSidebar === 'true');
const storedDefaultViewMode = readStoredString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE);
if (storedDefaultViewMode === 'list' || storedDefaultViewMode === 'tree') setSftpDefaultViewMode(storedDefaultViewMode);
// Immersive mode
const storedImmersive = readStoredString(STORAGE_KEY_IMMERSIVE_MODE);
@@ -442,6 +473,10 @@ export const useSettingsState = () => {
notifySettingsChanged(STORAGE_KEY_IMMERSIVE_MODE, val);
}
// Workspace focus style
const storedFocusStyle = readStoredString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
if (storedFocusStyle === 'dim' || storedFocusStyle === 'border') setWorkspaceFocusStyleState(storedFocusStyle);
// Custom terminal themes
customThemeStore.loadFromStorage();
}, [syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings, notifySettingsChanged]);
@@ -585,9 +620,20 @@ export const useSettingsState = () => {
if (key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && typeof value === 'boolean') {
setSftpAutoOpenSidebar((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE && typeof value === 'string') {
if (value === 'list' || value === 'tree') {
setSftpDefaultViewMode((prev) => (prev === value ? prev : value));
}
}
if (key === STORAGE_KEY_IMMERSIVE_MODE && typeof value === 'boolean') {
setImmersiveModeState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_WORKSPACE_FOCUS_STYLE && (value === 'dim' || value === 'border')) {
setWorkspaceFocusStyleState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY && typeof value === 'number') {
setSftpTransferConcurrencyState((prev) => (prev === value ? prev : value));
}
});
return () => {
try {
@@ -623,7 +669,7 @@ export const useSettingsState = () => {
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
globalHotkeyEnabled, autoUpdateEnabled, immersiveMode,
});
@@ -632,7 +678,7 @@ export const useSettingsState = () => {
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
globalHotkeyEnabled, autoUpdateEnabled, immersiveMode,
};
@@ -783,6 +829,12 @@ export const useSettingsState = () => {
setSftpAutoOpenSidebar(newValue);
}
}
// Sync SFTP default view mode from other windows
if (e.key === STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE && e.newValue) {
if ((e.newValue === 'list' || e.newValue === 'tree') && e.newValue !== s.sftpDefaultViewMode) {
setSftpDefaultViewMode(e.newValue);
}
}
// Sync global hotkey enabled setting from other windows
if (e.key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && e.newValue !== null) {
const newValue = e.newValue === 'true';
@@ -804,6 +856,19 @@ export const useSettingsState = () => {
setImmersiveModeState(newValue);
}
}
// Sync workspace focus style from other windows
if (e.key === STORAGE_KEY_WORKSPACE_FOCUS_STYLE && e.newValue !== null) {
if (e.newValue === 'dim' || e.newValue === 'border') {
setWorkspaceFocusStyleState(e.newValue);
}
}
// Sync transfer concurrency from other windows
if (e.key === STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY && e.newValue !== null) {
const num = Number(e.newValue);
if (num >= 1 && num <= 16) {
setSftpTransferConcurrencyState(num);
}
}
};
window.addEventListener('storage', handleStorageChange);
@@ -911,6 +976,13 @@ export const useSettingsState = () => {
notifySettingsChanged(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, sftpAutoOpenSidebar);
}, [sftpAutoOpenSidebar, notifySettingsChanged]);
// Persist SFTP default view mode
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE, sftpDefaultViewMode);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE, sftpDefaultViewMode);
}, [sftpDefaultViewMode, notifySettingsChanged]);
// Persist Session Logs settings
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_ENABLED, sessionLogsEnabled ? 'true' : 'false');
@@ -1145,6 +1217,10 @@ export const useSettingsState = () => {
setSftpUseCompressedUpload,
sftpAutoOpenSidebar,
setSftpAutoOpenSidebar,
sftpDefaultViewMode,
setSftpDefaultViewMode,
sftpTransferConcurrency,
setSftpTransferConcurrency,
// Editor Settings
editorWordWrap,
setEditorWordWrap: useCallback((enabled: boolean) => {
@@ -1173,6 +1249,8 @@ export const useSettingsState = () => {
reapplyCurrentTheme,
immersiveMode,
setImmersiveMode,
workspaceFocusStyle,
setWorkspaceFocusStyle,
// Opaque version that changes when any synced setting changes, used by useAutoSync.
// eslint-disable-next-line react-hooks/exhaustive-deps
settingsVersion: useMemo(() => Math.random(), [
@@ -1180,8 +1258,8 @@ export const useSettingsState = () => {
uiFontFamilyId, uiLanguage, customCSS,
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
customKeyBindings, editorWordWrap,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar,
customThemes, immersiveMode,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
customThemes, immersiveMode, workspaceFocusStyle,
]),
};
};

View File

@@ -3,10 +3,10 @@
* Uses a shared state pattern to sync across components
*/
import { useCallback, useEffect, useSyncExternalStore } from 'react';
import { STORAGE_KEY_SFTP_FILE_ASSOCIATIONS } from '../../infrastructure/config/storageKeys';
import { STORAGE_KEY_SFTP_FILE_ASSOCIATIONS, STORAGE_KEY_SFTP_DEFAULT_OPENER } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import type { FileAssociation, FileOpenerType, SystemAppInfo } from '../../lib/sftpFileUtils';
import { getFileExtension } from '../../lib/sftpFileUtils';
import { getFileExtension, isKnownBinaryFile } from '../../lib/sftpFileUtils';
export interface FileAssociationEntry {
openerType: FileOpenerType;
@@ -17,10 +17,12 @@ export interface FileAssociationsMap {
[extension: string]: FileAssociationEntry;
}
// Shared state and subscribers for cross-component synchronization
// ---------------------------------------------------------------------------
// Per-extension associations store
// ---------------------------------------------------------------------------
const subscribers = new Set<() => void>();
// Use a wrapper object so we can update the reference for useSyncExternalStore
let snapshotRef: { associations: FileAssociationsMap } = { associations: {} };
function loadFromStorage(): FileAssociationsMap {
@@ -39,7 +41,6 @@ function loadFromStorage(): FileAssociationsMap {
return {};
}
// Initialize from storage
snapshotRef = { associations: loadFromStorage() };
function saveToStorage(associations: FileAssociationsMap) {
@@ -47,7 +48,6 @@ function saveToStorage(associations: FileAssociationsMap) {
}
function updateAssociations(newAssociations: FileAssociationsMap) {
// Create new reference so useSyncExternalStore detects change
snapshotRef = { associations: newAssociations };
saveToStorage(newAssociations);
subscribers.forEach(callback => callback());
@@ -62,15 +62,54 @@ function getSnapshot() {
return snapshotRef;
}
// ---------------------------------------------------------------------------
// Default opener store (separate from per-extension associations)
// ---------------------------------------------------------------------------
const defaultOpenerSubscribers = new Set<() => void>();
let defaultOpenerSnapshot: { entry: FileAssociationEntry | null } = {
entry: localStorageAdapter.read<FileAssociationEntry>(STORAGE_KEY_SFTP_DEFAULT_OPENER) ?? null,
};
function subscribeDefaultOpener(callback: () => void) {
defaultOpenerSubscribers.add(callback);
return () => defaultOpenerSubscribers.delete(callback);
}
function getDefaultOpenerSnapshot() {
return defaultOpenerSnapshot;
}
function updateDefaultOpener(entry: FileAssociationEntry | null) {
defaultOpenerSnapshot = { entry };
if (entry) {
localStorageAdapter.write(STORAGE_KEY_SFTP_DEFAULT_OPENER, entry);
} else {
localStorageAdapter.remove(STORAGE_KEY_SFTP_DEFAULT_OPENER);
}
defaultOpenerSubscribers.forEach(callback => callback());
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
export function useSftpFileAssociations() {
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const associations = snapshot.associations;
const defaultOpenerState = useSyncExternalStore(subscribeDefaultOpener, getDefaultOpenerSnapshot, getDefaultOpenerSnapshot);
// Listen for storage events from other tabs/windows
useEffect(() => {
const handleStorage = (e: StorageEvent) => {
if (e.key === STORAGE_KEY_SFTP_FILE_ASSOCIATIONS) {
updateAssociations(loadFromStorage());
} else if (e.key === STORAGE_KEY_SFTP_DEFAULT_OPENER) {
updateDefaultOpener(
localStorageAdapter.read<FileAssociationEntry>(STORAGE_KEY_SFTP_DEFAULT_OPENER) ?? null,
);
}
};
window.addEventListener('storage', handleStorage);
@@ -78,18 +117,46 @@ export function useSftpFileAssociations() {
}, []);
/**
* Get the opener entry for a file based on its extension
* Get the opener entry for a file based on its extension.
* Falls back to the default opener when no per-extension association exists.
*/
const getOpenerForFile = useCallback((fileName: string): FileAssociationEntry | null => {
const ext = getFileExtension(fileName);
return associations[ext] || null;
}, [associations]);
if (associations[ext]) return associations[ext];
// Fall back to default opener, but skip built-in editor for binary files
const fallback = defaultOpenerState.entry;
if (fallback && fallback.openerType === 'builtin-editor' && isKnownBinaryFile(fileName)) {
return null;
}
return fallback;
}, [associations, defaultOpenerState]);
/**
* Get the default (fallback) opener, if set.
*/
const getDefaultOpener = useCallback((): FileAssociationEntry | null => {
return defaultOpenerState.entry;
}, [defaultOpenerState]);
/**
* Set the default opener used when no per-extension association exists.
*/
const setDefaultOpener = useCallback((openerType: FileOpenerType, systemApp?: SystemAppInfo) => {
updateDefaultOpener({ openerType, systemApp });
}, []);
/**
* Remove the default opener.
*/
const removeDefaultOpener = useCallback(() => {
updateDefaultOpener(null);
}, []);
/**
* Set the opener type for a specific extension
*/
const setOpenerForExtension = useCallback((
extension: string,
extension: string,
openerType: FileOpenerType,
systemApp?: SystemAppInfo
) => {
@@ -109,7 +176,7 @@ export function useSftpFileAssociations() {
}, []);
/**
* Get all associations as an array
* Get all per-extension associations as an array.
*/
const getAllAssociations = useCallback((): FileAssociation[] => {
return Object.entries(associations).map(([extension, entry]: [string, FileAssociationEntry]) => ({
@@ -129,6 +196,9 @@ export function useSftpFileAssociations() {
return {
associations,
getOpenerForFile,
getDefaultOpener,
setDefaultOpener,
removeDefaultOpener,
setOpenerForExtension,
removeAssociation,
getAllAssociations,

View File

@@ -57,6 +57,7 @@ export const useSftpState = (
getActivePane,
updateTab,
updateActiveTab,
clearSelectionsExcept,
setTabShowHiddenFiles,
addTab,
closeTab,
@@ -110,6 +111,30 @@ export const useSftpState = (
}
}, []);
const getPaneByConnectionId = useCallback((connectionId: string) => {
for (const tab of leftTabsRef.current.tabs) {
if (tab.connection?.id === connectionId) return tab;
}
for (const tab of rightTabsRef.current.tabs) {
if (tab.connection?.id === connectionId) return tab;
}
return null;
}, [leftTabsRef, rightTabsRef]);
const getTabByConnectionId = useCallback((connectionId: string) => {
for (const tab of leftTabsRef.current.tabs) {
if (tab.connection?.id === connectionId) {
return { side: "left" as const, tabId: tab.id, pane: tab };
}
}
for (const tab of rightTabsRef.current.tabs) {
if (tab.connection?.id === connectionId) {
return { side: "right" as const, tabId: tab.id, pane: tab };
}
}
return null;
}, [leftTabsRef, rightTabsRef]);
// Ref to track pending reconnections to avoid multiple reconnect attempts
const reconnectingRef = useRef<{ left: boolean; right: boolean }>({
left: false,
@@ -183,10 +208,14 @@ export const useSftpState = (
selectAll,
getFilteredFiles,
createDirectory,
createDirectoryAtPath,
createFile,
createFileAtPath,
deleteFiles,
deleteFilesAtPath,
renameFile,
renameFileAtPath,
moveEntriesToPath,
changePermissions,
} = useSftpPaneActions({
hosts,
@@ -207,6 +236,7 @@ export const useSftpState = (
listRemoteFiles,
handleSessionError,
isSessionError,
clearSelectionsExcept,
dirCacheTtlMs: DIR_CACHE_TTL_MS,
});
@@ -244,6 +274,7 @@ export const useSftpState = (
conflicts,
activeTransfersCount,
startTransfer,
downloadToLocal,
addExternalUpload,
updateExternalUpload,
cancelTransfer,
@@ -254,8 +285,13 @@ export const useSftpState = (
resolveConflict,
} = useSftpTransfers({
getActivePane,
getPaneByConnectionId,
getTabByConnectionId,
updateTab,
refresh,
clearCacheForConnection,
sftpSessionsRef,
connectionCacheKeyMapRef,
listLocalFiles,
listRemoteFiles,
handleSessionError,
@@ -305,15 +341,20 @@ export const useSftpState = (
toggleSelection,
rangeSelect,
clearSelection,
clearSelectionsExcept,
selectAll,
setFilter,
setFilenameEncoding,
setShowHiddenFiles,
createDirectory,
createDirectoryAtPath,
createFile,
createFileAtPath,
deleteFiles,
deleteFilesAtPath,
renameFile,
renameFileAtPath,
moveEntriesToPath,
changePermissions,
readTextFile,
readBinaryFile,
@@ -324,6 +365,7 @@ export const useSftpState = (
cancelExternalUpload,
selectApplication,
startTransfer,
downloadToLocal,
addExternalUpload,
updateExternalUpload,
cancelTransfer,
@@ -332,6 +374,7 @@ export const useSftpState = (
dismissTransfer,
resolveConflict,
getSftpIdForConnection,
reportSessionError: handleSessionError,
});
methodsRef.current = {
getFilteredFiles,
@@ -352,15 +395,20 @@ export const useSftpState = (
toggleSelection,
rangeSelect,
clearSelection,
clearSelectionsExcept,
selectAll,
setFilter,
setFilenameEncoding,
setShowHiddenFiles,
createDirectory,
createDirectoryAtPath,
createFile,
createFileAtPath,
deleteFiles,
deleteFilesAtPath,
renameFile,
renameFileAtPath,
moveEntriesToPath,
changePermissions,
readTextFile,
readBinaryFile,
@@ -371,6 +419,7 @@ export const useSftpState = (
cancelExternalUpload,
selectApplication,
startTransfer,
downloadToLocal,
addExternalUpload,
updateExternalUpload,
cancelTransfer,
@@ -379,6 +428,7 @@ export const useSftpState = (
dismissTransfer,
resolveConflict,
getSftpIdForConnection,
reportSessionError: handleSessionError,
};
// Create stable method wrappers that call through methodsRef
@@ -402,6 +452,8 @@ export const useSftpState = (
toggleSelection: (...args: Parameters<typeof toggleSelection>) => methodsRef.current.toggleSelection(...args),
rangeSelect: (...args: Parameters<typeof rangeSelect>) => methodsRef.current.rangeSelect(...args),
clearSelection: (...args: Parameters<typeof clearSelection>) => methodsRef.current.clearSelection(...args),
clearSelectionsExcept: (...args: Parameters<typeof clearSelectionsExcept>) =>
methodsRef.current.clearSelectionsExcept(...args),
selectAll: (...args: Parameters<typeof selectAll>) => methodsRef.current.selectAll(...args),
setFilter: (...args: Parameters<typeof setFilter>) => methodsRef.current.setFilter(...args),
setFilenameEncoding: (...args: Parameters<typeof setFilenameEncoding>) =>
@@ -409,11 +461,17 @@ export const useSftpState = (
setShowHiddenFiles: (...args: Parameters<typeof setShowHiddenFiles>) =>
methodsRef.current.setShowHiddenFiles(...args),
createDirectory: (...args: Parameters<typeof createDirectory>) => methodsRef.current.createDirectory(...args),
createDirectoryAtPath: (...args: Parameters<typeof createDirectoryAtPath>) =>
methodsRef.current.createDirectoryAtPath(...args),
createFile: (...args: Parameters<typeof createFile>) => methodsRef.current.createFile(...args),
createFileAtPath: (...args: Parameters<typeof createFileAtPath>) =>
methodsRef.current.createFileAtPath(...args),
deleteFiles: (...args: Parameters<typeof deleteFiles>) => methodsRef.current.deleteFiles(...args),
deleteFilesAtPath: (...args: Parameters<typeof deleteFilesAtPath>) =>
methodsRef.current.deleteFilesAtPath(...args),
renameFile: (...args: Parameters<typeof renameFile>) => methodsRef.current.renameFile(...args),
renameFileAtPath: (...args: Parameters<typeof renameFileAtPath>) => methodsRef.current.renameFileAtPath(...args),
moveEntriesToPath: (...args: Parameters<typeof moveEntriesToPath>) => methodsRef.current.moveEntriesToPath(...args),
changePermissions: (...args: Parameters<typeof changePermissions>) => methodsRef.current.changePermissions(...args),
readTextFile: (...args: Parameters<typeof readTextFile>) => methodsRef.current.readTextFile(...args),
readBinaryFile: (...args: Parameters<typeof readBinaryFile>) => methodsRef.current.readBinaryFile(...args),
@@ -425,6 +483,7 @@ export const useSftpState = (
cancelExternalUpload: () => methodsRef.current.cancelExternalUpload(),
selectApplication: () => methodsRef.current.selectApplication(),
startTransfer: (...args: Parameters<typeof startTransfer>) => methodsRef.current.startTransfer(...args),
downloadToLocal: (...args: Parameters<typeof downloadToLocal>) => methodsRef.current.downloadToLocal(...args),
addExternalUpload: (...args: Parameters<typeof addExternalUpload>) => methodsRef.current.addExternalUpload(...args),
updateExternalUpload: (...args: Parameters<typeof updateExternalUpload>) => methodsRef.current.updateExternalUpload(...args),
cancelTransfer: (...args: Parameters<typeof cancelTransfer>) => methodsRef.current.cancelTransfer(...args),
@@ -433,6 +492,7 @@ export const useSftpState = (
dismissTransfer: (...args: Parameters<typeof dismissTransfer>) => methodsRef.current.dismissTransfer(...args),
resolveConflict: (...args: Parameters<typeof resolveConflict>) => methodsRef.current.resolveConflict(...args),
getSftpIdForConnection: (...args: Parameters<typeof getSftpIdForConnection>) => methodsRef.current.getSftpIdForConnection(...args),
reportSessionError: (...args: Parameters<typeof handleSessionError>) => methodsRef.current.reportSessionError(...args),
activeFileWatchCountRef,
}), [activeFileWatchCountRef]); // activeFileWatchCountRef is a stable ref

View File

@@ -0,0 +1,29 @@
import { useCallback, useState } from "react";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
/**
* Hook for reading a number from localStorage with lazy persistence.
* Unlike useStoredString/useStoredBoolean, this hook does NOT auto-persist
* on every state change — call `persist()` explicitly when ready (e.g. on
* mouseup after a drag). This avoids flooding localStorage during
* high-frequency updates like resize drags.
*/
export const useStoredNumber = (
storageKey: string,
fallback: number,
clamp?: { min: number; max: number },
) => {
const [value, setValue] = useState<number>(() => {
const stored = localStorageAdapter.readNumber(storageKey);
if (stored === null) return fallback;
if (clamp) return Math.max(clamp.min, Math.min(clamp.max, stored));
return stored;
});
const persist = useCallback(
(v: number) => localStorageAdapter.writeNumber(storageKey, v),
[storageKey],
);
return [value, setValue, persist] as const;
};

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { checkForUpdates, getReleaseUrl, type ReleaseInfo, type UpdateCheckResult } from '../../infrastructure/services/updateService';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { STORAGE_KEY_UPDATE_DISMISSED_VERSION, STORAGE_KEY_UPDATE_LAST_CHECK, STORAGE_KEY_UPDATE_LATEST_RELEASE, STORAGE_KEY_AUTO_UPDATE_ENABLED } from '../../infrastructure/config/storageKeys';
import { STORAGE_KEY_UPDATE_DISMISSED_VERSION, STORAGE_KEY_UPDATE_LAST_CHECK, STORAGE_KEY_UPDATE_LATEST_RELEASE, STORAGE_KEY_AUTO_UPDATE_ENABLED, STORAGE_KEY_DEBUG_UPDATE_DEMO } from '../../infrastructure/config/storageKeys';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
// Check for updates at most once per hour
@@ -13,8 +13,7 @@ const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000;
// arrives after 8s the duplicate check is avoided.
const STARTUP_CHECK_DELAY_MS = 8000;
// Enable demo mode for development (set via localStorage: localStorage.setItem('debug.updateDemo', '1'))
const IS_UPDATE_DEMO_MODE = typeof window !== 'undefined' &&
window.localStorage?.getItem('debug.updateDemo') === '1';
const IS_UPDATE_DEMO_MODE = localStorageAdapter.readString(STORAGE_KEY_DEBUG_UPDATE_DEMO) === '1';
// Debug logging for update checks (no-op in production)
const debugLog = (..._args: unknown[]) => {};
@@ -44,6 +43,8 @@ export interface UseUpdateCheckResult {
dismissUpdate: () => void;
openReleasePage: () => void;
installUpdate: () => void;
startDownload: () => void;
isUpdateDemoMode: boolean;
}
/**
@@ -514,6 +515,46 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean }): UseUp
netcattyBridge.get()?.installUpdate?.();
}, []);
const startDownload = useCallback(async () => {
if (autoDownloadStatusRef.current === 'downloading' || autoDownloadStatusRef.current === 'ready') return;
const bridge = netcattyBridge.get();
try {
const checkResult = await bridge?.checkForUpdate?.();
if (!checkResult || checkResult.checking === true || checkResult.ready === true || checkResult.downloading === true) return;
if (checkResult.supported === false) {
openReleasePage();
return;
}
if (checkResult.available === false) {
openReleasePage();
return;
}
} catch {
return;
}
setUpdateState((prev) => ({
...prev,
autoDownloadStatus: 'downloading',
downloadPercent: 0,
downloadError: null,
}));
void bridge?.downloadUpdate?.().then((res) => {
if (res && !res.success) {
setUpdateState((prev) => ({
...prev,
autoDownloadStatus: 'error',
downloadError: res.error || 'Download failed',
}));
}
}).catch(() => {
setUpdateState((prev) => ({
...prev,
autoDownloadStatus: 'error',
downloadError: 'Download failed',
}));
});
}, [openReleasePage]);
// Startup check with delay - runs once on mount
useEffect(() => {
debugLog('Startup check effect mounted, IS_UPDATE_DEMO_MODE:', IS_UPDATE_DEMO_MODE);
@@ -653,5 +694,7 @@ export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean }): UseUp
dismissUpdate,
openReleasePage,
installUpdate,
startDownload,
isUpdateDemoMode: IS_UPDATE_DEMO_MODE,
};
}

View File

@@ -12,11 +12,13 @@ import type {
Identity,
KnownHost,
PortForwardingRule,
SftpBookmark,
Snippet,
SSHKey,
} from './models';
import type { SyncPayload } from './sync';
} from '../domain/models';
import type { SyncPayload } from '../domain/sync';
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
import { rehydrateGlobalBookmarks } from '../components/sftp/hooks/useGlobalSftpBookmarks';
import {
STORAGE_KEY_THEME,
STORAGE_KEY_UI_THEME_LIGHT,
@@ -37,6 +39,7 @@ import {
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS,
STORAGE_KEY_CUSTOM_THEMES,
STORAGE_KEY_IMMERSIVE_MODE,
} from '../infrastructure/config/storageKeys';
@@ -79,6 +82,8 @@ const SYNCABLE_TERMINAL_KEYS = [
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
'keepaliveInterval', 'disableBracketedPaste', 'osc52Clipboard',
'autocompleteEnabled', 'autocompleteGhostText', 'autocompletePopupMenu',
'autocompleteDebounceMs', 'autocompleteMinChars', 'autocompleteMaxSuggestions',
] as const;
/**
@@ -159,6 +164,10 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
const autoOpenSidebar = localStorageAdapter.readString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
if (autoOpenSidebar === 'true' || autoOpenSidebar === 'false') settings.sftpAutoOpenSidebar = autoOpenSidebar === 'true';
// SFTP Bookmarks (global only — local bookmarks are device-specific)
const globalBookmarks = localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS);
if (globalBookmarks && Array.isArray(globalBookmarks)) settings.sftpGlobalBookmarks = globalBookmarks;
// Immersive mode
const immersive = localStorageAdapter.readString(STORAGE_KEY_IMMERSIVE_MODE);
if (immersive === 'true' || immersive === 'false') settings.immersiveMode = immersive === 'true';
@@ -222,6 +231,9 @@ function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>):
if (settings.sftpUseCompressedUpload != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, String(settings.sftpUseCompressedUpload));
if (settings.sftpAutoOpenSidebar != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, String(settings.sftpAutoOpenSidebar));
// SFTP Bookmarks (global only)
if (settings.sftpGlobalBookmarks != null) localStorageAdapter.write(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS, settings.sftpGlobalBookmarks);
// Immersive mode
if (settings.immersiveMode != null) localStorageAdapter.writeString(STORAGE_KEY_IMMERSIVE_MODE, String(settings.immersiveMode));
}
@@ -296,6 +308,8 @@ export function applySyncPayload(
// Apply synced settings
if (payload.settings) {
applySyncableSettings(payload.settings);
// Rehydrate in-memory bookmark snapshot after localStorage was updated
if (payload.settings.sftpGlobalBookmarks != null) rehydrateGlobalBookmarks();
importers.onSettingsApplied?.();
}
}

View File

@@ -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
// -------------------------------------------------------------------
@@ -56,6 +71,7 @@ interface AIChatSidePanelProps {
deleteSession: (sessionId: string, scopeKey?: string) => void;
updateSessionTitle: (sessionId: string, title: string) => void;
updateSessionExternalSessionId: (sessionId: string, externalSessionId: string | undefined) => void;
retargetSessionScope: (sessionId: string, scope: AISessionScope) => void;
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
updateLastMessage: (
sessionId: string,
@@ -103,6 +119,7 @@ interface AIChatSidePanelProps {
username?: string;
protocol?: string;
shellType?: string;
deviceType?: string;
connected: boolean;
}>;
resolveExecutorContext?: (scope: {
@@ -152,6 +169,27 @@ function buildAcpHistoryMessages(messages: ChatMessage[]): Array<{ role: 'user'
});
}
function getSessionScopeMatchRank(
session: AISession,
scopeType: 'terminal' | 'workspace',
scopeTargetId?: string,
scopeHostIds?: string[],
activeTerminalTargetIds?: Set<string>,
): number {
if (session.scope.type !== scopeType) return 0;
if (session.scope.targetId === scopeTargetId) return 2;
if (scopeType !== 'terminal' || !scopeHostIds?.length || !session.scope.hostIds?.length) {
return 0;
}
if (session.scope.targetId && activeTerminalTargetIds?.has(session.scope.targetId)) {
return 0;
}
return session.scope.hostIds.some((hostId) => scopeHostIds.includes(hostId)) ? 1 : 0;
}
// -------------------------------------------------------------------
// Component
// -------------------------------------------------------------------
@@ -164,6 +202,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
deleteSession,
updateSessionTitle,
updateSessionExternalSessionId,
retargetSessionScope,
addMessageToSession,
updateLastMessage,
updateMessageById,
@@ -227,21 +266,115 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
// Per-scope active session ID
const activeSessionId = activeSessionIdMap[scopeKey] ?? null;
const isStreaming = activeSessionId ? streamingSessionIds.has(activeSessionId) : false;
const activeSessionIdForScope = activeSessionIdMap[scopeKey] ?? null;
const setActiveSessionId = useCallback((id: string | null) => {
setActiveSessionIdForScope(scopeKey, id);
}, [scopeKey, setActiveSessionIdForScope]);
// Restore agent selector from active session when scope changes
useEffect(() => {
if (activeSessionId) {
const session = sessions.find((s) => s.id === activeSessionId);
if (session) {
setCurrentAgentId(session.agentId);
const activeTerminalTargetIds = useMemo(() => {
const targetIds = new Set<string>();
for (const [sessionScopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
if (!sessionScopeKey.startsWith('terminal:') || !sessionId) continue;
const targetId = sessionScopeKey.slice('terminal:'.length);
if (!targetId || targetId === scopeTargetId) continue;
targetIds.add(targetId);
}
return targetIds;
}, [activeSessionIdMap, scopeTargetId]);
const historySessions = useMemo(
() =>
sessions
.map((session) => ({
session,
matchRank: getSessionScopeMatchRank(session, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds),
}))
.filter(({ matchRank }) => matchRank > 0)
.sort((a, b) => b.matchRank - a.matchRank || b.session.updatedAt - a.session.updatedAt)
.map(({ session }) => session),
[sessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds],
);
const activeSession = useMemo(() => {
if (activeSessionIdForScope) {
const session = sessions.find((s) => s.id === activeSessionIdForScope);
if (session && getSessionScopeMatchRank(session, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds) > 0) {
return session;
}
}
}, [scopeKey, activeSessionId, sessions]);
return historySessions[0] ?? null;
}, [sessions, activeSessionIdForScope, historySessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds]);
const activeSessionId = activeSession?.id ?? activeSessionIdForScope;
const isStreaming = activeSessionId ? streamingSessionIds.has(activeSessionId) : false;
const shouldRetargetActiveSession = useMemo(() => {
if (!activeSession || scopeType !== 'terminal' || !scopeTargetId || !scopeHostIds?.length) {
return false;
}
if (activeSession.scope.type !== scopeType || activeSession.scope.targetId === scopeTargetId) {
return false;
}
// Don't retarget sessions that are actively owned by another terminal
if (activeSession.scope.targetId && activeTerminalTargetIds.has(activeSession.scope.targetId)) {
return false;
}
return activeSession.scope.hostIds?.some((hostId) => scopeHostIds.includes(hostId)) ?? false;
}, [activeSession, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds]);
useEffect(() => {
if (!activeSession) return;
if (shouldRetargetActiveSession && isVisible) {
// Full cleanup of any in-flight work — the session came from a disconnected
// terminal, so any active response, pending approvals, or exec is dead.
if (streamingSessionIds.has(activeSession.id)) {
const controller = abortControllersRef.current.get(activeSession.id);
if (controller) {
controller.abort();
abortControllersRef.current.delete(activeSession.id);
}
setStreamingForScope(activeSession.id, false);
clearAllPendingApprovals(activeSession.id);
const bridge = getNetcattyBridge();
bridge?.aiCattyCancelExec?.(activeSession.id);
bridge?.aiAcpCancel?.('', activeSession.id);
}
retargetSessionScope(activeSession.id, {
type: scopeType,
targetId: scopeTargetId,
hostIds: scopeHostIds,
});
return;
}
if (isVisible && activeSessionIdForScope !== activeSession.id) {
setActiveSessionId(activeSession.id);
}
}, [
activeSession,
activeSessionIdForScope,
retargetSessionScope,
isVisible,
scopeHostIds,
scopeTargetId,
scopeType,
setActiveSessionId,
setStreamingForScope,
shouldRetargetActiveSession,
streamingSessionIds,
abortControllersRef,
]);
// Restore agent selector from active session when scope changes
useEffect(() => {
if (activeSession) {
setCurrentAgentId(activeSession.agentId);
}
}, [scopeKey, activeSession]);
// Proactively sync terminal session metadata to main process whenever scope or sessions change
useEffect(() => {
@@ -294,12 +427,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
[enableAgent, setExternalAgents],
);
// Active session (scoped)
const activeSession = useMemo(
() => sessions.find((s) => s.id === activeSessionId) ?? null,
[sessions, activeSessionId],
);
const messages = activeSession?.messages ?? [];
// ── Export hook ──
@@ -313,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
@@ -345,15 +519,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
setAgentModel(currentAgentId, modelId);
}, [currentAgentId, setAgentModel]);
// Filtered sessions for history (matching current scope type)
const historySessions = useMemo(
() =>
sessions
.filter((s) => s.scope.type === scopeType && s.scope.targetId === scopeTargetId)
.sort((a, b) => b.updatedAt - a.updatedAt),
[sessions, scopeType, scopeTargetId],
);
// -------------------------------------------------------------------
// Handlers
// -------------------------------------------------------------------
@@ -420,14 +585,34 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
/** Ensure a session exists for the current scope and return its ID. */
const ensureSession = useCallback((): string => {
if (activeSessionId && sessionsRef.current.some((session) => session.id === activeSessionId)) {
return activeSessionId;
if (activeSession && sessionsRef.current.some((session) => session.id === activeSession.id)) {
if (shouldRetargetActiveSession) {
retargetSessionScope(activeSession.id, {
type: scopeType,
targetId: scopeTargetId,
hostIds: scopeHostIds,
});
} else if (activeSessionIdForScope !== activeSession.id) {
setActiveSessionId(activeSession.id);
}
return activeSession.id;
}
const scope: AISessionScope = { type: scopeType, targetId: scopeTargetId, hostIds: scopeHostIds };
const session = createSession(scope, currentAgentId);
setActiveSessionId(session.id);
return session.id;
}, [activeSessionId, scopeType, scopeTargetId, scopeHostIds, currentAgentId, createSession, setActiveSessionId]);
}, [
activeSession,
activeSessionIdForScope,
createSession,
currentAgentId,
retargetSessionScope,
scopeHostIds,
scopeTargetId,
scopeType,
setActiveSessionId,
shouldRetargetActiveSession,
]);
// -------------------------------------------------------------------
// Main send handler (thin orchestrator)
@@ -470,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,
});
@@ -747,9 +934,12 @@ const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
const timeStr = formatRelativeTime(time, t);
return (
<button
<div
key={session.id}
role="button"
tabIndex={0}
onClick={() => onSelect(session.id)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onSelect(session.id); }}
className={cn(
'w-full flex items-center justify-between py-2.5 border-b border-border/20 text-left transition-colors cursor-pointer group',
isActive ? 'text-foreground' : 'text-foreground/70 hover:text-foreground',
@@ -770,7 +960,7 @@ const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
<Trash2 size={12} />
</button>
</div>
</button>
</div>
);
})
)}

View File

@@ -102,11 +102,14 @@ interface StatusDotProps {
}
const StatusDot: React.FC<StatusDotProps> = ({ status, className }) => {
if (status === 'connecting') {
return <Loader2 className={cn('w-3.5 h-3.5 animate-spin text-muted-foreground', className)} />;
}
const colors = {
connected: 'bg-green-500',
syncing: 'bg-blue-500 animate-pulse',
error: 'bg-red-500',
connecting: 'bg-yellow-500 animate-pulse',
disconnected: 'bg-muted-foreground/50',
};
@@ -279,6 +282,7 @@ interface ProviderCardProps {
disabled?: boolean; // Disable connect button when another provider is connected
onEdit?: () => void;
onConnect: () => void;
onCancelConnect?: () => void;
onDisconnect: () => void;
onSync: () => void;
}
@@ -296,6 +300,7 @@ const ProviderCard: React.FC<ProviderCardProps> = ({
disabled,
onEdit,
onConnect,
onCancelConnect,
onDisconnect,
onSync,
}) => {
@@ -367,7 +372,9 @@ const ProviderCard: React.FC<ProviderCardProps> = ({
{error}
</p>
) : (
<p className="text-xs text-muted-foreground mt-1">{t('cloudSync.provider.notConnected')}</p>
<p className="text-xs text-muted-foreground mt-1">
{isConnecting ? t('cloudSync.provider.connecting') : t('cloudSync.provider.notConnected')}
</p>
)}
</div>
@@ -408,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"
@@ -611,7 +628,7 @@ interface SyncDashboardProps {
onClearLocalData?: () => void;
}
export const SyncDashboard: React.FC<SyncDashboardProps> = ({
const SyncDashboard: React.FC<SyncDashboardProps> = ({
onBuildPayload,
onApplyPayload,
onClearLocalData,
@@ -800,6 +817,9 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
toast.success(t('cloudSync.connect.github.success'));
} catch (error) {
setIsPollingGitHub(false);
setShowGitHubModal(false);
// Reset provider status so button is clickable again (without tearing down existing connections)
sync.resetProviderStatus('github');
const message = getNetworkErrorMessage(error, t('common.unknownError'));
toast.error(message, t('cloudSync.connect.github.failedTitle'));
}
@@ -813,10 +833,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
// Note: Auth flow is handled automatically by oauthBridge
toast.info(t('cloudSync.connect.browserContinue'));
} catch (error) {
toast.error(
error instanceof Error ? error.message : t('common.unknownError'),
t('cloudSync.connect.google.failedTitle'),
);
// Reset provider status so button is clickable again (without tearing down existing connections)
sync.resetProviderStatus('google');
const msg = error instanceof Error ? error.message : t('common.unknownError');
// Don't show toast for user-initiated cancellation (popup closed)
if (!msg.includes('cancelled')) {
toast.error(msg, t('cloudSync.connect.google.failedTitle'));
}
}
};
@@ -828,10 +851,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
// Note: Auth flow is handled automatically by oauthBridge
toast.info(t('cloudSync.connect.browserContinue'));
} catch (error) {
toast.error(
error instanceof Error ? error.message : t('common.unknownError'),
t('cloudSync.connect.onedrive.failedTitle'),
);
// Reset provider status so button is clickable again (without tearing down existing connections)
sync.resetProviderStatus('onedrive');
const msg = error instanceof Error ? error.message : t('common.unknownError');
// Don't show toast for user-initiated cancellation (popup closed)
if (!msg.includes('cancelled')) {
toast.error(msg, t('cloudSync.connect.onedrive.failedTitle'));
}
}
};
@@ -1079,6 +1105,7 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
error={sync.providers.google.error}
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.google)}
onConnect={handleConnectGoogle}
onCancelConnect={sync.cancelOAuthConnect}
onDisconnect={() => sync.disconnectProvider('google')}
onSync={() => handleSync('google')}
/>
@@ -1095,6 +1122,7 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
error={sync.providers.onedrive.error}
disabled={sync.hasAnyConnectedProvider && !isProviderReadyForSync(sync.providers.onedrive)}
onConnect={handleConnectOneDrive}
onCancelConnect={sync.cancelOAuthConnect}
onDisconnect={() => sync.disconnectProvider('onedrive')}
onSync={() => handleSync('onedrive')}
/>
@@ -1250,6 +1278,9 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
onClose={() => {
setShowGitHubModal(false);
setIsPollingGitHub(false);
// Reset provider status so button is clickable again.
// The background polling will continue until expiry but is harmless.
sync.resetProviderStatus('github');
}}
/>

View File

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

@@ -25,12 +25,12 @@ import {
Trash2,
Variable,
Wifi,
Router,
X,
} from "lucide-react";
import React, { useEffect, useMemo, useState, useCallback } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { useApplicationBackend } from "../application/state/useApplicationBackend";
import { useSettingsState } from "../application/state/useSettingsState";
import { getEffectiveHostDistro, LINUX_DISTRO_OPTIONS } from "../domain/host";
import { customThemeStore } from "../application/state/customThemeStore";
import {
@@ -93,6 +93,8 @@ interface HostDetailsPanelProps {
allTags?: string[]; // All available tags for autocomplete
allHosts?: Host[]; // All hosts for chain selection
defaultGroup?: string | null; // Default group for new hosts (from current navigation)
terminalThemeId: string;
terminalFontSize: number;
onSave: (host: Host) => void;
onCancel: () => void;
onCreateGroup?: (groupPath: string) => void; // Callback to create a new group
@@ -108,6 +110,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
allTags = [],
allHosts = [],
defaultGroup,
terminalThemeId,
terminalFontSize,
onSave,
onCancel,
onCreateGroup,
@@ -115,7 +119,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
}) => {
const { t } = useI18n();
const { checkSshAgent } = useApplicationBackend();
const { terminalThemeId, terminalFontSize } = useSettingsState();
const [form, setForm] = useState<Host>(
() =>
initialData ||
@@ -622,6 +625,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
<AsidePanel
open={true}
onClose={onCancel}
width="w-[420px]"
title={
initialData ? t("hostDetails.title.details") : t("hostDetails.title.new")
}
@@ -735,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">
@@ -980,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
@@ -1175,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)}
@@ -1513,7 +1517,15 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
<ToggleRow
label="Mosh"
enabled={!!form.moshEnabled}
onToggle={() => update("moshEnabled", !form.moshEnabled)}
onToggle={() => {
const enabling = !form.moshEnabled;
if (enabling && form.deviceType === 'network') {
// Network device mode is incompatible with Mosh — clear it
setForm(prev => ({ ...prev, moshEnabled: true, deviceType: undefined }));
} else {
update("moshEnabled", enabling);
}
}}
/>
</Card>
@@ -1546,6 +1558,32 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
)}
</Card>
{/* Network Device Mode — only for SSH hosts without Mosh (serial already uses raw mode) */}
{(!form.protocol || form.protocol === 'ssh') && !form.moshEnabled && (
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center gap-2">
<Router size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t("hostDetails.section.deviceType")}</p>
</div>
<ToggleRow
label={t("hostDetails.deviceType")}
enabled={form.deviceType === 'network'}
onToggle={() => update("deviceType", form.deviceType === 'network' ? undefined : 'network')}
/>
<p className="text-xs text-muted-foreground break-words">
{t("hostDetails.deviceType.desc")}
</p>
{form.deviceType === 'network' && (
<div className="flex items-start gap-2 p-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
<AlertTriangle size={14} className="text-yellow-500 mt-0.5 flex-shrink-0" />
<p className="text-xs text-yellow-600 dark:text-yellow-400 break-words">
{t("hostDetails.deviceType.warning")}
</p>
</div>
)}
</Card>
)}
{/* Legacy Algorithms */}
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center gap-2">

View File

@@ -2,7 +2,7 @@ import {
Folder,
LayoutGrid,
Search,
Shield,
FolderLock,
Terminal,
TerminalSquare,
} from "lucide-react";
@@ -98,13 +98,17 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
// Reset state when opening
useEffect(() => {
if (isOpen) {
setSelectedIndex(0);
// Auto focus the input after a short delay
setTimeout(() => {
inputRef.current?.focus();
}, 50);
}
if (!isOpen) return;
const focusTimer = window.setTimeout(() => {
inputRef.current?.focus();
}, 50);
setSelectedIndex(0);
return () => {
window.clearTimeout(focusTimer);
};
}, [isOpen]);
// Handle clicks outside the container
@@ -287,7 +291,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
const isSelected = idx === selectedIndex;
const icon =
tabId === "vault" ? (
<Shield size={16} />
<FolderLock size={16} />
) : (
<Folder size={16} />
);

View File

@@ -19,6 +19,12 @@ import { Input } from "./ui/input";
import { ScrollArea } from "./ui/scroll-area";
import { SortDropdown, SortMode } from "./ui/sort-dropdown";
import { TagFilterDropdown } from "./ui/tag-filter-dropdown";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "./ui/tooltip";
interface SelectHostPanelProps {
hosts: Host[];
@@ -198,6 +204,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
}, [currentPath]);
return (
<TooltipProvider delayDuration={300}>
<div
className={cn(
"absolute right-0 top-0 bottom-0 w-[380px] border-l border-border/60 bg-background z-40 flex flex-col app-no-drag",
@@ -271,7 +278,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
{/* Content */}
<ScrollArea className="flex-1">
<div className="p-4 space-y-4">
<div className="p-3 space-y-3">
{/* Breadcrumbs */}
{currentPath && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
@@ -301,20 +308,20 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
)}
{groupsWithCounts.length > 0 && (
<div>
<h4 className="text-sm font-semibold mb-3">{t("vault.groups.title")}</h4>
<h4 className="text-xs font-semibold mb-2 text-muted-foreground">{t("vault.groups.title")}</h4>
<div className="space-y-1">
{groupsWithCounts.map((group) => (
<div
key={group.path}
className="flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-muted/70 cursor-pointer transition-colors"
className="flex items-center gap-2.5 px-2.5 py-2 rounded-lg hover:bg-muted/70 cursor-pointer transition-colors"
onClick={() => setCurrentPath(group.path)}
>
<div className="h-10 w-10 rounded-lg bg-primary/15 text-primary flex items-center justify-center">
<LayoutGrid size={18} />
<div className="h-8 w-8 rounded-lg bg-primary/15 text-primary flex items-center justify-center shrink-0">
<LayoutGrid size={15} />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium">{group.name}</div>
<div className="text-xs text-muted-foreground">
<div className="text-[13px] font-medium truncate">{group.name}</div>
<div className="text-[11px] text-muted-foreground">
{t("vault.groups.hostsCount", { count: group.count })}
</div>
</div>
@@ -327,18 +334,19 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
{/* Hosts Section */}
{filteredHosts.length > 0 && (
<div>
<h4 className="text-sm font-semibold mb-3">{t("vault.nav.hosts")}</h4>
<h4 className="text-xs font-semibold mb-2 text-muted-foreground">{t("vault.nav.hosts")}</h4>
<div className="space-y-1">
{filteredHosts.map((host) => {
const isSelected = selectedHostIds.includes(host.id);
const connectionStr = `${host.username}@${host.hostname}:${host.port || 22}`;
return (
<div
key={host.id}
className={cn(
"flex items-center gap-3 px-3 py-2.5 rounded-lg cursor-pointer transition-colors",
"flex items-center gap-2.5 px-2.5 py-2 rounded-lg cursor-pointer transition-colors",
isSelected
? "bg-muted border border-border"
? "bg-muted"
: "hover:bg-muted/70",
)}
onClick={() => onSelect(host)}
@@ -346,16 +354,32 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
<DistroAvatar
host={host}
fallback={host.os[0].toUpperCase()}
className="h-10 w-10"
className="h-8 w-8 rounded-md"
/>
<div className="flex-1 min-w-0">
<div className="font-medium">{host.label}</div>
<div className="text-xs text-muted-foreground truncate">
{host.username}@{host.hostname}:{host.port || 22}
</div>
<Tooltip>
<TooltipTrigger asChild>
<div className="text-[13px] font-medium truncate">
{host.label}
</div>
</TooltipTrigger>
<TooltipContent side="top" align="start">
<p>{host.label}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<div className="text-[11px] text-muted-foreground truncate">
{connectionStr}
</div>
</TooltipTrigger>
<TooltipContent side="top" align="start">
<p>{connectionStr}</p>
</TooltipContent>
</Tooltip>
</div>
{isSelected && (
<Check size={16} className="text-primary" />
<Check size={14} className="text-primary shrink-0" />
)}
</div>
);
@@ -413,6 +437,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
/>
)}
</div>
</TooltipProvider>
);
};

View File

@@ -35,7 +35,7 @@ interface SerialPort {
interface SerialConnectModalProps {
open: boolean;
onClose: () => void;
onConnect: (config: SerialConfig) => void;
onConnect: (config: SerialConfig, options?: { charset?: string }) => void;
onSaveHost?: (host: Host) => void;
}
@@ -65,6 +65,7 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
const [flowControl, setFlowControl] = useState<SerialFlowControl>('none');
const [localEcho, setLocalEcho] = useState(false);
const [lineMode, setLineMode] = useState(false);
const [charset, setCharset] = useState('UTF-8');
// Save configuration state
const [saveConfig, setSaveConfig] = useState(false);
@@ -131,12 +132,13 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
tags: ['serial'],
protocol: 'serial',
createdAt: Date.now(),
charset,
serialConfig: config, // Store full serial configuration for connection
};
onSaveHost(host);
}
onConnect(config);
onConnect(config, { charset });
onClose();
};
@@ -164,7 +166,7 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="max-w-md">
<DialogContent className="max-w-md max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Usb size={18} />
@@ -175,7 +177,7 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-4 py-2 overflow-y-auto flex-1 min-h-0">
{/* Serial Port Selection */}
<div className="space-y-2">
<div className="flex items-center justify-between">
@@ -368,6 +370,20 @@ export const SerialConnectModal: React.FC<SerialConnectModalProps> = ({
className="h-4 w-4 rounded border-input"
/>
</div>
{/* Charset */}
<div className="space-y-1">
<Label htmlFor="serial-charset" className="text-sm font-medium">
{t('serial.field.charset')}
</Label>
<Input
id="serial-charset"
placeholder={t("hostDetails.charset.placeholder")}
value={charset}
onChange={(e) => setCharset(e.target.value)}
className="h-9"
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>

View File

@@ -66,6 +66,7 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
const [flowControl, setFlowControl] = useState<SerialFlowControl>(initialData.serialConfig?.flowControl || 'none');
const [localEcho, setLocalEcho] = useState(initialData.serialConfig?.localEcho || false);
const [lineMode, setLineMode] = useState(initialData.serialConfig?.lineMode || false);
const [charset, setCharset] = useState(initialData.charset || 'UTF-8');
const [tags, setTags] = useState<string[]>(initialData.tags || []);
const [group, setGroup] = useState(initialData.group || '');
@@ -107,6 +108,7 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
port: baudRate,
tags,
group,
charset,
serialConfig: config,
};
@@ -392,6 +394,20 @@ export const SerialHostDetailsPanel: React.FC<SerialHostDetailsPanelProps> = ({
className="h-4 w-4 rounded border-input"
/>
</div>
{/* Charset */}
<div className="space-y-1">
<Label htmlFor="serial-charset" className="text-sm font-medium">
{t('serial.field.charset')}
</Label>
<Input
id="serial-charset"
placeholder={t("hostDetails.charset.placeholder")}
value={charset}
onChange={(e) => setCharset(e.target.value)}
className="h-9"
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>

View File

@@ -68,9 +68,11 @@ interface SettingsApplicationTabProps {
checkNow: UseUpdateCheckResult['checkNow'];
openReleasePage: UseUpdateCheckResult['openReleasePage'];
installUpdate: UseUpdateCheckResult['installUpdate'];
startDownload: UseUpdateCheckResult['startDownload'];
isUpdateDemoMode: boolean;
}
export default function SettingsApplicationTab({ updateState, checkNow, openReleasePage, installUpdate }: SettingsApplicationTabProps) {
export default function SettingsApplicationTab({ updateState, checkNow, openReleasePage, installUpdate, startDownload, isUpdateDemoMode }: SettingsApplicationTabProps) {
const { t } = useI18n();
const { openExternal, getApplicationInfo } = useApplicationBackend();
const [appInfo, setAppInfo] = useState<AppInfo>({ name: "Netcatty", version: "" });
@@ -94,10 +96,6 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
};
}, [getApplicationInfo]);
// Check if demo mode is enabled for development testing
const isUpdateDemoMode = typeof window !== 'undefined' &&
window.localStorage?.getItem('debug.updateDemo') === '1';
const handleCheckForUpdates = async () => {
// In demo mode, allow checking even for dev builds
if (!isUpdateDemoMode && (!appInfo.version || appInfo.version === '0.0.0')) {
@@ -150,7 +148,7 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
{/* Update badge - reflects auto-download state */}
{updateState.latestRelease && (updateState.hasUpdate || updateState.autoDownloadStatus === 'downloading' || updateState.autoDownloadStatus === 'ready') && (
<button
onClick={() => updateState.autoDownloadStatus === 'ready' ? installUpdate() : void openReleasePage()}
onClick={() => updateState.autoDownloadStatus === 'ready' ? installUpdate() : updateState.autoDownloadStatus === 'downloading' ? undefined : startDownload()}
className={cn(
"inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium",
updateState.autoDownloadStatus === 'ready'
@@ -177,7 +175,7 @@ export default function SettingsApplicationTab({ updateState, checkNow, openRele
variant="secondary"
className="gap-2"
onClick={() => void handleCheckForUpdates()}
disabled={updateState.isChecking}
disabled={updateState.isChecking || updateState.manualCheckStatus === 'checking' || updateState.autoDownloadStatus === 'downloading' || updateState.autoDownloadStatus === 'ready'}
>
{updateState.isChecking ? (
<Loader2 size={16} className="animate-spin" />

View File

@@ -63,6 +63,8 @@ const SettingsTerminalTabContainer: React.FC<{ settings: SettingsState }> = ({ s
terminalSettings={settings.terminalSettings}
updateTerminalSetting={settings.updateTerminalSetting}
availableFonts={availableFonts}
workspaceFocusStyle={settings.workspaceFocusStyle}
setWorkspaceFocusStyle={settings.setWorkspaceFocusStyle}
/>
);
};
@@ -149,7 +151,7 @@ const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = (
const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }) => {
const { t } = useI18n();
const { notifyRendererReady, closeSettingsWindow } = useWindowControls();
const { updateState, checkNow, installUpdate, openReleasePage } = useUpdateCheck({ autoUpdateEnabled: settings.autoUpdateEnabled });
const { updateState, checkNow, installUpdate, openReleasePage, startDownload, isUpdateDemoMode } = useUpdateCheck({ autoUpdateEnabled: settings.autoUpdateEnabled });
const [activeTab, setActiveTab] = useState("application");
const [mountedTabs, setMountedTabs] = useState(() => new Set(["application"]));
const isImmersive = settings.immersiveMode;
@@ -260,6 +262,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
checkNow={checkNow}
openReleasePage={openReleasePage}
installUpdate={installUpdate}
startDownload={startDownload}
isUpdateDemoMode={isUpdateDemoMode}
/>
)}
@@ -337,6 +341,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
checkNow={checkNow}
installUpdate={installUpdate}
openReleasePage={openReleasePage}
startDownload={startDownload}
/>
)}
</div>

View File

@@ -10,7 +10,7 @@
* Used in TerminalLayer to provide SFTP alongside terminal sessions.
*/
import React, { memo, useCallback, useEffect, useMemo, useRef } from "react";
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { formatHostPort } from "../domain/host";
import { useI18n } from "../application/i18n/I18nProvider";
import { useSftpState } from "../application/state/useSftpState";
@@ -31,12 +31,17 @@ import { SftpTransferQueue } from "./sftp/SftpTransferQueue";
import { SftpContextProvider } from "./sftp";
import { useSftpViewPaneCallbacks } from "./sftp/hooks/useSftpViewPaneCallbacks";
import { useSftpViewTabs } from "./sftp/hooks/useSftpViewTabs";
import { useSftpKeyboardShortcuts } from "./sftp/hooks/useSftpKeyboardShortcuts";
import { sftpFocusStore } from "./sftp/hooks/useSftpFocusedPane";
import { keepOnlyPaneSelections } from "./sftp/hooks/selectionScope";
import { KeyBinding, HotkeyScheme } from "../domain/models";
interface SftpSidePanelProps {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
updateHosts: (hosts: Host[]) => void;
sftpDefaultViewMode: "list" | "tree";
/** The host to connect to (follows focused terminal) */
activeHost: Host | null;
initialLocation?: { hostId: string; path: string } | null;
@@ -55,6 +60,8 @@ interface SftpSidePanelProps {
sftpAutoSync: boolean;
sftpShowHiddenFiles: boolean;
sftpUseCompressedUpload: boolean;
hotkeyScheme: HotkeyScheme;
keyBindings: KeyBinding[];
editorWordWrap: boolean;
setEditorWordWrap: (value: boolean) => void;
onGetTerminalCwd?: () => Promise<string | null>;
@@ -65,6 +72,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
keys,
identities,
updateHosts,
sftpDefaultViewMode,
activeHost,
initialLocation,
showWorkspaceHostHeader = false,
@@ -76,6 +84,8 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
sftpAutoSync,
sftpShowHiddenFiles,
sftpUseCompressedUpload,
hotkeyScheme,
keyBindings,
editorWordWrap,
setEditorWordWrap,
onGetTerminalCwd,
@@ -109,6 +119,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
listSftp,
mkdirLocal,
deleteLocalFile,
listLocalDir,
} = useSftpBackend();
const sftpRef = useRef(sftp);
@@ -119,6 +130,17 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
const autoSyncRef = useRef(sftpAutoSync);
autoSyncRef.current = sftpAutoSync;
const panelRootRef = useRef<HTMLDivElement>(null);
const dialogActionScopeIdRef = useRef(`sftp-side-panel:${crypto.randomUUID()}`);
const [hasPaneFocus, setHasPaneFocus] = useState(false);
useSftpKeyboardShortcuts({
keyBindings,
hotkeyScheme,
sftpRef,
dialogActionScopeId: dialogActionScopeIdRef.current,
isActive: isVisible && hasPaneFocus,
});
const { getOpenerForFile, setOpenerForExtension } = useSftpFileAssociations();
const getOpenerForFileRef = useRef(getOpenerForFile);
@@ -130,10 +152,60 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
sftpRef.current.setShowHiddenFiles("left", paneId, !pane.showHiddenFiles);
}, []);
const syncFocusedSelection = useCallback((tabId: string | null) => {
if (tabId) {
keepOnlyPaneSelections(sftpRef.current, { side: "left", tabId });
return;
}
keepOnlyPaneSelections(sftpRef.current, null);
}, []);
const handlePaneFocus = useCallback(() => {
sftpFocusStore.setFocusedSide("left");
setHasPaneFocus(true);
syncFocusedSelection(sftpRef.current.getActiveTabId("left"));
}, [syncFocusedSelection]);
// NOTE: We intentionally do NOT sync to activeTabStore here.
// activeTabStore is a global singleton shared with SftpView.
// Writing to it here would corrupt SftpView's left pane visibility.
useEffect(() => {
if (!isVisible) {
setHasPaneFocus(false);
syncFocusedSelection(null);
}
}, [isVisible, syncFocusedSelection]);
useEffect(() => {
if (!isVisible) return;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target as Node | null;
const elementTarget = target instanceof Element ? target : null;
const isPortalInteraction = !!elementTarget?.closest(
'#netcatty-context-menu-root, [role="dialog"], [data-radix-popper-content-wrapper]',
);
if (isPortalInteraction) {
return;
}
if (panelRootRef.current?.contains(target)) {
sftpFocusStore.setFocusedSide("left");
setHasPaneFocus(true);
syncFocusedSelection(sftpRef.current.getActiveTabId("left"));
} else {
setHasPaneFocus(false);
syncFocusedSelection(null);
}
};
document.addEventListener("pointerdown", handlePointerDown, true);
return () => {
document.removeEventListener("pointerdown", handlePointerDown, true);
};
}, [isVisible, syncFocusedSelection]);
const {
leftCallbacks,
rightCallbacks,
@@ -168,6 +240,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
selectDirectory,
startStreamTransfer,
getSftpIdForConnection: sftp.getSftpIdForConnection,
listLocalFiles: listLocalDir,
});
const {
@@ -432,6 +505,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
// Filter transfers to those relevant to the active connection's host,
// so workspace focus switches don't show transfers from other hosts.
const filtered = sftp.transfers.filter((t) => {
if (t.parentTaskId) return false; // Child tasks rendered by SftpTransferQueue
if (connection.isLocal) {
return t.sourceConnectionId === connection.id || t.targetConnectionId === connection.id;
}
@@ -504,9 +578,11 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
rightCallbacks={rightCallbacks}
>
<div
ref={panelRootRef}
className="h-full flex flex-col bg-background overflow-hidden"
style={isVisible ? undefined : { display: "none" }}
aria-hidden={!isVisible}
onClick={handlePaneFocus}
>
{showWorkspaceHostHeader && displayHost && (
<div className="shrink-0 border-b border-border/50 bg-muted/20 px-3 py-1.5">
@@ -546,8 +622,12 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
<SftpPaneView
side="left"
pane={pane}
dialogActionScopeId={dialogActionScopeIdRef.current}
isPaneFocused={isVisible && hasPaneFocus}
sftpDefaultViewMode={sftpDefaultViewMode}
showHeader
showEmptyHeader
forceActive
onToggleShowHiddenFiles={() => handleToggleHiddenFiles(pane.id)}
onGoToTerminalCwd={onGetTerminalCwd ? handleGoToTerminalCwd : undefined}
/>
@@ -558,6 +638,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
<SftpTransferQueue
sftp={sftp}
visibleTransfers={visibleTransfers}
allTransfers={sftp.transfers}
canRevealTransferTarget={canRevealTransferTarget}
onRevealTransferTarget={handleRevealTransferTarget}
/>
@@ -608,6 +689,7 @@ const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps):
prev.keys === next.keys &&
prev.identities === next.identities &&
prev.updateHosts === next.updateHosts &&
prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
prev.activeHost === next.activeHost &&
prev.showWorkspaceHostHeader === next.showWorkspaceHostHeader &&
prev.isVisible === next.isVisible &&

View File

@@ -19,10 +19,11 @@ import { useI18n } from "../application/i18n/I18nProvider";
import { useIsSftpActive } from "../application/state/activeTabStore";
import { useSftpState } from "../application/state/useSftpState";
import { useSftpBackend } from "../application/state/useSftpBackend";
import { useSettingsState } from "../application/state/useSettingsState";
import { HotkeyScheme, KeyBinding } from "../domain/models";
import { logger } from "../lib/logger";
import { useRenderTracker } from "../lib/useRenderTracker";
import { cn } from "../lib/utils";
import { useInstantThemeSwitch } from "../lib/useInstantThemeSwitch";
import { Host, Identity, SSHKey } from "../types";
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
import { toast } from "./ui/toast";
@@ -39,6 +40,8 @@ import { useSftpViewPaneCallbacks } from "./sftp/hooks/useSftpViewPaneCallbacks"
import { useSftpViewTabs } from "./sftp/hooks/useSftpViewTabs";
import { useSftpKeyboardShortcuts } from "./sftp/hooks/useSftpKeyboardShortcuts";
import { sftpFocusStore, SftpFocusedSide, useSftpFocusedSide } from "./sftp/hooks/useSftpFocusedPane";
import { keepOnlyActivePaneSelections, keepOnlyPaneSelections } from "./sftp/hooks/selectionScope";
// Wrapper component that subscribes to activeTabId for CSS visibility
// This isolates the activeTabId subscription - only this component re-renders on tab switch
@@ -49,21 +52,38 @@ interface SftpViewProps {
keys: SSHKey[];
identities: Identity[];
updateHosts: (hosts: Host[]) => void;
sftpDefaultViewMode: "list" | "tree";
sftpDoubleClickBehavior: "open" | "transfer";
sftpAutoSync: boolean;
sftpShowHiddenFiles: boolean;
sftpUseCompressedUpload: boolean;
hotkeyScheme: HotkeyScheme;
keyBindings: KeyBinding[];
editorWordWrap: boolean;
setEditorWordWrap: (enabled: boolean) => void;
}
const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updateHosts }) => {
const SftpViewInner: React.FC<SftpViewProps> = ({
hosts,
keys,
identities,
updateHosts,
sftpDefaultViewMode,
sftpDoubleClickBehavior,
sftpAutoSync,
sftpShowHiddenFiles,
sftpUseCompressedUpload,
hotkeyScheme,
keyBindings,
editorWordWrap,
setEditorWordWrap,
}) => {
const { t } = useI18n();
const isActive = useIsSftpActive();
const {
sftpDoubleClickBehavior,
sftpAutoSync,
sftpShowHiddenFiles,
sftpUseCompressedUpload,
hotkeyScheme,
keyBindings,
editorWordWrap,
setEditorWordWrap,
} = useSettingsState();
const rootRef = useRef<HTMLDivElement>(null);
const dialogActionScopeIdRef = useRef("sftp-main-view");
useInstantThemeSwitch(rootRef);
// File watch event handlers (stable refs to avoid re-creating the useSftpState options)
const fileWatchHandlers = useMemo(() => ({
@@ -94,6 +114,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
listSftp,
mkdirLocal,
deleteLocalFile,
listLocalDir,
} = useSftpBackend();
// Store sftp in a ref so callbacks can access the latest instance
@@ -114,6 +135,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
keyBindings,
hotkeyScheme,
sftpRef,
dialogActionScopeId: dialogActionScopeIdRef.current,
isActive,
});
@@ -121,8 +143,18 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
const focusedSide = useSftpFocusedSide();
// Handle pane focus when clicking on a pane container
const handlePaneFocus = useCallback((side: SftpFocusedSide) => {
// Clear the opposite side's selection so file operations only affect the focused pane
const handlePaneFocus = useCallback((side: SftpFocusedSide, targetTabId?: string) => {
const prevSide = sftpFocusStore.getFocusedSide();
sftpFocusStore.setFocusedSide(side);
if (prevSide !== side) {
if (targetTabId) {
keepOnlyPaneSelections(sftpRef.current, { side, tabId: targetTabId });
} else {
// Focus side changed — clear other panes but keep the newly focused pane intact.
keepOnlyActivePaneSelections(sftpRef.current, side);
}
}
}, []);
const handleToggleHiddenFiles = useCallback((side: "left" | "right", paneId: string) => {
@@ -190,10 +222,11 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
selectDirectory,
startStreamTransfer,
getSftpIdForConnection: sftp.getSftpIdForConnection,
listLocalFiles: listLocalDir,
});
const visibleTransfers = useMemo(
() => [...sftp.transfers].reverse().slice(0, 5),
() => [...sftp.transfers].filter((t) => !t.parentTaskId).reverse().slice(0, 5),
[sftp.transfers],
);
@@ -236,6 +269,26 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
handleHostSelectRight,
} = useSftpViewTabs({ sftp, sftpRef });
const handleAddTabLeftWithFocus = useCallback(() => {
const tabId = handleAddTabLeft();
handlePaneFocus("left", tabId);
}, [handleAddTabLeft, handlePaneFocus]);
const handleAddTabRightWithFocus = useCallback(() => {
const tabId = handleAddTabRight();
handlePaneFocus("right", tabId);
}, [handleAddTabRight, handlePaneFocus]);
const handleSelectTabLeftWithFocus = useCallback((tabId: string) => {
handleSelectTabLeft(tabId);
handlePaneFocus("left", tabId);
}, [handlePaneFocus, handleSelectTabLeft]);
const handleSelectTabRightWithFocus = useCallback((tabId: string) => {
handleSelectTabRight(tabId);
handlePaneFocus("right", tabId);
}, [handlePaneFocus, handleSelectTabRight]);
return (
<SftpContextProvider
hosts={hosts}
@@ -246,6 +299,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
rightCallbacks={rightCallbacks}
>
<div
ref={rootRef}
className={cn(
"absolute inset-0 min-h-0 flex flex-col",
isActive ? "z-20" : "",
@@ -275,9 +329,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
<SftpTabBar
tabs={leftTabsInfo}
side="left"
onSelectTab={handleSelectTabLeft}
onSelectTab={handleSelectTabLeftWithFocus}
onCloseTab={handleCloseTabLeft}
onAddTab={handleAddTabLeft}
onAddTab={handleAddTabLeftWithFocus}
onReorderTabs={handleReorderTabsLeft}
onMoveTabToOtherSide={handleMoveTabFromRightToLeft}
/>
@@ -293,6 +347,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
<SftpPaneView
side="left"
pane={pane}
dialogActionScopeId={dialogActionScopeIdRef.current}
isPaneFocused={focusedSide === "left"}
sftpDefaultViewMode={sftpDefaultViewMode}
showHeader
showEmptyHeader={false}
onToggleShowHiddenFiles={() => handleToggleHiddenFiles("left", pane.id)}
@@ -332,9 +389,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
<SftpTabBar
tabs={rightTabsInfo}
side="right"
onSelectTab={handleSelectTabRight}
onSelectTab={handleSelectTabRightWithFocus}
onCloseTab={handleCloseTabRight}
onAddTab={handleAddTabRight}
onAddTab={handleAddTabRightWithFocus}
onReorderTabs={handleReorderTabsRight}
onMoveTabToOtherSide={handleMoveTabFromLeftToRight}
/>
@@ -350,6 +407,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
<SftpPaneView
side="right"
pane={pane}
dialogActionScopeId={dialogActionScopeIdRef.current}
isPaneFocused={focusedSide === "right"}
sftpDefaultViewMode={sftpDefaultViewMode}
showHeader
showEmptyHeader={false}
onToggleShowHiddenFiles={() => handleToggleHiddenFiles("right", pane.id)}
@@ -408,7 +468,18 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
};
const sftpViewAreEqual = (prev: SftpViewProps, next: SftpViewProps): boolean =>
prev.hosts === next.hosts && prev.keys === next.keys && prev.identities === next.identities;
prev.hosts === next.hosts &&
prev.keys === next.keys &&
prev.identities === next.identities &&
prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
prev.sftpDoubleClickBehavior === next.sftpDoubleClickBehavior &&
prev.sftpAutoSync === next.sftpAutoSync &&
prev.sftpShowHiddenFiles === next.sftpShowHiddenFiles &&
prev.sftpUseCompressedUpload === next.sftpUseCompressedUpload &&
prev.hotkeyScheme === next.hotkeyScheme &&
prev.keyBindings === next.keyBindings &&
prev.editorWordWrap === next.editorWordWrap &&
prev.setEditorWordWrap === next.setEditorWordWrap;
export const SftpView = memo(SftpViewInner, sftpViewAreEqual);
SftpView.displayName = "SftpView";

View File

@@ -4,8 +4,8 @@ import { SerializeAddon } from "@xterm/addon-serialize";
import { SearchAddon } from "@xterm/addon-search";
import "@xterm/xterm/css/xterm.css";
import { Cpu, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine } from "lucide-react";
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
// flushSync removed - no longer needed
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import ReactDOM from "react-dom";
import { useI18n } from "../application/i18n/I18nProvider";
import { logger } from "../lib/logger";
import { cn, normalizeLineEndings, wrapBracketedPaste } from "../lib/utils";
@@ -26,8 +26,6 @@ import {
shouldScrollOnTerminalInput,
} from "../domain/terminalScroll";
import {
resolveHostTerminalFontFamilyId,
resolveHostTerminalFontSize,
resolveHostTerminalThemeId,
} from "../domain/terminalAppearance";
import { resolveHostAuth } from "../domain/sshAuth";
@@ -46,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";
@@ -54,6 +54,7 @@ import { useTerminalContextActions } from "./terminal/hooks/useTerminalContextAc
import { useTerminalAuthState } from "./terminal/hooks/useTerminalAuthState";
import { useServerStats } from "./terminal/hooks/useServerStats";
import { extractDropEntries, getPathForFile, DropEntry } from "../lib/sftpFileUtils";
import { useTerminalAutocomplete, AutocompletePopup } from "./terminal/autocomplete";
/**
* Extract unique root paths from drop entries for local terminal path insertion.
@@ -110,7 +111,8 @@ interface TerminalProps {
keys: SSHKey[];
identities: Identity[];
snippets: Snippet[];
allHosts?: Host[];
chainHosts?: Host[];
themePreviewId?: string;
knownHosts?: KnownHost[];
isVisible: boolean;
inWorkspace?: boolean;
@@ -183,7 +185,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
keys,
identities,
snippets,
allHosts = [],
chainHosts = [],
themePreviewId,
knownHosts: _knownHosts = [],
isVisible,
inWorkspace,
@@ -233,11 +236,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const serializeAddonRef = useRef<SerializeAddon | null>(null);
const searchAddonRef = useRef<SearchAddon | null>(null);
const xtermRuntimeRef = useRef<XTermRuntime | null>(null);
const knownCwdRef = useRef<string | undefined>(undefined);
const disposeDataRef = useRef<(() => void) | null>(null);
const disposeExitRef = useRef<(() => void) | null>(null);
const sessionRef = useRef<string | null>(null);
const hasConnectedRef = useRef(false);
const hasRunStartupCommandRef = useRef(false);
const terminalDataCapturedRef = useRef(false);
const onTerminalDataCaptureRef = useRef(onTerminalDataCapture);
const commandBufferRef = useRef<string>("");
const [hasMouseTracking, setHasMouseTracking] = useState(false);
const mouseTrackingRef = useRef(false);
@@ -245,6 +251,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const terminalSettingsRef = useRef(terminalSettings);
terminalSettingsRef.current = terminalSettings;
onTerminalDataCaptureRef.current = onTerminalDataCapture;
const isVisibleRef = useRef(isVisible);
isVisibleRef.current = isVisible;
const pendingOutputScrollRef = useRef(false);
@@ -296,6 +303,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const snippetsRef = useRef(snippets);
snippetsRef.current = snippets;
// Autocomplete handler refs (set after hook initialization)
const autocompleteKeyEventRef = useRef<((e: KeyboardEvent) => boolean) | undefined>(undefined);
const autocompleteInputRef = useRef<((data: string) => void) | undefined>(undefined);
const autocompleteRepositionRef = useRef<(() => void) | undefined>(undefined);
const terminalBackend = useTerminalBackend();
const { resizeSession, setSessionEncoding } = terminalBackend;
@@ -347,6 +359,135 @@ const TerminalComponent: React.FC<TerminalProps> = ({
handleCloseSearch,
} = terminalSearch;
// Terminal autocomplete — onAcceptText writes directly to session (no CustomEvent)
const autocompleteAcceptTextRef = useRef<((text: string) => void) | undefined>(undefined);
autocompleteAcceptTextRef.current = (text: string) => {
const id = sessionRef.current;
if (id && text) {
// Serial line mode: buffer text and handle local echo instead of direct send
if (host.protocol === "serial" && serialConfig?.lineMode) {
for (const ch of text) {
if (ch === "\r") {
const line = serialLineBufferRef.current + "\r";
terminalBackend.writeToSession(id, line);
serialLineBufferRef.current = "";
if (serialConfig?.localEcho) termRef.current?.write("\r\n");
} else if (ch === "\x15") {
if (serialConfig?.localEcho && serialLineBufferRef.current.length > 0) {
termRef.current?.write("\b \b".repeat(serialLineBufferRef.current.length));
}
serialLineBufferRef.current = "";
} else if (ch === "\b" || ch === "\x7f") {
if (serialLineBufferRef.current.length > 0) {
serialLineBufferRef.current = serialLineBufferRef.current.slice(0, -1);
if (serialConfig?.localEcho) termRef.current?.write("\b \b");
}
} else if (ch.charCodeAt(0) >= 32) {
serialLineBufferRef.current += ch;
if (serialConfig?.localEcho) termRef.current?.write(ch);
}
}
// Still update commandBuffer and broadcast for serial line mode
// (fall through to shared bookkeeping below — don't return early)
} else if (host.protocol === "serial" && serialConfig?.localEcho) {
// Serial character mode with local echo: echo accepted text locally
terminalBackend.writeToSession(id, text);
for (const ch of text) {
if (ch === "\r") {
termRef.current?.write("\r\n");
} else if (ch.charCodeAt(0) >= 32) {
termRef.current?.write(ch);
}
}
} else {
terminalBackend.writeToSession(id, text);
}
// Broadcast to other sessions if broadcast mode is enabled
if (isBroadcastEnabledRef.current && onBroadcastInputRef.current) {
onBroadcastInputRef.current(text, sessionId);
}
// Update command buffer for onCommandExecuted tracking
for (const ch of text) {
if (ch === "\r" || ch === "\n") {
const cmd = commandBufferRef.current.trim();
if (cmd && onCommandExecuted) onCommandExecuted(cmd, host.id, host.label, sessionId);
commandBufferRef.current = "";
} else if (ch === "\x15") {
// Ctrl+U: clear line — reset command buffer (fuzzy match sends this)
commandBufferRef.current = "";
} else if (ch === "\b" || ch === "\x7f") {
// Backspace: remove last character (Windows fuzzy replacement uses \b)
commandBufferRef.current = commandBufferRef.current.slice(0, -1);
} else if (ch.charCodeAt(0) >= 32) {
commandBufferRef.current += ch;
}
}
}
};
const autocomplete = useTerminalAutocomplete({
termRef,
sessionId,
hostId: host.id,
hostOs: host.os || (host.protocol === "local"
? (navigator.platform?.startsWith("Win") ? "windows" : navigator.platform?.startsWith("Mac") ? "macos" : "linux")
: "linux"),
settings: terminalSettings ? {
enabled: terminalSettings.autocompleteEnabled ?? true,
showGhostText: terminalSettings.autocompleteGhostText ?? true,
showPopupMenu: terminalSettings.autocompletePopupMenu ?? true,
debounceMs: terminalSettings.autocompleteDebounceMs ?? 100,
minChars: terminalSettings.autocompleteMinChars ?? 1,
maxSuggestions: terminalSettings.autocompleteMaxSuggestions ?? 8,
} : undefined,
onAcceptText: (text) => autocompleteAcceptTextRef.current?.(text),
protocol: host.protocol,
getCwd: () => knownCwdRef.current ?? xtermRuntimeRef.current?.currentCwd,
});
// Wire up autocomplete handler refs so createXTermRuntime can use them
autocompleteKeyEventRef.current = autocomplete.handleKeyEvent;
autocompleteInputRef.current = autocomplete.handleInput;
autocompleteRepositionRef.current = autocomplete.repositionPopup;
const autocompleteClosePopup = autocomplete.closePopup;
useEffect(() => {
knownCwdRef.current = undefined;
}, [sessionId, host.id]);
useEffect(() => {
if (host.protocol === "local" || host.protocol === "serial" || host.protocol === "telnet") {
return;
}
if (status !== "connected" || !sessionRef.current || knownCwdRef.current) return;
let cancelled = false;
const timer = setTimeout(async () => {
if (!sessionRef.current) return;
try {
const result = await terminalBackend.getSessionPwd(sessionRef.current);
if (!cancelled && result.success && result.cwd) {
knownCwdRef.current = result.cwd;
}
} catch {
// Best effort only.
}
}, 150);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [host.protocol, status, terminalBackend]);
useEffect(() => {
if (!isVisible) {
autocompleteClosePopup();
}
}, [isVisible, autocompleteClosePopup]);
// Check if this is a local or serial connection (doesn't need connection dialog during connecting)
const isLocalConnection = host.protocol === "local";
const isSerialConnection = host.protocol === "serial";
@@ -358,8 +499,30 @@ const TerminalComponent: React.FC<TerminalProps> = ({
refreshInterval: terminalSettings?.serverStatsRefreshInterval ?? 5,
isSupportedOs: host.os === 'linux' || host.os === 'macos',
isConnected: status === 'connected',
isVisible,
});
const zmodem = useZmodemTransfer(sessionId);
const zmodemToastedRef = useRef(false);
useEffect(() => {
if (zmodem.active) {
zmodemToastedRef.current = false;
return;
}
if (zmodemToastedRef.current) return;
if (zmodem.error) {
zmodemToastedRef.current = true;
toast.error(zmodem.error, 'ZMODEM');
} else if (zmodem.filename) {
zmodemToastedRef.current = true;
toast.success(
`${zmodem.transferType === 'upload' ? 'Uploaded' : 'Downloaded'}: ${zmodem.filename}`,
'ZMODEM',
);
}
}, [zmodem.active, zmodem.error, zmodem.filename, zmodem.transferType]);
useEffect(() => {
if (!error) {
lastToastedErrorRef.current = null;
@@ -411,27 +574,47 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// Subscribe to custom theme changes so editing triggers re-render
const customThemes = useCustomThemes();
const hasFontSizeOverride = host.fontSizeOverride === true || (host.fontSizeOverride === undefined && host.fontSize != null);
const hasFontFamilyOverride = host.fontFamilyOverride === true || (host.fontFamilyOverride === undefined && !!host.fontFamily);
const effectiveFontSize = useMemo(
() => (hasFontSizeOverride && host.fontSize != null ? host.fontSize : fontSize),
[fontSize, hasFontSizeOverride, host.fontSize],
);
const resolvedFontFamily = useMemo(() => {
const hostFontId = hasFontFamilyOverride && host.fontFamily
? host.fontFamily
: fontFamilyId;
const resolvedFontId = hostFontId || "menlo";
return (availableFonts.find((f) => f.id === resolvedFontId) || availableFonts[0]).family;
}, [availableFonts, fontFamilyId, hasFontFamilyOverride, host.fontFamily]);
const effectiveTheme = useMemo(() => {
const themeId = resolveHostTerminalThemeId(host, terminalTheme.id);
const themeId = themePreviewId ?? resolveHostTerminalThemeId(
{ theme: host.theme, themeOverride: host.themeOverride } as Pick<Host, 'theme' | 'themeOverride'>,
terminalTheme.id,
);
if (themeId) {
const hostTheme = TERMINAL_THEMES.find((t) => t.id === themeId)
|| customThemes.find((t) => t.id === themeId);
if (hostTheme) return hostTheme;
}
return terminalTheme;
}, [host, terminalTheme, customThemes]);
}, [customThemes, host.theme, host.themeOverride, terminalTheme, themePreviewId]);
const resolvedChainHosts =
(host.hostChain?.hostIds
?.map((id) => allHosts.find((h) => h.id === id))
.filter(Boolean) as Host[]) || [];
chainHosts;
const updateStatus = (next: TerminalSession["status"]) => {
setStatus(next);
hasConnectedRef.current = next === "connected";
onStatusChange?.(sessionId, next);
};
const handleTerminalDataCaptureOnce = useCallback((capturedSessionId: string, data: string) => {
const captureHandler = onTerminalDataCaptureRef.current;
if (!captureHandler || terminalDataCapturedRef.current) return;
terminalDataCapturedRef.current = true;
captureHandler(capturedSessionId, data);
}, []);
const cleanupSession = () => {
disposeDataRef.current?.();
@@ -499,7 +682,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
},
onSessionExit,
onTerminalDataCapture,
onTerminalDataCapture: handleTerminalDataCaptureOnce,
onOsDetected,
onCommandExecuted,
sessionLog,
@@ -508,6 +691,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
useEffect(() => {
let disposed = false;
terminalDataCapturedRef.current = false;
setError(null);
hasConnectedRef.current = false;
pendingOutputScrollRef.current = false;
@@ -544,7 +728,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
serialLocalEcho: serialConfig?.localEcho,
serialLineMode: serialConfig?.lineMode,
serialLineBufferRef,
onCwdChange: (cwd: string) => {
knownCwdRef.current = cwd;
},
onOsc52ReadRequest: handleOsc52ReadRequest,
// Autocomplete integration
onAutocompleteKeyEvent: (e: KeyboardEvent) => autocompleteKeyEventRef.current?.(e) ?? true,
onAutocompleteInput: (data: string) => autocompleteInputRef.current?.(data),
});
xtermRuntimeRef.current = runtime;
@@ -619,11 +809,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return () => {
disposed = true;
if (onTerminalDataCapture && serializeAddonRef.current) {
if (!terminalDataCapturedRef.current && serializeAddonRef.current) {
try {
const terminalData = serializeAddonRef.current.serialize();
logger.info("[Terminal] Capturing data on unmount", { sessionId, dataLength: terminalData.length });
onTerminalDataCapture(sessionId, terminalData);
handleTerminalDataCaptureOnce(sessionId, terminalData);
} catch (err) {
logger.warn("Failed to serialize terminal data on unmount:", err);
}
@@ -631,7 +821,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
teardown();
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- Effect only runs on host.id/sessionId change, internal functions are stable
}, [host.id, sessionId]);
}, [handleTerminalDataCaptureOnce, host.id, sessionId]);
// Connection timeline and timeout visuals
useEffect(() => {
@@ -696,6 +886,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (!options?.force) {
const lastSize = lastFittedSizeRef.current;
if (lastSize && lastSize.width === width && lastSize.height === height) {
autocompleteRepositionRef.current?.();
return;
}
}
@@ -704,6 +895,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
try {
lastFittedSizeRef.current = { width, height };
fitAddon.fit();
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => {
autocompleteRepositionRef.current?.();
});
} else {
autocompleteRepositionRef.current?.();
}
} catch (err) {
logger.warn("Fit failed", err);
}
@@ -719,15 +917,20 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
};
useEffect(() => {
// Sync xterm theme before browser paint so canvas + DOM CSS vars update in the same frame
useLayoutEffect(() => {
if (termRef.current) {
const effectiveFontSize = resolveHostTerminalFontSize(host, fontSize);
termRef.current.options.fontSize = effectiveFontSize;
termRef.current.options.theme = {
...effectiveTheme.colors,
selectionBackground: effectiveTheme.colors.selection,
};
}
}, [effectiveTheme]);
useEffect(() => {
if (termRef.current) {
termRef.current.options.fontSize = effectiveFontSize;
termRef.current.options.fontFamily = resolvedFontFamily;
if (terminalSettings) {
termRef.current.options.cursorStyle = terminalSettings.cursorShape;
@@ -780,27 +983,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
termRef.current.options.ignoreBracketedPasteMode = terminalSettings.disableBracketedPaste ?? false;
}
setTimeout(() => safeFit({ force: true }), 50);
if (isVisibleRef.current) {
setTimeout(() => safeFit({ force: true, requireVisible: true }), 50);
} else {
lastFittedSizeRef.current = null;
}
}
}, [fontSize, effectiveTheme, terminalSettings, host]);
useEffect(() => {
if (termRef.current) {
const effectiveFontSize = resolveHostTerminalFontSize(host, fontSize);
termRef.current.options.fontSize = effectiveFontSize;
const hostFontId = resolveHostTerminalFontFamilyId(host, fontFamilyId) || "menlo";
const fontObj = availableFonts.find((f) => f.id === hostFontId) || availableFonts[0];
termRef.current.options.fontFamily = fontObj.family;
termRef.current.options.theme = {
...effectiveTheme.colors,
selectionBackground: effectiveTheme.colors.selection,
};
setTimeout(() => safeFit({ force: true }), 50);
}
}, [host, fontFamilyId, fontSize, effectiveTheme, availableFonts]);
}, [effectiveFontSize, resolvedFontFamily, terminalSettings]);
useEffect(() => {
if (!isVisible) return;
@@ -848,7 +1037,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (terminalSettings && termRef.current) {
const fontFamily = termRef.current.options?.fontFamily || "";
const effectiveFontSize = resolveHostTerminalFontSize(host, fontSize);
if (typeof document !== "undefined" && document.fonts?.check) {
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${fontFamily}`;
const resolvedBold = document.fonts.check(weightSpec)
@@ -884,7 +1072,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return () => {
cancelled = true;
};
}, [host, fontFamilyId, fontSize, resizeSession, sessionId, terminalSettings]);
}, [effectiveFontSize, resizeSession, terminalSettings]);
useEffect(() => {
if (!isVisible || !containerRef.current || !fitAddonRef.current) return;
@@ -1022,6 +1210,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}, [sessionId]);
useEffect(() => {
if (!isVisible) return;
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
const handler = () => {
@@ -1039,7 +1229,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (resizeTimeout) clearTimeout(resizeTimeout);
window.removeEventListener("resize", handler);
};
}, []);
}, [isVisible]);
const disableBracketedPasteRef = useRef(terminalSettings?.disableBracketedPaste ?? false);
disableBracketedPasteRef.current = terminalSettings?.disableBracketedPaste ?? false;
@@ -1189,6 +1379,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (!termRef.current) return;
cleanupSession();
auth.resetForRetry();
terminalDataCapturedRef.current = false;
hasRunStartupCommandRef.current = false;
setIsDisconnectedDialogDismissed(false);
setStatus("connecting");
@@ -1322,6 +1513,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
: status === "connecting"
? "bg-amber-400"
: "bg-rose-500";
const terminalPreviewVars = useMemo(() => ({
['--terminal-ui-bg' as never]: `var(--terminal-preview-bg, ${effectiveTheme.colors.background})`,
['--terminal-ui-fg' as never]: `var(--terminal-preview-fg, ${effectiveTheme.colors.foreground})`,
['--terminal-ui-border' as never]: `var(--terminal-preview-border, color-mix(in srgb, ${effectiveTheme.colors.foreground} 8%, ${effectiveTheme.colors.background} 92%))`,
['--terminal-ui-toolbar-btn' as never]: `var(--terminal-preview-toolbar-btn, color-mix(in srgb, ${effectiveTheme.colors.background} 88%, ${effectiveTheme.colors.foreground} 12%))`,
['--terminal-ui-toolbar-btn-hover' as never]: `var(--terminal-preview-toolbar-btn-hover, color-mix(in srgb, ${effectiveTheme.colors.background} 78%, ${effectiveTheme.colors.foreground} 22%))`,
['--terminal-ui-toolbar-btn-active' as never]: `var(--terminal-preview-toolbar-btn-active, color-mix(in srgb, ${effectiveTheme.colors.background} 68%, ${effectiveTheme.colors.foreground} 32%))`,
}), [effectiveTheme.colors.background, effectiveTheme.colors.foreground]);
return (
<TerminalContextMenu
@@ -1344,6 +1543,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
"relative h-full w-full flex overflow-hidden bg-gradient-to-br from-[#050910] via-[#06101a] to-[#0b1220]",
isComposeBarOpen && !inWorkspace && "flex-col"
)}
style={terminalPreviewVars}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
@@ -1374,14 +1574,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
<div
className="flex items-center gap-1 px-2 py-0.5 backdrop-blur-md pointer-events-auto min-w-0 border-b-[0.5px]"
style={{
backgroundColor: effectiveTheme.colors.background,
color: effectiveTheme.colors.foreground,
borderColor: `color-mix(in srgb, ${effectiveTheme.colors.foreground} 8%, ${effectiveTheme.colors.background} 92%)`,
['--terminal-toolbar-fg' as never]: effectiveTheme.colors.foreground,
['--terminal-toolbar-bg' as never]: effectiveTheme.colors.background,
['--terminal-toolbar-btn' as never]: `color-mix(in srgb, ${effectiveTheme.colors.background} 88%, ${effectiveTheme.colors.foreground} 12%)`,
['--terminal-toolbar-btn-hover' as never]: `color-mix(in srgb, ${effectiveTheme.colors.background} 78%, ${effectiveTheme.colors.foreground} 22%)`,
['--terminal-toolbar-btn-active' as never]: `color-mix(in srgb, ${effectiveTheme.colors.background} 68%, ${effectiveTheme.colors.foreground} 32%)`,
backgroundColor: 'var(--terminal-ui-bg)',
color: 'var(--terminal-ui-fg)',
borderColor: 'var(--terminal-ui-border)',
['--terminal-toolbar-fg' as never]: 'var(--terminal-ui-fg)',
['--terminal-toolbar-bg' as never]: 'var(--terminal-ui-bg)',
['--terminal-toolbar-btn' as never]: 'var(--terminal-ui-toolbar-btn)',
['--terminal-toolbar-btn-hover' as never]: 'var(--terminal-ui-toolbar-btn-hover)',
['--terminal-toolbar-btn-active' as never]: 'var(--terminal-ui-toolbar-btn-active)',
}}
>
<div className="flex items-center gap-1 text-[11px] font-semibold">
@@ -1756,7 +1956,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
<div
className="h-full flex-1 min-w-0 relative overflow-hidden pt-8"
style={{ backgroundColor: effectiveTheme.colors.background }}
style={{ backgroundColor: 'var(--terminal-ui-bg)' }}
>
<div
ref={containerRef}
@@ -1764,10 +1964,34 @@ const TerminalComponent: React.FC<TerminalProps> = ({
style={{
top: isSearchOpen ? "64px" : "30px",
paddingLeft: 6,
backgroundColor: effectiveTheme.colors.background,
backgroundColor: 'var(--terminal-ui-bg)',
}}
/>
{/* Autocomplete popup — rendered via Portal to escape overflow:hidden */}
{isVisible && autocomplete.state.popupVisible && autocomplete.state.suggestions.length > 0 &&
ReactDOM.createPortal(
<AutocompletePopup
suggestions={autocomplete.state.suggestions}
selectedIndex={autocomplete.state.selectedIndex}
position={autocomplete.state.popupPosition}
cursorLineTop={autocomplete.state.popupCursorLineTop}
cursorLineBottom={autocomplete.state.popupCursorLineBottom}
visible={autocomplete.state.popupVisible}
expandUpward={autocomplete.state.expandUpward}
themeColors={effectiveTheme.colors}
onSelect={autocomplete.selectSuggestion}
subDirPanels={autocomplete.state.subDirPanels}
subDirFocusLevel={autocomplete.state.subDirFocusLevel}
containerRef={containerRef}
onRequestReposition={autocomplete.repositionPopup}
searchBarOffset={isSearchOpen ? 64 : 30}
onDismiss={autocompleteClosePopup}
/>,
document.body,
)
}
{needsHostKeyVerification && pendingHostKeyInfo && (
<div className="absolute inset-0 z-30 bg-background">
<KnownHostConfirmDialog
@@ -1847,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

@@ -1,6 +1,12 @@
import { Circle, FolderTree, LayoutGrid, MessageSquare, PanelLeft, PanelRight, Palette, Server, X, Zap } from 'lucide-react';
import React, { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import React, { createContext, memo, startTransition, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { useActiveTabId } from '../application/state/activeTabStore';
import {
getSessionActivityIdsToClear,
getValidSessionActivityIds,
shouldMarkSessionActivity,
} from '../application/state/sessionActivity';
import { sessionActivityStore } from '../application/state/sessionActivityStore';
import { useTerminalBackend } from '../application/state/useTerminalBackend';
import { collectSessionIds } from '../domain/workspace';
import { SplitDirection } from '../domain/workspace';
@@ -19,6 +25,8 @@ import {
import { cn, normalizeLineEndings } from '../lib/utils';
import { detectLocalOs } from '../lib/localShell';
import { useStoredString } from '../application/state/useStoredString';
import { useStoredNumber } from '../application/state/useStoredNumber';
import { STORAGE_KEY_SIDE_PANEL_WIDTH } from '../infrastructure/config/storageKeys';
import { buildCacheKey } from '../application/state/sftp/sharedRemoteHostCache';
import type { DropEntry } from '../lib/sftpFileUtils';
import { Host, Identity, KnownHost, SSHKey, Snippet, TerminalSession, TerminalTheme, Workspace, WorkspaceNode } from '../types';
@@ -67,6 +75,78 @@ type PendingSftpUpload = {
type SnippetExecutor = (command: string, noAutoRun?: boolean) => void;
function hexToHslToken(hex: string): string {
const normalized = hex.startsWith('#') ? hex : `#${hex}`;
const r = parseInt(normalized.slice(1, 3), 16) / 255;
const g = parseInt(normalized.slice(3, 5), 16) / 255;
const b = parseInt(normalized.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0;
let s = 0;
const l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r:
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
break;
case g:
h = ((b - r) / d + 2) / 6;
break;
default:
h = ((r - g) / d + 4) / 6;
break;
}
}
return `${Math.round(h * 3600) / 10} ${Math.round(s * 1000) / 10}% ${Math.round(l * 1000) / 10}%`;
}
function adjustLightnessToken(hsl: string, delta: number): string {
const parts = hsl.split(/\s+/);
const newL = Math.max(0, Math.min(100, parseFloat(parts[2]) + delta));
return `${parts[0]} ${parts[1]} ${Math.round(newL * 10) / 10}%`;
}
function adjustSaturationToken(hsl: string, factor: number): string {
const parts = hsl.split(/\s+/);
const newS = Math.max(0, Math.min(100, parseFloat(parts[1]) * factor));
return `${parts[0]} ${Math.round(newS * 10) / 10}% ${parts[2]}`;
}
const clearTerminalPreviewVars = (sessionId: string | null) => {
if (!sessionId || typeof document === 'undefined') return;
const pane = document.querySelector<HTMLElement>(`[data-session-id="${sessionId}"]`);
if (!pane) return;
pane.style.removeProperty('--terminal-preview-bg');
pane.style.removeProperty('--terminal-preview-fg');
pane.style.removeProperty('--terminal-preview-border');
pane.style.removeProperty('--terminal-preview-toolbar-btn');
pane.style.removeProperty('--terminal-preview-toolbar-btn-hover');
pane.style.removeProperty('--terminal-preview-toolbar-btn-active');
};
const clearTopTabsPreviewVars = () => {
if (typeof document === 'undefined') return;
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
if (!tabsRoot) return;
tabsRoot.style.removeProperty('--top-tabs-bg');
tabsRoot.style.removeProperty('--top-tabs-fg');
tabsRoot.style.removeProperty('--top-tabs-muted');
tabsRoot.style.removeProperty('--top-tabs-active-bg');
tabsRoot.style.removeProperty('--top-tabs-accent');
tabsRoot.style.removeProperty('--background');
tabsRoot.style.removeProperty('--foreground');
tabsRoot.style.removeProperty('--accent');
tabsRoot.style.removeProperty('--primary');
tabsRoot.style.removeProperty('--secondary');
tabsRoot.style.removeProperty('--border');
tabsRoot.style.removeProperty('--muted-foreground');
};
const filterTabsMap = <T,>(source: Map<string, T>, validIds: Set<string>): Map<string, T> => {
let changed = false;
const next = new Map<string, T>();
@@ -80,6 +160,41 @@ const filterTabsMap = <T,>(source: Map<string, T>, validIds: Set<string>): Map<s
return changed ? next : source;
};
// eslint-disable-next-line no-control-regex
const TERMINAL_OSC_SEQUENCE_REGEX = new RegExp('\\u001B\\][^\\u0007\\u001B]*(?:\\u0007|\\u001B\\\\)', 'g');
// eslint-disable-next-line no-control-regex
const TERMINAL_ESCAPE_SEQUENCE_REGEX = new RegExp('\\u001B(?:[@-Z\\\\-_]|\\[[0-?]*[ -/]*[@-~])', 'g');
// eslint-disable-next-line no-control-regex
const TERMINAL_CONTROL_CHAR_REGEX = new RegExp('[\\u0000-\\u0008\\u000B-\\u001F\\u007F]', 'g');
// eslint-disable-next-line no-control-regex
const INCOMPLETE_ESCAPE_TAIL_REGEX = new RegExp('\\u001B(?:\\][^\\u0007\\u001B]*(?:\\u001B)?|\\[[0-?]*[ -/]*)?$');
const stripTerminalControlSequences = (data: string): string => {
return data
.replace(TERMINAL_OSC_SEQUENCE_REGEX, '')
.replace(TERMINAL_ESCAPE_SEQUENCE_REGEX, '')
.replace(TERMINAL_CONTROL_CHAR_REGEX, '');
};
class ChunkedEscapeFilter {
private pending = '';
feed(chunk: string): string {
const data = this.pending + chunk;
const tailMatch = INCOMPLETE_ESCAPE_TAIL_REGEX.exec(data);
if (tailMatch) {
this.pending = tailMatch[0];
return stripTerminalControlSequences(data.slice(0, tailMatch.index));
}
this.pending = '';
return stripTerminalControlSequences(data);
}
}
const hasNotifiableTerminalOutput = (filter: ChunkedEscapeFilter, chunk: string): boolean => {
return filter.feed(chunk).trim().length > 0;
};
type AITerminalSessionInfo = {
sessionId: string;
hostId: string;
@@ -89,6 +204,7 @@ type AITerminalSessionInfo = {
username?: string;
protocol?: string;
shellType?: string;
deviceType?: string;
connected: boolean;
};
@@ -120,6 +236,9 @@ const buildAITerminalSessionInfo = (
username: host?.username || session?.username,
protocol,
shellType: session?.shellType && session.shellType !== 'unknown' ? session.shellType : undefined,
// Suppress deviceType for Mosh sessions — Mosh requires a shell-backed PTY
// and cannot connect to vendor CLIs, so network device mode doesn't apply.
deviceType: (session?.moshEnabled || host?.moshEnabled) ? undefined : host?.deviceType,
connected: session?.status === 'connected',
};
};
@@ -182,6 +301,7 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
deleteSession={aiState.deleteSession}
updateSessionTitle={aiState.updateSessionTitle}
updateSessionExternalSessionId={aiState.updateSessionExternalSessionId}
retargetSessionScope={aiState.retargetSessionScope}
addMessageToSession={aiState.addMessageToSession}
updateLastMessage={aiState.updateLastMessage}
updateMessageById={aiState.updateMessageById}
@@ -255,6 +375,7 @@ interface TerminalLayerProps {
onToggleBroadcast?: (workspaceId: string) => void;
// SFTP side panel
updateHosts: (hosts: Host[]) => void;
sftpDefaultViewMode: 'list' | 'tree';
sftpDoubleClickBehavior: 'open' | 'transfer';
sftpAutoSync: boolean;
sftpShowHiddenFiles: boolean;
@@ -305,6 +426,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
isBroadcastEnabled,
onToggleBroadcast,
updateHosts,
sftpDefaultViewMode,
sftpDoubleClickBehavior,
sftpAutoSync,
sftpShowHiddenFiles,
@@ -422,11 +544,18 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
snippetExecutorsRef.current.delete(sessionId);
}, []);
const onSessionData = terminalBackend.onSessionData;
const [workspaceArea, setWorkspaceArea] = useState<{ width: number; height: number }>({ width: 0, height: 0 });
const workspaceOuterRef = useRef<HTMLDivElement>(null);
const workspaceInnerRef = useRef<HTMLDivElement>(null);
const workspaceOverlayRef = useRef<HTMLDivElement>(null);
const [dropHint, setDropHint] = useState<SplitHint>(null);
const [themePreview, setThemePreview] = useState<{ targetSessionId: string | null; themeId: string | null }>({
targetSessionId: null,
themeId: null,
});
const themeCommitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [resizing, setResizing] = useState<{
workspaceId: string;
splitId: string;
@@ -473,10 +602,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
// Side panel state - per-tab tracking of which sub-panel is active
// Maps tab IDs to the active sub-panel type (sftp/scripts/theme), absent = closed
const [sidePanelOpenTabs, setSidePanelOpenTabs] = useState<Map<string, SidePanelTab>>(new Map());
const [sidePanelWidth, setSidePanelWidth] = useState(() => {
const stored = window.localStorage.getItem('netcatty_side_panel_width');
return stored ? Math.max(280, Math.min(800, Number(stored))) : 420;
});
const [sidePanelWidth, setSidePanelWidth, persistSidePanelWidth] = useStoredNumber(
STORAGE_KEY_SIDE_PANEL_WIDTH, 420, { min: 280, max: 800 },
);
const [sidePanelPosition, setSidePanelPosition] = useStoredString<'left' | 'right'>(
'netcatty_side_panel_position',
'left',
@@ -609,20 +737,27 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const startWidth = sidePanelWidth;
let lastWidth = startWidth;
let rafId: number | null = null;
const onMouseMove = (ev: MouseEvent) => {
const delta = ev.clientX - startX;
lastWidth = Math.max(280, Math.min(800, startWidth + (sidePanelPosition === 'left' ? delta : -delta)));
setSidePanelWidth(lastWidth);
if (rafId !== null) return;
rafId = requestAnimationFrame(() => {
rafId = null;
setSidePanelWidth(lastWidth);
});
};
const onMouseUp = () => {
if (rafId !== null) cancelAnimationFrame(rafId);
setSidePanelWidth(lastWidth);
sftpResizingRef.current = false;
window.localStorage.setItem('netcatty_side_panel_width', String(lastWidth));
persistSidePanelWidth(lastWidth);
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
};
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
}, [sidePanelWidth, sidePanelPosition]);
}, [sidePanelWidth, sidePanelPosition, setSidePanelWidth, persistSidePanelWidth]);
// Pre-compute host lookup map for O(1) access
const hostMap = useMemo(() => {
@@ -637,15 +772,24 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
for (const session of sessions) {
const existingHost = hostMap.get(session.hostId);
if (existingHost) {
// Apply session-time protocol overrides to the host
const hostWithOverrides: Host = {
...existingHost,
// Use session protocol settings if provided (from connection-time selection)
protocol: session.protocol ?? existingHost.protocol,
port: session.port ?? existingHost.port,
moshEnabled: session.moshEnabled ?? existingHost.moshEnabled,
};
map.set(session.id, hostWithOverrides);
const protocol = session.protocol ?? existingHost.protocol;
const port = session.port ?? existingHost.port;
const moshEnabled = session.moshEnabled ?? existingHost.moshEnabled;
if (
protocol === existingHost.protocol &&
port === existingHost.port &&
moshEnabled === existingHost.moshEnabled
) {
map.set(session.id, existingHost);
} else {
map.set(session.id, {
...existingHost,
protocol,
port,
moshEnabled,
});
}
} else {
// Create stable fallback host object
map.set(session.id, {
@@ -659,11 +803,26 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
tags: [],
protocol: session.protocol ?? 'local' as const,
moshEnabled: session.moshEnabled,
charset: session.charset,
});
}
}
return map;
}, [sessions, hostMap]);
const sessionChainHostsMap = useMemo(() => {
const map = new Map<string, Host[]>();
for (const session of sessions) {
const host = sessionHostsMap.get(session.id);
if (!host?.hostChain?.hostIds?.length) continue;
map.set(
session.id,
host.hostChain.hostIds
.map((hostId) => hostMap.get(hostId))
.filter((value): value is Host => Boolean(value)),
);
}
return map;
}, [sessions, sessionHostsMap, hostMap]);
const validTerminalTabIds = useMemo(() => {
const ids = new Set<string>();
@@ -672,6 +831,17 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
return ids;
}, [sessions, workspaces]);
const validSessionActivityIds = useMemo(() => {
return getValidSessionActivityIds(sessions);
}, [sessions]);
const activityTrackedSessions = useMemo(
() =>
sessions.filter(
(session) => session.status !== 'disconnected',
),
[sessions],
);
const onSplitSessionRef = useRef(onSplitSession);
onSplitSessionRef.current = onSplitSession;
const splitHorizontalHandlersRef = useRef<Map<string, () => void>>(new Map());
@@ -746,7 +916,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
setSftpHostForTab(prev => filterTabsMap(prev, validTerminalTabIds));
setSftpInitialLocationForTab(prev => filterTabsMap(prev, validTerminalTabIds));
setSftpPendingUploadsForTab(prev => filterTabsMap(prev, validTerminalTabIds));
}, [validTerminalTabIds]);
sessionActivityStore.prune(validSessionActivityIds);
}, [validSessionActivityIds, validTerminalTabIds]);
useEffect(() => {
cleanupOrphanedAISessions(validTerminalTabIds);
@@ -886,15 +1057,16 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
useEffect(() => {
if (!resizing) return;
const onMove = (e: MouseEvent) => {
let rafId: number | null = null;
let lastDelta = 0;
const applySizes = () => {
const dimension = resizing.direction === 'vertical' ? resizing.startArea.w : resizing.startArea.h;
if (dimension <= 0) return;
const total = resizing.startSizes.reduce((acc, n) => acc + n, 0) || 1;
const pxSizes = resizing.startSizes.map(s => (s / total) * dimension);
const i = resizing.index;
const delta = (resizing.direction === 'vertical' ? e.clientX - resizing.startClient.x : e.clientY - resizing.startClient.y);
let a = pxSizes[i] + delta;
let b = pxSizes[i + 1] - delta;
let a = pxSizes[i] + lastDelta;
let b = pxSizes[i + 1] - lastDelta;
const minPx = Math.min(120, dimension / 2);
if (a < minPx) {
const diff = minPx - a;
@@ -913,10 +1085,23 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const newSizes = newPxSizes.map(n => n / totalPx);
onUpdateSplitSizes(resizing.workspaceId, resizing.splitId, newSizes);
};
const onUp = () => setResizing(null);
const onMove = (e: MouseEvent) => {
lastDelta = resizing.direction === 'vertical' ? e.clientX - resizing.startClient.x : e.clientY - resizing.startClient.y;
if (rafId !== null) return;
rafId = requestAnimationFrame(() => {
rafId = null;
applySizes();
});
};
const onUp = () => {
if (rafId !== null) cancelAnimationFrame(rafId);
applySizes();
setResizing(null);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
return () => {
if (rafId !== null) cancelAnimationFrame(rafId);
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
@@ -1104,6 +1289,38 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
return () => window.removeEventListener('netcatty:toggle-ai-panel', handler);
}, [handleOpenAI]);
useEffect(() => {
const sessionIdsToClear = getSessionActivityIdsToClear(activeTabId, sessions);
if (sessionIdsToClear.length === 1) {
sessionActivityStore.clearTab(sessionIdsToClear[0]);
return;
}
if (sessionIdsToClear.length > 1) {
sessionActivityStore.clearTabs(sessionIdsToClear);
}
}, [activeTabId, sessions]);
useEffect(() => {
const unsubscribers = activityTrackedSessions.map((session) => {
const filter = new ChunkedEscapeFilter();
return onSessionData(session.id, (chunk) => {
if (!hasNotifiableTerminalOutput(filter, chunk)) return;
if (!shouldMarkSessionActivity(activeTabIdRef.current, session)) {
return;
}
sessionActivityStore.setTabActive(session.id, true);
});
});
return () => {
for (const unsubscribe of unsubscribers) {
unsubscribe();
}
};
}, [activityTrackedSessions, onSessionData]);
// Execute snippet on the focused terminal session
const handleSnippetClickForFocusedSession = useCallback((command: string, noAutoRun?: boolean) => {
const sessionId = activeWorkspace?.focusedSessionId ?? activeSession?.id;
@@ -1137,51 +1354,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const isFocusedHostLocal = useMemo(() => {
return focusedHost?.protocol === 'local' || !!focusedHost?.id?.startsWith('local-');
}, [focusedHost]);
const handleThemeChangeForFocusedSession = useCallback((themeId: string) => {
if (isFocusedHostLocal) {
onUpdateTerminalThemeId?.(themeId);
return;
}
if (focusedHost) {
onUpdateHost({ ...focusedHost, theme: themeId, themeOverride: true });
}
}, [focusedHost, isFocusedHostLocal, onUpdateTerminalThemeId, onUpdateHost]);
const handleThemeResetForFocusedSession = useCallback(() => {
if (!focusedHost || isFocusedHostLocal) return;
onUpdateHost(clearHostThemeOverride(focusedHost));
}, [focusedHost, isFocusedHostLocal, onUpdateHost]);
const handleFontFamilyChangeForFocusedSession = useCallback((fontFamilyId: string) => {
if (isFocusedHostLocal) {
onUpdateTerminalFontFamilyId?.(fontFamilyId);
return;
}
if (focusedHost) {
onUpdateHost({ ...focusedHost, fontFamily: fontFamilyId, fontFamilyOverride: true });
}
}, [focusedHost, isFocusedHostLocal, onUpdateTerminalFontFamilyId, onUpdateHost]);
const handleFontFamilyResetForFocusedSession = useCallback(() => {
if (!focusedHost || isFocusedHostLocal) return;
onUpdateHost(clearHostFontFamilyOverride(focusedHost));
}, [focusedHost, isFocusedHostLocal, onUpdateHost]);
const handleFontSizeChangeForFocusedSession = useCallback((newFontSize: number) => {
if (isFocusedHostLocal) {
onUpdateTerminalFontSize?.(newFontSize);
return;
}
if (focusedHost) {
onUpdateHost({ ...focusedHost, fontSize: newFontSize, fontSizeOverride: true });
}
}, [focusedHost, isFocusedHostLocal, onUpdateTerminalFontSize, onUpdateHost]);
const handleFontSizeResetForFocusedSession = useCallback(() => {
if (!focusedHost || isFocusedHostLocal) return;
onUpdateHost(clearHostFontSizeOverride(focusedHost));
}, [focusedHost, isFocusedHostLocal, onUpdateHost]);
const previewTargetSessionId = activeWorkspace?.focusedSessionId ?? activeSession?.id ?? null;
const activeThemePreviewId = themePreview.targetSessionId === previewTargetSessionId
? themePreview.themeId
: null;
// Current theme/font/size for the focused session (for ThemeSidePanel)
const focusedThemeId = resolveHostTerminalThemeId(focusedHost, terminalTheme.id);
@@ -1190,6 +1366,190 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const focusedThemeOverridden = hasHostThemeOverride(focusedHost);
const focusedFontFamilyOverridden = hasHostFontFamilyOverride(focusedHost);
const focusedFontSizeOverridden = hasHostFontSizeOverride(focusedHost);
const activeTopTabsThemeId = activeSidePanelTab === 'theme' && previewTargetSessionId
? (activeThemePreviewId ?? focusedThemeId)
: null;
const appliedPreviewSessionRef = useRef<string | null>(null);
const customThemes = useCustomThemes();
const applyTerminalPreviewVars = useCallback((sessionId: string | null, themeId: string | null) => {
if (!sessionId || !themeId || typeof document === 'undefined') {
clearTerminalPreviewVars(sessionId);
return;
}
const pane = document.querySelector<HTMLElement>(`[data-session-id="${sessionId}"]`);
const theme = TERMINAL_THEMES.find((entry) => entry.id === themeId)
|| customThemes.find((entry) => entry.id === themeId);
if (!pane || !theme) {
clearTerminalPreviewVars(sessionId);
return;
}
pane.style.setProperty('--terminal-preview-bg', theme.colors.background);
pane.style.setProperty('--terminal-preview-fg', theme.colors.foreground);
pane.style.setProperty('--terminal-preview-border', `color-mix(in srgb, ${theme.colors.foreground} 8%, ${theme.colors.background} 92%)`);
pane.style.setProperty('--terminal-preview-toolbar-btn', `color-mix(in srgb, ${theme.colors.background} 88%, ${theme.colors.foreground} 12%)`);
pane.style.setProperty('--terminal-preview-toolbar-btn-hover', `color-mix(in srgb, ${theme.colors.background} 78%, ${theme.colors.foreground} 22%)`);
pane.style.setProperty('--terminal-preview-toolbar-btn-active', `color-mix(in srgb, ${theme.colors.background} 68%, ${theme.colors.foreground} 32%)`);
}, [customThemes]);
const applyTopTabsPreviewVars = useCallback((themeId: string | null) => {
if (!themeId || typeof document === 'undefined') {
clearTopTabsPreviewVars();
return;
}
const tabsRoot = document.querySelector<HTMLElement>('[data-top-tabs-root]');
const theme = TERMINAL_THEMES.find((entry) => entry.id === themeId)
|| customThemes.find((entry) => entry.id === themeId);
if (!tabsRoot || !theme) {
clearTopTabsPreviewVars();
return;
}
const bg = hexToHslToken(theme.colors.background);
const fg = hexToHslToken(theme.colors.foreground);
const accent = fg;
const isDark = theme.type === 'dark';
const secondary = adjustLightnessToken(bg, isDark ? 6 : -5);
const border = adjustLightnessToken(bg, isDark ? 12 : -10);
const mutedFg = adjustSaturationToken(adjustLightnessToken(fg, isDark ? -20 : 20), 0.5);
tabsRoot.style.setProperty('--background', bg);
tabsRoot.style.setProperty('--foreground', fg);
tabsRoot.style.setProperty('--accent', accent);
tabsRoot.style.setProperty('--primary', accent);
tabsRoot.style.setProperty('--secondary', secondary);
tabsRoot.style.setProperty('--border', border);
tabsRoot.style.setProperty('--muted-foreground', mutedFg);
tabsRoot.style.setProperty('--top-tabs-bg', 'hsl(var(--secondary))');
tabsRoot.style.setProperty('--top-tabs-fg', 'hsl(var(--foreground))');
tabsRoot.style.setProperty('--top-tabs-muted', 'hsl(var(--muted-foreground))');
tabsRoot.style.setProperty('--top-tabs-active-bg', 'hsl(var(--background))');
tabsRoot.style.setProperty('--top-tabs-accent', 'hsl(var(--foreground))');
}, [customThemes]);
useEffect(() => {
return () => {
if (themeCommitTimerRef.current) {
clearTimeout(themeCommitTimerRef.current);
}
clearTerminalPreviewVars(appliedPreviewSessionRef.current);
clearTopTabsPreviewVars();
};
}, []);
useEffect(() => {
const appliedSessionId = appliedPreviewSessionRef.current;
if (
appliedSessionId &&
(appliedSessionId !== themePreview.targetSessionId || !themePreview.themeId)
) {
clearTerminalPreviewVars(appliedSessionId);
appliedPreviewSessionRef.current = null;
}
if (themePreview.targetSessionId && themePreview.themeId) {
applyTerminalPreviewVars(themePreview.targetSessionId, themePreview.themeId);
appliedPreviewSessionRef.current = themePreview.targetSessionId;
}
}, [applyTerminalPreviewVars, themePreview]);
useEffect(() => {
if (activeTopTabsThemeId) {
applyTopTabsPreviewVars(activeTopTabsThemeId);
return;
}
clearTopTabsPreviewVars();
}, [activeTopTabsThemeId, applyTopTabsPreviewVars]);
useEffect(() => {
const shouldKeepPreview =
activeSidePanelTab === 'theme' &&
!!previewTargetSessionId &&
!!themePreview.targetSessionId &&
!!themePreview.themeId;
if (shouldKeepPreview) return;
const appliedSessionId = appliedPreviewSessionRef.current;
if (appliedSessionId) {
clearTerminalPreviewVars(appliedSessionId);
appliedPreviewSessionRef.current = null;
}
clearTopTabsPreviewVars();
if (themePreview.targetSessionId || themePreview.themeId) {
setThemePreview({ targetSessionId: null, themeId: null });
}
}, [activeSidePanelTab, previewTargetSessionId, themePreview.targetSessionId, themePreview.themeId]);
useEffect(() => {
if (
themePreview.targetSessionId === previewTargetSessionId &&
themePreview.themeId &&
themePreview.themeId === focusedThemeId
) {
setThemePreview({ targetSessionId: null, themeId: null });
}
}, [focusedThemeId, previewTargetSessionId, themePreview]);
const handleThemeChangeForFocusedSession = useCallback((themeId: string) => {
if (!focusedHost || themeId === focusedThemeId) return;
applyTerminalPreviewVars(previewTargetSessionId, themeId);
applyTopTabsPreviewVars(themeId);
setThemePreview({ targetSessionId: previewTargetSessionId, themeId });
if (themeCommitTimerRef.current) {
clearTimeout(themeCommitTimerRef.current);
}
themeCommitTimerRef.current = setTimeout(() => {
startTransition(() => {
if (isFocusedHostLocal) {
onUpdateTerminalThemeId?.(themeId);
return;
}
onUpdateHost({ ...focusedHost, theme: themeId, themeOverride: true });
});
}, 160);
}, [applyTerminalPreviewVars, applyTopTabsPreviewVars, focusedHost, focusedThemeId, isFocusedHostLocal, onUpdateTerminalThemeId, onUpdateHost, previewTargetSessionId]);
const handleThemeResetForFocusedSession = useCallback(() => {
if (themeCommitTimerRef.current) {
clearTimeout(themeCommitTimerRef.current);
}
clearTerminalPreviewVars(previewTargetSessionId);
setThemePreview({ targetSessionId: null, themeId: null });
if (!focusedHost || isFocusedHostLocal) return;
onUpdateHost(clearHostThemeOverride(focusedHost));
}, [focusedHost, isFocusedHostLocal, onUpdateHost, previewTargetSessionId]);
const handleFontFamilyChangeForFocusedSession = useCallback((fontFamilyId: string) => {
if (!focusedHost || fontFamilyId === focusedFontFamilyId) return;
startTransition(() => {
if (isFocusedHostLocal) {
onUpdateTerminalFontFamilyId?.(fontFamilyId);
return;
}
onUpdateHost({ ...focusedHost, fontFamily: fontFamilyId, fontFamilyOverride: true });
});
}, [focusedHost, focusedFontFamilyId, isFocusedHostLocal, onUpdateTerminalFontFamilyId, onUpdateHost]);
const handleFontFamilyResetForFocusedSession = useCallback(() => {
if (!focusedHost || isFocusedHostLocal) return;
onUpdateHost(clearHostFontFamilyOverride(focusedHost));
}, [focusedHost, isFocusedHostLocal, onUpdateHost]);
const handleFontSizeChangeForFocusedSession = useCallback((newFontSize: number) => {
if (!focusedHost || newFontSize === focusedFontSize) return;
startTransition(() => {
if (isFocusedHostLocal) {
onUpdateTerminalFontSize?.(newFontSize);
return;
}
onUpdateHost({ ...focusedHost, fontSize: newFontSize, fontSizeOverride: true });
});
}, [focusedHost, focusedFontSize, isFocusedHostLocal, onUpdateTerminalFontSize, onUpdateHost]);
const handleFontSizeResetForFocusedSession = useCallback(() => {
if (!focusedHost || isFocusedHostLocal) return;
onUpdateHost(clearHostFontSizeOverride(focusedHost));
}, [focusedHost, isFocusedHostLocal, onUpdateHost]);
// Keep MCP/ACP approval IPC listener alive for the entire terminal lifecycle.
// Must live here (TerminalLayer), not inside the AI panel subtree, so closing
@@ -1283,20 +1643,25 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
};
}, []);
// Subscribe to custom theme changes so editing triggers re-render
const customThemes = useCustomThemes();
const resolvedPreviewTheme = useMemo(() => {
const themeId = activeThemePreviewId ?? focusedThemeId;
return TERMINAL_THEMES.find((theme) => theme.id === themeId)
|| customThemes.find((theme) => theme.id === themeId)
|| terminalTheme;
}, [activeThemePreviewId, customThemes, focusedThemeId, terminalTheme]);
const sessionLogConfig = useMemo(
() =>
sessionLogsEnabled && sessionLogsDir
? { enabled: true as const, directory: sessionLogsDir, format: sessionLogsFormat || 'txt' }
: undefined,
[sessionLogsDir, sessionLogsEnabled, sessionLogsFormat],
);
// Resolve the effective theme for the compose bar in workspace mode
const composeBarThemeColors = useMemo(() => {
if (!activeWorkspace || !focusedSessionId) return terminalTheme.colors;
const focusedHost = sessionHostsMap.get(focusedSessionId);
if (focusedHost?.theme) {
const hostTheme = TERMINAL_THEMES.find(t => t.id === focusedHost.theme)
|| customThemes.find(t => t.id === focusedHost.theme);
if (hostTheme) return hostTheme.colors;
}
return terminalTheme.colors;
}, [activeWorkspace, focusedSessionId, sessionHostsMap, terminalTheme, customThemes]);
return resolvedPreviewTheme.colors;
}, [activeWorkspace, focusedSessionId, resolvedPreviewTheme, terminalTheme.colors]);
// Handle compose bar send for workspace mode
const handleComposeSend = useCallback((text: string) => {
@@ -1334,14 +1699,16 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
// Track previous focusedSessionId to detect changes
const prevFocusedSessionIdRef = useRef<string | undefined>(undefined);
// When focusedSessionId changes in split view, focus the corresponding terminal
// When focusedSessionId changes or terminal layer becomes visible,
// focus the corresponding terminal to restore :focus-within CSS state
useEffect(() => {
// Only handle split view mode (not focus mode)
if (isFocusMode || !focusedSessionId || !activeWorkspace) return;
// Only trigger when focusedSessionId actually changes
if (prevFocusedSessionIdRef.current === focusedSessionId) return;
const prevFocusedId = prevFocusedSessionIdRef.current;
// Trigger on focusedSessionId change OR when layer becomes visible again
const sessionChanged = prevFocusedSessionIdRef.current !== focusedSessionId;
if (!sessionChanged && !isTerminalLayerVisible) return;
const prevFocusedId = sessionChanged ? prevFocusedSessionIdRef.current : undefined;
prevFocusedSessionIdRef.current = focusedSessionId;
// First, blur the currently focused terminal immediately
@@ -1379,7 +1746,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
clearTimeout(timer2);
clearTimeout(timer3);
};
}, [focusedSessionId, isFocusMode, activeWorkspace]);
}, [focusedSessionId, isFocusMode, activeWorkspace, isTerminalLayerVisible]);
// Get sessions for the active workspace in focus mode
const workspaceSessionIds = useMemo(() => {
@@ -1388,7 +1755,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}, [activeWorkspace]);
const workspaceSessions = useMemo(() => {
return sessions.filter(s => workspaceSessionIds.includes(s.id));
const idSet = new Set(workspaceSessionIds);
return sessions.filter(s => idSet.has(s.id));
}, [sessions, workspaceSessionIds]);
// Render focus mode sidebar
@@ -1467,7 +1835,11 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
<div
ref={workspaceOuterRef}
className="absolute inset-0 bg-background flex flex-col"
style={{ display: isTerminalLayerVisible ? 'flex' : 'none', zIndex: isTerminalLayerVisible ? 10 : 0 }}
style={{
visibility: isTerminalLayerVisible ? 'visible' : 'hidden',
pointerEvents: isTerminalLayerVisible ? 'auto' : 'none',
zIndex: isTerminalLayerVisible ? 10 : 0,
}}
>
<div className={cn("flex-1 flex min-h-0 relative", sidePanelPosition === 'right' && "flex-row-reverse")}>
{/* Side panel with tab header + content (SFTP / Scripts / Theme) */}
@@ -1493,19 +1865,32 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
"h-full flex flex-col overflow-hidden",
!isSidePanelOpenForCurrentTab && "pointer-events-none",
)}
>
style={{
['--terminal-sidepanel-bg' as never]: resolvedPreviewTheme.colors.background,
['--terminal-sidepanel-fg' as never]: resolvedPreviewTheme.colors.foreground,
['--terminal-sidepanel-muted' as never]: `color-mix(in srgb, ${resolvedPreviewTheme.colors.foreground} 62%, ${resolvedPreviewTheme.colors.background} 38%)`,
['--terminal-sidepanel-border' as never]: `color-mix(in srgb, ${resolvedPreviewTheme.colors.foreground} 12%, ${resolvedPreviewTheme.colors.background} 88%)`,
backgroundColor: 'var(--terminal-sidepanel-bg)',
color: 'var(--terminal-sidepanel-fg)',
borderColor: 'var(--terminal-sidepanel-border)',
}}
>
{isSidePanelOpenForCurrentTab && (
<div className="flex h-9 items-center px-1.5 py-1 flex-shrink-0 gap-1">
<div
className="flex h-9 items-center px-1.5 py-1 flex-shrink-0 gap-1"
style={{
borderBottom: '1px solid var(--terminal-sidepanel-border)',
}}
>
<Button
variant="ghost"
size="icon"
className={cn(
"h-7 w-7 rounded-md p-0",
activeSidePanelTab === 'sftp'
? "text-foreground opacity-100"
: "text-muted-foreground opacity-70 hover:opacity-100",
"hover:bg-transparent",
)}
className="h-7 w-7 rounded-md p-0 hover:bg-transparent"
style={{
color: activeSidePanelTab === 'sftp'
? 'var(--terminal-sidepanel-fg)'
: 'var(--terminal-sidepanel-muted)',
}}
onClick={handleToggleSftpFromBar}
title="SFTP"
>
@@ -1514,13 +1899,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
<Button
variant="ghost"
size="icon"
className={cn(
"h-7 w-7 rounded-md p-0",
activeSidePanelTab === 'scripts'
? "text-foreground opacity-100"
: "text-muted-foreground opacity-70 hover:opacity-100",
"hover:bg-transparent",
)}
className="h-7 w-7 rounded-md p-0 hover:bg-transparent"
style={{
color: activeSidePanelTab === 'scripts'
? 'var(--terminal-sidepanel-fg)'
: 'var(--terminal-sidepanel-muted)',
}}
onClick={handleOpenScripts}
title="Scripts"
>
@@ -1529,13 +1913,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
<Button
variant="ghost"
size="icon"
className={cn(
"h-7 w-7 rounded-md p-0",
activeSidePanelTab === 'theme'
? "text-foreground opacity-100"
: "text-muted-foreground opacity-70 hover:opacity-100",
"hover:bg-transparent",
)}
className="h-7 w-7 rounded-md p-0 hover:bg-transparent"
style={{
color: activeSidePanelTab === 'theme'
? 'var(--terminal-sidepanel-fg)'
: 'var(--terminal-sidepanel-muted)',
}}
onClick={handleOpenTheme}
title="Theme"
>
@@ -1544,13 +1927,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
<Button
variant="ghost"
size="icon"
className={cn(
"h-7 w-7 rounded-md p-0",
activeSidePanelTab === 'ai'
? "text-foreground opacity-100"
: "text-muted-foreground opacity-70 hover:opacity-100",
"hover:bg-transparent",
)}
className="h-7 w-7 rounded-md p-0 hover:bg-transparent"
style={{
color: activeSidePanelTab === 'ai'
? 'var(--terminal-sidepanel-fg)'
: 'var(--terminal-sidepanel-muted)',
}}
onClick={handleOpenAI}
title="AI Chat"
>
@@ -1560,10 +1942,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
<Button
variant="ghost"
size="icon"
className={cn(
"h-7 w-7 rounded-md p-0 text-muted-foreground",
"hover:bg-transparent hover:text-foreground",
)}
className="h-7 w-7 rounded-md p-0 hover:bg-transparent"
style={{
color: 'var(--terminal-sidepanel-muted)',
}}
onClick={() => setSidePanelPosition(p => p === 'left' ? 'right' : 'left')}
title={sidePanelPosition === 'left' ? 'Move panel to right' : 'Move panel to left'}
>
@@ -1572,10 +1954,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
<Button
variant="ghost"
size="icon"
className={cn(
"h-7 w-7 rounded-md p-0 text-muted-foreground",
"hover:bg-transparent hover:text-foreground",
)}
className="h-7 w-7 rounded-md p-0 hover:bg-transparent"
style={{
color: 'var(--terminal-sidepanel-muted)',
}}
onClick={handleCloseSidePanel}
title="Close panel"
>
@@ -1594,6 +1976,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
keys={keys}
identities={identities}
updateHosts={updateHosts}
sftpDefaultViewMode={sftpDefaultViewMode}
activeHost={isVisibleSftpPanel ? sftpActiveHost : null}
initialLocation={
isVisibleSftpPanel
@@ -1609,6 +1992,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
sftpAutoSync={isVisibleSftpPanel ? sftpAutoSync : false}
sftpShowHiddenFiles={sftpShowHiddenFiles}
sftpUseCompressedUpload={sftpUseCompressedUpload}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
editorWordWrap={editorWordWrap}
setEditorWordWrap={setEditorWordWrap}
onGetTerminalCwd={getTerminalCwd}
@@ -1631,7 +2016,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
{activeSidePanelTab === 'theme' && (
<div className="absolute inset-0 z-10">
<ThemeSidePanel
currentThemeId={focusedThemeId}
currentThemeId={activeThemePreviewId ?? focusedThemeId}
globalThemeId={terminalTheme.id}
currentFontFamilyId={focusedFontFamilyId}
globalFontFamilyId={terminalFontFamilyId}
@@ -1645,6 +2030,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onFontFamilyReset={handleFontFamilyResetForFocusedSession}
onFontSizeChange={handleFontSizeChangeForFocusedSession}
onFontSizeReset={handleFontSizeResetForFocusedSession}
previewColors={resolvedPreviewTheme.colors}
/>
</div>
)}
@@ -1731,7 +2117,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const style: React.CSSProperties = { ...layoutStyle };
if (!isVisible) {
style.display = 'none';
style.visibility = 'hidden';
style.pointerEvents = 'none';
// Use absolute offscreen position instead of display:none to preserve
// xterm canvas state in memory and avoid full re-render on tab switch.
style.left = '-9999px';
style.top = '-9999px';
}
// Check if this pane is the focused one in the workspace
@@ -1753,7 +2144,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
"absolute bg-background",
inActiveWorkspace && "workspace-pane",
isVisible && "z-10",
isFocusedPane && "ring-1 ring-primary/50 ring-inset"
// Focus indicator is handled by CSS .workspace-pane:not(:focus-within)
)}
style={style}
tabIndex={-1}
@@ -1769,7 +2160,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
keys={keys}
identities={identities}
snippets={snippets}
allHosts={hosts}
chainHosts={sessionChainHostsMap.get(session.id)}
themePreviewId={session.id === previewTargetSessionId ? activeThemePreviewId ?? undefined : undefined}
knownHosts={knownHosts}
isVisible={isVisible}
inWorkspace={inActiveWorkspace}
@@ -1807,7 +2199,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
isWorkspaceComposeBarOpen={inActiveWorkspace ? isComposeBarOpen : undefined}
onBroadcastInput={inActiveWorkspace && activeWorkspace && isBroadcastEnabled?.(activeWorkspace.id) ? handleBroadcastInput : undefined}
onSnippetExecutorChange={handleSnippetExecutorChange}
sessionLog={sessionLogsEnabled && sessionLogsDir ? { enabled: true, directory: sessionLogsDir, format: sessionLogsFormat || 'txt' } : undefined}
sessionLog={sessionLogConfig}
/>
</div>
);
@@ -1906,6 +2298,7 @@ const terminalLayerAreEqual = (prev: TerminalLayerProps, next: TerminalLayerProp
prev.fontSize === next.fontSize &&
prev.hotkeyScheme === next.hotkeyScheme &&
prev.keyBindings === next.keyBindings &&
prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
prev.sftpDoubleClickBehavior === next.sftpDoubleClickBehavior &&
prev.sftpAutoSync === next.sftpAutoSync &&
prev.sftpShowHiddenFiles === next.sftpShowHiddenFiles &&

View File

@@ -1,6 +1,8 @@
import { Bell, Copy, FileText, Folder, LayoutGrid, Minus, Moon, MoreHorizontal, Plus, Server, Shield, Sparkles, Square, Sun, TerminalSquare, Usb, X } from 'lucide-react';
import { Bell, Copy, FileText, Folder, FolderLock, LayoutGrid, Minus, Moon, MoreHorizontal, Plus, Server, Sparkles, Square, Sun, TerminalSquare, Usb, X } from 'lucide-react';
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { activeTabStore, useActiveTabId } from '../application/state/activeTabStore';
import { buildWorkspaceActivityMap } from '../application/state/sessionActivity';
import { useSessionActivityMap } from '../application/state/sessionActivityStore';
import { LogView } from '../application/state/useSessionState';
import { useWindowControls } from '../application/state/useWindowControls';
import { useI18n } from '../application/i18n/I18nProvider';
@@ -55,7 +57,7 @@ const localOsId = (() => {
const SessionTabIcon: React.FC<{ host: Host | undefined; isActive: boolean; protocol?: string }> = memo(({ host, isActive, protocol }) => {
const boxBase = "shrink-0 h-4 w-4 rounded flex items-center justify-center";
const iconSize = "h-2.5 w-2.5";
const fallbackIcon = cn(iconSize, isActive ? "text-accent" : "text-muted-foreground");
const fallbackStyle = { color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' };
// Serial protocol → USB icon
if (protocol === 'serial' || host?.protocol === 'serial') {
@@ -82,7 +84,7 @@ const SessionTabIcon: React.FC<{ host: Host | undefined; isActive: boolean; prot
);
}
return (
<div className={cn(boxBase, "bg-primary/15 text-primary")}>
<div className={boxBase} style={{ backgroundColor: 'color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 15%, transparent)', color: 'var(--top-tabs-accent, hsl(var(--accent)))' }}>
<TerminalSquare className={iconSize} />
</div>
);
@@ -109,22 +111,33 @@ const SessionTabIcon: React.FC<{ host: Host | undefined; isActive: boolean; prot
// Fallback: generic server icon for remote, terminal for unknown
if (host && host.protocol !== 'local') {
return (
<div className={cn(boxBase, "bg-primary/15 text-primary")}>
<div className={boxBase} style={{ backgroundColor: 'color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 15%, transparent)', color: 'var(--top-tabs-accent, hsl(var(--accent)))' }}>
<Server className={iconSize} />
</div>
);
}
return <TerminalSquare className={fallbackIcon} />;
return <TerminalSquare className={iconSize} style={fallbackStyle} />;
});
SessionTabIcon.displayName = 'SessionTabIcon';
const sessionStatusDot = (status: TerminalSession['status']) => {
const sessionStatusDot = (status: TerminalSession['status'], hasActivity: boolean) => {
const tone = status === 'connected'
? "bg-emerald-400"
: status === 'connecting'
? "bg-amber-400"
: "bg-rose-500";
return <span className={cn("inline-block h-2 w-2 rounded-full ring-2 ring-background/60", tone)} />;
return (
<span className="relative inline-flex h-2 w-2 shrink-0 items-center justify-center">
<span
className={cn(
"relative inline-block h-2 w-2 rounded-full ring-2",
tone,
hasActivity && "session-activity-dot",
)}
style={{ boxShadow: '0 0 0 2px color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 60%, transparent)' }}
/>
</span>
);
};
// Custom window controls for Windows/Linux (frameless window)
@@ -168,14 +181,16 @@ const WindowControls: React.FC = memo(() => {
<div className="flex items-center app-drag h-full">
<button
onClick={handleMinimize}
className="h-full w-10 flex items-center justify-center text-muted-foreground hover:bg-foreground/10 hover:text-foreground transition-all duration-150 app-no-drag"
className="h-full w-10 flex items-center justify-center transition-all duration-150 app-no-drag"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
title="Minimize"
>
<Minus size={16} />
</button>
<button
onClick={handleMaximize}
className="h-full w-10 flex items-center justify-center text-muted-foreground hover:bg-foreground/10 hover:text-foreground transition-all duration-150 app-no-drag"
className="h-full w-10 flex items-center justify-center transition-all duration-150 app-no-drag"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
title={isMaximized ? "Restore" : "Maximize"}
>
{isMaximized ? (
@@ -227,6 +242,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
// Subscribe to activeTabId from external store
const { maximize, isFullscreen, onFullscreenChanged } = useWindowControls();
const activeTabId = useActiveTabId();
const sessionActivityMap = useSessionActivityMap();
const isVaultActive = activeTabId === 'vault';
const isSftpActive = activeTabId === 'sftp';
const onSelectTab = activeTabStore.setActiveTabId;
@@ -330,6 +346,10 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
return map;
}, [hosts]);
const workspaceActivityMap = useMemo(() => {
return buildWorkspaceActivityMap(sessions, sessionActivityMap);
}, [sessionActivityMap, sessions]);
// Pre-compute session counts per workspace for O(1) access
const workspacePaneCounts = useMemo(() => {
const counts = new Map<string, number>();
@@ -453,6 +473,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
if (item.type === 'session') {
const session = item.session;
const hasActivity = !!sessionActivityMap[session.id];
const isBeingDragged = draggingSessionId === session.id;
const shiftStyle = tabShiftStyles[session.id] || {};
const showDropIndicatorBefore = dropIndicator?.tabId === session.id && dropIndicator.position === 'before';
@@ -472,30 +493,56 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onDrop={(e) => handleTabDrop(e, session.id)}
className={cn(
"relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
"transition-all duration-150",
activeTabId === session.id
? "bg-background text-foreground"
: "text-muted-foreground hover:bg-background/40 hover:text-foreground",
"transition-transform duration-150",
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : ""
)}
style={shiftStyle}
style={{
...shiftStyle,
backgroundColor: activeTabId === session.id
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
: 'transparent',
color: activeTabId === session.id
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
}}
onMouseEnter={(e) => {
if (activeTabId !== session.id) {
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
}
}}
onMouseLeave={(e) => {
if (activeTabId !== session.id) {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
}
}}
>
{/* Active tab top accent line */}
{activeTabId === session.id && (
<div className="absolute top-0 left-0 right-0 h-[2px] bg-accent" />
<div
className="absolute top-0 left-0 right-0 h-[2px]"
style={{ backgroundColor: 'var(--top-tabs-fg, hsl(var(--foreground)))' }}
/>
)}
{/* Drop indicator line - before */}
{showDropIndicatorBefore && isDraggingForReorder && (
<div className="absolute -left-0.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
<div
className="absolute -left-0.5 top-1 bottom-1 w-0.5 rounded-full animate-pulse"
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))', boxShadow: '0 0 8px 2px color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 50%, transparent)' }}
/>
)}
{/* Drop indicator line - after */}
{showDropIndicatorAfter && isDraggingForReorder && (
<div className="absolute -right-0.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
<div
className="absolute -right-0.5 top-1 bottom-1 w-0.5 rounded-full animate-pulse"
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))', boxShadow: '0 0 8px 2px color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 50%, transparent)' }}
/>
)}
<div className="flex items-center gap-2 min-w-0 flex-1">
<SessionTabIcon host={hostMap.get(session.hostId)} isActive={activeTabId === session.id} protocol={session.protocol} />
<span className="truncate">{session.hostLabel}</span>
<div className="flex-shrink-0">{sessionStatusDot(session.status)}</div>
<div className="flex-shrink-0">{sessionStatusDot(session.status, hasActivity)}</div>
</div>
<button
onClick={(e) => onCloseSession(session.id, e)}
@@ -524,6 +571,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
if (item.type === 'workspace') {
const workspace = item.workspace;
const paneCount = item.paneCount;
const hasActivity = !!workspaceActivityMap.get(workspace.id);
const isActive = activeTabId === workspace.id;
const isBeingDragged = draggingSessionId === workspace.id;
const shiftStyle = tabShiftStyles[workspace.id] || {};
@@ -544,32 +592,71 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onDrop={(e) => handleTabDrop(e, workspace.id)}
className={cn(
"relative h-7 pl-3 pr-2 min-w-[150px] max-w-[260px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
"transition-all duration-150",
isActive
? "bg-background text-foreground"
: "text-muted-foreground hover:bg-background/40 hover:text-foreground",
"transition-transform duration-150",
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : ""
)}
style={shiftStyle}
style={{
...shiftStyle,
backgroundColor: isActive
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
: 'transparent',
color: isActive
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
}}
onMouseEnter={(e) => {
if (!isActive) {
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
}
}}
onMouseLeave={(e) => {
if (!isActive) {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
}
}}
>
{/* Active tab top accent line */}
{isActive && (
<div className="absolute top-0 left-0 right-0 h-[2px] bg-accent" />
<div
className="absolute top-0 left-0 right-0 h-[2px]"
style={{ backgroundColor: 'var(--top-tabs-fg, hsl(var(--foreground)))' }}
/>
)}
{/* Drop indicator line - before */}
{showDropIndicatorBefore && isDraggingForReorder && (
<div className="absolute -left-0.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
<div
className="absolute -left-0.5 top-1 bottom-1 w-0.5 rounded-full animate-pulse"
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))', boxShadow: '0 0 8px 2px color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 50%, transparent)' }}
/>
)}
{/* Drop indicator line - after */}
{showDropIndicatorAfter && isDraggingForReorder && (
<div className="absolute -right-0.5 top-1 bottom-1 w-0.5 bg-primary rounded-full shadow-[0_0_8px_2px] shadow-primary/50 animate-pulse" />
<div
className="absolute -right-0.5 top-1 bottom-1 w-0.5 rounded-full animate-pulse"
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))', boxShadow: '0 0 8px 2px color-mix(in srgb, var(--top-tabs-accent, hsl(var(--accent))) 50%, transparent)' }}
/>
)}
<div className="flex items-center gap-2 truncate">
<LayoutGrid size={14} className={cn("shrink-0", isActive ? "text-primary" : "text-muted-foreground")} />
<LayoutGrid
size={14}
className="shrink-0"
style={{ color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
/>
<span className="truncate">{workspace.title}</span>
</div>
<div className="text-[10px] px-1.5 py-0.5 rounded-full border border-border/70 bg-background/60 min-w-[22px] text-center">
{paneCount}
<div className="flex items-center gap-1.5 shrink-0">
{hasActivity && sessionStatusDot('connected', true)}
<div
className="text-[10px] px-1.5 py-0.5 rounded-full min-w-[22px] text-center"
style={{
border: '1px solid color-mix(in srgb, var(--top-tabs-fg, hsl(var(--foreground))) 18%, transparent)',
backgroundColor: 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 60%, transparent)',
}}
>
{paneCount}
</div>
</div>
</div>
</ContextMenuTrigger>
@@ -597,18 +684,41 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onClick={() => onSelectTab(logView.id)}
className={cn(
"relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
"transition-colors duration-150",
isActive
? "bg-background text-foreground"
: "text-muted-foreground hover:bg-background/40 hover:text-foreground"
)}
style={{
backgroundColor: isActive
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
: 'transparent',
color: isActive
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
}}
onMouseEnter={(e) => {
if (!isActive) {
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
}
}}
onMouseLeave={(e) => {
if (!isActive) {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
}
}}
>
{/* Active tab top accent line */}
{isActive && (
<div className="absolute top-0 left-0 right-0 h-[2px] bg-accent" />
<div
className="absolute top-0 left-0 right-0 h-[2px]"
style={{ backgroundColor: 'var(--top-tabs-fg, hsl(var(--foreground)))' }}
/>
)}
<div className="flex items-center gap-2 min-w-0 flex-1">
<FileText size={14} className={cn("shrink-0", isActive ? "text-accent" : "text-muted-foreground")} />
<FileText
size={14}
className="shrink-0"
style={{ color: isActive ? 'var(--top-tabs-accent, hsl(var(--accent)))' : 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
/>
<span className="truncate">
{t('tabs.logPrefix')} {isLocal ? t('tabs.logLocal') : logView.log.hostname}
</span>
@@ -642,8 +752,13 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
return (
<div
data-top-tabs-root
className="relative w-full bg-secondary app-drag"
style={dragRegionNoSelect}
style={{
...dragRegionNoSelect,
backgroundColor: 'var(--top-tabs-bg, hsl(var(--secondary)))',
color: 'var(--top-tabs-fg, hsl(var(--foreground)))',
}}
onDoubleClick={handleTitleBarDoubleClick}
>
{/* Always-on drag stripe so the window can be moved even when tabs fill the bar */}
@@ -658,25 +773,62 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
onClick={() => onSelectTab('vault')}
className={cn(
"relative h-7 px-3 rounded text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
"transition-colors duration-150",
isVaultActive
? "bg-foreground/10 text-foreground"
: "text-muted-foreground hover:bg-background/40 hover:text-foreground"
)}
style={{
backgroundColor: isVaultActive
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
: 'transparent',
color: isVaultActive
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
}}
onMouseEnter={(e) => {
if (!isVaultActive) {
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
}
}}
onMouseLeave={(e) => {
if (!isVaultActive) {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
}
}}
>
<Shield size={14} /> Vaults
<FolderLock size={14} /> Vaults
</div>
<div
onClick={() => onSelectTab('sftp')}
className={cn(
"relative h-7 px-3 rounded-none text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
"transition-colors duration-150",
isSftpActive
? "bg-background text-foreground"
: "text-muted-foreground hover:bg-background/40 hover:text-foreground"
)}
style={{
backgroundColor: isSftpActive
? 'var(--top-tabs-active-bg, hsl(var(--background)))'
: 'transparent',
color: isSftpActive
? 'var(--top-tabs-fg, hsl(var(--foreground)))'
: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))',
}}
onMouseEnter={(e) => {
if (!isSftpActive) {
e.currentTarget.style.backgroundColor = 'color-mix(in srgb, var(--top-tabs-active-bg, hsl(var(--background))) 40%, transparent)';
e.currentTarget.style.color = 'var(--top-tabs-fg, hsl(var(--foreground)))';
}
}}
onMouseLeave={(e) => {
if (!isSftpActive) {
e.currentTarget.style.backgroundColor = 'transparent';
e.currentTarget.style.color = 'var(--top-tabs-muted, hsl(var(--muted-foreground)))';
}
}}
>
{isSftpActive && <div className="absolute top-0 left-0 right-0 h-[2px] bg-accent" />}
{isSftpActive && (
<div
className="absolute top-0 left-0 right-0 h-[2px]"
style={{ backgroundColor: 'var(--top-tabs-accent, hsl(var(--accent)))' }}
/>
)}
<Folder size={14} /> SFTP
</div>
</div>
@@ -698,7 +850,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
{canScrollLeft && (
<div
className="absolute left-0 top-0 bottom-0 w-8 pointer-events-none z-10"
style={{ background: 'linear-gradient(to right, hsl(var(--secondary) / 0.9), transparent)' }}
style={{ background: 'linear-gradient(to right, var(--top-tabs-bg, hsl(var(--secondary))), transparent)' }}
/>
)}
@@ -715,6 +867,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
variant="ghost"
size="icon"
className="h-7 w-7 flex-shrink-0 app-no-drag mb-0 rounded-none"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
onClick={onOpenQuickSwitcher}
title="Open quick switcher"
>
@@ -729,7 +882,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
{canScrollRight && (
<div
className="absolute right-0 top-0 bottom-0 w-8 pointer-events-none z-10"
style={{ background: 'linear-gradient(to left, hsl(var(--secondary) / 0.9), transparent)' }}
style={{ background: 'linear-gradient(to left, var(--top-tabs-bg, hsl(var(--secondary))), transparent)' }}
/>
)}
</div>
@@ -740,6 +893,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
variant="ghost"
size="icon"
className="h-7 w-7 flex-shrink-0 app-no-drag self-end rounded-none"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
onClick={onOpenQuickSwitcher}
title="More tabs"
>
@@ -752,20 +906,22 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-foreground app-no-drag"
className="h-6 w-6 app-no-drag"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
title="AI Assistant"
onClick={() => window.dispatchEvent(new CustomEvent('netcatty:toggle-ai-panel'))}
>
<Sparkles size={16} />
</Button>
<Button variant="ghost" size="icon" className="h-6 w-6 text-muted-foreground hover:text-foreground app-no-drag">
<Button variant="ghost" size="icon" className="h-6 w-6 app-no-drag" style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}>
<Bell size={16} />
</Button>
<SyncStatusButton onOpenSettings={onOpenSettings} onSyncNow={onSyncNow} />
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-foreground app-no-drag"
className="h-6 w-6 app-no-drag"
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
onClick={onToggleTheme}
disabled={isImmersiveActive}
title="Toggle theme"

View File

@@ -27,7 +27,7 @@ import {
X,
Zap,
} from "lucide-react";
import React, { Suspense, lazy, memo, useCallback, useEffect, useMemo, useState } from "react";
import React, { Suspense, lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { useStoredViewMode } from "../application/state/useStoredViewMode";
import { useStoredBoolean } from "../application/state/useStoredBoolean";
@@ -37,6 +37,7 @@ import { importVaultHostsFromText, exportHostsToCsvWithStats } from "../domain/v
import type { VaultImportFormat } from "../domain/vaultImport";
import { STORAGE_KEY_VAULT_HOSTS_VIEW_MODE, STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED, STORAGE_KEY_VAULT_SIDEBAR_COLLAPSED } from "../infrastructure/config/storageKeys";
import { cn } from "../lib/utils";
import { useInstantThemeSwitch } from "../lib/useInstantThemeSwitch";
import {
ConnectionLog,
GroupNode,
@@ -109,10 +110,12 @@ interface VaultViewProps {
sessions: TerminalSession[];
hotkeyScheme: HotkeyScheme;
keyBindings: KeyBinding[];
terminalThemeId: string;
terminalFontSize: number;
onOpenSettings: () => void;
onOpenQuickSwitcher: () => void;
onCreateLocalTerminal: () => void;
onConnectSerial?: (config: SerialConfig) => void;
onConnectSerial?: (config: SerialConfig, options?: { charset?: string }) => void;
onDeleteHost: (id: string) => void;
onConnect: (host: Host) => void;
onUpdateHosts: (hosts: Host[]) => void;
@@ -151,6 +154,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
sessions,
hotkeyScheme,
keyBindings,
terminalThemeId,
terminalFontSize,
onOpenSettings,
onOpenQuickSwitcher,
onCreateLocalTerminal,
@@ -178,6 +183,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
onNavigateToSectionHandled,
}) => {
const { t } = useI18n();
const rootRef = useRef<HTMLDivElement>(null);
const [currentSection, setCurrentSection] = useState<VaultSection>("hosts");
const [search, setSearch] = useState("");
const [selectedGroupPath, setSelectedGroupPath] = useState<string | null>(
@@ -196,6 +202,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
const [deleteTargetPath, setDeleteTargetPath] = useState<string | null>(null);
const [deleteGroupWithHosts, setDeleteGroupWithHosts] = useState(false);
useInstantThemeSwitch(rootRef);
// Sidebar collapsed state with localStorage persistence
const [sidebarCollapsed, setSidebarCollapsed] = useStoredBoolean(
STORAGE_KEY_VAULT_SIDEBAR_COLLAPSED,
@@ -1272,7 +1280,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
// Component no longer handles visibility - that's done by VaultViewWrapper
return (
<div className="absolute inset-0 min-h-0 flex">
<div ref={rootRef} className="absolute inset-0 min-h-0 flex">
{/* Sidebar */}
<TooltipProvider delayDuration={100}>
<div className={cn(
@@ -2302,6 +2310,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
allTags={allTags}
allHosts={hosts}
defaultGroup={editingHost ? undefined : (newHostGroupPath || selectedGroupPath)}
terminalThemeId={terminalThemeId}
terminalFontSize={terminalFontSize}
onSave={(host) => {
// Check if host already exists in the list (for updates vs. new/duplicate)
const hostExists = hosts.some((h) => h.id === host.id);
@@ -2538,9 +2548,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
<SerialConnectModal
open={isSerialModalOpen}
onClose={() => setIsSerialModalOpen(false)}
onConnect={(config) => {
onConnect={(config, options) => {
if (onConnectSerial) {
onConnectSerial(config);
onConnectSerial(config, options);
}
}}
onSaveHost={(host) => {
@@ -2567,7 +2577,9 @@ const vaultViewAreEqual = (
prev.shellHistory === next.shellHistory &&
prev.connectionLogs === next.connectionLogs &&
prev.sessions === next.sessions &&
prev.managedSources === next.managedSources;
prev.managedSources === next.managedSources &&
prev.terminalThemeId === next.terminalThemeId &&
prev.terminalFontSize === next.terminalFontSize;
return isEqual;
};

View File

@@ -1,5 +1,5 @@
import { cn } from '../../lib/utils';
import type { ComponentProps, HTMLAttributes, ReactNode } from 'react';
import type { ComponentProps } from 'react';
import React, { useCallback } from 'react';
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom';
import { ArrowDown } from 'lucide-react';
@@ -25,41 +25,6 @@ export const ConversationContent = ({ className, ...props }: ConversationContent
/>
);
export interface ConversationEmptyStateProps extends HTMLAttributes<HTMLDivElement> {
title?: string;
description?: string;
icon?: ReactNode;
}
export const ConversationEmptyState = ({
className,
title,
description,
icon,
children,
...props
}: ConversationEmptyStateProps) => (
<div
className={cn(
'flex size-full flex-col items-center justify-center gap-3 p-8 text-center',
className,
)}
{...props}
>
{children ?? (
<>
{icon && <div className="text-muted-foreground">{icon}</div>}
<div className="space-y-1">
<h3 className="font-medium text-sm">{title}</h3>
{description && (
<p className="text-muted-foreground text-sm">{description}</p>
)}
</div>
</>
)}
</div>
);
export const ConversationScrollButton = ({ className, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
const { isAtBottom, scrollToBottom } = useStickToBottomContext();

View File

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

View File

@@ -8,8 +8,6 @@
import { ArrowUp, Square, X } from 'lucide-react';
import type {
ComponentProps,
ComponentPropsWithoutRef,
ElementRef,
FormEvent,
HTMLAttributes,
KeyboardEvent,
@@ -17,13 +15,6 @@ import type {
} from 'react';
import { forwardRef, useCallback, useRef } from 'react';
import { cn } from '../../lib/utils';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../ui/select';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
import {
InputGroup,
@@ -254,30 +245,3 @@ export const PromptInputSubmit = forwardRef<HTMLButtonElement, PromptInputSubmit
);
PromptInputSubmit.displayName = 'PromptInputSubmit';
// ---------------------------------------------------------------------------
// PromptInputSelect (thin wrappers around the project's Select component)
// ---------------------------------------------------------------------------
export const PromptInputSelect = Select;
export const PromptInputSelectTrigger = forwardRef<
ElementRef<typeof SelectTrigger>,
ComponentPropsWithoutRef<typeof SelectTrigger>
>(({ className, ...props }, ref) => (
<SelectTrigger
ref={ref}
className={cn(
'h-7 min-w-0 w-auto gap-1 border-none bg-transparent px-2 text-[11px]',
'text-muted-foreground/40 hover:text-muted-foreground/70',
'focus:ring-0 focus:ring-offset-0',
'[&>svg]:h-3 [&>svg]:w-3 [&>svg]:opacity-40',
className,
)}
{...props}
/>
));
PromptInputSelectTrigger.displayName = 'PromptInputSelectTrigger';
export const PromptInputSelectContent = SelectContent;
export const PromptInputSelectItem = SelectItem;
export const PromptInputSelectValue = SelectValue;

View File

@@ -5,6 +5,39 @@ import { Button } from '../ui/button';
import { Badge } from '../ui/badge';
import { useI18n } from '../../application/i18n/I18nProvider';
/**
* Format tool result for display. Extracts stdout/stderr from structured
* command results for terminal-like output.
*/
function formatToolResult(result: unknown): string {
let parsed = result;
if (typeof parsed === 'string') {
try {
const obj = JSON.parse(parsed);
if (obj && typeof obj === 'object') parsed = obj;
} catch {
return parsed;
}
}
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
const obj = parsed as Record<string, unknown>;
if (typeof obj.stdout === 'string' || typeof obj.stderr === 'string') {
const parts: string[] = [];
if (typeof obj.stdout === 'string' && obj.stdout) parts.push(obj.stdout);
if (typeof obj.stderr === 'string' && obj.stderr) parts.push(obj.stderr);
if (typeof obj.exitCode === 'number' && obj.exitCode !== 0) {
parts.push(`exit code: ${obj.exitCode}`);
}
if (parts.length > 0) return parts.join('\n');
}
}
if (typeof parsed === 'string') return parsed;
return JSON.stringify(parsed, null, 2);
}
export interface ToolCallProps extends HTMLAttributes<HTMLDivElement> {
name: string;
args?: Record<string, unknown>;
@@ -133,7 +166,7 @@ export const ToolCall = ({
{args && Object.keys(args).length > 0 && (
<div className="px-3 py-2">
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/30 mb-1">Arguments</div>
<pre className="text-[11px] font-mono text-muted-foreground/50 whitespace-pre-wrap break-all">
<pre className="max-h-64 overflow-auto text-[11px] font-mono text-muted-foreground/50 whitespace-pre [overflow-wrap:normal]">
{JSON.stringify(args, null, 2)}
</pre>
</div>
@@ -174,10 +207,10 @@ export const ToolCall = ({
<div className="px-3 py-2 border-t border-border/20">
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/30 mb-1">Result</div>
<pre className={cn(
'text-[11px] font-mono whitespace-pre-wrap break-all',
'max-h-64 overflow-auto text-[11px] font-mono whitespace-pre [overflow-wrap:normal]',
isError ? 'text-red-400/60' : 'text-muted-foreground/50',
)}>
{typeof result === 'string' ? result : JSON.stringify(result, null, 2)}
{formatToolResult(result)}
</pre>
</div>
)}

View File

@@ -11,6 +11,7 @@ type AgentLike = {
type AgentIconKey =
| 'catty'
| 'copilot'
| 'openai'
| 'claude'
| 'anthropic'
@@ -20,7 +21,7 @@ type AgentIconKey =
| 'openrouter'
| 'zed'
| 'atom'
| 'terminal'
| 'terminal'
| 'plus';
type AgentIconVisual = {
@@ -35,6 +36,11 @@ const AGENT_ICON_VISUALS: Record<AgentIconKey, AgentIconVisual> = {
badgeClassName: 'border-violet-500/20 bg-violet-500/10',
imageClassName: 'object-contain dark:brightness-0 dark:invert opacity-90',
},
copilot: {
src: '/ai/agents/copilot.svg',
badgeClassName: 'border-zinc-300 bg-white',
imageClassName: 'object-contain brightness-0',
},
openai: {
src: '/ai/providers/openai.svg',
badgeClassName: 'border-emerald-500/22 bg-emerald-500/12',
@@ -115,6 +121,9 @@ function getAgentIconKey(agent: AgentLike | 'add-more'): AgentIconKey {
if (tokens.some((token) => token.includes('claude'))) {
return 'claude';
}
if (tokens.some((token) => token.includes('copilot'))) {
return 'copilot';
}
if (tokens.some((token) => token.includes('anthropic'))) {
return 'anthropic';
}
@@ -154,20 +163,14 @@ function getAgentIconKey(agent: AgentLike | 'add-more'): AgentIconKey {
return 'terminal';
}
export function getAgentCommandLabel(agent: AgentLike): string | undefined {
if (agent.type === 'builtin') {
return 'Built-in terminal assistant';
}
return agent.command ? `CLI: ${agent.command}` : 'External CLI agent';
}
export const AgentIconBadge: React.FC<{
agent: AgentLike | 'add-more';
size?: 'xs' | 'sm' | 'md' | 'lg';
variant?: 'plain' | 'badge';
className?: string;
}> = ({ agent, size = 'md', variant = 'badge', className }) => {
const visual = AGENT_ICON_VISUALS[getAgentIconKey(agent)];
const iconKey = getAgentIconKey(agent);
const visual = AGENT_ICON_VISUALS[iconKey];
const badgeSize =
size === 'xs'
? 'h-4 w-4 rounded-sm'

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

@@ -229,7 +229,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
value={value}
onChange={(e) => handleInputChange(e.target.value)}
placeholder={placeholder || defaultPlaceholder}
disabled={disabled || isStreaming}
disabled={disabled}
className={expanded ? 'max-h-[220px]' : undefined}
/>
<button

View File

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

@@ -1,169 +0,0 @@
/**
* ExecutionPlan - Renders a multi-step execution plan for AI agent tasks.
*
* Shows a numbered list of steps with status indicators, host badges,
* optional command previews, and action buttons.
*/
import {
CheckCircle2,
Circle,
Loader2,
SkipForward,
XCircle,
} from 'lucide-react';
import React from 'react';
import { cn } from '../../lib/utils';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
// -------------------------------------------------------------------
// Types
// -------------------------------------------------------------------
interface ExecutionPlanStep {
description: string;
host?: string;
command?: string;
status: 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
}
interface ExecutionPlanProps {
steps: ExecutionPlanStep[];
onApprove: () => void;
onModify: () => void;
onReject: () => void;
isExecuting: boolean;
}
// -------------------------------------------------------------------
// Status icon mapping
// -------------------------------------------------------------------
function StepStatusIcon({
status,
}: {
status: ExecutionPlanStep['status'];
}) {
switch (status) {
case 'pending':
return <Circle size={16} className="text-muted-foreground" />;
case 'running':
return (
<Loader2 size={16} className="text-blue-500 animate-spin" />
);
case 'completed':
return <CheckCircle2 size={16} className="text-green-500" />;
case 'failed':
return <XCircle size={16} className="text-destructive" />;
case 'skipped':
return (
<SkipForward size={16} className="text-muted-foreground/60" />
);
}
}
// -------------------------------------------------------------------
// Component
// -------------------------------------------------------------------
const ExecutionPlan: React.FC<ExecutionPlanProps> = ({
steps,
onApprove,
onModify,
onReject,
isExecuting,
}) => {
return (
<div className="rounded-lg border border-border bg-muted/30 overflow-hidden">
{/* Header */}
<div className="px-3 py-2 border-b border-border/60 bg-muted/50">
<span className="text-sm font-medium">
Execution Plan ({steps.length} step{steps.length !== 1 ? 's' : ''})
</span>
</div>
{/* Steps list */}
<div className="divide-y divide-border/30">
{steps.map((step, index) => (
<div
key={index}
className={cn(
'flex items-start gap-3 px-3 py-2.5 transition-colors',
step.status === 'running' && 'bg-blue-500/5',
step.status === 'completed' && 'bg-green-500/5',
step.status === 'failed' && 'bg-destructive/5',
step.status === 'skipped' && 'opacity-50',
)}
>
{/* Step number + status icon */}
<div className="flex items-center gap-2 shrink-0 pt-0.5">
<span className="text-xs text-muted-foreground font-mono w-4 text-right">
{index + 1}
</span>
<StepStatusIcon status={step.status} />
</div>
{/* Step content */}
<div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<span
className={cn(
'text-sm',
step.status === 'skipped' && 'line-through',
)}
>
{step.description}
</span>
{step.host && (
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0"
>
{step.host}
</Badge>
)}
</div>
{step.command && (
<code className="block text-xs font-mono bg-muted/80 px-2 py-1 rounded text-muted-foreground truncate">
{step.command}
</code>
)}
</div>
</div>
))}
</div>
{/* Action buttons */}
<div className="px-3 py-2.5 border-t border-border/60 flex items-center justify-end gap-2">
{isExecuting ? (
<Button
variant="destructive"
size="sm"
onClick={onReject}
>
Cancel
</Button>
) : (
<>
<Button variant="ghost" size="sm" onClick={onReject}>
Cancel
</Button>
<Button variant="outline" size="sm" onClick={onModify}>
Modify Plan
</Button>
<Button size="sm" onClick={onApprove}>
Approve
</Button>
</>
)}
</div>
</div>
);
};
ExecutionPlan.displayName = 'ExecutionPlan';
export default ExecutionPlan;
export { ExecutionPlan };
export type { ExecutionPlanProps, ExecutionPlanStep };

View File

@@ -1,200 +0,0 @@
/**
* PermissionDialog - Modal for AI agent tool call permission requests.
*
* Shown when the agent needs user approval to execute a tool call.
* Displays tool name, arguments, recommendation, and approve/reject actions.
*/
import { ShieldAlert } from 'lucide-react';
import React, { useCallback } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '../ui/dialog';
// -------------------------------------------------------------------
// Types
// -------------------------------------------------------------------
interface PermissionDialogProps {
open: boolean;
toolCall: { name: string; arguments: Record<string, unknown> } | null;
recommendation: 'allow' | 'confirm' | 'deny';
onApprove: () => void;
onReject: () => void;
onDismiss: () => void;
}
// -------------------------------------------------------------------
// Component
// -------------------------------------------------------------------
const PermissionDialog: React.FC<PermissionDialogProps> = ({
open,
toolCall,
recommendation,
onApprove,
onReject,
onDismiss,
}) => {
const { t } = useI18n();
const isDenied = recommendation === 'deny';
// Keyboard shortcuts: Enter to approve, Escape to reject
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !isDenied) {
e.preventDefault();
onApprove();
} else if (e.key === 'Escape') {
e.preventDefault();
onReject();
}
},
[isDenied, onApprove, onReject],
);
// Format arguments as readable code block content
let formattedArgs = '';
if (toolCall) {
try {
formattedArgs = JSON.stringify(toolCall.arguments, null, 2);
} catch {
formattedArgs = String(toolCall.arguments);
}
}
// Extract host/session info from arguments if present
const sessionId =
toolCall?.arguments?.sessionId as string | undefined;
const sessionIds =
toolCall?.arguments?.sessionIds as string[] | undefined;
const recommendationBadge = () => {
switch (recommendation) {
case 'allow':
return (
<Badge className="bg-green-600/20 text-green-400 border-green-600/30">
{t('ai.chat.recommendAllow')}
</Badge>
);
case 'confirm':
return (
<Badge className="bg-yellow-600/20 text-yellow-400 border-yellow-600/30">
{t('ai.chat.recommendConfirm')}
</Badge>
);
case 'deny':
return <Badge variant="destructive">{t('ai.chat.recommendDeny')}</Badge>;
}
};
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onDismiss()}>
<DialogContent hideCloseButton onKeyDown={handleKeyDown}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ShieldAlert
size={20}
className={cn(
isDenied ? 'text-destructive' : 'text-yellow-500',
)}
/>
{t('ai.chat.permissionRequired')}
</DialogTitle>
<DialogDescription>
{t('ai.chat.permissionDescription')}
</DialogDescription>
</DialogHeader>
{toolCall && (
<div className="space-y-3">
{/* Tool name and recommendation */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">{t('ai.chat.toolLabel')}:</span>
<code className="text-sm font-mono bg-muted px-1.5 py-0.5 rounded">
{toolCall.name}
</code>
</div>
{recommendationBadge()}
</div>
{/* Target session(s) */}
{(sessionId || sessionIds) && (
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">{t('ai.chat.targetLabel')}:</span>
{sessionId && (
<code className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded">
{sessionId}
</code>
)}
{sessionIds && (
<div className="flex flex-wrap gap-1">
{sessionIds.map((id) => (
<code
key={id}
className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded"
>
{id}
</code>
))}
</div>
)}
</div>
)}
{/* Arguments code block */}
<div className="rounded-md border border-border bg-muted/50 p-3 max-h-48 overflow-auto">
<pre className="text-xs font-mono whitespace-pre-wrap break-all text-foreground">
{formattedArgs}
</pre>
</div>
{/* Deny warning */}
{isDenied && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3">
<p className="text-sm text-destructive">
{t('ai.chat.commandBlocked')}
</p>
</div>
)}
</div>
)}
<DialogFooter>
{isDenied ? (
<Button variant="destructive" onClick={onReject} className="w-full">
{t('ai.chat.reject')}
</Button>
) : (
<>
<Button
variant="outline"
onClick={onReject}
className="border-destructive/30 text-destructive hover:bg-destructive/10"
>
{t('ai.chat.reject')}
</Button>
<Button onClick={onApprove}>{t('ai.chat.approve')}</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
};
PermissionDialog.displayName = 'PermissionDialog';
export default PermissionDialog;
export { PermissionDialog };
export type { PermissionDialogProps };

View File

@@ -18,6 +18,7 @@ import type {
ChatMessage,
ChatMessageAttachment,
ExternalAgentConfig,
ProviderAdvancedParams,
ProviderConfig,
WebSearchConfig,
} from '../../../infrastructure/ai/types';
@@ -111,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;
}
@@ -125,6 +133,7 @@ export interface TerminalSessionInfo {
username?: string;
protocol?: string;
shellType?: string;
deviceType?: string;
connected: boolean;
}
@@ -186,6 +195,7 @@ export interface UseAIChatStreamingReturn {
sdkMessages: Array<ModelMessage>,
signal: AbortSignal,
currentAssistantMsgId: string,
advancedParams?: ProviderAdvancedParams,
) => Promise<void>;
/** Send a message to the Catty agent (built-in). */
sendToCattyAgent: (
@@ -320,6 +330,7 @@ export function useAIChatStreaming({
sdkMessages: Array<ModelMessage>,
signal: AbortSignal,
currentAssistantMsgId: string,
advancedParams?: ProviderAdvancedParams,
): Promise<void> => {
const result = streamText({
model,
@@ -328,6 +339,11 @@ export function useAIChatStreaming({
tools,
stopWhen: stepCountIs(maxIterations),
abortSignal: signal,
...(advancedParams?.maxTokens != null && { maxOutputTokens: advancedParams.maxTokens }),
...(advancedParams?.temperature != null && { temperature: advancedParams.temperature }),
...(advancedParams?.topP != null && { topP: advancedParams.topP }),
...(advancedParams?.frequencyPenalty != null && { frequencyPenalty: advancedParams.frequencyPenalty }),
...(advancedParams?.presencePenalty != null && { presencePenalty: advancedParams.presencePenalty }),
});
// Track the current assistant message ID so updates target the correct message
@@ -680,6 +696,7 @@ export function useAIChatStreaming({
username: s.username,
protocol: s.protocol,
shellType: s.shellType,
deviceType: s.deviceType,
connected: s.connected,
})),
permissionMode: context.globalPermissionMode,
@@ -804,7 +821,7 @@ export function useAIChatStreaming({
sdkMessages.push({ role: 'user', content: trimmed });
}
await processCattyStream(sessionId, model, systemPrompt, tools, sdkMessages, abortController.signal, assistantMsgId);
await processCattyStream(sessionId, model, systemPrompt, tools, sdkMessages, abortController.signal, assistantMsgId, context.activeProvider?.advancedParams);
} catch (err) {
console.error('[Catty] streamText error:', err);
reportStreamError(sessionId, abortController.signal, err);

View File

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

@@ -28,10 +28,12 @@ const getOpenerLabel = (
export default function SettingsFileAssociationsTab() {
const { t } = useI18n();
const { getAllAssociations, removeAssociation, setOpenerForExtension } = useSftpFileAssociations();
const { sftpDoubleClickBehavior, setSftpDoubleClickBehavior, sftpAutoSync, setSftpAutoSync, sftpShowHiddenFiles, setSftpShowHiddenFiles, sftpUseCompressedUpload, setSftpUseCompressedUpload, sftpAutoOpenSidebar, setSftpAutoOpenSidebar } = useSettingsState();
const { getAllAssociations, removeAssociation, setOpenerForExtension, getDefaultOpener, setDefaultOpener, removeDefaultOpener } = useSftpFileAssociations();
const { sftpDoubleClickBehavior, setSftpDoubleClickBehavior, sftpAutoSync, setSftpAutoSync, sftpShowHiddenFiles, setSftpShowHiddenFiles, sftpUseCompressedUpload, setSftpUseCompressedUpload, sftpAutoOpenSidebar, setSftpAutoOpenSidebar, sftpDefaultViewMode, setSftpDefaultViewMode, sftpTransferConcurrency, setSftpTransferConcurrency } = useSettingsState();
const associations = getAllAssociations();
const defaultOpener = getDefaultOpener();
const [editingExtension, setEditingExtension] = useState<string | null>(null);
const [isSelectingDefaultApp, setIsSelectingDefaultApp] = useState(false);
const handleRemove = useCallback((extension: string) => {
if (confirm(t('settings.sftpFileAssociations.removeConfirm', { ext: extension === 'file' ? t('sftp.opener.noExtension') : extension }))) {
@@ -39,6 +41,22 @@ export default function SettingsFileAssociationsTab() {
}
}, [removeAssociation, t]);
const handleSelectDefaultSystemApp = useCallback(async () => {
setIsSelectingDefaultApp(true);
try {
const bridge = netcattyBridge.get();
if (!bridge?.selectApplication) return;
const result = await bridge.selectApplication();
if (result) {
setDefaultOpener('system-app', { path: result.path, name: result.name });
}
} catch (e) {
console.error('Failed to select application:', e);
} finally {
setIsSelectingDefaultApp(false);
}
}, [setDefaultOpener]);
const handleEdit = useCallback(async (extension: string) => {
setEditingExtension(extension);
try {
@@ -130,6 +148,76 @@ export default function SettingsFileAssociationsTab() {
</div>
</div>
{/* Default view mode section */}
<div className="space-y-4">
<SectionHeader title={t('settings.sftp.defaultViewMode')} />
<p className="text-sm text-muted-foreground">
{t('settings.sftp.defaultViewMode.desc')}
</p>
<div className="space-y-3">
<button
onClick={() => setSftpDefaultViewMode('list')}
className={cn(
"w-full text-left p-4 rounded-lg border-2 transition-colors",
sftpDefaultViewMode === 'list'
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-secondary/50"
)}
>
<div className="flex items-start gap-3">
<div className={cn(
"h-5 w-5 rounded-full border-2 flex items-center justify-center mt-0.5 shrink-0",
sftpDefaultViewMode === 'list'
? "border-primary"
: "border-muted-foreground/30"
)}>
{sftpDefaultViewMode === 'list' && (
<div className="h-2.5 w-2.5 rounded-full bg-primary" />
)}
</div>
<div className="space-y-1">
<Label className="font-medium cursor-pointer">
{t('settings.sftp.defaultViewMode.list')}
</Label>
<p className="text-sm text-muted-foreground">
{t('settings.sftp.defaultViewMode.listDesc')}
</p>
</div>
</div>
</button>
<button
onClick={() => setSftpDefaultViewMode('tree')}
className={cn(
"w-full text-left p-4 rounded-lg border-2 transition-colors",
sftpDefaultViewMode === 'tree'
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-secondary/50"
)}
>
<div className="flex items-start gap-3">
<div className={cn(
"h-5 w-5 rounded-full border-2 flex items-center justify-center mt-0.5 shrink-0",
sftpDefaultViewMode === 'tree'
? "border-primary"
: "border-muted-foreground/30"
)}>
{sftpDefaultViewMode === 'tree' && (
<div className="h-2.5 w-2.5 rounded-full bg-primary" />
)}
</div>
<div className="space-y-1">
<Label className="font-medium cursor-pointer">
{t('settings.sftp.defaultViewMode.tree')}
</Label>
<p className="text-sm text-muted-foreground">
{t('settings.sftp.defaultViewMode.treeDesc')}
</p>
</div>
</div>
</button>
</div>
</div>
{/* Auto-sync section */}
<div className="space-y-4">
<SectionHeader title={t('settings.sftp.autoSync')} />
@@ -290,6 +378,117 @@ export default function SettingsFileAssociationsTab() {
</button>
</div>
{/* Transfer concurrency section */}
<div className="space-y-4">
<SectionHeader title={t('settings.sftp.transferConcurrency')} />
<p className="text-sm text-muted-foreground">
{t('settings.sftp.transferConcurrency.desc')}
</p>
<div className="flex items-center gap-3">
<input
type="range"
min={1}
max={16}
step={1}
value={sftpTransferConcurrency}
onChange={(e) => setSftpTransferConcurrency(Number(e.target.value))}
className="flex-1 accent-primary"
/>
<span className="text-sm font-mono w-6 text-center">{sftpTransferConcurrency}</span>
</div>
</div>
{/* Default opener section */}
<div className="space-y-4">
<SectionHeader title={t('settings.sftp.defaultOpener')} />
<p className="text-sm text-muted-foreground">
{t('settings.sftp.defaultOpener.desc')}
</p>
<div className="space-y-3">
<button
onClick={() => removeDefaultOpener()}
className={cn(
"w-full text-left p-4 rounded-lg border-2 transition-colors",
!defaultOpener
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-secondary/50"
)}
>
<div className="flex items-start gap-3">
<div className={cn(
"h-5 w-5 rounded-full border-2 flex items-center justify-center mt-0.5 shrink-0",
!defaultOpener ? "border-primary" : "border-muted-foreground/30"
)}>
{!defaultOpener && <div className="h-2.5 w-2.5 rounded-full bg-primary" />}
</div>
<div className="space-y-1">
<Label className="font-medium cursor-pointer">
{t('settings.sftp.defaultOpener.ask')}
</Label>
<p className="text-sm text-muted-foreground">
{t('settings.sftp.defaultOpener.askDesc')}
</p>
</div>
</div>
</button>
<button
onClick={() => setDefaultOpener('builtin-editor')}
className={cn(
"w-full text-left p-4 rounded-lg border-2 transition-colors",
defaultOpener?.openerType === 'builtin-editor'
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-secondary/50"
)}
>
<div className="flex items-start gap-3">
<div className={cn(
"h-5 w-5 rounded-full border-2 flex items-center justify-center mt-0.5 shrink-0",
defaultOpener?.openerType === 'builtin-editor' ? "border-primary" : "border-muted-foreground/30"
)}>
{defaultOpener?.openerType === 'builtin-editor' && <div className="h-2.5 w-2.5 rounded-full bg-primary" />}
</div>
<div className="space-y-1">
<Label className="font-medium cursor-pointer">
{t('sftp.opener.builtInEditor')}
</Label>
<p className="text-sm text-muted-foreground">
{t('settings.sftp.defaultOpener.builtInDesc')}
</p>
</div>
</div>
</button>
<button
onClick={handleSelectDefaultSystemApp}
disabled={isSelectingDefaultApp}
className={cn(
"w-full text-left p-4 rounded-lg border-2 transition-colors",
defaultOpener?.openerType === 'system-app'
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-secondary/50"
)}
>
<div className="flex items-start gap-3">
<div className={cn(
"h-5 w-5 rounded-full border-2 flex items-center justify-center mt-0.5 shrink-0",
defaultOpener?.openerType === 'system-app' ? "border-primary" : "border-muted-foreground/30"
)}>
{defaultOpener?.openerType === 'system-app' && <div className="h-2.5 w-2.5 rounded-full bg-primary" />}
</div>
<div className="space-y-1">
<Label className="font-medium cursor-pointer">
{defaultOpener?.openerType === 'system-app' && defaultOpener.systemApp
? defaultOpener.systemApp.name
: t('settings.sftp.defaultOpener.systemApp')}
</Label>
<p className="text-sm text-muted-foreground">
{t('settings.sftp.defaultOpener.systemAppDesc')}
</p>
</div>
</div>
</button>
</div>
</div>
{/* File associations section */}
<div className="space-y-4">
<SectionHeader title={t('settings.sftpFileAssociations.title')} />

View File

@@ -1,8 +1,8 @@
import React, { useCallback } from "react";
import type { PortForwardingRule } from "../../../domain/models";
import type { SyncPayload } from "../../../domain/sync";
import { buildSyncPayload, applySyncPayload } from "../../../domain/syncPayload";
import type { SyncableVaultData } from "../../../domain/syncPayload";
import { buildSyncPayload, applySyncPayload } from "../../../application/syncPayload";
import type { SyncableVaultData } from "../../../application/syncPayload";
import { STORAGE_KEY_PORT_FORWARDING } from "../../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
import { getEffectiveKnownHosts } from "../../../infrastructure/syncHelpers";

View File

@@ -89,6 +89,7 @@ interface SettingsSystemTabProps {
checkNow: () => Promise<unknown>;
installUpdate: () => void;
openReleasePage: () => void;
startDownload: () => void;
}
const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
@@ -111,6 +112,7 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
checkNow,
installUpdate,
openReleasePage,
startDownload,
}) => {
const { t } = useI18n();
const isMac = typeof navigator !== "undefined" && /Mac/i.test(navigator.platform);
@@ -463,7 +465,16 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
</Button>
)}
{/* Open releases — shown when update found on unsupported platform, or on check error */}
{/* Download button — shown when update found and no download in progress */}
{updateState.autoDownloadStatus === 'idle' &&
updateState.manualCheckStatus === 'available' && (
<Button variant="outline" size="sm" onClick={startDownload}>
<Download size={14} className="mr-1.5" />
{t('update.downloadNow')}
</Button>
)}
{/* Open releases — fallback for unsupported platforms or check errors */}
{updateState.autoDownloadStatus === 'idle' &&
(updateState.manualCheckStatus === 'available' || updateState.manualCheckStatus === 'error' || (updateState.manualCheckStatus === 'idle' && updateState.hasUpdate)) && (
<Button variant="ghost" size="sm" onClick={openReleasePage}>

View File

@@ -84,6 +84,8 @@ export default function SettingsTerminalTab(props: {
value: TerminalSettings[K],
) => void;
availableFonts: TerminalFont[];
workspaceFocusStyle: 'dim' | 'border';
setWorkspaceFocusStyle: (style: 'dim' | 'border') => void;
}) {
const {
terminalThemeId,
@@ -95,6 +97,8 @@ export default function SettingsTerminalTab(props: {
terminalSettings,
updateTerminalSetting,
availableFonts,
workspaceFocusStyle,
setWorkspaceFocusStyle,
} = props;
const { t } = useI18n();
@@ -114,6 +118,20 @@ export default function SettingsTerminalTab(props: {
|| TERMINAL_THEMES[0];
}, [terminalThemeId, customThemes]);
const handleAutocompleteGhostTextChange = useCallback((enabled: boolean) => {
updateTerminalSetting("autocompleteGhostText", enabled);
if (enabled) {
updateTerminalSetting("autocompletePopupMenu", false);
}
}, [updateTerminalSetting]);
const handleAutocompletePopupMenuChange = useCallback((enabled: boolean) => {
updateTerminalSetting("autocompletePopupMenu", enabled);
if (enabled) {
updateTerminalSetting("autocompleteGhostText", false);
}
}, [updateTerminalSetting]);
// Import .itermcolors file
const importFileRef = useRef<HTMLInputElement>(null);
const handleImportItermcolors = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
@@ -851,6 +869,56 @@ export default function SettingsTerminalTab(props: {
/>
</SettingRow>
</div>
{/* Autocomplete */}
<SectionHeader title={t("settings.terminal.section.workspaceFocus")} />
<div className="space-y-1">
<SettingRow
label={t("settings.terminal.workspaceFocus.style")}
description={t("settings.terminal.workspaceFocus.style.desc")}
>
<Select
value={workspaceFocusStyle}
onChange={(v) => setWorkspaceFocusStyle(v as 'dim' | 'border')}
options={[
{ value: 'dim', label: t("settings.terminal.workspaceFocus.dim") },
{ value: 'border', label: t("settings.terminal.workspaceFocus.border") },
]}
/>
</SettingRow>
</div>
<SectionHeader title={t("settings.terminal.section.autocomplete")} />
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
<SettingRow
label={t("settings.terminal.autocomplete.enabled")}
description={t("settings.terminal.autocomplete.enabled.desc")}
>
<Toggle
checked={terminalSettings.autocompleteEnabled}
onChange={(v) => updateTerminalSetting("autocompleteEnabled", v)}
/>
</SettingRow>
<SettingRow
label={t("settings.terminal.autocomplete.ghostText")}
description={t("settings.terminal.autocomplete.ghostText.desc")}
>
<Toggle
checked={terminalSettings.autocompleteGhostText}
onChange={handleAutocompleteGhostTextChange}
disabled={!terminalSettings.autocompleteEnabled}
/>
</SettingRow>
<SettingRow
label={t("settings.terminal.autocomplete.popupMenu")}
description={t("settings.terminal.autocomplete.popupMenu.desc")}
>
<Toggle
checked={terminalSettings.autocompletePopupMenu}
onChange={handleAutocompletePopupMenuChange}
disabled={!terminalSettings.autocompleteEnabled}
/>
</SettingRow>
</div>
</SettingsTabContent>
);
}

View File

@@ -0,0 +1,87 @@
import React from "react";
import { RefreshCw } from "lucide-react";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Button } from "../../../ui/button";
import { cn } from "../../../../lib/utils";
import type { AgentPathInfo } from "./types";
import { ProviderIconBadge } from "./ProviderIconBadge";
export const CopilotCliCard: React.FC<{
pathInfo: AgentPathInfo | null;
isResolvingPath: boolean;
customPath: string;
onCustomPathChange: (path: string) => void;
onRecheckPath: () => void;
}> = ({
pathInfo,
isResolvingPath,
customPath,
onCustomPathChange,
onRecheckPath,
}) => {
const { t } = useI18n();
const found = pathInfo?.available;
const statusText = isResolvingPath
? t('ai.copilot.detecting')
: found
? t('ai.copilot.detected')
: t('ai.copilot.notFound');
const statusClassName = isResolvingPath
? "text-muted-foreground"
: found
? "text-emerald-500"
: "text-amber-500";
return (
<div className="rounded-lg border border-border/60 bg-muted/20 p-4 space-y-3">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<ProviderIconBadge providerId="copilot" size="sm" />
<span className="text-sm font-medium">{t('ai.copilot.title')}</span>
</div>
<p className="text-xs text-muted-foreground mt-2 leading-5">
{t('ai.copilot.description')}
</p>
</div>
<div className={cn("text-xs font-medium shrink-0", statusClassName)}>
{statusText}
</div>
</div>
{found ? (
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">{t('ai.copilot.path')}</span>
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
{pathInfo.version && (
<>
<span className="text-muted-foreground">|</span>
<span className="text-muted-foreground">{pathInfo.version}</span>
</>
)}
</div>
) : !isResolvingPath ? (
<div className="space-y-2">
<p className="text-xs text-amber-500">
{t('ai.copilot.notFoundHint')}
</p>
<div className="flex items-center gap-2">
<input
type="text"
value={customPath}
onChange={(e) => onCustomPathChange(e.target.value)}
placeholder={t('ai.copilot.customPathPlaceholder')}
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!customPath.trim()}>
<RefreshCw size={14} className="mr-1.5" />
{t('ai.copilot.check')}
</Button>
</div>
</div>
) : null}
</div>
);
};

View File

@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useState } from "react";
import { Check, Eye, EyeOff } from "lucide-react";
import type { ProviderConfig } from "../../../../infrastructure/ai/types";
import { Check, ChevronDown, ChevronRight, Eye, EyeOff } from "lucide-react";
import type { ProviderConfig, ProviderAdvancedParams } from "../../../../infrastructure/ai/types";
import { PROVIDER_PRESETS } from "../../../../infrastructure/ai/types";
import { encryptField, decryptField } from "../../../../infrastructure/persistence/secureFieldAdapter";
import { useI18n } from "../../../../application/i18n/I18nProvider";
@@ -20,10 +20,12 @@ export const ProviderConfigForm: React.FC<{
baseURL: provider.baseURL ?? PROVIDER_PRESETS[provider.providerId]?.defaultBaseURL ?? "",
defaultModel: provider.defaultModel ?? "",
skipTLSVerify: provider.skipTLSVerify ?? false,
advancedParams: provider.advancedParams ?? {},
});
const isCustom = provider.providerId === "custom";
const [showApiKey, setShowApiKey] = useState(false);
const [isDecrypting, setIsDecrypting] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
const preset = PROVIDER_PRESETS[provider.providerId];
@@ -43,11 +45,37 @@ export const ProviderConfigForm: React.FC<{
}
}, [provider.apiKey]);
const [advancedParamRaw, setAdvancedParamRaw] = useState<Record<string, string>>({});
const handleAdvancedParam = useCallback((key: keyof ProviderAdvancedParams, raw: string) => {
setAdvancedParamRaw((prev) => ({ ...prev, [key]: raw }));
setForm((prev) => {
const next = { ...prev.advancedParams };
if (raw.trim() === "" || raw.trim() === "-") {
delete next[key];
} else {
const num = Number(raw);
if (!Number.isNaN(num)) {
next[key] = num;
}
}
return { ...prev, advancedParams: next };
});
}, []);
const handleSave = useCallback(async () => {
const cleanedParams: ProviderAdvancedParams = {};
const ap = form.advancedParams;
if (ap.maxTokens != null && Number.isFinite(ap.maxTokens) && ap.maxTokens > 0) cleanedParams.maxTokens = Math.max(1, Math.round(ap.maxTokens));
if (ap.temperature != null) cleanedParams.temperature = Math.min(2, Math.max(0, ap.temperature));
if (ap.topP != null) cleanedParams.topP = Math.min(1, Math.max(0, ap.topP));
if (ap.frequencyPenalty != null) cleanedParams.frequencyPenalty = Math.min(2, Math.max(-2, ap.frequencyPenalty));
if (ap.presencePenalty != null) cleanedParams.presencePenalty = Math.min(2, Math.max(-2, ap.presencePenalty));
const updates: Partial<ProviderConfig> = {
baseURL: form.baseURL || undefined,
defaultModel: form.defaultModel || undefined,
skipTLSVerify: form.skipTLSVerify || undefined,
advancedParams: Object.keys(cleanedParams).length > 0 ? cleanedParams : undefined,
...(isCustom && form.name.trim() ? { name: form.name.trim() } : {}),
};
@@ -137,6 +165,92 @@ export const ProviderConfigForm: React.FC<{
<span className="text-xs text-muted-foreground">{t('ai.providers.skipTLSVerify')}</span>
</label>
{/* Advanced Parameters */}
<div className="space-y-2">
<button
type="button"
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors"
>
{showAdvanced ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
{t('ai.providers.advancedParams')}
</button>
{showAdvanced && (
<div className="space-y-2.5 pl-1 border-l-2 border-border/40 ml-1">
<p className="text-[11px] text-muted-foreground/70 pl-3">{t('ai.providers.advancedParams.hint')}</p>
{/* max_tokens */}
<div className="space-y-1 pl-3">
<label className="text-xs text-muted-foreground">max_tokens</label>
<input
type="number"
min={1}
step={1}
value={advancedParamRaw.maxTokens ?? (form.advancedParams.maxTokens != null ? String(form.advancedParams.maxTokens) : "")}
onChange={(e) => handleAdvancedParam("maxTokens", e.target.value)}
placeholder={t('ai.providers.advancedParams.maxTokens.placeholder')}
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
{/* temperature */}
<div className="space-y-1 pl-3">
<label className="text-xs text-muted-foreground">temperature <span className="text-muted-foreground/50">(02)</span></label>
<input
type="number"
min={0}
max={2}
step={0.1}
value={advancedParamRaw.temperature ?? (form.advancedParams.temperature != null ? String(form.advancedParams.temperature) : "")}
onChange={(e) => handleAdvancedParam("temperature", e.target.value)}
placeholder={t('ai.providers.advancedParams.default')}
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
{/* top_p */}
<div className="space-y-1 pl-3">
<label className="text-xs text-muted-foreground">top_p <span className="text-muted-foreground/50">(01)</span></label>
<input
type="number"
min={0}
max={1}
step={0.05}
value={advancedParamRaw.topP ?? (form.advancedParams.topP != null ? String(form.advancedParams.topP) : "")}
onChange={(e) => handleAdvancedParam("topP", e.target.value)}
placeholder={t('ai.providers.advancedParams.default')}
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
{/* frequency_penalty */}
<div className="space-y-1 pl-3">
<label className="text-xs text-muted-foreground">frequency_penalty <span className="text-muted-foreground/50">(-22)</span></label>
<input
type="number"
min={-2}
max={2}
step={0.1}
value={advancedParamRaw.frequencyPenalty ?? (form.advancedParams.frequencyPenalty != null ? String(form.advancedParams.frequencyPenalty) : "")}
onChange={(e) => handleAdvancedParam("frequencyPenalty", e.target.value)}
placeholder={t('ai.providers.advancedParams.default')}
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
{/* presence_penalty */}
<div className="space-y-1 pl-3">
<label className="text-xs text-muted-foreground">presence_penalty <span className="text-muted-foreground/50">(-22)</span></label>
<input
type="number"
min={-2}
max={2}
step={0.1}
value={advancedParamRaw.presencePenalty ?? (form.advancedParams.presencePenalty != null ? String(form.advancedParams.presencePenalty) : "")}
onChange={(e) => handleAdvancedParam("presencePenalty", e.target.value)}
placeholder={t('ai.providers.advancedParams.default')}
className="w-full h-8 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
</div>
</div>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-2 pt-1">
<Button variant="default" size="sm" onClick={() => void handleSave()}>

View File

@@ -20,7 +20,8 @@ export const ProviderIconBadge: React.FC<{
aria-hidden="true"
draggable={false}
className={cn(
"object-contain brightness-0 invert",
"object-contain",
providerId === "copilot" ? "brightness-0" : "brightness-0 invert",
size === "sm" ? "w-3 h-3" : "w-4 h-4",
)}
/>

View File

@@ -5,4 +5,5 @@ export { ProviderCard } from "./ProviderCard";
export { AddProviderDropdown } from "./AddProviderDropdown";
export { CodexConnectionCard } from "./CodexConnectionCard";
export { ClaudeCodeCard } from "./ClaudeCodeCard";
export { CopilotCliCard } from "./CopilotCliCard";
export { SafetySettings } from "./SafetySettings";

View File

@@ -4,6 +4,7 @@
import type {
AIProviderId,
ExternalAgentConfig,
ProviderAdvancedParams,
} from "../../../../infrastructure/ai/types";
export type CodexIntegrationState =
@@ -42,6 +43,7 @@ export interface ProviderFormState {
baseURL: string;
defaultModel: string;
skipTLSVerify: boolean;
advancedParams: ProviderAdvancedParams;
}
export interface FetchedModel {
@@ -80,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"],
},
};
// ---------------------------------------------------------------------------
@@ -106,12 +115,13 @@ export function normalizeCodexBridgeError(error: unknown): string {
// Provider icon helper
// ---------------------------------------------------------------------------
export type SettingsIconId = AIProviderId | "claude";
export type SettingsIconId = AIProviderId | "claude" | "copilot";
export const SETTINGS_ICON_PATHS: Record<SettingsIconId, string> = {
openai: "/ai/providers/openai.svg",
anthropic: "/ai/providers/anthropic.svg",
claude: "/ai/agents/claude.svg",
copilot: "/ai/agents/copilot.svg",
google: "/ai/providers/google.svg",
ollama: "/ai/providers/ollama.svg",
openrouter: "/ai/providers/openrouter.svg",
@@ -122,6 +132,7 @@ export const SETTINGS_ICON_COLORS: Record<SettingsIconId, string> = {
openai: "bg-emerald-600",
anthropic: "bg-orange-600",
claude: "bg-orange-600",
copilot: "border border-zinc-300 bg-white",
google: "bg-blue-600",
ollama: "bg-purple-600",
openrouter: "bg-pink-600",

View File

@@ -9,37 +9,53 @@
import React, { createContext, useContext, useMemo, useSyncExternalStore } from "react";
import { Host, SftpFileEntry, SftpFilenameEncoding } from "../../types";
export interface SftpTransferSource {
name: string;
isDirectory: boolean;
sourcePath?: string;
sourceConnectionId?: string;
targetPath?: string;
}
// Types for the context
export interface SftpPaneCallbacks {
onConnect: (host: Host | "local") => void;
onDisconnect: () => void;
onPrepareSelection: () => void;
onNavigateTo: (path: string) => void;
onNavigateUp: () => void;
onRefresh: () => void;
onRefreshTab: (tabId: string) => void;
onSetFilenameEncoding: (encoding: SftpFilenameEncoding) => void;
onOpenEntry: (entry: SftpFileEntry) => void;
onOpenEntry: (entry: SftpFileEntry, fullPath?: string) => void;
onToggleSelection: (fileName: string, multiSelect: boolean) => void;
onRangeSelect: (fileNames: string[]) => void;
onClearSelection: () => void;
onSetFilter: (filter: string) => void;
onCreateDirectory: (name: string) => Promise<void>;
onCreateDirectoryAtPath: (path: string, name: string) => Promise<void>;
onCreateFile: (name: string) => Promise<void>;
onCreateFileAtPath: (path: string, name: string) => Promise<void>;
onDeleteFiles: (fileNames: string[]) => Promise<void>;
onDeleteFilesAtPath: (connectionId: string, path: string, fileNames: string[]) => Promise<void>;
onRenameFile: (oldName: string, newName: string) => Promise<void>;
onCopyToOtherPane: (files: { name: string; isDirectory: boolean }[]) => void;
onReceiveFromOtherPane: (files: { name: string; isDirectory: boolean }[]) => void;
onEditPermissions?: (file: SftpFileEntry) => void;
onRenameFileAtPath: (oldPath: string, newName: string) => Promise<void>;
onMoveEntriesToPath: (sourcePaths: string[], targetPath: string) => Promise<void>;
onCopyToOtherPane: (files: SftpTransferSource[]) => void;
onReceiveFromOtherPane: (files: SftpTransferSource[]) => void;
onEditPermissions?: (file: SftpFileEntry, fullPath?: string) => void;
// File operations
onEditFile?: (entry: SftpFileEntry) => void;
onOpenFile?: (entry: SftpFileEntry) => void;
onOpenFileWith?: (entry: SftpFileEntry) => void; // Always show opener dialog
onDownloadFile?: (entry: SftpFileEntry) => void; // Download to local filesystem
onEditFile?: (entry: SftpFileEntry, fullPath?: string) => void;
onOpenFile?: (entry: SftpFileEntry, fullPath?: string) => void;
onOpenFileWith?: (entry: SftpFileEntry, fullPath?: string) => void; // Always show opener dialog
onDownloadFile?: (entry: SftpFileEntry, fullPath?: string) => void; // Download to local filesystem
// External file upload (supports folders via DataTransfer)
onUploadExternalFiles?: (dataTransfer: DataTransfer) => Promise<void>;
onUploadExternalFiles?: (dataTransfer: DataTransfer, targetPath?: string) => Promise<void>;
onListDirectory: (path: string) => Promise<SftpFileEntry[]>;
}
export interface SftpDragCallbacks {
onDragStart: (files: { name: string; isDirectory: boolean }[], side: "left" | "right") => void;
onDragStart: (files: SftpTransferSource[], side: "left" | "right") => void;
onDragEnd: () => void;
}
@@ -91,16 +107,18 @@ export interface SftpContextValue {
// Host updater for bookmark persistence
updateHosts: (hosts: Host[]) => void;
// Drag state (shared between panes)
draggedFiles: { name: string; isDirectory: boolean; side: "left" | "right" }[] | null;
dragCallbacks: SftpDragCallbacks;
// Callbacks for each side
leftCallbacks: SftpPaneCallbacks;
rightCallbacks: SftpPaneCallbacks;
}
export interface SftpDragContextValue {
draggedFiles: (SftpTransferSource & { side: "left" | "right" })[] | null;
dragCallbacks: SftpDragCallbacks;
}
const SftpContext = createContext<SftpContextValue | null>(null);
const SftpDragContext = createContext<SftpDragContextValue | null>(null);
export const useSftpContext = () => {
const context = useContext(SftpContext);
@@ -116,13 +134,19 @@ export const useSftpPaneCallbacks = (side: "left" | "right"): SftpPaneCallbacks
return side === "left" ? context.leftCallbacks : context.rightCallbacks;
};
// Hook to get drag-related values
// Hook to get drag-related values (reads from separate SftpDragContext)
export const useSftpDrag = () => {
const context = useSftpContext();
return {
draggedFiles: context.draggedFiles,
...context.dragCallbacks,
};
const context = useContext(SftpDragContext);
if (!context) {
throw new Error("useSftpDrag must be used within SftpContextProvider");
}
return useMemo(
() => ({
draggedFiles: context.draggedFiles,
...context.dragCallbacks,
}),
[context.draggedFiles, context.dragCallbacks],
);
};
// Hook to get hosts
@@ -140,7 +164,7 @@ export const useSftpUpdateHosts = () => {
interface SftpContextProviderProps {
hosts: Host[];
updateHosts: (hosts: Host[]) => void;
draggedFiles: { name: string; isDirectory: boolean; side: "left" | "right" }[] | null;
draggedFiles: (SftpTransferSource & { side: "left" | "right" })[] | null;
dragCallbacks: SftpDragCallbacks;
leftCallbacks: SftpPaneCallbacks;
rightCallbacks: SftpPaneCallbacks;
@@ -156,19 +180,29 @@ export const SftpContextProvider: React.FC<SftpContextProviderProps> = ({
rightCallbacks,
children,
}) => {
// Memoize the context value to prevent unnecessary re-renders
// Note: The callbacks objects should be stable (created with useMemo in parent)
// Memoize the main context value (no drag state, so drag changes won't cause re-renders here)
const value = useMemo<SftpContextValue>(
() => ({
hosts,
updateHosts,
draggedFiles,
dragCallbacks,
leftCallbacks,
rightCallbacks,
}),
[hosts, updateHosts, draggedFiles, dragCallbacks, leftCallbacks, rightCallbacks],
[hosts, updateHosts, leftCallbacks, rightCallbacks],
);
return <SftpContext.Provider value={value}>{children}</SftpContext.Provider>;
// Memoize drag context separately so only drag consumers re-render on drag state changes
const dragValue = useMemo<SftpDragContextValue>(
() => ({
draggedFiles,
dragCallbacks,
}),
[draggedFiles, dragCallbacks],
);
return (
<SftpContext.Provider value={value}>
<SftpDragContext.Provider value={dragValue}>{children}</SftpDragContext.Provider>
</SftpContext.Provider>
);
};

View File

@@ -6,12 +6,13 @@ import { Folder, Link } from 'lucide-react';
import React, { memo, useCallback } from 'react';
import { cn } from '../../lib/utils';
import { SftpFileEntry } from '../../types';
import { ColumnWidths, formatBytes, formatDate, getFileIcon, isNavigableDirectory } from './utils';
import { buildSftpColumnTemplate, ColumnWidths, formatBytes, formatDate, getFileIcon, isNavigableDirectory } from './utils';
interface SftpFileRowProps {
entry: SftpFileEntry;
index: number;
isSelected: boolean;
showSelectionHighlight: boolean;
isDragOver: boolean;
columnWidths: ColumnWidths;
onSelect: (entry: SftpFileEntry, index: number, e: React.MouseEvent) => void;
@@ -27,6 +28,7 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
entry,
index,
isSelected,
showSelectionHighlight,
isDragOver,
columnWidths,
onSelect,
@@ -58,10 +60,13 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
const handleDrop = useCallback((e: React.DragEvent) => {
onDrop(entry, e);
}, [entry, onDrop]);
const isSelectionVisible = isSelected && showSelectionHighlight;
return (
<div
data-sftp-row="true"
data-entry-name={entry.name}
data-selected={isSelected ? "true" : "false"}
draggable={!isParentDir}
onDragStart={handleDragStart}
onDragEnd={onDragEnd}
@@ -71,33 +76,53 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
onClick={handleSelect}
onDoubleClick={handleOpen}
className={cn(
"px-4 py-2 items-center cursor-pointer text-sm transition-colors",
isSelected ? "bg-primary/15 text-foreground" : "hover:bg-secondary/40",
"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: `${columnWidths.name}% ${columnWidths.modified}% ${columnWidths.size}% ${columnWidths.type}%` }}
style={{ display: 'grid', gridTemplateColumns: buildSftpColumnTemplate(columnWidths) }}
>
<div className="flex items-center gap-3 min-w-0">
<div className={cn(
"h-7 w-7 rounded flex items-center justify-center shrink-0 relative",
isNavDir ? "bg-primary/10 text-primary" : "bg-secondary/60 text-muted-foreground"
isSelectionVisible
? "bg-accent-foreground/10 text-accent-foreground"
: isNavDir
? "bg-primary/10 text-primary"
: "bg-secondary/60 text-muted-foreground"
)}>
{isNavDir ? <Folder size={14} /> : getFileIcon(entry)}
{/* Show link indicator for symlinks */}
{entry.type === 'symlink' && (
<Link size={8} className="absolute -bottom-0.5 -right-0.5 text-muted-foreground" aria-hidden="true" />
<Link
size={8}
className={cn(
"absolute -bottom-0.5 -right-0.5",
isSelectionVisible ? "text-accent-foreground/80" : "text-muted-foreground",
)}
aria-hidden="true"
/>
)}
</div>
<span className={cn("truncate", entry.type === 'symlink' && "italic pr-1")} title={entry.name}>
<span
className={cn(
"truncate",
entry.type === 'symlink' && "italic pr-1",
isSelectionVisible && "font-medium",
)}
title={entry.name}
>
{entry.name}
{entry.type === 'symlink' && <span className="sr-only"> (symbolic link)</span>}
</span>
</div>
<span className="text-xs text-muted-foreground truncate">{modifiedLabel}</span>
<span className="text-xs text-muted-foreground truncate text-right">
<span className={cn("text-xs truncate", isSelectionVisible ? "text-accent-foreground/85" : "text-muted-foreground")}>{modifiedLabel}</span>
<span className={cn("text-xs truncate text-right", isSelectionVisible ? "text-accent-foreground/85" : "text-muted-foreground")}>
{isNavDir ? '--' : sizeLabel}
</span>
<span className="text-xs text-muted-foreground truncate capitalize text-right">
<span className={cn("text-xs truncate capitalize text-right", isSelectionVisible ? "text-accent-foreground/85" : "text-muted-foreground")}>
{isSymlinkToDirectory ? 'link → folder' : entry.type === 'directory' ? 'folder' : entry.type === 'symlink' ? 'link' : entry.name.split('.').pop()?.toLowerCase() || 'file'}
</span>
</div>
@@ -107,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

@@ -24,8 +24,8 @@ interface SftpOverlaysProps {
setHostSearchRight: (value: string) => void;
handleHostSelectLeft: (host: Host | "local") => void;
handleHostSelectRight: (host: Host | "local") => void;
permissionsState: { file: SftpFileEntry; side: "left" | "right" } | null;
setPermissionsState: (state: { file: SftpFileEntry; side: "left" | "right" } | null) => void;
permissionsState: { file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null;
setPermissionsState: (state: { file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null) => void;
showTextEditor: boolean;
setShowTextEditor: (open: boolean) => void;
textEditorTarget: { file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null;
@@ -43,7 +43,7 @@ interface SftpOverlaysProps {
handleSelectSystemApp: (systemApp: { path: string; name: string }) => void;
}
export const SftpOverlays: React.FC<SftpOverlaysProps> = ({
export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
hosts,
sftp,
visibleTransfers,
@@ -101,7 +101,7 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = ({
/>
{showTransferQueue && (
<SftpTransferQueue sftp={sftp} visibleTransfers={visibleTransfers} />
<SftpTransferQueue sftp={sftp} visibleTransfers={visibleTransfers} allTransfers={sftp.transfers} />
)}
<SftpConflictDialog
@@ -114,17 +114,11 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = ({
open={!!permissionsState}
onOpenChange={(open) => !open && setPermissionsState(null)}
file={permissionsState?.file ?? null}
onSave={(file, permissions) => {
onSave={(_file, permissions) => {
if (permissionsState) {
const fullPath = sftp.joinPath(
permissionsState.side === "left"
? sftp.leftPane.connection?.currentPath || ""
: sftp.rightPane.connection?.currentPath || "",
file.name,
);
sftp.changePermissions(
permissionsState.side,
fullPath,
permissionsState.fullPath,
permissions,
);
}
@@ -160,4 +154,4 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = ({
/>
</>
);
};
});

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

@@ -1,5 +1,5 @@
import React, { useCallback, useMemo, useState } from "react";
import { AlertCircle, ArrowDown, ChevronDown, ClipboardCopy, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderPlus, Loader2, Pencil, RefreshCw, Shield, Trash2 } from "lucide-react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ArrowDown, ArrowRight, ArrowUp, ChevronDown, ClipboardCopy, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderPlus, Loader2, Pencil, RefreshCw, Shield, Trash2, Unplug } from "lucide-react";
import { Button } from "../ui/button";
import {
ContextMenu,
@@ -9,10 +9,12 @@ import {
ContextMenuTrigger,
} from "../ui/context-menu";
import { cn } from "../../lib/utils";
import { joinPath } from "../../application/state/sftp/utils";
import { getParentPath, joinPath } from "../../application/state/sftp/utils";
import type { SftpFileEntry } from "../../types";
import type { SftpPane } from "../../application/state/sftp/types";
import type { ColumnWidths, SortField, SortOrder } from "./utils";
import type { SftpTransferSource } from "./SftpContext";
import { sftpListOrderStore } from "./hooks/useSftpListOrderStore";
import { buildSftpColumnTemplate, type ColumnWidths, type SortField, type SortOrder } from "./utils";
import { isNavigableDirectory } from "./index";
import { isKnownBinaryFile } from "../../lib/sftpFileUtils";
import { SftpFileRow } from "./index";
@@ -21,6 +23,7 @@ interface SftpPaneFileListProps {
t: (key: string, params?: Record<string, unknown>) => string;
pane: SftpPane;
side: "left" | "right";
isPaneFocused: boolean;
columnWidths: ColumnWidths;
sortField: SortField;
sortOrder: SortOrder;
@@ -32,8 +35,10 @@ interface SftpPaneFileListProps {
totalHeight: number;
sortedDisplayFiles: SftpFileEntry[];
isDragOverPane: boolean;
draggedFiles: { name: string; isDirectory: boolean; side: "left" | "right" }[] | null;
draggedFiles: (SftpTransferSource & { side: "left" | "right" })[] | null;
onRefresh: () => void;
onNavigateTo: (path: string) => void;
onClearSelection: () => void;
setShowNewFolderDialog: (open: boolean) => void;
setShowNewFileDialog: (open: boolean) => void;
getNextUntitledName: (existingNames: string[]) => string;
@@ -48,7 +53,8 @@ interface SftpPaneFileListProps {
handleEntryDragOver: (entry: SftpFileEntry, e: React.DragEvent) => void;
handleRowDragLeave: () => void;
handleEntryDrop: (entry: SftpFileEntry, e: React.DragEvent) => void;
onCopyToOtherPane: (files: { name: string; isDirectory: boolean }[]) => void;
onCopyToOtherPane: (files: SftpTransferSource[]) => void;
onMoveEntriesToPath: (sourcePaths: string[], targetPath: string) => Promise<void>;
onOpenFileWith?: (entry: SftpFileEntry) => void;
onEditFile?: (entry: SftpFileEntry) => void;
onDownloadFile?: (entry: SftpFileEntry) => void;
@@ -65,20 +71,20 @@ const SftpErrorWithLogs: React.FC<{
onRetry: () => void;
t: (key: string) => string;
}> = ({ error, connectionLogs, onRetry, t }) => {
const [showLogs, setShowLogs] = useState(false);
const [showLogs, setShowLogs] = useState(connectionLogs.length > 0);
return (
<div className="flex flex-col items-center justify-center h-full gap-2 text-destructive">
<AlertCircle size={24} />
<span className="text-sm text-center px-4">{t(error)}</span>
<div className="flex flex-col items-center justify-center h-full gap-3 text-muted-foreground">
<Unplug size={28} className="text-destructive/70" />
<span className="text-xs text-center px-6 max-w-xs leading-relaxed">{t(error)}</span>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={onRetry}>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={onRetry}>
{t("sftp.retry")}
</Button>
{connectionLogs.length > 0 && (
<Button
variant="ghost"
size="sm"
className="text-muted-foreground"
className="h-7 text-xs text-muted-foreground"
onClick={() => setShowLogs(!showLogs)}
>
<ChevronDown size={14} className={`mr-1 transition-transform ${showLogs ? 'rotate-180' : ''}`} />
@@ -99,10 +105,11 @@ const SftpErrorWithLogs: React.FC<{
);
};
export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
t,
pane,
side,
isPaneFocused,
columnWidths,
sortField,
sortOrder,
@@ -116,6 +123,8 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
isDragOverPane,
draggedFiles,
onRefresh,
onNavigateTo,
onClearSelection,
setShowNewFolderDialog,
setShowNewFileDialog,
getNextUntitledName,
@@ -130,6 +139,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
handleRowDragLeave,
handleEntryDrop,
onCopyToOtherPane,
onMoveEntriesToPath,
onOpenFileWith,
onEditFile,
onDownloadFile,
@@ -147,6 +157,39 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
return map;
}, [sortedDisplayFiles]);
// Push sorted file names into the list order store for keyboard navigation
useEffect(() => {
const names = sortedDisplayFiles
.filter((f) => f.name !== "..")
.map((f) => f.name);
sftpListOrderStore.setItems(pane.id, names);
return () => sftpListOrderStore.clearPane(pane.id);
}, [sortedDisplayFiles, pane.id]);
useEffect(() => {
if (pane.selectedFiles.size !== 1) return;
const selectedName = Array.from(pane.selectedFiles)[0];
if (!selectedName) return;
const container = fileListRef.current;
if (!container) return;
const row = Array.from(container.querySelectorAll<HTMLElement>('[data-sftp-row="true"]'))
.find((element) => element.dataset.entryName === selectedName);
row?.scrollIntoView({ block: "nearest" });
}, [fileListRef, pane.selectedFiles]);
// Use refs for frequently-changing values in context-menu actions
const selectedFilesRef = useRef(pane.selectedFiles);
selectedFilesRef.current = pane.selectedFiles;
const handleBackgroundClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement;
if (target.closest('[data-sftp-row="true"]')) return;
if (pane.selectedFiles.size === 0) return;
onClearSelection();
}, [onClearSelection, pane.selectedFiles.size]);
const renderRow = useCallback(
(entry: SftpFileEntry, index: number) => (
<ContextMenu>
@@ -155,6 +198,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
entry={entry}
index={index}
isSelected={pane.selectedFiles.has(entry.name)}
showSelectionHighlight={isPaneFocused}
isDragOver={dragOverEntry === entry.name}
columnWidths={columnWidths}
onSelect={handleRowSelect}
@@ -180,6 +224,11 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
</>
)}
</ContextMenuItem>
{isNavigableDirectory(entry) && (
<ContextMenuItem onClick={() => onNavigateTo(joinPath(pane.connection.currentPath, entry.name))}>
<ArrowRight size={14} className="mr-2" /> {t("sftp.context.navigateTo")}
</ContextMenuItem>
)}
{!isNavigableDirectory(entry) && onOpenFileWith && (
<ContextMenuItem onClick={() => onOpenFileWith(entry)}>
<ExternalLink size={14} className="mr-2" />{" "}
@@ -202,8 +251,9 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
<ContextMenuSeparator />
<ContextMenuItem
onClick={() => {
const files = pane.selectedFiles.has(entry.name)
? Array.from(pane.selectedFiles)
const currentSelected = selectedFilesRef.current;
const files = currentSelected.has(entry.name)
? Array.from(currentSelected)
: [entry.name];
const fileData = files.map((name) => {
const fileName = String(name);
@@ -211,6 +261,8 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
return {
name: fileName,
isDirectory: file ? isNavigableDirectory(file) : false,
sourceConnectionId: pane.connection?.id,
sourcePath: pane.connection?.currentPath,
};
});
onCopyToOtherPane(fileData);
@@ -228,7 +280,27 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
{t("sftp.context.copyPath")}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => openRenameDialog(entry.name)}>
{(() => {
const sourceParent = getParentPath(joinPath(pane.connection?.currentPath ?? "", entry.name));
const targetParent = getParentPath(sourceParent);
if (sourceParent === targetParent) return null;
return (
<ContextMenuItem
onClick={() => {
const currentSelected = selectedFilesRef.current;
const sourcePaths = currentSelected.has(entry.name)
? Array.from(currentSelected as Set<string>).map((n) => joinPath(pane.connection?.currentPath ?? "", n))
: [joinPath(pane.connection?.currentPath ?? "", entry.name)];
void onMoveEntriesToPath(sourcePaths, targetParent);
}}
>
<ArrowUp size={14} className="mr-2" />{" "}
{t("sftp.context.moveToParent")}
</ContextMenuItem>
);
})()}
<ContextMenuItem onClick={() => openRenameDialog(joinPath(pane.connection?.currentPath ?? "", entry.name))}>
<Pencil size={14} className="mr-2" /> {t("common.rename")}
</ContextMenuItem>
{onEditPermissions && pane.connection && !pane.connection.isLocal && (
@@ -240,9 +312,10 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
<ContextMenuItem
className="text-destructive"
onClick={() => {
const files = pane.selectedFiles.has(entry.name)
? Array.from(pane.selectedFiles)
: [entry.name];
const currentSelected = selectedFilesRef.current;
const files = currentSelected.has(entry.name)
? Array.from(currentSelected as Set<string>).map((n) => joinPath(pane.connection?.currentPath ?? "", n))
: [joinPath(pane.connection?.currentPath ?? "", entry.name)];
openDeleteConfirm(files);
}}
>
@@ -264,7 +337,6 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
),
[
columnWidths,
dragOverEntry,
filesByName,
handleEntryDragOver,
handleEntryDrop,
@@ -272,11 +344,15 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
handleRowDragLeave,
handleRowOpen,
handleRowSelect,
dragOverEntry,
isPaneFocused,
onCopyToOtherPane,
onMoveEntriesToPath,
onDownloadFile,
onDragEnd,
onEditFile,
onEditPermissions,
onNavigateTo,
onOpenFileWith,
onRefresh,
openDeleteConfirm,
@@ -306,7 +382,13 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
{renderRow(entry, index)}
</React.Fragment>
)),
[renderRow, rowHeight, shouldVirtualize, sortedDisplayFiles, visibleRows],
[
renderRow,
rowHeight,
shouldVirtualize,
sortedDisplayFiles,
visibleRows,
],
);
return (
@@ -316,16 +398,16 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
className="text-[11px] uppercase tracking-wide text-muted-foreground px-4 py-2 border-b border-border/40 bg-secondary/10 select-none"
style={{
display: "grid",
gridTemplateColumns: `${columnWidths.name}% ${columnWidths.modified}% ${columnWidths.size}% ${columnWidths.type}%`,
gridTemplateColumns: buildSftpColumnTemplate(columnWidths),
}}
>
<div
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2"
className="flex min-w-0 items-center gap-1 cursor-pointer hover:text-foreground relative pr-2 overflow-hidden"
onClick={() => handleSort("name")}
>
<span>{t("sftp.columns.name")}</span>
<span className="truncate whitespace-nowrap">{t("sftp.columns.name")}</span>
{sortField === "name" && (
<span className="text-primary">
<span className="shrink-0 text-primary">
{sortOrder === "asc" ? "↑" : "↓"}
</span>
)}
@@ -335,12 +417,12 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
/>
</div>
<div
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2"
className="flex min-w-0 items-center gap-1 cursor-pointer hover:text-foreground relative pr-2 overflow-hidden"
onClick={() => handleSort("modified")}
>
<span>{t("sftp.columns.modified")}</span>
<span className="truncate whitespace-nowrap">{t("sftp.columns.modified")}</span>
{sortField === "modified" && (
<span className="text-primary">
<span className="shrink-0 text-primary">
{sortOrder === "asc" ? "↑" : "↓"}
</span>
)}
@@ -350,30 +432,30 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
/>
</div>
<div
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2 justify-end"
className="flex min-w-0 items-center gap-1 cursor-pointer hover:text-foreground relative pr-2 justify-end overflow-hidden"
onClick={() => handleSort("size")}
>
{sortField === "size" && (
<span className="text-primary">
<span className="shrink-0 text-primary">
{sortOrder === "asc" ? "↑" : "↓"}
</span>
)}
<span>{t("sftp.columns.size")}</span>
<span className="truncate whitespace-nowrap">{t("sftp.columns.size")}</span>
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
onMouseDown={(e) => handleResizeStart("size", e)}
/>
</div>
<div
className="flex items-center gap-1 cursor-pointer hover:text-foreground justify-end"
className="flex min-w-0 items-center gap-1 cursor-pointer hover:text-foreground justify-end overflow-hidden"
onClick={() => handleSort("type")}
>
{sortField === "type" && (
<span className="text-primary">
<span className="shrink-0 text-primary">
{sortOrder === "asc" ? "↑" : "↓"}
</span>
)}
<span>{t("sftp.columns.kind")}</span>
<span className="truncate whitespace-nowrap">{t("sftp.columns.kind")}</span>
</div>
</div>
@@ -386,6 +468,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
"flex-1 min-h-0 overflow-y-auto relative",
isDragOverPane && "ring-2 ring-primary/30 ring-inset",
)}
onClick={handleBackgroundClick}
onScroll={handleFileListScroll}
>
{pane.loading && sortedDisplayFiles.length === 0 ? (
@@ -457,7 +540,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
<div className="h-9 shrink-0 px-4 flex items-center justify-between text-[11px] text-muted-foreground border-t border-border/40 bg-secondary/30">
<span>
{t("sftp.itemsCount", {
count: sortedDisplayFiles.filter((f) => f.name !== "..").length,
count: sortedDisplayFiles.length - (sortedDisplayFiles[0]?.name === ".." ? 1 : 0),
})}
{pane.selectedFiles.size > 0 &&
` - ${t("sftp.selectedCount", { count: pane.selectedFiles.size })}`}
@@ -497,4 +580,4 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
)}
</>
);
};
});

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { Bookmark, Check, Eye, EyeOff, FilePlus, Folder, FolderPlus, Home, Languages, MoreHorizontal, RefreshCw, Search, TerminalSquare, Trash2, X } from "lucide-react";
import { Bookmark, Check, Eye, EyeOff, FilePlus, Folder, FolderPlus, Globe, Home, Languages, List, ListTree, MoreHorizontal, RefreshCw, Search, TerminalSquare, Trash2, X } from "lucide-react";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from "../ui/popover";
@@ -46,11 +46,15 @@ interface SftpPaneToolbarProps {
bookmarks: SftpBookmark[];
isCurrentPathBookmarked: boolean;
onToggleBookmark: () => void;
onAddGlobalBookmark: (path: string) => void;
isCurrentPathGlobalBookmarked: boolean;
onNavigateToBookmark: (path: string) => void;
onDeleteBookmark: (id: string) => void;
showHiddenFiles: boolean;
onToggleShowHiddenFiles?: () => void;
onGoToTerminalCwd?: () => void;
viewMode: 'list' | 'tree';
onSetViewMode: (mode: 'list' | 'tree') => void;
}
// Prioritize breadcrumb path display. 6 action buttons need ~156px,
@@ -58,7 +62,7 @@ interface SftpPaneToolbarProps {
// always gets at least ~200px of space.
const COLLAPSE_WIDTH = 400;
export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
t,
pane,
onNavigateTo,
@@ -92,14 +96,29 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
bookmarks,
isCurrentPathBookmarked,
onToggleBookmark,
onAddGlobalBookmark,
isCurrentPathGlobalBookmarked,
onNavigateToBookmark,
onDeleteBookmark,
showHiddenFiles,
onToggleShowHiddenFiles,
onGoToTerminalCwd,
viewMode,
onSetViewMode,
}) => {
const outerRef = useRef<HTMLDivElement>(null);
const [collapsed, setCollapsed] = useState(false);
const [displayPath, setDisplayPath] = useState(pane.connection?.currentPath ?? "");
const prevDisplayConnectionIdRef = useRef(pane.connection?.id);
useEffect(() => {
const connectionChanged = pane.connection?.id !== prevDisplayConnectionIdRef.current;
prevDisplayConnectionIdRef.current = pane.connection?.id;
// Sync immediately on connection change; otherwise defer until loading completes
if (connectionChanged || !pane.loading) {
setDisplayPath(pane.connection?.currentPath ?? "");
}
}, [pane.connection?.currentPath, pane.connection?.id, pane.loading]);
// Observe the overall toolbar width to decide whether to collapse action buttons
useEffect(() => {
@@ -153,6 +172,36 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
<TooltipContent>{t("sftp.goToTerminalCwd")}</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn("h-6 w-6", viewMode === 'list' && "bg-secondary text-foreground")}
aria-pressed={viewMode === 'list'}
aria-label={t('sftp.viewMode.list')}
onClick={() => onSetViewMode('list')}
>
<List size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('sftp.viewMode.list')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn("h-6 w-6", viewMode === 'tree' && "bg-secondary text-foreground")}
aria-pressed={viewMode === 'tree'}
aria-label={t('sftp.viewMode.tree')}
onClick={() => onSetViewMode('tree')}
>
<ListTree size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t('sftp.viewMode.tree')}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -275,6 +324,32 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
// Overflow dropdown menu items (same collapsible actions as menu items)
const overflowMenuItems = (
<div className="flex flex-col min-w-[140px]">
<div role="radiogroup" aria-label={t('sftp.viewMode.label')}>
<button
className={cn(
"flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors w-full text-left",
viewMode === 'list' && "text-primary"
)}
role="radio"
aria-checked={viewMode === 'list'}
onClick={() => onSetViewMode('list')}
>
<List size={14} className="shrink-0" />
{t('sftp.viewMode.list')}
</button>
<button
className={cn(
"flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors w-full text-left",
viewMode === 'tree' && "text-primary"
)}
role="radio"
aria-checked={viewMode === 'tree'}
onClick={() => onSetViewMode('tree')}
>
<ListTree size={14} className="shrink-0" />
{t('sftp.viewMode.tree')}
</button>
</div>
{isRemote && (
<Popover>
<PopoverTrigger asChild>
@@ -406,7 +481,7 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
title={t("sftp.path.doubleClickToEdit")}
>
<SftpBreadcrumb
path={pane.connection.currentPath}
path={displayPath}
onNavigate={onNavigateTo}
onHome={() =>
pane.connection?.homeDir &&
@@ -440,16 +515,31 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
<TooltipContent>{isCurrentPathBookmarked ? t("sftp.bookmark.remove") : t("sftp.bookmark.add")}</TooltipContent>
</Tooltip>
<PopoverContent className="w-64 p-0" align="start">
<div className="p-2 border-b border-border/40">
<div className="p-2 border-b border-border/40 flex gap-1">
<Button
variant={isCurrentPathBookmarked ? "secondary" : "ghost"}
size="sm"
className="w-full justify-start text-xs h-7"
className="flex-1 justify-start text-xs h-7"
onClick={onToggleBookmark}
>
<Bookmark size={12} fill={isCurrentPathBookmarked ? "currentColor" : "none"} className={cn("mr-2", isCurrentPathBookmarked && "text-yellow-500")} />
{isCurrentPathBookmarked ? t("sftp.bookmark.remove") : t("sftp.bookmark.add")}
</Button>
{pane.connection?.currentPath && !isCurrentPathGlobalBookmarked && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="text-xs h-7 px-2 shrink-0"
onClick={() => pane.connection?.currentPath && onAddGlobalBookmark(pane.connection.currentPath)}
>
{t("sftp.bookmark.addGlobal")}
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.bookmark.addGlobalTooltip")}</TooltipContent>
</Tooltip>
)}
</div>
{bookmarks.length > 0 ? (
<div className="max-h-48 overflow-auto py-1">
@@ -458,6 +548,9 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
key={bm.id}
className="flex items-center gap-1 px-2 py-1 hover:bg-secondary/60 group"
>
{bm.global && (
<Globe size={10} className="shrink-0 text-primary" />
)}
<button
type="button"
className="flex-1 text-left text-xs truncate font-mono"
@@ -578,4 +671,4 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
)}
</TooltipProvider>
);
};
});

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,7 @@ import { SftpPaneDialogs } from "./SftpPaneDialogs";
import { SftpPaneEmptyState } from "./SftpPaneEmptyState";
import { SftpPaneFileList } from "./SftpPaneFileList";
import { SftpPaneToolbar } from "./SftpPaneToolbar";
import { SftpPaneTreeView } from "./SftpPaneTreeView";
import {
useActiveTabId,
useSftpDrag,
@@ -15,6 +16,7 @@ import {
useSftpUpdateHosts,
} from "./index";
import type { SftpPane } from "../../application/state/sftp/types";
import { joinPath } from "../../application/state/sftp/utils";
import type { Host } from "../../domain/models";
import { useSftpPaneDialogs } from "./hooks/useSftpPaneDialogs";
import { useSftpPaneDragAndSelect } from "./hooks/useSftpPaneDragAndSelect";
@@ -25,6 +27,16 @@ import { useSftpPaneVirtualList } from "./hooks/useSftpPaneVirtualList";
import { useSftpDialogActionHandler } from "./hooks/useSftpDialogAction";
import { useSftpBookmarks } from "./hooks/useSftpBookmarks";
import { useLocalSftpBookmarks } from "./hooks/useLocalSftpBookmarks";
import { useGlobalSftpBookmarks } from "./hooks/useGlobalSftpBookmarks";
import { useSftpHostViewMode } from "./hooks/useSftpHostViewMode";
import { sftpListOrderStore } from "./hooks/useSftpListOrderStore";
import { sftpTreeSelectionStore } from "./hooks/useSftpTreeSelectionStore";
interface TreeReloadRequest {
token: number;
paths?: string[];
full?: boolean;
}
interface SftpPaneWrapperProps {
side: "left" | "right";
@@ -55,31 +67,66 @@ 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();
const hosts = useSftpHosts();
const { t } = useI18n();
const hostId = pane.connection?.hostId;
const { hostViewMode, setHostViewMode: saveHostViewMode } = useSftpHostViewMode(hostId);
const [, startTransition] = useTransition();
const [showFilterBar, setShowFilterBar] = useState(false);
const initialViewMode = hostViewMode ?? sftpDefaultViewMode ?? 'list';
const [viewMode, setViewMode] = useState<'list' | 'tree'>(initialViewMode);
const [treeReloadRequest, setTreeReloadRequest] = useState<TreeReloadRequest>({ token: 0, full: true });
// Lazy-mount: only render the tree component once tree mode has been activated
const [treeEverMounted, setTreeEverMounted] = useState(initialViewMode === 'tree');
useEffect(() => {
if (viewMode === 'tree' && !treeEverMounted) setTreeEverMounted(true);
}, [viewMode, treeEverMounted]);
const filterInputRef = useRef<HTMLInputElement>(null);
const requestTreeReload = useCallback((paths?: string[], full = false) => {
setTreeReloadRequest((prev) => ({
token: prev.token + 1,
paths,
full,
}));
}, []);
const requestNestedTreeReload = useCallback((paths?: string[]) => {
const targets = Array.from(new Set((paths ?? []).filter(Boolean)));
if (targets.length > 0) {
requestTreeReload(targets);
}
}, [requestTreeReload]);
useRenderTracker(`SftpPaneView[${side}]`, {
side,
paneId: pane.id,
@@ -109,18 +156,43 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
const localBookmarks = useLocalSftpBookmarks({
currentPath: pane.connection?.currentPath,
});
const {
bookmarks,
isCurrentPathBookmarked,
toggleBookmark,
deleteBookmark,
} = pane.connection?.isLocal ? localBookmarks : remoteBookmarks;
const globalBookmarks = useGlobalSftpBookmarks({
currentPath: pane.connection?.currentPath,
});
const hostBookmarks = pane.connection?.isLocal ? localBookmarks : remoteBookmarks;
const mergedBookmarks = useMemo(
() => [...globalBookmarks.bookmarks.map((b) => ({ ...b, global: true as const })), ...hostBookmarks.bookmarks],
[hostBookmarks.bookmarks, globalBookmarks.bookmarks],
);
const isCurrentPathBookmarked = hostBookmarks.isCurrentPathBookmarked || globalBookmarks.isCurrentPathBookmarked;
const toggleBookmark = useCallback(() => {
if (globalBookmarks.isCurrentPathBookmarked && !hostBookmarks.isCurrentPathBookmarked) {
const currentPath = pane.connection?.currentPath;
if (currentPath) {
const bm = globalBookmarks.bookmarks.find((b) => b.path === currentPath);
if (bm) globalBookmarks.deleteBookmark(bm.id);
}
} else {
hostBookmarks.toggleBookmark();
}
}, [hostBookmarks, globalBookmarks, pane.connection?.currentPath]);
const deleteBookmark = useCallback(
(id: string) => {
if (id.startsWith("gbm-")) {
globalBookmarks.deleteBookmark(id);
} else {
hostBookmarks.deleteBookmark(id);
}
},
[hostBookmarks, globalBookmarks],
);
const { filteredFiles, sortedDisplayFiles } = useSftpPaneFiles({
const { sortedDisplayFiles } = useSftpPaneFiles({
files: pane.files,
filter: pane.filter,
connection: pane.connection,
showHiddenFiles: pane.showHiddenFiles,
enableListView: viewMode === 'list',
sortField,
sortOrder,
});
@@ -141,7 +213,8 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
handlePathSubmit,
} = useSftpPanePath({
connection: pane.connection,
filteredFiles,
files: pane.files,
showHiddenFiles: pane.showHiddenFiles,
onNavigateTo: callbacks.onNavigateTo,
});
const {
@@ -179,6 +252,8 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
handleConfirmOverwrite,
handleRename,
handleDelete,
openNewFolderDialogAtPath,
openNewFileDialogAtPath,
openRenameDialog,
openDeleteConfirm,
getNextUntitledName,
@@ -186,11 +261,25 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
t,
pane,
onCreateDirectory: callbacks.onCreateDirectory,
onCreateDirectoryAtPath: callbacks.onCreateDirectoryAtPath,
onCreateFile: callbacks.onCreateFile,
onRenameFile: callbacks.onRenameFile,
onDeleteFiles: callbacks.onDeleteFiles,
onCreateFileAtPath: callbacks.onCreateFileAtPath,
onRenameFileAtPath: callbacks.onRenameFileAtPath,
onDeleteFilesAtPath: callbacks.onDeleteFilesAtPath,
onClearSelection: callbacks.onClearSelection,
onMutateSuccess: (paths?: string[]) => requestNestedTreeReload(paths),
});
const handleUploadExternalFiles = useCallback(async (dataTransfer: DataTransfer, targetPath?: string) => {
await callbacks.onUploadExternalFiles?.(dataTransfer, targetPath);
const affectedPath = targetPath ?? pane.connection?.currentPath;
if (affectedPath && affectedPath !== pane.connection?.currentPath) {
requestTreeReload([affectedPath]);
}
}, [callbacks, pane.connection?.currentPath, requestTreeReload]);
const handleMoveEntriesToPath = useCallback(async (sourcePaths: string[], targetPath: string) => {
await callbacks.onMoveEntriesToPath(sourcePaths, targetPath);
}, [callbacks]);
const {
dragOverEntry,
isDragOverPane,
@@ -211,7 +300,8 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
draggedFiles,
onDragStart,
onReceiveFromOtherPane: callbacks.onReceiveFromOtherPane,
onUploadExternalFiles: callbacks.onUploadExternalFiles,
onMoveEntriesToPath: callbacks.onMoveEntriesToPath,
onUploadExternalFiles: handleUploadExternalFiles,
onOpenEntry: callbacks.onOpenEntry,
onRangeSelect: callbacks.onRangeSelect,
onToggleSelection: callbacks.onToggleSelection,
@@ -225,14 +315,26 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
visibleRows,
} = useSftpPaneVirtualList({
isActive,
enabled: viewMode === 'list',
sortedDisplayFiles,
});
const toFullPath = useCallback(
(target: string) => {
const currentPath = pane.connection?.currentPath;
if (!currentPath || target.includes("/") || target.includes("\\")) {
return target;
}
return joinPath(currentPath, target);
},
[pane.connection?.currentPath],
);
// Handle keyboard shortcut dialog actions
const dialogActionHandlers = useMemo(
() => ({
onRename: (fileName: string) => openRenameDialog(fileName),
onDelete: (fileNames: string[]) => openDeleteConfirm(fileNames),
onRename: (fileName: string) => openRenameDialog(toFullPath(fileName)),
onDelete: (fileNames: string[]) => openDeleteConfirm(fileNames.map(toFullPath)),
onNewFolder: () => {
setNewFolderName("");
setShowNewFolderDialog(true);
@@ -249,6 +351,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
openDeleteConfirm,
openRenameDialog,
pane.files,
toFullPath,
setFileNameError,
setNewFileName,
setNewFolderName,
@@ -257,12 +360,51 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
],
);
useSftpDialogActionHandler(side, dialogActionHandlers);
useSftpDialogActionHandler(side, dialogActionScopeId, dialogActionHandlers, isActive);
const handleSortWithTransition = (field: typeof sortField) => {
startTransition(() => handleSort(field));
};
const handleRefresh = useCallback(() => {
callbacks.onRefresh();
if (viewMode === 'tree') {
requestTreeReload(undefined, true);
}
}, [callbacks, requestTreeReload, viewMode]);
const onSetFilterRef = useRef(callbacks.onSetFilter);
onSetFilterRef.current = callbacks.onSetFilter;
const onClearSelectionRef = useRef(callbacks.onClearSelection);
onClearSelectionRef.current = callbacks.onClearSelection;
const handleSetViewMode = useCallback((mode: 'list' | 'tree') => {
setViewMode(mode);
saveHostViewMode(mode);
if (mode === 'tree') {
setShowFilterBar(false);
onSetFilterRef.current('');
onClearSelectionRef.current();
}
}, [saveHostViewMode]);
useEffect(() => {
if (viewMode === 'list') {
sftpTreeSelectionStore.clearPane(pane.id);
return;
}
sftpListOrderStore.clearPane(pane.id);
}, [pane.id, viewMode]);
// When connecting to a host, restore its saved view mode preference
const prevHostIdRef = useRef<string | undefined>(undefined);
useEffect(() => {
if (hostId && hostId !== prevHostIdRef.current) {
setViewMode(hostViewMode ?? sftpDefaultViewMode);
}
prevHostIdRef.current = hostId;
}, [hostId, hostViewMode, sftpDefaultViewMode]);
useEffect(() => {
logger.debug("SftpPaneView active state", {
side,
@@ -271,6 +413,17 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
});
}, [isActive, pane.id, side]);
const lastHandledTransferMutationTokenRef = useRef(0);
useEffect(() => {
if (!pane.connection || pane.transferMutationToken === 0) return;
if (pane.transferMutationToken === lastHandledTransferMutationTokenRef.current) return;
lastHandledTransferMutationTokenRef.current = pane.transferMutationToken;
callbacks.onRefreshTab(pane.id);
if (viewMode === 'tree') {
requestTreeReload(undefined, true);
}
}, [callbacks, pane.connection, pane.id, pane.transferMutationToken, requestTreeReload, viewMode]);
if (!pane.connection) {
return (
<SftpPaneEmptyState
@@ -304,7 +457,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
onNavigateTo={callbacks.onNavigateTo}
onSetFilter={callbacks.onSetFilter}
onSetFilenameEncoding={callbacks.onSetFilenameEncoding}
onRefresh={callbacks.onRefresh}
onRefresh={handleRefresh}
showFilterBar={showFilterBar}
setShowFilterBar={setShowFilterBar}
filterInputRef={filterInputRef}
@@ -329,20 +482,61 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
setShowNewFileDialog={setShowNewFileDialog}
setShowNewFolderDialog={setShowNewFolderDialog}
setNewFolderName={setNewFolderName}
bookmarks={bookmarks}
bookmarks={mergedBookmarks}
isCurrentPathBookmarked={isCurrentPathBookmarked}
onToggleBookmark={toggleBookmark}
onAddGlobalBookmark={globalBookmarks.addBookmark}
isCurrentPathGlobalBookmarked={globalBookmarks.isCurrentPathBookmarked}
onNavigateToBookmark={callbacks.onNavigateTo}
onDeleteBookmark={deleteBookmark}
showHiddenFiles={pane.showHiddenFiles}
onToggleShowHiddenFiles={onToggleShowHiddenFiles}
onGoToTerminalCwd={onGoToTerminalCwd}
viewMode={viewMode}
onSetViewMode={handleSetViewMode}
/>
{treeEverMounted && (
<div className={viewMode === 'tree' ? 'flex-1 min-h-0 flex flex-col' : 'hidden'}>
<SftpPaneTreeView
pane={pane}
side={side}
onPrepareSelection={callbacks.onPrepareSelection}
onLoadChildren={callbacks.onListDirectory}
onMoveEntriesToPath={handleMoveEntriesToPath}
onNavigateUp={callbacks.onNavigateUp}
onNavigateTo={callbacks.onNavigateTo}
onRefresh={handleRefresh}
onOpenEntry={callbacks.onOpenEntry}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
openRenameDialog={openRenameDialog}
openDeleteConfirm={openDeleteConfirm}
onCopyToOtherPane={callbacks.onCopyToOtherPane}
onReceiveFromOtherPane={callbacks.onReceiveFromOtherPane}
onOpenFileWith={callbacks.onOpenFileWith}
onEditFile={callbacks.onEditFile}
onDownloadFile={callbacks.onDownloadFile}
onEditPermissions={callbacks.onEditPermissions}
draggedFiles={draggedFiles}
openNewFolderDialog={openNewFolderDialogAtPath}
openNewFileDialog={openNewFileDialogAtPath}
onUploadExternalFiles={handleUploadExternalFiles}
columnWidths={columnWidths}
handleSort={handleSortWithTransition}
handleResizeStart={handleResizeStart}
sortField={sortField}
sortOrder={sortOrder}
reloadRequest={treeReloadRequest}
/>
</div>
)}
<div className={viewMode === 'list' ? 'flex-1 min-h-0 flex flex-col' : 'hidden'}>
<SftpPaneFileList
t={t}
pane={pane}
side={side}
isPaneFocused={isPaneFocused}
columnWidths={columnWidths}
sortField={sortField}
sortOrder={sortOrder}
@@ -355,7 +549,9 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
sortedDisplayFiles={sortedDisplayFiles}
isDragOverPane={isDragOverPane}
draggedFiles={draggedFiles}
onRefresh={callbacks.onRefresh}
onRefresh={handleRefresh}
onNavigateTo={callbacks.onNavigateTo}
onClearSelection={callbacks.onClearSelection}
setShowNewFolderDialog={setShowNewFolderDialog}
setShowNewFileDialog={setShowNewFileDialog}
getNextUntitledName={getNextUntitledName}
@@ -370,6 +566,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
handleRowDragLeave={handleRowDragLeave}
handleEntryDrop={handleEntryDrop}
onCopyToOtherPane={callbacks.onCopyToOtherPane}
onMoveEntriesToPath={handleMoveEntriesToPath}
onOpenFileWith={callbacks.onOpenFileWith}
onEditFile={callbacks.onEditFile}
onDownloadFile={callbacks.onDownloadFile}
@@ -379,9 +576,12 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
rowHeight={rowHeight}
visibleRows={visibleRows}
/>
</div>
<SftpPaneDialogs
t={t}
hostLabel={pane.connection?.hostLabel}
currentPath={pane.connection?.currentPath}
showNewFolderDialog={showNewFolderDialog}
setShowNewFolderDialog={setShowNewFolderDialog}
newFolderName={newFolderName}
@@ -430,8 +630,11 @@ 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;
if (prev.sftpDefaultViewMode !== next.sftpDefaultViewMode) return false;
return true;
};

View File

@@ -147,7 +147,8 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
container.scrollLeft += tabRect.right - containerRect.right + 8;
}
}
setTimeout(updateScrollState, 100);
const timer = setTimeout(updateScrollState, 100);
return () => clearTimeout(timer);
}, [activeTabId, updateScrollState]);
// Drag handlers
@@ -213,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) => {
@@ -301,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}
@@ -378,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} />
@@ -417,4 +434,3 @@ const sftpTabBarAreEqual = (
export const SftpTabBar = memo(SftpTabBarInner, sftpTabBarAreEqual);
SftpTabBar.displayName = "SftpTabBar";

View File

@@ -4,237 +4,375 @@
import {
ArrowDown,
ArrowRight,
CheckCircle2,
ChevronDown,
ChevronUp,
File,
FolderUp,
GripVertical,
Loader2,
RefreshCw,
X,
XCircle,
} from 'lucide-react';
import React, { memo } from 'react';
import { getParentPath } from '../../application/state/sftp/utils';
import { useI18n } from '../../application/i18n/I18nProvider';
import { getParentPath } from '../../application/state/sftp/utils';
import { cn } from '../../lib/utils';
import { TransferTask } from '../../types';
import { Button } from '../ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
import { formatSpeed, formatTransferBytes } from './utils';
interface SftpTransferItemProps {
task: TransferTask;
isChild?: boolean;
childNameColumnWidth?: number;
onResizeNameColumn?: (event: React.MouseEvent<HTMLDivElement>) => void;
onCancel: () => void;
onRetry: () => void;
onDismiss: () => void;
canRevealTarget?: boolean;
onRevealTarget?: () => void;
canToggleChildren?: boolean;
isExpanded?: boolean;
visibleChildCount?: number;
onToggleChildren?: () => void;
}
const TruncatedTextWithTooltip: React.FC<{
text: string;
className?: string;
}> = ({ text, className }) => (
<TooltipProvider delayDuration={300} skipDelayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<span className={cn("truncate", className)}>
{text}
</span>
</TooltipTrigger>
<TooltipContent side="top" align="start" className="max-w-md break-all">
{text}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
task,
isChild = false,
childNameColumnWidth = 260,
onResizeNameColumn,
onCancel,
onRetry,
onDismiss,
canRevealTarget = false,
onRevealTarget,
canToggleChildren = false,
isExpanded = false,
visibleChildCount: _visibleChildCount = 0,
onToggleChildren,
}) => {
const { t } = useI18n();
const hasKnownTotal = task.totalBytes > 0;
const progress = task.totalBytes > 0 ? Math.min((task.transferredBytes / task.totalBytes) * 100, 100) : 0;
// Show indeterminate state when transferring but no real progress received yet
const isIndeterminate = task.status === 'transferring' && hasKnownTotal && task.transferredBytes === 0;
// Calculate remaining time from backend-reported sliding-window speed
const remainingBytes = task.totalBytes - task.transferredBytes;
const progressMode = task.progressMode ?? 'bytes';
const isDirParent = task.isDirectory && !task.parentTaskId && progressMode === 'files';
const hasKnownTotal = task.totalBytes > 0 || (!isDirParent && !!task.sourceLastModified);
const progress = hasKnownTotal
? Math.min((task.transferredBytes / task.totalBytes) * 100, 100)
: 0;
const isIndeterminate = task.status === 'transferring' && !hasKnownTotal;
const effectiveSpeed = task.status === 'transferring'
? (Number.isFinite(task.speed) && task.speed > 0 ? task.speed : 0)
: 0;
const remainingTime = hasKnownTotal && effectiveSpeed > 0
? Math.ceil(remainingBytes / effectiveSpeed)
: 0;
const remainingFormatted = remainingTime > 60
? `~${Math.ceil(remainingTime / 60)}m left`
: remainingTime > 0
? `~${remainingTime}s left`
: '';
// Format bytes transferred / total
const bytesDisplay = task.status === 'transferring' && task.totalBytes > 0
? `${formatTransferBytes(task.transferredBytes)} / ${formatTransferBytes(task.totalBytes)}`
: task.status === 'transferring'
? formatTransferBytes(task.transferredBytes)
: task.status === 'completed' && task.totalBytes > 0
? formatTransferBytes(task.totalBytes)
const bytesDisplay = isDirParent
? ''
: task.status === 'transferring' && hasKnownTotal
? `${formatTransferBytes(task.transferredBytes)} / ${formatTransferBytes(task.totalBytes)}`
: task.status === 'transferring'
? formatTransferBytes(task.transferredBytes)
: task.status === 'completed' && hasKnownTotal
? formatTransferBytes(task.totalBytes)
: '';
const fileCountDisplay = isDirParent && task.status === 'transferring'
? (task.totalBytes > 0
? t('sftp.transfers.filesProgress', { current: task.transferredBytes, total: task.totalBytes })
: t('sftp.transfers.filesCount', { count: task.transferredBytes }))
: isDirParent && task.status === 'completed' && task.totalBytes > 0
? t('sftp.transfers.filesCount', { count: task.totalBytes })
: '';
const speedFormatted = effectiveSpeed > 0 ? formatSpeed(effectiveSpeed) : '';
const targetDirectoryPath = task.isDirectory ? task.targetPath : getParentPath(task.targetPath);
const details = (
<>
<div className="h-5 w-5 rounded flex items-center justify-center shrink-0">
{task.status === 'transferring' && <Loader2 size={12} className="animate-spin text-primary" />}
{task.status === 'pending' && (task.isDirectory
? <FolderUp size={12} className="text-muted-foreground animate-pulse" />
: <ArrowDown size={12} className="text-muted-foreground animate-bounce" />
)}
{task.status === 'completed' && <CheckCircle2 size={12} className="text-green-500" />}
{task.status === 'failed' && <XCircle size={12} className="text-destructive" />}
{task.status === 'cancelled' && <XCircle size={12} className="text-muted-foreground" />}
</div>
const progressOverlayText = task.status === 'pending'
? t('sftp.task.waiting')
: isIndeterminate
? t('sftp.transfer.preparing')
: isDirParent
? (fileCountDisplay
? `${fileCountDisplay}${hasKnownTotal ? `${Math.round(progress)}%` : ''}`
: hasKnownTotal
? `${Math.round(progress)}%`
: '...')
: bytesDisplay
? `${bytesDisplay}${hasKnownTotal ? `${Math.round(progress)}%` : ''}`
: hasKnownTotal
? `${Math.round(progress)}%`
: '...';
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-[13px] leading-5 truncate font-medium">{task.fileName}</span>
{task.status === 'transferring' && !isIndeterminate && speedFormatted && (
<span className="text-[10px] text-primary/80 font-mono transition-opacity duration-300">{speedFormatted}</span>
)}
{task.status === 'transferring' && !isIndeterminate && remainingFormatted && (
<span className="text-[10px] text-muted-foreground transition-opacity duration-300">{remainingFormatted}</span>
)}
const progressBarWidth = task.status === 'pending' || (task.status === 'transferring' && !hasKnownTotal) || isIndeterminate
? (task.status === 'pending' || !hasKnownTotal ? '100%' : `${progress}%`)
: `${progress}%`;
const statusIcon = task.status === 'transferring'
? <Loader2 size={12} className="animate-spin text-primary" />
: task.status === 'pending'
? (task.isDirectory
? <FolderUp size={12} className="text-muted-foreground animate-pulse" />
: <ArrowDown size={12} className="text-muted-foreground animate-bounce" />)
: task.status === 'completed'
? <CheckCircle2 size={12} className="text-green-500" />
: <XCircle size={12} className={task.status === 'failed' ? "text-destructive" : "text-muted-foreground"} />;
const childProgressBar = (
<div className="relative h-full overflow-hidden border border-border/60 bg-secondary/70">
<div
className={cn(
"h-full relative overflow-hidden",
task.status === 'pending' || (task.status === 'transferring' && !hasKnownTotal)
? "bg-muted-foreground/35 animate-pulse"
: isIndeterminate
? "bg-primary/60 animate-pulse"
: task.status === 'completed'
? "bg-emerald-500/80"
: task.status === 'failed'
? "bg-destructive/70"
: task.status === 'cancelled'
? "bg-muted-foreground/45"
: "bg-gradient-to-r from-primary via-primary/90 to-primary"
)}
style={{
width: progressBarWidth,
transition: 'width 150ms ease-out',
}}
>
{task.status === 'transferring' && (
<div
className="absolute inset-0 w-1/2 h-full"
style={{
background: 'linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.32) 50%, transparent 100%)',
animation: 'progress-shimmer 1.5s ease-in-out infinite',
}}
/>
)}
</div>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center px-2">
<span className="truncate whitespace-nowrap text-[10px] font-medium text-foreground">
{progressOverlayText}
</span>
</div>
</div>
);
const progressSummaryText = task.status === 'transferring' || task.status === 'pending'
? [speedFormatted, progressOverlayText].filter(Boolean).join(' • ')
: '';
const showTransferSizeCalculation = task.status === 'transferring' && !hasKnownTotal && !isDirParent;
const showFailedError = task.status === 'failed' && !!task.error;
const hasFooterContent = showTransferSizeCalculation || showFailedError;
const actionButtons = (
<div className="flex items-center gap-1 shrink-0">
{task.status === 'failed' && task.retryable !== false && (
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={onRetry} title="Retry">
<RefreshCw size={12} />
</Button>
)}
{(task.status === 'pending' || task.status === 'transferring') && (
<Button variant="ghost" size="icon" className="h-6 w-6 text-destructive hover:text-destructive" onClick={onCancel} title="Cancel">
<X size={12} />
</Button>
)}
{(task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') && (
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={onDismiss} title="Dismiss">
<X size={12} />
</Button>
)}
</div>
);
if (isChild) {
return (
<div
className="grid h-7 items-stretch border-t border-border/20 bg-background/20 px-3"
style={{
gridTemplateColumns: `24px ${childNameColumnWidth}px 10px minmax(0, 1fr) 24px`,
}}
>
<div className="flex h-full items-center justify-center text-muted-foreground">
{task.isDirectory ? <FolderUp size={12} /> : <File size={12} />}
</div>
<div className="flex min-w-0 items-center pr-2">
<TruncatedTextWithTooltip
text={task.fileName}
className="min-w-0 text-[11px] font-medium text-foreground/90"
/>
</div>
<div
className={cn(
"text-[9px] mt-0.5 truncate",
canRevealTarget ? "text-primary/80" : "text-muted-foreground",
)}
title={targetDirectoryPath}
className="flex h-full cursor-col-resize items-center justify-center text-muted-foreground/35 hover:text-foreground/70"
onMouseDown={onResizeNameColumn}
title="Resize file name column"
>
{targetDirectoryPath}
<GripVertical size={10} />
</div>
<div className="min-w-0">
{childProgressBar}
</div>
<div className="flex h-full items-center justify-center">
{actionButtons}
</div>
{(task.status === 'transferring' || task.status === 'pending') && (
<div className="flex items-center gap-2 mt-1">
<div className="flex-1 h-1.5 bg-secondary/80 rounded-full overflow-hidden">
<div
className={cn(
"h-full rounded-full relative overflow-hidden",
task.status === 'pending' || (task.status === 'transferring' && !hasKnownTotal)
? "bg-muted-foreground/50 animate-pulse"
: isIndeterminate
? "bg-primary/60 animate-pulse"
: "bg-gradient-to-r from-primary via-primary/90 to-primary"
)}
style={{
width: task.status === 'pending' || (task.status === 'transferring' && !hasKnownTotal) || isIndeterminate
? '100%'
: `${progress}%`,
transition: 'width 150ms ease-out'
}}
>
{/* Animated shine effect */}
{task.status === 'transferring' && (
<div
className="absolute inset-0 w-1/2 h-full"
style={{
background: 'linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.4) 50%, transparent 100%)',
animation: 'progress-shimmer 1.5s ease-in-out infinite',
}}
/>
)}
</div>
</div>
<span className="text-[10px] text-muted-foreground shrink-0 min-w-[34px] text-right font-mono">
{task.status === 'pending'
? 'waiting...'
: isIndeterminate
? t('sftp.transfer.preparing')
: hasKnownTotal
? `${Math.round(progress)}%`
: '...'}
</span>
</div>
)}
{task.status === 'transferring' && bytesDisplay && (
<div className="text-[9px] text-muted-foreground mt-0.5 font-mono">
{bytesDisplay}
</div>
)}
{task.status === 'transferring' && !hasKnownTotal && (
<div className="text-[9px] text-muted-foreground mt-0.5">
{t('sftp.transfers.calculatingTotal')}
</div>
)}
{task.status === 'completed' && bytesDisplay && (
<div className="text-[9px] text-green-600 mt-0.5">
Completed - {bytesDisplay}
</div>
)}
{task.status === 'failed' && task.error && (
<span className="text-[10px] text-destructive">{task.error}</span>
)}
</div>
</>
);
}
const showBelowParentProgress = task.status === 'transferring' || task.status === 'pending';
const titleBlock = (
<div className="flex min-w-0 flex-1 items-center gap-1.5">
<TruncatedTextWithTooltip
text={task.fileName}
className="text-[12px] font-medium leading-5"
/>
<ArrowRight size={11} className="shrink-0 text-muted-foreground/70" />
<TruncatedTextWithTooltip
text={targetDirectoryPath}
className={cn(
"min-w-0 text-[11px]",
canRevealTarget ? "text-primary/80" : "text-muted-foreground",
)}
/>
{canToggleChildren && (
<button
type="button"
className="inline-flex shrink-0 items-center gap-1 rounded border border-border/60 bg-secondary/60 px-1.5 py-0.5 text-[10px] text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
onClick={onToggleChildren}
title={isExpanded ? t('sftp.transfers.collapseChildList') : t('sftp.transfers.expandChildList')}
>
{isExpanded ? t('sftp.transfers.collapseChildList') : t('sftp.transfers.expandChildList')}
{isExpanded ? <ChevronUp size={10} /> : <ChevronDown size={10} />}
</button>
)}
</div>
);
return (
<div className="flex items-center gap-2.5 px-3 py-2 bg-background/60 border-t border-border/40 backdrop-blur-sm">
{canRevealTarget && onRevealTarget ? (
<button
type="button"
className="flex flex-1 min-w-0 items-center gap-2.5 rounded-sm text-left transition-colors hover:bg-primary/5 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50"
onClick={onRevealTarget}
title="Open transfer destination"
>
{details}
</button>
) : (
details
<div className="border-t border-border/40 bg-background/60 px-3 py-2.5 backdrop-blur-sm">
<div className="flex items-center gap-1">
<div className="flex h-5 w-5 items-center justify-center shrink-0 -translate-y-px">
{statusIcon}
</div>
{canRevealTarget && onRevealTarget ? (
<button
type="button"
className="flex min-w-0 flex-1 rounded-sm text-left transition-colors hover:bg-primary/5 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50"
onClick={onRevealTarget}
>
{titleBlock}
</button>
) : (
<div className="min-w-0 flex-1">
{titleBlock}
</div>
)}
{progressSummaryText && (
<span className="ml-auto shrink-0 whitespace-nowrap text-[10px] text-muted-foreground font-mono">
{progressSummaryText}
</span>
)}
{actionButtons}
</div>
{showBelowParentProgress && (
<div className="mt-2 ml-7">
<div className="h-1.5 overflow-hidden bg-secondary/80">
<div
className={cn(
"h-full relative overflow-hidden",
task.status === 'pending' || (task.status === 'transferring' && !hasKnownTotal)
? "bg-muted-foreground/50 animate-pulse"
: isIndeterminate
? "bg-primary/60 animate-pulse"
: "bg-gradient-to-r from-primary via-primary/90 to-primary",
)}
style={{
width: progressBarWidth,
transition: 'width 150ms ease-out',
}}
>
{task.status === 'transferring' && (
<div
className="absolute inset-0 w-1/2 h-full"
style={{
background: 'linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.32) 50%, transparent 100%)',
animation: 'progress-shimmer 1.5s ease-in-out infinite',
}}
/>
)}
</div>
</div>
</div>
)}
<div className="flex items-center gap-1 shrink-0">
{task.status === 'failed' && task.retryable !== false && (
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={onRetry} title="Retry">
<RefreshCw size={12} />
</Button>
{hasFooterContent && (
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-[10px]">
{showTransferSizeCalculation && (
<span className="text-muted-foreground">{t('sftp.transfers.calculatingTotal')}</span>
)}
{(task.status === 'pending' || task.status === 'transferring') && (
<Button variant="ghost" size="icon" className="h-6 w-6 text-destructive hover:text-destructive" onClick={onCancel} title="Cancel">
<X size={12} />
</Button>
{showFailedError && (
<span className="text-destructive">{task.error}</span>
)}
{(task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') && (
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={onDismiss} title="Dismiss">
<X size={12} />
</Button>
)}
</div>
</div>
)}
</div>
);
};
// Custom comparison function to reduce unnecessary re-renders
// Only re-render if meaningful values change
const arePropsEqual = (
prevProps: SftpTransferItemProps,
nextProps: SftpTransferItemProps
nextProps: SftpTransferItemProps,
): boolean => {
const prev = prevProps.task;
const next = nextProps.task;
// Always re-render on status change
if (prev.status !== next.status) return false;
// Always re-render on error change
if (prev.error !== next.error) return false;
// Always re-render on fileName change
if (prev.fileName !== next.fileName) return false;
if (prev.targetPath !== next.targetPath) return false;
if (prev.totalBytes !== next.totalBytes) return false;
if ((prevProps.canRevealTarget ?? false) !== (nextProps.canRevealTarget ?? false)) return false;
if ((prevProps.isChild ?? false) !== (nextProps.isChild ?? false)) return false;
if ((prevProps.childNameColumnWidth ?? 260) !== (nextProps.childNameColumnWidth ?? 260)) return false;
if ((prevProps.canToggleChildren ?? false) !== (nextProps.canToggleChildren ?? false)) return false;
if ((prevProps.isExpanded ?? false) !== (nextProps.isExpanded ?? false)) return false;
if ((prevProps.visibleChildCount ?? 0) !== (nextProps.visibleChildCount ?? 0)) return false;
// For transferring status, allow frequent re-renders for smooth progress bar
if (next.status === 'transferring') {
if (next.totalBytes <= 0 && prev.transferredBytes !== next.transferredBytes) return false;
// Re-render on any meaningful progress change (0.1% for smooth bar animation)
const prevProgress = prev.totalBytes > 0 ? (prev.transferredBytes / prev.totalBytes) * 100 : 0;
const nextProgress = next.totalBytes > 0 ? (next.transferredBytes / next.totalBytes) * 100 : 0;
if (Math.abs(nextProgress - prevProgress) >= 0.1) return false;
// Re-render on any speed change (backend already smooths via sliding window)
if (next.speed !== prev.speed) return false;
}
// For pending status, don't re-render unless status changes
if (next.status === 'pending') {
return true;
}

View File

@@ -1,8 +1,14 @@
import React from "react";
import { Button } from "../ui/button";
import { GripHorizontal } from "lucide-react";
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useI18n } from "../../application/i18n/I18nProvider";
import { useStoredNumber } from "../../application/state/useStoredNumber";
import type { useSftpState } from "../../application/state/useSftpState";
import {
STORAGE_KEY_SFTP_TRANSFER_CHILD_NAME_WIDTH,
STORAGE_KEY_SFTP_TRANSFER_PANEL_HEIGHT,
} from "../../infrastructure/config/storageKeys";
import type { TransferTask } from "../../types";
import { Button } from "../ui/button";
import { SftpTransferItem } from "./SftpTransferItem";
type SftpState = ReturnType<typeof useSftpState>;
@@ -10,25 +16,327 @@ type SftpState = ReturnType<typeof useSftpState>;
interface SftpTransferQueueProps {
sftp: SftpState;
visibleTransfers: SftpState["transfers"];
allTransfers: SftpState["transfers"];
canRevealTransferTarget?: (task: TransferTask) => boolean;
onRevealTransferTarget?: (task: TransferTask) => void | Promise<void>;
}
const MIN_PANEL_HEIGHT = 112;
const MAX_PANEL_HEIGHT = 480;
const HEADER_HEIGHT = 42;
const MIN_CHILD_NAME_WIDTH = 160;
const MAX_CHILD_NAME_WIDTH = 480;
const CHILD_ROW_HEIGHT = 28;
const CHILD_VIRTUALIZE_THRESHOLD = 80;
const CHILD_OVERSCAN = 8;
interface TransferChildListProps {
childTasks: TransferTask[];
childNameWidth: number;
onResizeNameColumn: (event: React.MouseEvent<HTMLDivElement>) => void;
scrollContainerRef: React.RefObject<HTMLDivElement>;
scrollTop: number;
viewportHeight: number;
onCancel: (taskId: string) => void;
onRetry: (taskId: string) => Promise<void>;
onDismiss: (taskId: string) => void;
}
const TransferChildList: React.FC<TransferChildListProps> = ({
childTasks,
childNameWidth,
onResizeNameColumn,
scrollContainerRef,
scrollTop,
viewportHeight,
onCancel,
onRetry,
onDismiss,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [contentTop, setContentTop] = useState(0);
useLayoutEffect(() => {
const container = containerRef.current;
const scrollContainer = scrollContainerRef.current;
if (!container || !scrollContainer) return;
const nextTop =
container.getBoundingClientRect().top -
scrollContainer.getBoundingClientRect().top +
scrollTop;
if (Math.abs(nextTop - contentTop) > 1) {
setContentTop(nextTop);
}
}, [childTasks.length, contentTop, scrollContainerRef, scrollTop, viewportHeight]);
const needsVirtualization = childTasks.length > CHILD_VIRTUALIZE_THRESHOLD;
// Use a fallback viewport height when not yet measured to avoid rendering
// all children on the first frame. This caps the initial render to ~15 rows
// instead of potentially thousands.
const effectiveViewportHeight = viewportHeight > 0 ? viewportHeight : MAX_PANEL_HEIGHT;
const shouldVirtualize = needsVirtualization;
const { startIndex, visibleTasks } = useMemo(() => {
if (!shouldVirtualize) {
return {
startIndex: 0,
visibleTasks: childTasks,
};
}
const relativeTop = Math.max(0, scrollTop - contentTop);
const relativeBottom = Math.max(0, scrollTop + effectiveViewportHeight - contentTop);
const start = Math.max(0, Math.floor(relativeTop / CHILD_ROW_HEIGHT) - CHILD_OVERSCAN);
const end = Math.min(
childTasks.length - 1,
Math.ceil(relativeBottom / CHILD_ROW_HEIGHT) + CHILD_OVERSCAN,
);
return {
startIndex: start,
visibleTasks: childTasks.slice(start, end + 1),
};
}, [childTasks, contentTop, effectiveViewportHeight, scrollTop, shouldVirtualize]);
return (
<div
ref={containerRef}
className="border-t border-border/30 bg-background/30"
>
<div
className={shouldVirtualize ? "relative" : undefined}
style={shouldVirtualize ? { height: childTasks.length * CHILD_ROW_HEIGHT } : undefined}
>
{visibleTasks.map((child, visibleIndex) => {
const index = shouldVirtualize ? startIndex + visibleIndex : visibleIndex;
return (
<div
key={child.id}
className={shouldVirtualize ? "absolute left-0 right-0" : undefined}
style={shouldVirtualize ? { top: index * CHILD_ROW_HEIGHT } : undefined}
>
<SftpTransferItem
task={child}
isChild
childNameColumnWidth={childNameWidth}
onResizeNameColumn={onResizeNameColumn}
onCancel={() => onCancel(child.id)}
onRetry={() => onRetry(child.id)}
onDismiss={() => onDismiss(child.id)}
/>
</div>
);
})}
</div>
</div>
);
};
export const SftpTransferQueue: React.FC<SftpTransferQueueProps> = ({
sftp,
visibleTransfers,
allTransfers,
canRevealTransferTarget,
onRevealTransferTarget,
}) => {
const { t } = useI18n();
const [expandedParents, setExpandedParents] = useState<Record<string, boolean>>({});
const [panelHeight, setPanelHeight, persistPanelHeight] = useStoredNumber(
STORAGE_KEY_SFTP_TRANSFER_PANEL_HEIGHT,
220,
{ min: MIN_PANEL_HEIGHT, max: MAX_PANEL_HEIGHT },
);
const [childNameWidth, setChildNameWidth, persistChildNameWidth] = useStoredNumber(
STORAGE_KEY_SFTP_TRANSFER_CHILD_NAME_WIDTH,
260,
{ min: MIN_CHILD_NAME_WIDTH, max: MAX_CHILD_NAME_WIDTH },
);
const panelHeightRef = useRef(panelHeight);
const childNameWidthRef = useRef(childNameWidth);
const dragStateRef = useRef<{ startY: number; startHeight: number } | null>(null);
const childColumnDragRef = useRef<{ startX: number; startWidth: number } | null>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [scrollTop, setScrollTop] = useState(0);
const [viewportHeight, setViewportHeight] = useState(0);
const scrollFrameRef = useRef<number | null>(null);
if (sftp.transfers.length === 0) {
panelHeightRef.current = panelHeight;
childNameWidthRef.current = childNameWidth;
const childrenByParent = useMemo(() => {
const map = new Map<string, TransferTask[]>();
for (const task of allTransfers) {
if (task.parentTaskId && task.status !== "cancelled") {
const children = map.get(task.parentTaskId) || [];
children.push(task);
map.set(task.parentTaskId, children);
}
}
for (const [parentId, children] of map) {
map.set(
parentId,
[...children].sort((a, b) => b.startTime - a.startTime),
);
}
return map;
}, [allTransfers]);
const topLevelTransfers = useMemo(
() => visibleTransfers.filter((task) => !task.parentTaskId),
[visibleTransfers],
);
const clampPanelHeight = useCallback((height: number) => {
if (typeof window === "undefined") {
return Math.max(MIN_PANEL_HEIGHT, Math.min(MAX_PANEL_HEIGHT, height));
}
const viewportMax = Math.floor(window.innerHeight * 0.6);
return Math.max(MIN_PANEL_HEIGHT, Math.min(Math.min(MAX_PANEL_HEIGHT, viewportMax), height));
}, []);
useEffect(() => {
setExpandedParents((prev) => {
const next: Record<string, boolean> = {};
let changed = false;
for (const task of topLevelTransfers) {
const hasChildren = (childrenByParent.get(task.id)?.length ?? 0) > 0;
if (!hasChildren) continue;
next[task.id] = prev[task.id] ?? true;
if (next[task.id] !== prev[task.id]) {
changed = true;
}
}
if (!changed && Object.keys(prev).length === Object.keys(next).length) {
return prev;
}
return next;
});
}, [childrenByParent, topLevelTransfers]);
useEffect(() => {
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) return;
const updateViewport = () => setViewportHeight(scrollContainer.clientHeight);
updateViewport();
const resizeObserver = new ResizeObserver(updateViewport);
resizeObserver.observe(scrollContainer);
return () => {
resizeObserver.disconnect();
};
}, []);
useEffect(() => {
return () => {
if (scrollFrameRef.current !== null) {
window.cancelAnimationFrame(scrollFrameRef.current);
}
};
}, []);
useEffect(() => {
const handleMouseMove = (event: MouseEvent) => {
if (dragStateRef.current) {
const deltaY = dragStateRef.current.startY - event.clientY;
setPanelHeight(clampPanelHeight(dragStateRef.current.startHeight + deltaY));
}
if (childColumnDragRef.current) {
const deltaX = event.clientX - childColumnDragRef.current.startX;
const nextWidth = Math.max(
MIN_CHILD_NAME_WIDTH,
Math.min(MAX_CHILD_NAME_WIDTH, childColumnDragRef.current.startWidth + deltaX),
);
setChildNameWidth(nextWidth);
}
};
const handleMouseUp = () => {
const hadPanelDrag = !!dragStateRef.current;
const hadChildColumnDrag = !!childColumnDragRef.current;
dragStateRef.current = null;
childColumnDragRef.current = null;
document.body.style.cursor = "";
document.body.style.userSelect = "";
if (hadPanelDrag) {
persistPanelHeight(panelHeightRef.current);
}
if (hadChildColumnDrag) {
persistChildNameWidth(childNameWidthRef.current);
}
};
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
}, [clampPanelHeight, panelHeight, persistChildNameWidth, persistPanelHeight, setChildNameWidth, setPanelHeight]);
const handleResizeStart = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
dragStateRef.current = {
startY: event.clientY,
startHeight: panelHeight,
};
document.body.style.cursor = "row-resize";
document.body.style.userSelect = "none";
}, [panelHeight]);
const handleChildColumnResizeStart = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
childColumnDragRef.current = {
startX: event.clientX,
startWidth: childNameWidth,
};
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
}, [childNameWidth]);
const toggleExpanded = useCallback((taskId: string) => {
setExpandedParents((prev) => ({
...prev,
[taskId]: !(prev[taskId] ?? true),
}));
}, []);
const handleScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
const nextTop = event.currentTarget.scrollTop;
if (scrollFrameRef.current !== null) return;
scrollFrameRef.current = window.requestAnimationFrame(() => {
scrollFrameRef.current = null;
setScrollTop(nextTop);
});
}, []);
if (topLevelTransfers.length === 0) {
return null;
}
return (
<div className="border-t border-border/70 bg-secondary/80 backdrop-blur-sm shrink-0">
<div className="flex items-center justify-between px-3 py-1.5 text-[11px] text-muted-foreground border-b border-border/40">
<div
className="border-t border-border/70 bg-secondary/80 backdrop-blur-sm shrink-0"
style={{ height: clampPanelHeight(panelHeight) }}
>
<div
className="group flex h-3 cursor-row-resize items-center justify-center border-b border-border/30 text-muted-foreground/70"
onMouseDown={handleResizeStart}
title={t("sftp.transfers.dragToResize")}
>
<GripHorizontal size={14} className="transition-colors group-hover:text-foreground/80" />
</div>
<div className="flex items-center justify-between border-b border-border/40 px-3 py-1.5 text-[11px] text-muted-foreground">
<span className="font-medium">
{t("sftp.transfers")}
{sftp.activeTransfersCount > 0 && (
@@ -37,8 +345,9 @@ export const SftpTransferQueue: React.FC<SftpTransferQueueProps> = ({
</span>
)}
</span>
{sftp.transfers.some(
(tr) => tr.status === "completed" || tr.status === "cancelled",
(transfer) => transfer.status === "completed" || transfer.status === "cancelled",
) && (
<Button
variant="ghost"
@@ -50,29 +359,59 @@ export const SftpTransferQueue: React.FC<SftpTransferQueueProps> = ({
</Button>
)}
</div>
<div className="max-h-40 overflow-auto">
{visibleTransfers.map((task) => (
<SftpTransferItem
key={task.id}
task={task}
onCancel={() => {
if (task.sourceConnectionId === "external") {
sftp.cancelExternalUpload();
}
sftp.cancelTransfer(task.id);
}}
onRetry={() => sftp.retryTransfer(task.id)}
onDismiss={() => sftp.dismissTransfer(task.id)}
canRevealTarget={canRevealTransferTarget?.(task) ?? false}
onRevealTarget={
onRevealTransferTarget
? () => {
void onRevealTransferTarget(task);
<div
ref={scrollContainerRef}
className="overflow-auto"
style={{ height: `calc(100% - ${HEADER_HEIGHT}px)` }}
onScroll={handleScroll}
>
{topLevelTransfers.map((task) => {
const childTasks = childrenByParent.get(task.id) ?? [];
const isExpanded = expandedParents[task.id] ?? true;
return (
<React.Fragment key={task.id}>
<SftpTransferItem
task={task}
canToggleChildren={childTasks.length > 0}
isExpanded={isExpanded}
visibleChildCount={childTasks.length}
onToggleChildren={() => toggleExpanded(task.id)}
onCancel={() => {
if (task.sourceConnectionId === "external") {
sftp.cancelExternalUpload();
}
: undefined
}
/>
))}
sftp.cancelTransfer(task.id);
}}
onRetry={() => sftp.retryTransfer(task.id)}
onDismiss={() => sftp.dismissTransfer(task.id)}
canRevealTarget={canRevealTransferTarget?.(task) ?? false}
onRevealTarget={
onRevealTransferTarget
? () => {
void onRevealTransferTarget(task);
}
: undefined
}
/>
{isExpanded && childTasks.length > 0 && (
<TransferChildList
childTasks={childTasks}
childNameWidth={childNameWidth}
onResizeNameColumn={handleChildColumnResizeStart}
scrollContainerRef={scrollContainerRef}
scrollTop={scrollTop}
viewportHeight={viewportHeight}
onCancel={(taskId) => sftp.cancelTransfer(taskId)}
onRetry={(taskId) => sftp.retryTransfer(taskId)}
onDismiss={(taskId) => sftp.dismissTransfer(taskId)}
/>
)}
</React.Fragment>
);
})}
</div>
</div>
);

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

@@ -0,0 +1,83 @@
import { useCallback, useMemo, useSyncExternalStore } from "react";
import type { SftpBookmark } from "../../../domain/models";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
import { STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS } from "../../../infrastructure/config/storageKeys";
type Listener = () => void;
const listeners = new Set<Listener>();
let snapshot: SftpBookmark[] =
localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS) ?? [];
function subscribe(listener: Listener) {
listeners.add(listener);
return () => { listeners.delete(listener); };
}
function getSnapshot() {
return snapshot;
}
/** Re-read bookmarks from localStorage (e.g. after cloud sync import). */
export function rehydrateGlobalBookmarks() {
snapshot = localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS) ?? [];
for (const l of listeners) l();
}
// Rehydrate when another window updates the same localStorage key
if (typeof window !== 'undefined') {
window.addEventListener('storage', (e) => {
if (e.key === STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS) {
rehydrateGlobalBookmarks();
}
});
}
function setBookmarks(next: SftpBookmark[] | ((prev: SftpBookmark[]) => SftpBookmark[])) {
snapshot = typeof next === "function" ? next(snapshot) : next;
localStorageAdapter.write(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS, snapshot);
for (const l of listeners) l();
window.dispatchEvent(new CustomEvent('sftp-bookmarks-changed'));
}
interface UseGlobalSftpBookmarksParams {
currentPath: string | undefined;
}
export const useGlobalSftpBookmarks = ({
currentPath,
}: UseGlobalSftpBookmarksParams) => {
const bookmarks = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const isCurrentPathBookmarked = useMemo(
() => !!currentPath && bookmarks.some((b) => b.path === currentPath),
[currentPath, bookmarks],
);
const addBookmark = useCallback((path: string) => {
if (!path) return;
if (bookmarks.some((b) => b.path === path)) return;
const isRoot = path === "/" || /^[A-Za-z]:\\?$/.test(path);
const label = isRoot
? path
: path.split(/[\\/]/).filter(Boolean).pop() || path;
const newBookmark: SftpBookmark = {
id: `gbm-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
path,
label,
global: true,
};
setBookmarks((prev) => [...prev, newBookmark]);
}, [bookmarks]);
const deleteBookmark = useCallback((id: string) => {
setBookmarks((prev) => prev.filter((b) => b.id !== id));
}, []);
return {
bookmarks,
isCurrentPathBookmarked,
addBookmark,
deleteBookmark,
};
};

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

@@ -0,0 +1,70 @@
import { useCallback, useSyncExternalStore } from "react";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
import { STORAGE_KEY_SFTP_HOST_VIEW_MODES } from "../../../infrastructure/config/storageKeys";
// ── Shared external store for per-host SFTP view mode preferences ──
type ViewMode = 'list' | 'tree';
type Listener = () => void;
const listeners = new Set<Listener>();
let snapshot: Record<string, ViewMode> =
localStorageAdapter.read<Record<string, ViewMode>>(STORAGE_KEY_SFTP_HOST_VIEW_MODES) ?? {};
function subscribe(listener: Listener) {
listeners.add(listener);
return () => { listeners.delete(listener); };
}
function getSnapshot() {
return snapshot;
}
function persist(next: Record<string, ViewMode>) {
snapshot = next;
localStorageAdapter.write(STORAGE_KEY_SFTP_HOST_VIEW_MODES, snapshot);
for (const l of listeners) l();
}
// Sync across windows/tabs via storage events
if (typeof window !== 'undefined') {
window.addEventListener('storage', (e) => {
if (e.key !== STORAGE_KEY_SFTP_HOST_VIEW_MODES) return;
try {
snapshot = e.newValue
? (JSON.parse(e.newValue) as Record<string, ViewMode>)
: {};
} catch {
snapshot = {};
}
for (const l of listeners) l();
});
}
/** Get the saved view mode for a specific host, or null if none saved. */
export function getHostViewMode(hostId: string): ViewMode | null {
return snapshot[hostId] ?? null;
}
/** Save the view mode preference for a specific host. */
export function setHostViewMode(hostId: string, mode: ViewMode): void {
if (snapshot[hostId] === mode) return;
persist({ ...snapshot, [hostId]: mode });
}
// ── Hook ──
export function useSftpHostViewMode(hostId: string | undefined) {
const store = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const mode: ViewMode | null = hostId ? (store[hostId] ?? null) : null;
const setMode = useCallback((newMode: ViewMode) => {
if (hostId) {
setHostViewMode(hostId, newMode);
}
}, [hostId]);
return { hostViewMode: mode, setHostViewMode: setMode };
}

View File

@@ -8,11 +8,16 @@
import { useCallback, useEffect } from "react";
import type { MutableRefObject } from "react";
import { KeyBinding, matchesKeyBinding } from "../../../domain/models";
import { getParentPath, joinPath } from "../../../application/state/sftp/utils";
import { sftpClipboardStore, SftpClipboardFile } from "./useSftpClipboard";
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";
import { toast } from "../../ui/toast";
// SFTP action names that we handle
@@ -25,12 +30,70 @@ const SFTP_ACTIONS = new Set([
"sftpDelete",
"sftpRefresh",
"sftpNewFolder",
"sftpOpen",
"sftpGoParent",
"sftpNavigateTo",
]);
// ── Tree Enter key action store ──────────────────────────────────────
// Allows the keyboard shortcut hook to signal tree views to handle Enter.
type TreeEnterListener = () => void;
interface TreeEnterAction {
paneId: string;
entryPath: string;
isDirectory: boolean;
timestamp: number;
}
let _treeEnterAction: TreeEnterAction | null = null;
const _treeEnterListeners = new Set<TreeEnterListener>();
const notifyTreeEnterListeners = () => _treeEnterListeners.forEach((l) => l());
export const sftpTreeEnterStore = {
trigger: (paneId: string, entryPath: string, isDirectory: boolean) => {
_treeEnterAction = { paneId, entryPath, isDirectory, timestamp: Date.now() };
notifyTreeEnterListeners();
},
get: () => _treeEnterAction,
clear: () => {
_treeEnterAction = null;
notifyTreeEnterListeners();
},
subscribe: (listener: TreeEnterListener) => {
_treeEnterListeners.add(listener);
return () => { _treeEnterListeners.delete(listener); };
},
getSnapshot: () => _treeEnterAction,
};
// ── Keyboard selection anchor/focus tracking ────────────────────────
// Tracks the anchor (where Shift-selection started) and focus (cursor)
// indices per pane so Shift+Arrow extends correctly.
const _kbSelectionState = new Map<string, { anchor: number; focus: number }>();
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> = {
'Enter': 'sftpOpen',
'Backspace': 'sftpGoParent',
};
interface UseSftpKeyboardShortcutsParams {
keyBindings: KeyBinding[];
hotkeyScheme: "disabled" | "mac" | "pc";
sftpRef: MutableRefObject<SftpStateApi>;
dialogActionScopeId: string;
isActive: boolean;
}
@@ -56,12 +119,14 @@ export const useSftpKeyboardShortcuts = ({
keyBindings,
hotkeyScheme,
sftpRef,
dialogActionScopeId,
isActive,
}: UseSftpKeyboardShortcutsParams) => {
const handleKeyDown = useCallback(
async (e: KeyboardEvent) => {
// Skip if shortcuts are disabled or SFTP is not active
if (hotkeyScheme === "disabled" || !isActive) return;
// Basic SFTP keyboard navigation should work whenever the SFTP tab is active,
// even if the user has disabled global/custom hotkeys.
if (!isActive) return;
// Skip if focus is on an input element
const target = e.target as HTMLElement;
@@ -74,12 +139,126 @@ export const useSftpKeyboardShortcuts = ({
return;
}
const isMac = hotkeyScheme === "mac";
const matched = matchSftpAction(e, keyBindings, isMac);
if (!matched) 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;
}
const { action } = matched;
if (!SFTP_ACTIONS.has(action)) return;
// ── Arrow Up/Down: move selection ────────────────────────────────
if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && !e.ctrlKey && !e.metaKey && !e.altKey) {
const sftp = sftpRef.current;
const focusedSide = sftpFocusStore.getFocusedSide();
const pane = focusedSide === "left"
? sftp.leftTabs.tabs.find(p => p.id === sftp.leftTabs.activeTabId)
: sftp.rightTabs.tabs.find(p => p.id === sftp.rightTabs.activeTabId);
if (!pane || !pane.connection) return;
const delta = e.key === 'ArrowDown' ? 1 : -1;
// List view: navigate sorted display files.
// Prefer the list store when it exists so stale tree selection state
// cannot swallow keyboard navigation after switching views.
const listItems = sftpListOrderStore.getItems(pane.id);
if (listItems.length > 0) {
e.preventDefault();
e.stopPropagation();
// 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 } = sftpKeyboardSelectionStore.get(pane.id);
const currentSelected = Array.from(pane.selectedFiles) as string[];
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;
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));
sftpKeyboardSelectionStore.set(pane.id, anchorIdx, nextIdx);
} else {
sftp.rangeSelect(focusedSide, [listItems[nextIdx]]);
sftpKeyboardSelectionStore.set(pane.id, nextIdx, nextIdx);
}
return;
}
// Tree view: navigate visible items
const treeState = sftpTreeSelectionStore.getPaneState(pane.id);
if (treeState.visibleItems.length > 0) {
e.preventDefault();
e.stopPropagation();
const items = treeState.visibleItems;
const currentSelected = [...treeState.selectedPaths];
// Use tracked state, re-sync if needed
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);
sftpKeyboardSelectionStore.set(pane.id, anchorIdx, nextIdx);
} else {
sftpTreeSelectionStore.setSelection(pane.id, [items[nextIdx].path]);
sftpKeyboardSelectionStore.set(pane.id, nextIdx, nextIdx);
}
return;
}
return;
}
// Basic navigation actions (Enter, Backspace) must work even when
// custom hotkeys are disabled — they are essential SFTP navigation.
// When hotkeys are enabled, defer to matchSftpAction so user
// customizations are respected.
const basicNavAction = hotkeyScheme === "disabled" && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey
? BASIC_NAV_KEYS[e.key]
: undefined;
if (hotkeyScheme === "disabled" && !basicNavAction) return;
const isMac = hotkeyScheme === "mac";
const matched = basicNavAction ? null : matchSftpAction(e, keyBindings, isMac);
if (!matched && !basicNavAction) return;
const action = basicNavAction ?? matched?.action;
if (!action || !SFTP_ACTIONS.has(action)) return;
// Prevent default behavior
e.preventDefault();
@@ -94,49 +273,100 @@ export const useSftpKeyboardShortcuts = ({
: sftp.rightTabs.tabs.find(p => p.id === sftp.rightTabs.activeTabId);
if (!pane || !pane.connection) return;
const treeSelectionState = sftpTreeSelectionStore.getPaneState(pane.id);
const treeSelection = sftpTreeSelectionStore.getSelectedItems(pane.id);
const treeActionSelection = treeSelection.filter((entry) => entry.name !== '..');
switch (action) {
case "sftpCopy": {
if (treeActionSelection.length > 0) {
const parentPaths = new Set(treeActionSelection.map((entry) => getParentPath(entry.path)));
if (parentPaths.size !== 1) {
toast.info("Tree selection across multiple folders can't be copied with shortcuts yet.", "SFTP");
return;
}
const clipboardFiles: SftpClipboardFile[] = treeActionSelection.map((entry) => ({
name: entry.name,
isDirectory: entry.isDirectory,
}));
sftpClipboardStore.copy(
clipboardFiles,
Array.from(parentPaths)[0],
pane.connection.id,
focusedSide,
);
break;
}
// Copy selected files to clipboard
const selectedFiles = Array.from(pane.selectedFiles) as string[];
if (selectedFiles.length === 0) return;
const clipboardFiles: SftpClipboardFile[] = selectedFiles.map((name: string) => {
const file = pane.files.find((f) => f.name === name);
return {
name,
isDirectory: file ? isNavigableDirectory(file) : false,
};
});
{
const filesByName = new Map((pane.files as SftpFileEntry[]).map(f => [f.name, f]));
const clipboardFiles: SftpClipboardFile[] = selectedFiles.map((name: string) => {
const file = filesByName.get(name);
return {
name,
isDirectory: file ? isNavigableDirectory(file) : false,
};
});
sftpClipboardStore.copy(
clipboardFiles,
pane.connection.currentPath,
pane.connection.id,
focusedSide
);
sftpClipboardStore.copy(
clipboardFiles,
pane.connection.currentPath,
pane.connection.id,
focusedSide
);
}
break;
}
case "sftpCut": {
if (treeActionSelection.length > 0) {
const parentPaths = new Set(treeActionSelection.map((entry) => getParentPath(entry.path)));
if (parentPaths.size !== 1) {
toast.info("Tree selection across multiple folders can't be cut with shortcuts yet.", "SFTP");
return;
}
const clipboardFiles: SftpClipboardFile[] = treeActionSelection.map((entry) => ({
name: entry.name,
isDirectory: entry.isDirectory,
}));
sftpClipboardStore.cut(
clipboardFiles,
Array.from(parentPaths)[0],
pane.connection.id,
focusedSide,
);
break;
}
// Cut selected files to clipboard
const selectedFiles = Array.from(pane.selectedFiles) as string[];
if (selectedFiles.length === 0) return;
const clipboardFiles: SftpClipboardFile[] = selectedFiles.map((name: string) => {
const file = pane.files.find((f) => f.name === name);
return {
name,
isDirectory: file ? isNavigableDirectory(file) : false,
};
});
{
const filesByName = new Map((pane.files as SftpFileEntry[]).map(f => [f.name, f]));
const clipboardFiles: SftpClipboardFile[] = selectedFiles.map((name: string) => {
const file = filesByName.get(name);
return {
name,
isDirectory: file ? isNavigableDirectory(file) : false,
};
});
sftpClipboardStore.cut(
clipboardFiles,
pane.connection.currentPath,
pane.connection.id,
focusedSide
);
sftpClipboardStore.cut(
clipboardFiles,
pane.connection.currentPath,
pane.connection.id,
focusedSide
);
}
break;
}
@@ -146,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);
@@ -234,7 +466,17 @@ export const useSftpKeyboardShortcuts = ({
}
case "sftpSelectAll": {
if (treeSelectionState.visibleItems.length > 0) {
keepOnlyPaneSelections(sftp, { side: focusedSide, tabId: pane.id });
sftpTreeSelectionStore.selectAllVisible(pane.id);
break;
}
// Select all files in the current pane
// TODO: Reference already-computed filtered files from useSftpPaneFiles
// instead of re-implementing the hidden file + filter logic here.
// This requires either lifting the computed files into pane state or
// passing them via a shared store, which needs a larger refactor.
const term = pane.filter.trim().toLowerCase();
let visibleFiles = filterHiddenFiles(pane.files, pane.showHiddenFiles);
if (term) {
@@ -245,23 +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", 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",
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;
}
@@ -273,12 +530,70 @@ export const useSftpKeyboardShortcuts = ({
case "sftpNewFolder": {
// Create new folder
sftpDialogActionStore.trigger("newFolder");
sftpDialogActionStore.trigger("newFolder", dialogActionScopeId);
break;
}
case "sftpOpen": {
// Prefer list selection when the list store is active
const listItems = sftpListOrderStore.getItems(pane.id);
const selectedFiles = Array.from(pane.selectedFiles) as string[];
if (listItems.length > 0 && selectedFiles.length === 1) {
const fileName = selectedFiles[0];
const entry = (pane.files as SftpFileEntry[]).find(f => f.name === fileName);
if (entry) {
if (isNavigableDirectory(entry)) {
_kbSelectionState.delete(pane.id);
sftp.navigateTo(focusedSide, joinPath(pane.connection.currentPath, entry.name));
} else {
sftp.openEntry(focusedSide, entry);
}
}
break;
}
// Only fall through to tree view if list store is empty (tree view mode)
if (listItems.length > 0) break;
const treeOpenSelection = sftpTreeSelectionStore.getSelectedItems(pane.id);
if (treeOpenSelection.length === 1) {
const item = treeOpenSelection[0];
if (item.isDirectory) _kbSelectionState.delete(pane.id);
sftpTreeEnterStore.trigger(pane.id, item.path, item.isDirectory);
}
break;
}
case "sftpGoParent": {
const parentPath = getParentPath(pane.connection.currentPath);
if (parentPath !== pane.connection.currentPath) {
_kbSelectionState.delete(pane.id);
sftp.navigateTo(focusedSide, parentPath);
}
break;
}
case "sftpNavigateTo": {
// Navigate to the selected directory (useful in tree view)
// Filter out ".." entry for consistency with other handlers
if (treeActionSelection.length === 1 && treeActionSelection[0].isDirectory) {
_kbSelectionState.delete(pane.id);
sftp.navigateTo(focusedSide, treeActionSelection[0].path);
break;
}
// In list view, navigate to selected directory
const selectedFiles = Array.from(pane.selectedFiles) as string[];
if (selectedFiles.length === 1) {
const entry = (pane.files as SftpFileEntry[]).find(f => f.name === selectedFiles[0]);
if (entry && isNavigableDirectory(entry)) {
_kbSelectionState.delete(pane.id);
sftp.navigateTo(focusedSide, joinPath(pane.connection.currentPath, entry.name));
}
}
break;
}
}
},
[hotkeyScheme, isActive, keyBindings, sftpRef]
[dialogActionScopeId, hotkeyScheme, isActive, keyBindings, sftpRef]
);
useEffect(() => {

View File

@@ -0,0 +1,20 @@
/**
* Lightweight store that tracks the sorted display file names per SFTP pane.
* Used by keyboard shortcuts to navigate with ArrowUp/ArrowDown in list view.
*/
const paneItems = new Map<string, string[]>();
export const sftpListOrderStore = {
/** Update the ordered list of file names for a pane (call from SftpPaneFileList). */
setItems: (paneId: string, names: string[]) => {
paneItems.set(paneId, names);
},
/** Get the ordered list of file names (excluding "..") for arrow key navigation. */
getItems: (paneId: string): string[] => paneItems.get(paneId) ?? [],
clearPane: (paneId: string) => {
paneItems.delete(paneId);
},
};

View File

@@ -1,15 +1,46 @@
import { useCallback, useState } from "react";
import { useCallback, useRef, useState } from "react";
import type { SftpPaneCallbacks } from "../SftpContext";
import type { SftpPane } from "../../../application/state/sftp/types";
import { getFileName, getParentPath } from "../../../application/state/sftp/utils";
import { logger } from "../../../lib/logger";
const INVALID_FILENAME_CHARS = /[/\\:*?"<>|]/;
const RESERVED_NAMES = new Set([
"CON",
"PRN",
"AUX",
"NUL",
"COM1",
"COM2",
"COM3",
"COM4",
"COM5",
"COM6",
"COM7",
"COM8",
"COM9",
"LPT1",
"LPT2",
"LPT3",
"LPT4",
"LPT5",
"LPT6",
"LPT7",
"LPT8",
"LPT9",
]);
interface UseSftpPaneDialogsParams {
t: (key: string, params?: Record<string, unknown>) => string;
pane: SftpPane;
onCreateDirectory: SftpPaneCallbacks["onCreateDirectory"];
onCreateDirectoryAtPath: SftpPaneCallbacks["onCreateDirectoryAtPath"];
onCreateFile: SftpPaneCallbacks["onCreateFile"];
onRenameFile: SftpPaneCallbacks["onRenameFile"];
onDeleteFiles: SftpPaneCallbacks["onDeleteFiles"];
onCreateFileAtPath: SftpPaneCallbacks["onCreateFileAtPath"];
onRenameFileAtPath: SftpPaneCallbacks["onRenameFileAtPath"];
onDeleteFilesAtPath: SftpPaneCallbacks["onDeleteFilesAtPath"];
onClearSelection: SftpPaneCallbacks["onClearSelection"];
onMutateSuccess?: (paths?: string[]) => void;
}
interface UseSftpPaneDialogsResult {
@@ -47,6 +78,8 @@ interface UseSftpPaneDialogsResult {
handleConfirmOverwrite: () => Promise<void>;
handleRename: () => Promise<void>;
handleDelete: () => Promise<void>;
openNewFolderDialogAtPath: (path: string) => void;
openNewFileDialogAtPath: (path: string) => void;
openRenameDialog: (name: string) => void;
openDeleteConfirm: (names: string[]) => void;
getNextUntitledName: (existingFiles: string[]) => string;
@@ -56,17 +89,21 @@ export const useSftpPaneDialogs = ({
t,
pane,
onCreateDirectory,
onCreateDirectoryAtPath,
onCreateFile,
onRenameFile,
onDeleteFiles,
onCreateFileAtPath,
onRenameFileAtPath,
onDeleteFilesAtPath,
onClearSelection,
onMutateSuccess,
}: UseSftpPaneDialogsParams): UseSftpPaneDialogsResult => {
const [showHostPicker, setShowHostPicker] = useState(false);
const [hostSearch, setHostSearch] = useState("");
const [showNewFolderDialog, setShowNewFolderDialog] = useState(false);
const [showNewFolderDialogState, setShowNewFolderDialogState] = useState(false);
const [newFolderName, setNewFolderName] = useState("");
const [showNewFileDialog, setShowNewFileDialog] = useState(false);
const [showNewFileDialogState, setShowNewFileDialogState] = useState(false);
const [newFileName, setNewFileName] = useState("");
const [createTargetPath, setCreateTargetPath] = useState<string | null>(null);
const [fileNameError, setFileNameError] = useState<string | null>(null);
const [showOverwriteConfirm, setShowOverwriteConfirm] = useState(false);
const [overwriteTarget, setOverwriteTarget] = useState<string | null>(null);
@@ -80,34 +117,24 @@ export const useSftpPaneDialogs = ({
const [isRenaming, setIsRenaming] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
// Refs for values accessed inside useCallback to avoid stale closures
const newFolderNameRef = useRef(newFolderName);
newFolderNameRef.current = newFolderName;
const newFileNameRef = useRef(newFileName);
newFileNameRef.current = newFileName;
const createTargetPathRef = useRef(createTargetPath);
createTargetPathRef.current = createTargetPath;
const renameTargetRef = useRef(renameTarget);
renameTargetRef.current = renameTarget;
const renameNameRef = useRef(renameName);
renameNameRef.current = renameName;
const deleteTargetsRef = useRef(deleteTargets);
deleteTargetsRef.current = deleteTargets;
const paneRef = useRef(pane);
paneRef.current = pane;
const validateFileName = useCallback(
(name: string): string | null => {
const INVALID_FILENAME_CHARS = /[/\\:*?"<>|]/;
const RESERVED_NAMES = new Set([
"CON",
"PRN",
"AUX",
"NUL",
"COM1",
"COM2",
"COM3",
"COM4",
"COM5",
"COM6",
"COM7",
"COM8",
"COM9",
"LPT1",
"LPT2",
"LPT3",
"LPT4",
"LPT5",
"LPT6",
"LPT7",
"LPT8",
"LPT9",
]);
const trimmed = name.trim();
if (!trimmed) return null;
@@ -145,22 +172,29 @@ export const useSftpPaneDialogs = ({
return `untitled_${Date.now()}.txt`;
}, []);
const handleCreateFolder = async () => {
if (!newFolderName.trim() || isCreating) return;
const handleCreateFolder = useCallback(async () => {
if (!newFolderNameRef.current.trim() || isCreating) return;
setIsCreating(true);
try {
await onCreateDirectory(newFolderName.trim());
setShowNewFolderDialog(false);
if (createTargetPathRef.current) {
await onCreateDirectoryAtPath(createTargetPathRef.current, newFolderNameRef.current.trim());
} else {
await onCreateDirectory(newFolderNameRef.current.trim());
}
const affectedPath = createTargetPathRef.current ?? paneRef.current.connection?.currentPath;
onMutateSuccess?.(affectedPath ? [affectedPath] : undefined);
setShowNewFolderDialogState(false);
setCreateTargetPath(null);
setNewFolderName("");
} catch {
/* Error handling */
} catch (err) {
logger.warn("Failed to create folder", err);
} finally {
setIsCreating(false);
}
};
}, [isCreating, onCreateDirectory, onCreateDirectoryAtPath, onMutateSuccess]);
const handleCreateFile = async (forceOverwrite = false) => {
const trimmedName = newFileName.trim();
const handleCreateFile = useCallback(async (forceOverwrite = false) => {
const trimmedName = newFileNameRef.current.trim();
if (!trimmedName || isCreatingFile) return;
const error = validateFileName(trimmedName);
@@ -169,8 +203,9 @@ export const useSftpPaneDialogs = ({
return;
}
if (!forceOverwrite) {
const existingFile = pane.files.find(
const currentPane = paneRef.current;
if (!forceOverwrite && (!createTargetPathRef.current || createTargetPathRef.current === currentPane.connection?.currentPath)) {
const existingFile = currentPane.files.find(
(f) =>
f.name.toLowerCase() === trimmedName.toLowerCase() && f.type === "file",
);
@@ -183,59 +218,112 @@ export const useSftpPaneDialogs = ({
setIsCreatingFile(true);
try {
await onCreateFile(trimmedName);
setShowNewFileDialog(false);
if (createTargetPathRef.current) {
await onCreateFileAtPath(createTargetPathRef.current, trimmedName);
} else {
await onCreateFile(trimmedName);
}
const affectedPath = createTargetPathRef.current ?? paneRef.current.connection?.currentPath;
onMutateSuccess?.(affectedPath ? [affectedPath] : undefined);
setShowNewFileDialogState(false);
setShowOverwriteConfirm(false);
setOverwriteTarget(null);
setCreateTargetPath(null);
setNewFileName("");
setFileNameError(null);
} catch {
/* Error handling */
} catch (err) {
logger.warn("Failed to create file", err);
} finally {
setIsCreatingFile(false);
}
};
}, [isCreatingFile, validateFileName, onCreateFile, onCreateFileAtPath, onMutateSuccess]);
const handleConfirmOverwrite = async () => {
const handleConfirmOverwrite = useCallback(async () => {
await handleCreateFile(true);
};
}, [handleCreateFile]);
const handleRename = async () => {
if (!renameTarget || !renameName.trim() || isRenaming) return;
const handleRename = useCallback(async () => {
if (!renameTargetRef.current || !renameNameRef.current.trim() || isRenaming) return;
setIsRenaming(true);
try {
await onRenameFile(renameTarget, renameName.trim());
// renameTarget is always a full path; use the path-aware variant
await onRenameFileAtPath(renameTargetRef.current, renameNameRef.current.trim());
onMutateSuccess?.([getParentPath(renameTargetRef.current)]);
setShowRenameDialog(false);
setRenameTarget(null);
setRenameName("");
} catch {
/* Error handling */
} catch (err) {
logger.warn("Failed to rename file", err);
} finally {
setIsRenaming(false);
}
};
}, [isRenaming, onRenameFileAtPath, onMutateSuccess]);
const handleDelete = async () => {
if (deleteTargets.length === 0 || isDeleting) return;
const handleDelete = useCallback(async () => {
if (deleteTargetsRef.current.length === 0 || isDeleting) return;
setIsDeleting(true);
try {
await onDeleteFiles(deleteTargets);
// deleteTargets are full paths; group by parent dir and use path-aware variant
const byDir = new Map<string, string[]>();
for (const fullPath of deleteTargetsRef.current) {
const dir = getParentPath(fullPath);
const name = getFileName(fullPath);
const list = byDir.get(dir) ?? [];
list.push(name);
byDir.set(dir, list);
}
const connectionId = paneRef.current.connection?.id;
if (!connectionId) {
throw new Error("Pane connection is no longer available");
}
for (const [dir, names] of byDir) {
await onDeleteFilesAtPath(connectionId, dir, names);
}
onMutateSuccess?.(Array.from(byDir.keys()));
setShowDeleteConfirm(false);
setDeleteTargets([]);
onClearSelection();
} catch {
/* Error handling */
} catch (err) {
logger.warn("Failed to delete files", err);
} finally {
setIsDeleting(false);
}
};
}, [isDeleting, onDeleteFilesAtPath, onMutateSuccess, onClearSelection]);
const openRenameDialog = useCallback((name: string) => {
setRenameTarget(name);
setRenameName(name);
// entryPath is the full path; renameName is initialized to the basename
const openRenameDialog = useCallback((entryPath: string) => {
setRenameTarget(entryPath);
setRenameName(getFileName(entryPath) || entryPath);
setShowRenameDialog(true);
}, []);
const setShowNewFolderDialog = useCallback((open: boolean) => {
if (!open) {
setCreateTargetPath(null);
}
setShowNewFolderDialogState(open);
}, []);
const setShowNewFileDialog = useCallback((open: boolean) => {
if (!open) {
setCreateTargetPath(null);
}
setShowNewFileDialogState(open);
}, []);
const openNewFolderDialogAtPath = useCallback((path: string) => {
setCreateTargetPath(path);
setNewFolderName("");
setShowNewFolderDialogState(true);
}, []);
const openNewFileDialogAtPath = useCallback((path: string) => {
setCreateTargetPath(path);
setNewFileName("");
setFileNameError(null);
setShowNewFileDialogState(true);
}, []);
const openDeleteConfirm = useCallback((names: string[]) => {
setDeleteTargets(names);
setShowDeleteConfirm(true);
@@ -244,9 +332,9 @@ export const useSftpPaneDialogs = ({
return {
showHostPicker,
hostSearch,
showNewFolderDialog,
showNewFolderDialog: showNewFolderDialogState,
newFolderName,
showNewFileDialog,
showNewFileDialog: showNewFileDialogState,
newFileName,
fileNameError,
showOverwriteConfirm,
@@ -276,6 +364,8 @@ export const useSftpPaneDialogs = ({
handleConfirmOverwrite,
handleRename,
handleDelete,
openNewFolderDialogAtPath,
openNewFileDialogAtPath,
openRenameDialog,
openDeleteConfirm,
getNextUntitledName,

View File

@@ -1,15 +1,20 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import type { SftpFileEntry } from "../../../types";
import type { SftpPaneCallbacks, SftpDragCallbacks } from "../SftpContext";
import type { SftpPaneCallbacks, SftpDragCallbacks, SftpTransferSource } from "../SftpContext";
import { isNavigableDirectory } from "../index";
import { joinPath } from "../../../application/state/sftp/utils";
interface UseSftpPaneDragAndSelectParams {
side: "left" | "right";
pane: { selectedFiles: Set<string> };
pane: {
selectedFiles: Set<string>;
connection?: { currentPath: string; id: string } | null;
};
sortedDisplayFiles: SftpFileEntry[];
draggedFiles: { name: string; isDirectory: boolean; side: "left" | "right" }[] | null;
draggedFiles: (SftpTransferSource & { side: "left" | "right" })[] | null;
onDragStart: SftpDragCallbacks["onDragStart"];
onReceiveFromOtherPane: SftpPaneCallbacks["onReceiveFromOtherPane"];
onMoveEntriesToPath: SftpPaneCallbacks["onMoveEntriesToPath"];
onUploadExternalFiles?: SftpPaneCallbacks["onUploadExternalFiles"];
onOpenEntry: SftpPaneCallbacks["onOpenEntry"];
onRangeSelect: SftpPaneCallbacks["onRangeSelect"];
@@ -38,6 +43,7 @@ export const useSftpPaneDragAndSelect = ({
draggedFiles,
onDragStart,
onReceiveFromOtherPane,
onMoveEntriesToPath,
onUploadExternalFiles,
onOpenEntry,
onRangeSelect,
@@ -49,17 +55,38 @@ export const useSftpPaneDragAndSelect = ({
const lastSelectedIndexRef = useRef<number | null>(null);
const selectedFilesRef = useRef(pane.selectedFiles);
selectedFilesRef.current = pane.selectedFiles;
const sortedFilesRef = useRef(sortedDisplayFiles);
sortedFilesRef.current = sortedDisplayFiles;
const draggedFilesRef = useRef(draggedFiles);
draggedFilesRef.current = draggedFiles;
const onReceiveRef = useRef(onReceiveFromOtherPane);
onReceiveRef.current = onReceiveFromOtherPane;
const onMoveEntriesToPathRef = useRef(onMoveEntriesToPath);
onMoveEntriesToPathRef.current = onMoveEntriesToPath;
const onUploadRef = useRef(onUploadExternalFiles);
onUploadRef.current = onUploadExternalFiles;
useEffect(() => {
selectedFilesRef.current = pane.selectedFiles;
}, [pane.selectedFiles]);
if (pane.selectedFiles.size === 0) {
lastSelectedIndexRef.current = null;
}
}, [pane.selectedFiles.size]);
useEffect(() => {
sortedFilesRef.current = sortedDisplayFiles;
}, [sortedDisplayFiles]);
const getSamePaneDragPaths = useCallback((): string[] | null => {
const dragged = draggedFilesRef.current;
if (!dragged || dragged.length === 0) return null;
if (dragged[0]?.side !== side) return null;
const handlePaneDragOver = (e: React.DragEvent) => {
const currentConnectionId = pane.connection?.id;
const paths = dragged
.filter((file) => file.sourceConnectionId === currentConnectionId && file.sourcePath)
.map((file) => joinPath(file.sourcePath!, file.name));
return paths.length > 0 ? paths : null;
}, [pane.connection?.id, side]);
const handlePaneDragOver = useCallback((e: React.DragEvent) => {
const hasFiles = e.dataTransfer.types.includes("Files");
if (hasFiles) {
@@ -69,38 +96,36 @@ export const useSftpPaneDragAndSelect = ({
return;
}
if (!draggedFiles || draggedFiles[0]?.side === side) return;
if (!draggedFilesRef.current || draggedFilesRef.current[0]?.side === side) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
setIsDragOverPane(true);
};
}, [side]);
const handlePaneDragLeave = (e: React.DragEvent) => {
const handlePaneDragLeave = useCallback((e: React.DragEvent) => {
const relatedTarget = e.relatedTarget as Node | null;
if (relatedTarget && paneContainerRef.current?.contains(relatedTarget)) return;
setIsDragOverPane(false);
setDragOverEntry(null);
};
}, []);
const handlePaneDrop = async (e: React.DragEvent) => {
const handlePaneDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOverPane(false);
setDragOverEntry(null);
if (draggedFiles && draggedFiles.length > 0) {
if (draggedFiles[0]?.side !== side) {
onReceiveFromOtherPane(
draggedFiles.map((f) => ({ name: f.name, isDirectory: f.isDirectory })),
);
if (draggedFilesRef.current && draggedFilesRef.current.length > 0) {
if (draggedFilesRef.current[0]?.side !== side) {
onReceiveRef.current(draggedFilesRef.current);
}
return;
}
if (e.dataTransfer.items.length > 0 && onUploadExternalFiles) {
await onUploadExternalFiles(e.dataTransfer);
if (e.dataTransfer.items.length > 0 && onUploadRef.current) {
await onUploadRef.current(e.dataTransfer);
}
};
}, [side]);
const handleFileDragStart = useCallback(
(entry: SftpFileEntry, e: React.DragEvent) => {
@@ -108,55 +133,112 @@ export const useSftpPaneDragAndSelect = ({
e.preventDefault();
return;
}
const selectedNames = Array.from(selectedFilesRef.current);
const files = selectedNames.includes(entry.name)
const selectedNames = new Set(selectedFilesRef.current);
const files = selectedNames.has(entry.name)
? sortedFilesRef.current
.filter((f) => selectedNames.includes(f.name))
.filter((f) => selectedNames.has(f.name))
.map((f) => ({
name: f.name,
isDirectory: isNavigableDirectory(f),
sourceConnectionId: pane.connection?.id,
sourcePath: pane.connection?.currentPath,
side,
}))
: [
{
name: entry.name,
isDirectory: isNavigableDirectory(entry),
sourceConnectionId: pane.connection?.id,
sourcePath: pane.connection?.currentPath,
side,
},
];
e.dataTransfer.effectAllowed = "copy";
e.dataTransfer.effectAllowed = "copyMove";
e.dataTransfer.setData("text/plain", files.map((f) => f.name).join("\n"));
onDragStart(files, side);
},
[onDragStart, side],
[onDragStart, pane.connection?.currentPath, pane.connection?.id, side],
);
const handleEntryDragOver = useCallback(
(entry: SftpFileEntry, e: React.DragEvent) => {
if (!draggedFiles || draggedFiles[0]?.side === side) return;
if (isNavigableDirectory(entry) && entry.name !== "..") {
const samePaneDragPaths = getSamePaneDragPaths();
if (samePaneDragPaths && isNavigableDirectory(entry) && entry.name !== "..") {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "move";
setDragOverEntry(entry.name);
return;
}
// Handle cross-pane internal drag
if (draggedFilesRef.current && draggedFilesRef.current[0]?.side !== side) {
if (isNavigableDirectory(entry) && entry.name !== "..") {
e.preventDefault();
e.stopPropagation();
setDragOverEntry(entry.name);
}
return;
}
// Handle external file drag (from OS file explorer)
const hasFiles = e.dataTransfer.types.includes("Files");
if (hasFiles && isNavigableDirectory(entry) && entry.name !== "..") {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "copy";
setDragOverEntry(entry.name);
}
},
[draggedFiles, side],
[getSamePaneDragPaths, side],
);
const handleEntryDrop = useCallback(
(entry: SftpFileEntry, e: React.DragEvent) => {
if (!draggedFiles || draggedFiles[0]?.side === side) return;
if (isNavigableDirectory(entry) && entry.name !== "..") {
async (entry: SftpFileEntry, e: React.DragEvent) => {
const samePaneDragPaths = getSamePaneDragPaths();
if (samePaneDragPaths && isNavigableDirectory(entry) && entry.name !== "..") {
e.preventDefault();
e.stopPropagation();
setDragOverEntry(null);
setIsDragOverPane(false);
onReceiveFromOtherPane(
draggedFiles.map((f) => ({ name: f.name, isDirectory: f.isDirectory })),
);
const targetPath = pane.connection?.currentPath
? joinPath(pane.connection.currentPath, entry.name)
: undefined;
if (targetPath) {
await onMoveEntriesToPathRef.current(samePaneDragPaths, targetPath);
}
return;
}
// Handle cross-pane internal drag
if (draggedFilesRef.current && draggedFilesRef.current[0]?.side !== side) {
if (isNavigableDirectory(entry) && entry.name !== "..") {
e.preventDefault();
e.stopPropagation();
setDragOverEntry(null);
setIsDragOverPane(false);
const targetPath = pane.connection?.currentPath
? joinPath(pane.connection.currentPath, entry.name)
: undefined;
onReceiveRef.current(
draggedFilesRef.current.map((file) => ({ ...file, targetPath })),
);
}
return;
}
// Handle external file drop on a directory entry
const hasFiles = e.dataTransfer.types.includes("Files");
if (hasFiles && isNavigableDirectory(entry) && entry.name !== "..") {
e.preventDefault();
e.stopPropagation();
setDragOverEntry(null);
setIsDragOverPane(false);
if (onUploadRef.current && pane.connection?.currentPath) {
const targetPath = joinPath(pane.connection.currentPath, entry.name);
void onUploadRef.current(e.dataTransfer, targetPath);
}
}
},
[draggedFiles, onReceiveFromOtherPane, side],
[getSamePaneDragPaths, side, pane.connection?.currentPath],
);
const handleRowSelect = useCallback(
@@ -165,7 +247,7 @@ export const useSftpPaneDragAndSelect = ({
if (e.shiftKey && lastSelectedIndexRef.current !== null) {
const start = Math.min(lastSelectedIndexRef.current, index);
const end = Math.max(lastSelectedIndexRef.current, index);
const selectedFileNames = sortedDisplayFiles
const selectedFileNames = sortedFilesRef.current
.slice(start, end + 1)
.filter((f) => f.name !== "..")
.map((f) => f.name);
@@ -175,7 +257,7 @@ export const useSftpPaneDragAndSelect = ({
lastSelectedIndexRef.current = index;
}
},
[onRangeSelect, onToggleSelection, sortedDisplayFiles],
[onRangeSelect, onToggleSelection],
);
const handleRowOpen = useCallback(

View File

@@ -2,13 +2,14 @@ import { useMemo } from "react";
import type { SftpFileEntry } from "../../../types";
import type { SftpPane } from "../../../application/state/sftp/types";
import type { SortField, SortOrder } from "../utils";
import { filterHiddenFiles } from "../index";
import { filterHiddenFiles, sortSftpEntries } from "../index";
interface UseSftpPaneFilesParams {
files: SftpFileEntry[];
filter: string;
connection: SftpPane["connection"] | null;
showHiddenFiles: boolean;
enableListView: boolean;
sortField: SortField;
sortOrder: SortOrder;
}
@@ -24,76 +25,62 @@ export const useSftpPaneFiles = ({
filter,
connection,
showHiddenFiles,
enableListView,
sortField,
sortOrder,
}: UseSftpPaneFilesParams): UseSftpPaneFilesResult => {
// Extract ".." once and process the remaining files through filter -> sort
// in fewer passes, instead of repeatedly filtering/finding ".." entries.
const filteredFiles = useMemo(() => {
if (!enableListView) return [] as SftpFileEntry[];
const term = filter.trim().toLowerCase();
let nextFiles = filterHiddenFiles(files, showHiddenFiles);
if (!term) return nextFiles;
return nextFiles.filter(
(f) => f.name === ".." || f.name.toLowerCase().includes(term),
);
}, [files, filter, showHiddenFiles]);
}, [enableListView, files, filter, showHiddenFiles]);
const { displayFiles, sortedDisplayFiles } = useMemo(() => {
if (!connection || !enableListView) {
return { displayFiles: [] as SftpFileEntry[], sortedDisplayFiles: [] as SftpFileEntry[] };
}
const displayFiles = useMemo(() => {
if (!connection) return [];
const isRootPath =
connection.currentPath === "/" ||
/^[A-Za-z]:[\\/]?$/.test(connection.currentPath);
if (isRootPath) return filteredFiles;
const parentEntry: SftpFileEntry = {
name: "..",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: 0,
lastModifiedFormatted: "--",
};
return [parentEntry, ...filteredFiles.filter((f) => f.name !== "..")];
}, [connection, filteredFiles]);
const sortedDisplayFiles = useMemo(() => {
if (!displayFiles.length) return displayFiles;
const parentEntry = displayFiles.find((f) => f.name === "..");
const otherFiles = displayFiles.filter((f) => f.name !== "..");
const sorted = [...otherFiles].sort((a, b) => {
if (sortField !== "type") {
if (a.type === "directory" && b.type !== "directory") return -1;
if (a.type !== "directory" && b.type === "directory") return 1;
// Split ".." from other files in a single pass
let parentEntry: SftpFileEntry | undefined;
const otherFiles: SftpFileEntry[] = [];
for (const f of filteredFiles) {
if (f.name === "..") {
parentEntry = f;
} else {
otherFiles.push(f);
}
}
let cmp = 0;
switch (sortField) {
case "name":
cmp = a.name.localeCompare(b.name);
break;
case "size":
cmp = (a.size || 0) - (b.size || 0);
break;
case "modified":
cmp = (a.lastModified || 0) - (b.lastModified || 0);
break;
case "type": {
const extA =
a.type === "directory"
? "folder"
: a.name.split(".").pop()?.toLowerCase() || "";
const extB =
b.type === "directory"
? "folder"
: b.name.split(".").pop()?.toLowerCase() || "";
cmp = extA.localeCompare(extB);
break;
}
}
return sortOrder === "asc" ? cmp : -cmp;
});
// For non-root paths, always ensure a ".." entry exists
if (!isRootPath && !parentEntry) {
parentEntry = {
name: "..",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: 0,
lastModifiedFormatted: "--",
};
}
return parentEntry ? [parentEntry, ...sorted] : sorted;
}, [displayFiles, sortField, sortOrder]);
const display = parentEntry ? [parentEntry, ...otherFiles] : otherFiles;
const sorted = otherFiles.length
? sortSftpEntries(otherFiles, sortField, sortOrder)
: otherFiles;
const sortedDisplay = parentEntry ? [parentEntry, ...sorted] : sorted;
return { displayFiles: display, sortedDisplayFiles: sortedDisplay };
}, [connection, enableListView, filteredFiles, sortField, sortOrder]);
return { filteredFiles, displayFiles, sortedDisplayFiles };
};

View File

@@ -1,11 +1,12 @@
import React, { useCallback, useMemo, useRef, useState } from "react";
import type { SftpFileEntry } from "../../../types";
import type { SftpPane } from "../../../application/state/sftp/types";
import { isNavigableDirectory } from "../index";
import { filterHiddenFiles, isNavigableDirectory } from "../index";
interface UseSftpPanePathParams {
connection: SftpPane["connection"] | null;
filteredFiles: SftpFileEntry[];
files: SftpFileEntry[];
showHiddenFiles: boolean;
onNavigateTo: (path: string) => void;
}
@@ -28,7 +29,8 @@ interface UseSftpPanePathResult {
export const useSftpPanePath = ({
connection,
filteredFiles,
files,
showHiddenFiles,
onNavigateTo,
}: UseSftpPanePathParams): UseSftpPanePathResult => {
const [isEditingPath, setIsEditingPath] = useState(false);
@@ -43,7 +45,7 @@ export const useSftpPanePath = ({
const currentValue = editingPathValue.trim().toLowerCase();
const suggestions: { path: string; type: "folder" | "history" }[] = [];
const folders = filteredFiles.filter(
const folders = filterHiddenFiles(files, showHiddenFiles).filter(
(f) => isNavigableDirectory(f) && f.name !== "..",
);
folders.forEach((f) => {
@@ -70,7 +72,7 @@ export const useSftpPanePath = ({
});
return suggestions.slice(0, 8);
}, [connection, editingPathValue, filteredFiles, isEditingPath]);
}, [connection, editingPathValue, files, isEditingPath, showHiddenFiles]);
const handlePathDoubleClick = () => {
if (!connection) return;

View File

@@ -13,10 +13,10 @@ export const useSftpPaneSorting = (): UseSftpPaneSortingResult => {
const [sortField, setSortField] = useState<SortField>("name");
const [sortOrder, setSortOrder] = useState<SortOrder>("asc");
const [columnWidths, setColumnWidths] = useState<ColumnWidths>({
name: 45,
modified: 25,
size: 15,
type: 15,
name: 56,
modified: 28,
size: 7,
type: 9,
});
const resizingRef = useRef<{
@@ -34,24 +34,48 @@ export const useSftpPaneSorting = (): UseSftpPaneSortingResult => {
}
};
const handleResizeMove = useCallback((e: MouseEvent) => {
const rafIdRef = useRef<number | null>(null);
const lastClientXRef = useRef(0);
const applyColumnWidth = useCallback(() => {
if (!resizingRef.current) return;
const diff = e.clientX - resizingRef.current.startX;
const { field, startX, startWidth } = resizingRef.current;
const diff = lastClientXRef.current - startX;
const limits: Record<keyof ColumnWidths, { min: number; max: number }> = {
name: { min: 36, max: 78 },
modified: { min: 18, max: 42 },
size: { min: 5, max: 16 },
type: { min: 6, max: 18 },
};
const { min, max } = limits[field];
const newWidth = Math.max(
10,
Math.min(60, resizingRef.current.startWidth + diff / 5),
min,
Math.min(max, startWidth + diff / 8),
);
setColumnWidths((prev) => ({
...prev,
[resizingRef.current!.field]: newWidth,
[field]: newWidth,
}));
}, []);
const handleResizeMove = useCallback((e: MouseEvent) => {
if (!resizingRef.current) return;
lastClientXRef.current = e.clientX;
if (rafIdRef.current !== null) return;
rafIdRef.current = requestAnimationFrame(() => {
rafIdRef.current = null;
applyColumnWidth();
});
}, [applyColumnWidth]);
const handleResizeEnd = useCallback(() => {
if (rafIdRef.current !== null) cancelAnimationFrame(rafIdRef.current);
applyColumnWidth();
rafIdRef.current = null;
resizingRef.current = null;
document.removeEventListener("mousemove", handleResizeMove);
document.removeEventListener("mouseup", handleResizeEnd);
}, [handleResizeMove]);
}, [applyColumnWidth, handleResizeMove]);
const handleResizeStart = (
field: keyof ColumnWidths,
@@ -59,6 +83,7 @@ export const useSftpPaneSorting = (): UseSftpPaneSortingResult => {
) => {
e.preventDefault();
e.stopPropagation();
lastClientXRef.current = e.clientX;
resizingRef.current = {
field,
startX: e.clientX,

View File

@@ -3,6 +3,7 @@ import type { SftpFileEntry } from "../../../types";
interface UseSftpPaneVirtualListParams {
isActive: boolean;
enabled?: boolean;
sortedDisplayFiles: SftpFileEntry[];
}
@@ -17,6 +18,7 @@ interface UseSftpPaneVirtualListResult {
export const useSftpPaneVirtualList = ({
isActive,
enabled = true,
sortedDisplayFiles,
}: UseSftpPaneVirtualListParams): UseSftpPaneVirtualListResult => {
const fileListRef = useRef<HTMLDivElement>(null);
@@ -27,7 +29,7 @@ export const useSftpPaneVirtualList = ({
useLayoutEffect(() => {
const container = fileListRef.current;
if (!container || !isActive) return;
if (!container || !isActive || !enabled) return;
const update = () => setViewportHeight(container.clientHeight);
update();
const raf = window.requestAnimationFrame(update);
@@ -37,11 +39,11 @@ export const useSftpPaneVirtualList = ({
resizeObserver.disconnect();
window.cancelAnimationFrame(raf);
};
}, [isActive, sortedDisplayFiles.length]);
}, [enabled, isActive, sortedDisplayFiles.length]);
useLayoutEffect(() => {
const container = fileListRef.current;
if (!container || !isActive || sortedDisplayFiles.length === 0) return;
if (!container || !isActive || !enabled || sortedDisplayFiles.length === 0) return;
const raf = window.requestAnimationFrame(() => {
const rowElement = container.querySelector(
'[data-sftp-row="true"]',
@@ -53,7 +55,7 @@ export const useSftpPaneVirtualList = ({
}
});
return () => window.cancelAnimationFrame(raf);
}, [isActive, rowHeight, sortedDisplayFiles.length]);
}, [enabled, isActive, rowHeight, sortedDisplayFiles.length]);
useEffect(() => {
return () => {
@@ -65,7 +67,7 @@ export const useSftpPaneVirtualList = ({
const handleFileListScroll = useCallback(
(e: React.UIEvent<HTMLDivElement>) => {
if (!isActive) return;
if (!isActive || !enabled) return;
const nextTop = e.currentTarget.scrollTop;
if (scrollFrameRef.current !== null) return;
scrollFrameRef.current = window.requestAnimationFrame(() => {
@@ -73,12 +75,12 @@ export const useSftpPaneVirtualList = ({
setScrollTop(nextTop);
});
},
[isActive],
[enabled, isActive],
);
const { shouldVirtualize, totalHeight, visibleRows } = useMemo(() => {
const overscan = 6;
const canVirtualize = isActive && viewportHeight > 0 && rowHeight > 0;
const canVirtualize = enabled && isActive && viewportHeight > 0 && rowHeight > 0;
const shouldVirtualizeLocal = canVirtualize && sortedDisplayFiles.length > 50;
const totalHeightLocal = shouldVirtualizeLocal
? sortedDisplayFiles.length * rowHeight
@@ -111,7 +113,7 @@ export const useSftpPaneVirtualList = ({
totalHeight: totalHeightLocal,
visibleRows: visibleRowsLocal,
};
}, [isActive, rowHeight, scrollTop, sortedDisplayFiles, viewportHeight]);
}, [enabled, isActive, rowHeight, scrollTop, sortedDisplayFiles, viewportHeight]);
return {
fileListRef,

View File

@@ -0,0 +1,153 @@
import { useCallback, useSyncExternalStore } from "react";
export interface SftpTreeSelectionItem {
path: string;
name: string;
isDirectory: boolean;
sourcePath: string;
}
interface SftpTreeSelectionState {
visibleItems: SftpTreeSelectionItem[];
visibleItemsByPath: Map<string, SftpTreeSelectionItem>;
visibleIndexByPath: Map<string, number>;
visiblePathsSet: Set<string>;
selectedPaths: Set<string>;
}
const EMPTY_PATHS = new Set<string>();
const EMPTY_STATE: SftpTreeSelectionState = {
visibleItems: [],
visibleItemsByPath: new Map(),
visibleIndexByPath: new Map(),
visiblePathsSet: new Set(),
selectedPaths: EMPTY_PATHS,
};
type Listener = () => void;
const paneStates = new Map<string, SftpTreeSelectionState>();
const paneListeners = new Map<string, Set<Listener>>();
const notifyPaneListeners = (paneId: string) => {
paneListeners.get(paneId)?.forEach((listener) => listener());
};
const getPaneState = (paneId: string): SftpTreeSelectionState =>
paneStates.get(paneId) ?? EMPTY_STATE;
const setPaneState = (
paneId: string,
updater: (state: SftpTreeSelectionState) => SftpTreeSelectionState,
) => {
const prev = getPaneState(paneId);
const next = updater(prev);
if (next === prev) return;
if (next.visibleItems.length === 0 && next.selectedPaths.size === 0) {
paneStates.delete(paneId);
} else {
paneStates.set(paneId, next);
}
notifyPaneListeners(paneId);
};
export const sftpTreeSelectionStore = {
getPaneState,
getSelectedItems: (paneId: string): SftpTreeSelectionItem[] => {
const state = getPaneState(paneId);
const result: SftpTreeSelectionItem[] = [];
for (const path of state.selectedPaths) {
const item = state.visibleItemsByPath.get(path);
if (item) result.push(item);
}
return result;
},
setVisibleItems: (paneId: string, visibleItems: SftpTreeSelectionItem[]) => {
const visibleItemsByPath = new Map<string, SftpTreeSelectionItem>();
const visibleIndexByPath = new Map<string, number>();
const visiblePathsSet = new Set(visibleItems.map((item) => item.path));
visibleItems.forEach((item, index) => {
visibleItemsByPath.set(item.path, item);
visibleIndexByPath.set(item.path, index);
});
setPaneState(paneId, (state) => {
const newSelected = new Set([...state.selectedPaths].filter((p) => visiblePathsSet.has(p)));
const changed =
newSelected.size !== state.selectedPaths.size ||
[...newSelected].some((p) => !state.selectedPaths.has(p));
return {
visibleItems,
visibleItemsByPath,
visibleIndexByPath,
visiblePathsSet,
selectedPaths: changed ? newSelected : state.selectedPaths,
};
});
},
setSelection: (paneId: string, selectedPaths: Iterable<string>) => {
setPaneState(paneId, (state) => ({
...state,
selectedPaths: new Set(Array.from(selectedPaths).filter((path) => state.visiblePathsSet.has(path))),
}));
},
clearSelection: (paneId: string) => {
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,
selectedPaths: new Set(
state.visibleItems.map((item) => item.path),
),
}));
},
clearPane: (paneId: string) => {
if (!paneStates.has(paneId)) return;
paneStates.delete(paneId);
notifyPaneListeners(paneId);
},
subscribe: (paneId: string, listener: Listener) => {
const listeners = paneListeners.get(paneId) ?? new Set<Listener>();
listeners.add(listener);
paneListeners.set(paneId, listeners);
return () => {
const current = paneListeners.get(paneId);
if (!current) return;
current.delete(listener);
if (current.size === 0) {
paneListeners.delete(paneId);
}
};
},
};
export const useSftpTreeSelectionState = (paneId: string): SftpTreeSelectionState => {
const subscribe = useCallback(
(listener: () => void) => sftpTreeSelectionStore.subscribe(paneId, listener),
[paneId],
);
return useSyncExternalStore(
subscribe,
() => sftpTreeSelectionStore.getPaneState(paneId),
() => sftpTreeSelectionStore.getPaneState(paneId),
);
};

View File

@@ -1,7 +1,7 @@
import React, { useCallback, useState } from "react";
import React, { useCallback, useRef, useState } from "react";
import type { MutableRefObject } from "react";
import type { RemoteFile, SftpFileEntry, SftpFilenameEncoding } from "../../../types";
import { joinPath as joinFsPath } from "../../../application/state/sftp/utils";
import type { SftpFileEntry, SftpFilenameEncoding } from "../../../types";
import { getParentPath, joinPath as joinFsPath } from "../../../application/state/sftp/utils";
import type { SftpStateApi } from "../../../application/state/useSftpState";
import { logger } from "../../../lib/logger";
import { toast } from "../../ui/toast";
@@ -21,9 +21,6 @@ interface UseSftpViewFileOpsParams {
systemApp?: SystemAppInfo,
) => void;
t: (key: string, vars?: Record<string, string | number>) => string;
listSftp?: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<RemoteFile[]>;
mkdirLocal?: (path: string) => Promise<unknown>;
deleteLocalFile?: (path: string) => Promise<unknown>;
showSaveDialog?: (defaultPath: string, filters?: Array<{ name: string; extensions: string[] }>) => Promise<string | null>;
selectDirectory?: (title?: string, defaultPath?: string) => Promise<string | null>;
startStreamTransfer?: (
@@ -47,9 +44,9 @@ interface UseSftpViewFileOpsParams {
}
interface UseSftpViewFileOpsResult {
permissionsState: { file: SftpFileEntry; side: "left" | "right" } | null;
permissionsState: { file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null;
setPermissionsState: React.Dispatch<
React.SetStateAction<{ file: SftpFileEntry; side: "left" | "right" } | null>
React.SetStateAction<{ file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null>
>;
showTextEditor: boolean;
setShowTextEditor: React.Dispatch<React.SetStateAction<boolean>>;
@@ -89,20 +86,20 @@ interface UseSftpViewFileOpsResult {
systemApp?: SystemAppInfo,
) => Promise<void>;
handleSelectSystemApp: () => Promise<SystemAppInfo | null>;
onEditPermissionsLeft: (file: SftpFileEntry) => void;
onEditPermissionsRight: (file: SftpFileEntry) => void;
onOpenEntryLeft: (entry: SftpFileEntry) => void;
onOpenEntryRight: (entry: SftpFileEntry) => void;
onEditFileLeft: (file: SftpFileEntry) => void;
onEditFileRight: (file: SftpFileEntry) => void;
onOpenFileLeft: (file: SftpFileEntry) => void;
onOpenFileRight: (file: SftpFileEntry) => void;
onOpenFileWithLeft: (file: SftpFileEntry) => void;
onOpenFileWithRight: (file: SftpFileEntry) => void;
onDownloadFileLeft: (file: SftpFileEntry) => void;
onDownloadFileRight: (file: SftpFileEntry) => void;
onUploadExternalFilesLeft: (dataTransfer: DataTransfer) => void;
onUploadExternalFilesRight: (dataTransfer: DataTransfer) => void;
onEditPermissionsLeft: (file: SftpFileEntry, fullPath?: string) => void;
onEditPermissionsRight: (file: SftpFileEntry, fullPath?: string) => void;
onOpenEntryLeft: (entry: SftpFileEntry, fullPath?: string) => void;
onOpenEntryRight: (entry: SftpFileEntry, fullPath?: string) => void;
onEditFileLeft: (file: SftpFileEntry, fullPath?: string) => void;
onEditFileRight: (file: SftpFileEntry, fullPath?: string) => void;
onOpenFileLeft: (file: SftpFileEntry, fullPath?: string) => void;
onOpenFileRight: (file: SftpFileEntry, fullPath?: string) => void;
onOpenFileWithLeft: (file: SftpFileEntry, fullPath?: string) => void;
onOpenFileWithRight: (file: SftpFileEntry, fullPath?: string) => void;
onDownloadFileLeft: (file: SftpFileEntry, fullPath?: string) => void;
onDownloadFileRight: (file: SftpFileEntry, fullPath?: string) => void;
onUploadExternalFilesLeft: (dataTransfer: DataTransfer, targetPath?: string) => void;
onUploadExternalFilesRight: (dataTransfer: DataTransfer, targetPath?: string) => void;
}
export const useSftpViewFileOps = ({
@@ -112,9 +109,6 @@ export const useSftpViewFileOps = ({
getOpenerForFileRef,
setOpenerForExtension,
t,
listSftp,
mkdirLocal,
deleteLocalFile,
showSaveDialog,
selectDirectory,
startStreamTransfer,
@@ -123,6 +117,7 @@ export const useSftpViewFileOps = ({
const [permissionsState, setPermissionsState] = useState<{
file: SftpFileEntry;
side: "left" | "right";
fullPath: string;
} | null>(null);
const [showTextEditor, setShowTextEditor] = useState(false);
@@ -145,27 +140,49 @@ export const useSftpViewFileOps = ({
fullPath: string;
} | null>(null);
// Refs for frequently-changing state used inside stable callbacks
const fileOpenerTargetRef = useRef(fileOpenerTarget);
fileOpenerTargetRef.current = fileOpenerTarget;
const textEditorTargetRef = useRef(textEditorTarget);
textEditorTargetRef.current = textEditorTarget;
const onEditPermissionsLeft = useCallback(
(file: SftpFileEntry) => setPermissionsState({ file, side: "left" }),
[],
(file: SftpFileEntry, fullPath?: string) => {
const pane = sftpRef.current.leftPane;
if (!pane.connection) return;
setPermissionsState({
file,
side: "left",
fullPath: fullPath ?? sftpRef.current.joinPath(pane.connection.currentPath, file.name),
});
},
[sftpRef],
);
const onEditPermissionsRight = useCallback(
(file: SftpFileEntry) => setPermissionsState({ file, side: "right" }),
[],
(file: SftpFileEntry, fullPath?: string) => {
const pane = sftpRef.current.rightPane;
if (!pane.connection) return;
setPermissionsState({
file,
side: "right",
fullPath: fullPath ?? sftpRef.current.joinPath(pane.connection.currentPath, file.name),
});
},
[sftpRef],
);
const handleEditFileForSide = useCallback(
async (side: "left" | "right", file: SftpFileEntry) => {
async (side: "left" | "right", file: SftpFileEntry, fullPath?: string) => {
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
if (!pane.connection) return;
const fullPath = sftpRef.current.joinPath(pane.connection.currentPath, file.name);
const resolvedFullPath = fullPath ?? sftpRef.current.joinPath(pane.connection.currentPath, file.name);
try {
setLoadingTextContent(true);
setTextEditorTarget({ file, side, fullPath, hostId: pane.connection.hostId });
setTextEditorTarget({ file, side, fullPath: resolvedFullPath, hostId: pane.connection.hostId });
const content = await sftpRef.current.readTextFile(side, fullPath);
const content = await sftpRef.current.readTextFile(side, resolvedFullPath);
setTextEditorContent(content);
setShowTextEditor(true);
@@ -180,22 +197,22 @@ export const useSftpViewFileOps = ({
);
const handleOpenFileForSide = useCallback(
async (side: "left" | "right", file: SftpFileEntry) => {
async (side: "left" | "right", file: SftpFileEntry, fullPath?: string) => {
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
if (!pane.connection) return;
const fullPath = sftpRef.current.joinPath(pane.connection.currentPath, file.name);
const resolvedFullPath = fullPath ?? sftpRef.current.joinPath(pane.connection.currentPath, file.name);
const savedOpener = getOpenerForFileRef.current(file.name);
if (savedOpener && savedOpener.openerType) {
if (savedOpener.openerType === "builtin-editor") {
handleEditFileForSide(side, file);
handleEditFileForSide(side, file, resolvedFullPath);
return;
} else if (savedOpener.openerType === "system-app" && savedOpener.systemApp) {
try {
await sftpRef.current.downloadToTempAndOpen(
side,
fullPath,
resolvedFullPath,
file.name,
savedOpener.systemApp.path,
{ enableWatch: autoSyncRef.current },
@@ -207,7 +224,7 @@ export const useSftpViewFileOps = ({
}
}
setFileOpenerTarget({ file, side, fullPath });
setFileOpenerTarget({ file, side, fullPath: resolvedFullPath });
setShowFileOpenerDialog(true);
},
[sftpRef, handleEditFileForSide, getOpenerForFileRef, autoSyncRef],
@@ -215,23 +232,24 @@ export const useSftpViewFileOps = ({
const handleFileOpenerSelect = useCallback(
async (openerType: FileOpenerType, setAsDefault: boolean, systemApp?: SystemAppInfo) => {
if (!fileOpenerTarget) return;
const target = fileOpenerTargetRef.current;
if (!target) return;
if (setAsDefault) {
const ext = getFileExtension(fileOpenerTarget.file.name);
const ext = getFileExtension(target.file.name);
setOpenerForExtension(ext, openerType, systemApp);
}
setShowFileOpenerDialog(false);
if (openerType === "builtin-editor") {
handleEditFileForSide(fileOpenerTarget.side, fileOpenerTarget.file);
handleEditFileForSide(target.side, target.file, target.fullPath);
} else if (openerType === "system-app" && systemApp) {
try {
await sftpRef.current.downloadToTempAndOpen(
fileOpenerTarget.side,
fileOpenerTarget.fullPath,
fileOpenerTarget.file.name,
target.side,
target.fullPath,
target.file.name,
systemApp.path,
{ enableWatch: autoSyncRef.current },
);
@@ -242,7 +260,7 @@ export const useSftpViewFileOps = ({
setFileOpenerTarget(null);
},
[fileOpenerTarget, setOpenerForExtension, handleEditFileForSide, autoSyncRef, sftpRef],
[setOpenerForExtension, handleEditFileForSide, autoSyncRef, sftpRef],
);
const handleSelectSystemApp = useCallback(async (): Promise<SystemAppInfo | null> => {
@@ -255,7 +273,8 @@ export const useSftpViewFileOps = ({
const handleSaveTextFile = useCallback(
async (content: string) => {
if (!textEditorTarget) return;
const target = textEditorTargetRef.current;
if (!target) return;
// Verify the SFTP connection hasn't switched to a different host.
// We check hostId (not connectionId) because auto-reconnect after a
@@ -263,64 +282,64 @@ export const useSftpViewFileOps = ({
// endpoint. The auto-connect effect in SftpSidePanel blocks
// host-switching while the editor is open, so a hostId mismatch here
// reliably indicates a genuinely different endpoint.
const currentPane = textEditorTarget.side === "left"
const currentPane = target.side === "left"
? sftpRef.current.leftPane
: sftpRef.current.rightPane;
if (textEditorTarget.hostId && currentPane.connection?.hostId !== textEditorTarget.hostId) {
if (target.hostId && currentPane.connection?.hostId !== target.hostId) {
throw new Error("SFTP connection changed while editing — file not saved to prevent writing to wrong host");
}
await sftpRef.current.writeTextFile(
textEditorTarget.side,
textEditorTarget.fullPath,
target.side,
target.fullPath,
content,
);
},
[textEditorTarget, sftpRef],
[sftpRef],
);
const onEditFileLeft = useCallback(
(file: SftpFileEntry) => handleEditFileForSide("left", file),
(file: SftpFileEntry, fullPath?: string) => handleEditFileForSide("left", file, fullPath),
[handleEditFileForSide],
);
const onEditFileRight = useCallback(
(file: SftpFileEntry) => handleEditFileForSide("right", file),
(file: SftpFileEntry, fullPath?: string) => handleEditFileForSide("right", file, fullPath),
[handleEditFileForSide],
);
const onOpenFileLeft = useCallback(
(file: SftpFileEntry) => handleOpenFileForSide("left", file),
(file: SftpFileEntry, fullPath?: string) => handleOpenFileForSide("left", file, fullPath),
[handleOpenFileForSide],
);
const onOpenFileRight = useCallback(
(file: SftpFileEntry) => handleOpenFileForSide("right", file),
(file: SftpFileEntry, fullPath?: string) => handleOpenFileForSide("right", file, fullPath),
[handleOpenFileForSide],
);
const handleOpenFileWithForSide = useCallback(
(side: "left" | "right", file: SftpFileEntry) => {
(side: "left" | "right", file: SftpFileEntry, fullPath?: string) => {
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
if (!pane.connection) return;
const fullPath = sftpRef.current.joinPath(pane.connection.currentPath, file.name);
setFileOpenerTarget({ file, side, fullPath });
const resolvedFullPath = fullPath ?? sftpRef.current.joinPath(pane.connection.currentPath, file.name);
setFileOpenerTarget({ file, side, fullPath: resolvedFullPath });
setShowFileOpenerDialog(true);
},
[sftpRef],
);
const onOpenFileWithLeft = useCallback(
(file: SftpFileEntry) => handleOpenFileWithForSide("left", file),
(file: SftpFileEntry, fullPath?: string) => handleOpenFileWithForSide("left", file, fullPath),
[handleOpenFileWithForSide],
);
const onOpenFileWithRight = useCallback(
(file: SftpFileEntry) => handleOpenFileWithForSide("right", file),
(file: SftpFileEntry, fullPath?: string) => handleOpenFileWithForSide("right", file, fullPath),
[handleOpenFileWithForSide],
);
const handleUploadExternalFilesForSide = useCallback(
async (side: "left" | "right", dataTransfer: DataTransfer) => {
async (side: "left" | "right", dataTransfer: DataTransfer, targetPath?: string) => {
try {
const results = await sftpRef.current.uploadExternalFiles(side, dataTransfer);
const results = await sftpRef.current.uploadExternalFiles(side, dataTransfer, targetPath);
// Check if upload was cancelled
if (results.some((r) => r.cancelled)) {
@@ -359,21 +378,21 @@ export const useSftpViewFileOps = ({
);
const onUploadExternalFilesLeft = useCallback(
(dataTransfer: DataTransfer) => handleUploadExternalFilesForSide("left", dataTransfer),
(dataTransfer: DataTransfer, targetPath?: string) => handleUploadExternalFilesForSide("left", dataTransfer, targetPath),
[handleUploadExternalFilesForSide],
);
const onUploadExternalFilesRight = useCallback(
(dataTransfer: DataTransfer) => handleUploadExternalFilesForSide("right", dataTransfer),
(dataTransfer: DataTransfer, targetPath?: string) => handleUploadExternalFilesForSide("right", dataTransfer, targetPath),
[handleUploadExternalFilesForSide],
);
const handleDownloadFileForSide = useCallback(
async (side: "left" | "right", file: SftpFileEntry) => {
async (side: "left" | "right", file: SftpFileEntry, fullPath?: string) => {
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
if (!pane.connection) return;
const fullPath = sftpRef.current.joinPath(pane.connection.currentPath, file.name);
const resolvedFullPath = fullPath ?? sftpRef.current.joinPath(pane.connection.currentPath, file.name);
const isDirectory = isNavigableDirectory(file);
try {
@@ -384,7 +403,7 @@ export const useSftpViewFileOps = ({
return;
}
const content = await sftpRef.current.readBinaryFile(side, fullPath);
const content = await sftpRef.current.readBinaryFile(side, resolvedFullPath);
const blob = new Blob([content], { type: "application/octet-stream" });
const url = URL.createObjectURL(blob);
@@ -412,7 +431,7 @@ export const useSftpViewFileOps = ({
}
if (isDirectory) {
if (!listSftp || !mkdirLocal || !selectDirectory) {
if (!selectDirectory) {
toast.error(t("sftp.error.downloadFailed"), "SFTP");
return;
}
@@ -422,402 +441,30 @@ export const useSftpViewFileOps = ({
const targetPath = joinFsPath(selectedDirectory, file.name);
const transferId = `download-dir-${Date.now()}-${Math.random().toString(36).slice(2)}`;
let completedBytes = 0;
const MAX_SYMLINK_DEPTH = 32;
const DIRECTORY_DOWNLOAD_MAX_CONCURRENCY = 10;
const activeChildTransferIds = new Set<string>();
const activeFileProgress = new Map<string, { transferred: number; speed: number }>();
const activeFileSizes = new Map<string, number>();
const visitedPaths = new Set<string>();
const directoryTaskQueue: Array<{
type: "directory";
remotePath: string;
localPath: string;
symlinkDepth: number;
}> = [];
const fileTaskQueue: Array<{
type: "file";
remotePath: string;
localPath: string;
size: number;
}> = [];
let pendingDirectoryTasks = 0;
let discoveredTotalBytes = 0;
let estimatedTotalBytes = 0;
let activeQueueTasks = 0;
const isTaskCancelled = () =>
sftpRef.current.transfers.some(
(task) => task.id === transferId && task.status === "cancelled",
);
const updateAggregateProgress = () => {
let activeTransferredBytes = 0;
let activeSpeed = 0;
for (const progress of activeFileProgress.values()) {
activeTransferredBytes += progress.transferred;
activeSpeed += progress.speed;
}
sftpRef.current.updateExternalUpload(transferId, {
fileName: pendingDirectoryTasks > 0 ? `${file.name} (${t("sftp.upload.scanning")})` : file.name,
transferredBytes: completedBytes + activeTransferredBytes,
totalBytes: estimatedTotalBytes > 0 ? estimatedTotalBytes : 0,
speed: activeSpeed,
});
};
const cancelActiveChildTransfers = async () => {
await Promise.all(
Array.from(activeChildTransferIds).map((childTransferId) =>
sftpRef.current.cancelTransfer(childTransferId).catch(() => undefined),
),
);
};
const maybeFinalizeDiscovery = () => {
if (pendingDirectoryTasks === 0) {
estimatedTotalBytes = discoveredTotalBytes;
updateAggregateProgress();
}
};
const getDynamicConcurrencyLimit = () => {
let largeFiles = 0;
let mediumFiles = 0;
for (const size of activeFileSizes.values()) {
if (size >= 32 * 1024 * 1024) largeFiles += 1;
else if (size >= 1 * 1024 * 1024) mediumFiles += 1;
}
if (largeFiles > 0) return 2;
if (mediumFiles >= 2) return 4;
if (mediumFiles === 1) return 5;
return DIRECTORY_DOWNLOAD_MAX_CONCURRENCY;
};
const enqueueDirectoryTask = (task: {
type: "directory";
remotePath: string;
localPath: string;
symlinkDepth: number;
}) => {
directoryTaskQueue.push(task);
};
const enqueueFileTask = (task: {
type: "file";
remotePath: string;
localPath: string;
size: number;
}) => {
const insertIndex = fileTaskQueue.findIndex((queuedTask) => queuedTask.size > task.size);
if (insertIndex === -1) {
fileTaskQueue.push(task);
} else {
fileTaskQueue.splice(insertIndex, 0, task);
}
};
const dequeueTask = () => {
if (pendingDirectoryTasks > 0 && directoryTaskQueue.length > 0) {
return directoryTaskQueue.shift() ?? null;
}
if (fileTaskQueue.length > 0) return fileTaskQueue.shift() ?? null;
if (directoryTaskQueue.length > 0) return directoryTaskQueue.shift() ?? null;
return null;
};
const processFileTask = async (task: {
type: "file";
remotePath: string;
localPath: string;
size: number;
}) => {
const childTransferId = `download-${Date.now()}-${Math.random().toString(36).slice(2)}`;
activeChildTransferIds.add(childTransferId);
activeFileSizes.set(childTransferId, task.size);
activeFileProgress.set(childTransferId, { transferred: 0, speed: 0 });
updateAggregateProgress();
try {
await new Promise<void>((resolve, reject) => {
startStreamTransfer(
{
transferId: childTransferId,
sourcePath: task.remotePath,
targetPath: task.localPath,
sourceType: "sftp",
targetType: "local",
sourceSftpId: sftpId,
totalBytes: task.size,
sourceEncoding: pane.filenameEncoding,
},
(transferred, _total, speed) => {
if (isTaskCancelled()) {
sftpRef.current.cancelTransfer(childTransferId).catch(() => undefined);
return;
}
activeFileProgress.set(childTransferId, {
transferred,
speed: Number.isFinite(speed) && speed > 0 ? speed : 0,
});
updateAggregateProgress();
},
() => {
completedBytes += task.size;
activeChildTransferIds.delete(childTransferId);
activeFileSizes.delete(childTransferId);
activeFileProgress.delete(childTransferId);
updateAggregateProgress();
resolve();
},
(error) => {
activeChildTransferIds.delete(childTransferId);
activeFileSizes.delete(childTransferId);
activeFileProgress.delete(childTransferId);
updateAggregateProgress();
reject(new Error(error));
},
)
.then((result) => {
if (result === undefined) {
activeChildTransferIds.delete(childTransferId);
activeFileSizes.delete(childTransferId);
activeFileProgress.delete(childTransferId);
updateAggregateProgress();
reject(new Error("Stream transfer unavailable"));
} else if (result.error) {
activeChildTransferIds.delete(childTransferId);
activeFileSizes.delete(childTransferId);
activeFileProgress.delete(childTransferId);
updateAggregateProgress();
reject(new Error(result.error));
}
})
.catch(reject);
});
} finally {
activeChildTransferIds.delete(childTransferId);
activeFileSizes.delete(childTransferId);
activeFileProgress.delete(childTransferId);
}
};
const processDirectoryTask = async (task: {
type: "directory";
remotePath: string;
localPath: string;
symlinkDepth: number;
}) => {
if (visitedPaths.has(task.remotePath)) {
pendingDirectoryTasks -= 1;
maybeFinalizeDiscovery();
return;
}
visitedPaths.add(task.remotePath);
if (isTaskCancelled()) {
throw new Error("Transfer cancelled");
}
const entries = await listSftp(sftpId, task.remotePath, pane.filenameEncoding);
for (const entry of entries) {
if (entry.name === ".." || entry.name === ".") continue;
if (isTaskCancelled()) {
await cancelActiveChildTransfers();
throw new Error("Transfer cancelled");
}
const remoteEntryPath = sftpRef.current.joinPath(task.remotePath, entry.name);
const localEntryPath = joinFsPath(task.localPath, entry.name);
const isRealDir = entry.type === "directory";
const isSymlinkDir =
entry.type === "symlink" && entry.linkTarget === "directory";
if (isRealDir || isSymlinkDir) {
if (isSymlinkDir && task.symlinkDepth >= MAX_SYMLINK_DEPTH) {
throw new Error(
"Maximum symlink directory depth exceeded (possible symlink cycle)",
);
}
try {
await mkdirLocal(localEntryPath);
} catch (mkdirErr: unknown) {
const isEEXIST =
mkdirErr instanceof Error && mkdirErr.message.includes("EEXIST");
if (!isEEXIST) throw mkdirErr;
}
pendingDirectoryTasks += 1;
enqueueDirectoryTask({
type: "directory",
remotePath: remoteEntryPath,
localPath: localEntryPath,
symlinkDepth: isSymlinkDir ? task.symlinkDepth + 1 : task.symlinkDepth,
});
continue;
}
const entrySize =
typeof entry.size === "string"
? parseInt(String(entry.size), 10) || 0
: entry.size || 0;
discoveredTotalBytes += entrySize;
enqueueFileTask({
type: "file",
remotePath: remoteEntryPath,
localPath: localEntryPath,
size: entrySize,
});
}
pendingDirectoryTasks -= 1;
maybeFinalizeDiscovery();
};
const runQueue = async () =>
new Promise<void>((resolve, reject) => {
let settled = false;
const pump = () => {
if (settled) return;
if (isTaskCancelled()) {
settled = true;
void cancelActiveChildTransfers().finally(() =>
reject(new Error("Transfer cancelled")),
);
return;
}
while (
activeQueueTasks < getDynamicConcurrencyLimit()
) {
const nextTask = dequeueTask();
if (!nextTask) break;
activeQueueTasks += 1;
Promise.resolve(
nextTask.type === "directory"
? processDirectoryTask(nextTask)
: processFileTask(nextTask),
)
.then(() => {
activeQueueTasks -= 1;
if (
!settled &&
fileTaskQueue.length === 0 &&
directoryTaskQueue.length === 0 &&
activeQueueTasks === 0 &&
pendingDirectoryTasks === 0
) {
settled = true;
resolve();
return;
}
pump();
})
.catch((error) => {
if (settled) return;
settled = true;
void cancelActiveChildTransfers().finally(() => reject(error));
});
}
if (
!settled &&
fileTaskQueue.length === 0 &&
directoryTaskQueue.length === 0 &&
activeQueueTasks === 0 &&
pendingDirectoryTasks === 0
) {
settled = true;
resolve();
}
};
pump();
});
sftpRef.current.addExternalUpload({
id: transferId,
fileName: `${file.name} (${t("sftp.upload.scanning")})`,
sourcePath: fullPath,
targetPath,
sourceConnectionId: pane.connection.id,
targetConnectionId: "local",
direction: "download",
status: "transferring",
totalBytes: 0,
transferredBytes: 0,
speed: 0,
startTime: Date.now(),
isDirectory: true,
retryable: false,
});
try {
try {
await mkdirLocal(targetPath);
} catch (mkdirErr: unknown) {
const isEEXIST =
mkdirErr instanceof Error && mkdirErr.message.includes("EEXIST");
if (isEEXIST && deleteLocalFile) {
await deleteLocalFile(targetPath);
await mkdirLocal(targetPath);
} else {
throw mkdirErr;
}
}
pendingDirectoryTasks = 1;
enqueueDirectoryTask({
type: "directory",
remotePath: fullPath,
localPath: targetPath,
symlinkDepth: 0,
});
await runQueue();
sftpRef.current.updateExternalUpload(transferId, {
status: "completed",
const status = await sftpRef.current.downloadToLocal({
fileName: file.name,
transferredBytes: completedBytes,
totalBytes: estimatedTotalBytes > 0 ? estimatedTotalBytes : completedBytes,
speed: 0,
endTime: Date.now(),
sourcePath: resolvedFullPath,
targetPath,
sftpId,
connectionId: pane.connection.id,
sourceEncoding: pane.filenameEncoding,
isDirectory: true,
});
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
if (status === "completed") {
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
} else if (status === "failed") {
toast.error(`${t("sftp.error.downloadFailed")}: ${file.name}`, "SFTP");
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : t("sftp.error.downloadFailed");
const isCancelled =
errorMessage.includes("cancelled") || errorMessage.includes("canceled");
sftpRef.current.updateExternalUpload(transferId, {
status: isCancelled ? "cancelled" : "failed",
error: isCancelled ? undefined : errorMessage,
speed: 0,
endTime: Date.now(),
});
if (!isCancelled) {
const errorMessage = error instanceof Error ? error.message : t("sftp.error.downloadFailed");
if (!errorMessage.includes("cancelled") && !errorMessage.includes("canceled")) {
toast.error(errorMessage, "SFTP");
}
}
return;
}
// Show save dialog to get target path
const targetPath = await showSaveDialog(file.name);
if (!targetPath) {
@@ -832,7 +479,7 @@ export const useSftpViewFileOps = ({
sftpRef.current.addExternalUpload({
id: transferId,
fileName: file.name,
sourcePath: fullPath,
sourcePath: resolvedFullPath,
targetPath,
sourceConnectionId: pane.connection.id,
targetConnectionId: 'local',
@@ -851,7 +498,7 @@ export const useSftpViewFileOps = ({
const result = await startStreamTransfer(
{
transferId,
sourcePath: fullPath,
sourcePath: resolvedFullPath,
targetPath,
sourceType: 'sftp',
targetType: 'local',
@@ -925,9 +572,6 @@ export const useSftpViewFileOps = ({
[
sftpRef,
t,
listSftp,
mkdirLocal,
deleteLocalFile,
showSaveDialog,
selectDirectory,
startStreamTransfer,
@@ -936,17 +580,18 @@ export const useSftpViewFileOps = ({
);
const onDownloadFileLeft = useCallback(
(file: SftpFileEntry) => handleDownloadFileForSide("left", file),
(file: SftpFileEntry, fullPath?: string) => handleDownloadFileForSide("left", file, fullPath),
[handleDownloadFileForSide],
);
const onDownloadFileRight = useCallback(
(file: SftpFileEntry) => handleDownloadFileForSide("right", file),
(file: SftpFileEntry, fullPath?: string) => handleDownloadFileForSide("right", file, fullPath),
[handleDownloadFileForSide],
);
const onOpenEntryLeft = useCallback(
(entry: SftpFileEntry) => {
(entry: SftpFileEntry, fullPath?: string) => {
const pane = sftpRef.current.leftPane;
const isDir = isNavigableDirectory(entry);
if (entry.name === ".." || isDir) {
@@ -955,20 +600,28 @@ export const useSftpViewFileOps = ({
}
if (behaviorRef.current === "transfer") {
const sourcePath = fullPath ? getParentPath(fullPath) : pane.connection?.currentPath;
const sourceConnectionId = pane.connection?.id;
const fileData = [{
name: entry.name,
isDirectory: isDir,
sourceConnectionId,
sourcePath,
}];
sftpRef.current.startTransfer(fileData, "left", "right");
sftpRef.current.startTransfer(fileData, "left", "right", {
sourceConnectionId,
sourcePath,
});
} else {
onOpenFileLeft(entry);
onOpenFileLeft(entry, fullPath);
}
},
[sftpRef, onOpenFileLeft, behaviorRef],
);
const onOpenEntryRight = useCallback(
(entry: SftpFileEntry) => {
(entry: SftpFileEntry, fullPath?: string) => {
const pane = sftpRef.current.rightPane;
const isDir = isNavigableDirectory(entry);
if (entry.name === ".." || isDir) {
@@ -977,13 +630,20 @@ export const useSftpViewFileOps = ({
}
if (behaviorRef.current === "transfer") {
const sourcePath = fullPath ? getParentPath(fullPath) : pane.connection?.currentPath;
const sourceConnectionId = pane.connection?.id;
const fileData = [{
name: entry.name,
isDirectory: isDir,
sourceConnectionId,
sourcePath,
}];
sftpRef.current.startTransfer(fileData, "right", "left");
sftpRef.current.startTransfer(fileData, "right", "left", {
sourceConnectionId,
sourcePath,
});
} else {
onOpenFileRight(entry);
onOpenFileRight(entry, fullPath);
}
},
[sftpRef, onOpenFileRight, behaviorRef],

View File

@@ -1,7 +1,8 @@
import { useCallback, useMemo, useState } from "react";
import type { MutableRefObject } from "react";
import type { SftpStateApi } from "../../../application/state/useSftpState";
import type { SftpDragCallbacks } from "../SftpContext";
import type { SftpDragCallbacks, SftpTransferSource } from "../SftpContext";
import { keepOnlyActivePaneSelections } from "./selectionScope";
interface UseSftpViewPaneActionsParams {
sftpRef: MutableRefObject<SftpStateApi>;
@@ -9,17 +10,21 @@ interface UseSftpViewPaneActionsParams {
interface UseSftpViewPaneActionsResult {
dragCallbacks: SftpDragCallbacks;
draggedFiles: { name: string; isDirectory: boolean; side: "left" | "right" }[] | null;
draggedFiles: (SftpTransferSource & { side: "left" | "right" })[] | null;
onConnectLeft: (host: Parameters<SftpStateApi["connect"]>[1]) => void;
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;
onNavigateUpRight: () => void;
onRefreshLeft: () => void;
onRefreshRight: () => void;
onRefreshTabLeft: (tabId: string) => void;
onRefreshTabRight: (tabId: string) => void;
onSetFilenameEncodingLeft: (encoding: Parameters<SftpStateApi["setFilenameEncoding"]>[1]) => void;
onSetFilenameEncodingRight: (encoding: Parameters<SftpStateApi["setFilenameEncoding"]>[1]) => void;
onToggleSelectionLeft: (name: string, multi: boolean) => void;
@@ -32,28 +37,38 @@ interface UseSftpViewPaneActionsResult {
onSetFilterRight: (filter: string) => void;
onCreateDirectoryLeft: (name: string) => void;
onCreateDirectoryRight: (name: string) => void;
onCreateDirectoryAtPathLeft: (path: string, name: string) => void;
onCreateDirectoryAtPathRight: (path: string, name: string) => void;
onCreateFileLeft: (name: string) => void;
onCreateFileRight: (name: string) => void;
onCreateFileAtPathLeft: (path: string, name: string) => void;
onCreateFileAtPathRight: (path: string, name: string) => void;
onDeleteFilesLeft: (names: string[]) => void;
onDeleteFilesRight: (names: string[]) => void;
onDeleteFilesAtPathLeft: (connectionId: string, path: string, names: string[]) => void;
onDeleteFilesAtPathRight: (connectionId: string, path: string, names: string[]) => void;
onRenameFileLeft: (old: string, newName: string) => void;
onRenameFileRight: (old: string, newName: string) => void;
onCopyToOtherPaneLeft: (files: { name: string; isDirectory: boolean }[]) => void;
onCopyToOtherPaneRight: (files: { name: string; isDirectory: boolean }[]) => void;
onReceiveFromOtherPaneLeft: (files: { name: string; isDirectory: boolean }[]) => void;
onReceiveFromOtherPaneRight: (files: { name: string; isDirectory: boolean }[]) => void;
onRenameFileAtPathLeft: (oldPath: string, newName: string) => void;
onRenameFileAtPathRight: (oldPath: string, newName: string) => void;
onMoveEntriesToPathLeft: (sourcePaths: string[], targetPath: string) => void;
onMoveEntriesToPathRight: (sourcePaths: string[], targetPath: string) => void;
onCopyToOtherPaneLeft: (files: SftpTransferSource[]) => void;
onCopyToOtherPaneRight: (files: SftpTransferSource[]) => void;
onReceiveFromOtherPaneLeft: (files: SftpTransferSource[]) => void;
onReceiveFromOtherPaneRight: (files: SftpTransferSource[]) => void;
}
export const useSftpViewPaneActions = ({
sftpRef,
}: UseSftpViewPaneActionsParams): UseSftpViewPaneActionsResult => {
const [draggedFiles, setDraggedFiles] = useState<
{ name: string; isDirectory: boolean; side: "left" | "right" }[] | null
(SftpTransferSource & { side: "left" | "right" })[] | null
>(null);
const handleDragStart = useCallback(
(
files: { name: string; isDirectory: boolean }[],
files: SftpTransferSource[],
side: "left" | "right",
) => {
setDraggedFiles(files.map((f) => ({ ...f, side })));
@@ -65,25 +80,43 @@ export const useSftpViewPaneActions = ({
setDraggedFiles(null);
}, []);
const onCopyToOtherPaneLeft = useCallback(
(files: { name: string; isDirectory: boolean }[]) =>
sftpRef.current.startTransfer(files, "left", "right"),
const startGroupedTransfer = useCallback(
(files: SftpTransferSource[], sourceSide: "left" | "right", targetSide: "left" | "right") => {
const groups = new Map<string, SftpTransferSource[]>();
for (const file of files) {
const key = `${file.sourceConnectionId ?? ""}::${file.sourcePath ?? ""}`;
const group = groups.get(key) ?? [];
group.push(file);
groups.set(key, group);
}
for (const group of groups.values()) {
const [{ sourceConnectionId, sourcePath, targetPath }] = group;
void sftpRef.current.startTransfer(group, sourceSide, targetSide, {
sourceConnectionId,
sourcePath,
targetPath,
});
}
},
[sftpRef],
);
const onCopyToOtherPaneLeft = useCallback(
(files: SftpTransferSource[]) => startGroupedTransfer(files, "left", "right"),
[startGroupedTransfer],
);
const onCopyToOtherPaneRight = useCallback(
(files: { name: string; isDirectory: boolean }[]) =>
sftpRef.current.startTransfer(files, "right", "left"),
[sftpRef],
(files: SftpTransferSource[]) => startGroupedTransfer(files, "right", "left"),
[startGroupedTransfer],
);
const onReceiveFromOtherPaneLeft = useCallback(
(files: { name: string; isDirectory: boolean }[]) =>
sftpRef.current.startTransfer(files, "right", "left"),
[sftpRef],
(files: SftpTransferSource[]) => startGroupedTransfer(files, "right", "left"),
[startGroupedTransfer],
);
const onReceiveFromOtherPaneRight = useCallback(
(files: { name: string; isDirectory: boolean }[]) =>
sftpRef.current.startTransfer(files, "left", "right"),
[sftpRef],
(files: SftpTransferSource[]) => startGroupedTransfer(files, "left", "right"),
[startGroupedTransfer],
);
const onConnectLeft = useCallback(
@@ -96,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],
@@ -108,6 +147,8 @@ export const useSftpViewPaneActions = ({
const onNavigateUpRight = useCallback(() => sftpRef.current.navigateUp("right"), [sftpRef]);
const onRefreshLeft = useCallback(() => sftpRef.current.refresh("left"), [sftpRef]);
const onRefreshRight = useCallback(() => sftpRef.current.refresh("right"), [sftpRef]);
const onRefreshTabLeft = useCallback((tabId: string) => sftpRef.current.refresh("left", { tabId }), [sftpRef]);
const onRefreshTabRight = useCallback((tabId: string) => sftpRef.current.refresh("right", { tabId }), [sftpRef]);
const onSetFilenameEncodingLeft = useCallback(
(encoding: Parameters<SftpStateApi["setFilenameEncoding"]>[1]) =>
sftpRef.current.setFilenameEncoding("left", encoding),
@@ -119,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]);
@@ -152,6 +205,14 @@ export const useSftpViewPaneActions = ({
(name: string) => sftpRef.current.createDirectory("right", name),
[sftpRef],
);
const onCreateDirectoryAtPathLeft = useCallback(
(path: string, name: string) => sftpRef.current.createDirectoryAtPath("left", path, name),
[sftpRef],
);
const onCreateDirectoryAtPathRight = useCallback(
(path: string, name: string) => sftpRef.current.createDirectoryAtPath("right", path, name),
[sftpRef],
);
const onCreateFileLeft = useCallback(
(name: string) => sftpRef.current.createFile("left", name),
[sftpRef],
@@ -160,6 +221,14 @@ export const useSftpViewPaneActions = ({
(name: string) => sftpRef.current.createFile("right", name),
[sftpRef],
);
const onCreateFileAtPathLeft = useCallback(
(path: string, name: string) => sftpRef.current.createFileAtPath("left", path, name),
[sftpRef],
);
const onCreateFileAtPathRight = useCallback(
(path: string, name: string) => sftpRef.current.createFileAtPath("right", path, name),
[sftpRef],
);
const onDeleteFilesLeft = useCallback(
(names: string[]) => sftpRef.current.deleteFiles("left", names),
[sftpRef],
@@ -168,6 +237,16 @@ export const useSftpViewPaneActions = ({
(names: string[]) => sftpRef.current.deleteFiles("right", names),
[sftpRef],
);
const onDeleteFilesAtPathLeft = useCallback(
(connectionId: string, path: string, names: string[]) =>
sftpRef.current.deleteFilesAtPath("left", connectionId, path, names),
[sftpRef],
);
const onDeleteFilesAtPathRight = useCallback(
(connectionId: string, path: string, names: string[]) =>
sftpRef.current.deleteFilesAtPath("right", connectionId, path, names),
[sftpRef],
);
const onRenameFileLeft = useCallback(
(old: string, newName: string) => sftpRef.current.renameFile("left", old, newName),
[sftpRef],
@@ -176,6 +255,22 @@ export const useSftpViewPaneActions = ({
(old: string, newName: string) => sftpRef.current.renameFile("right", old, newName),
[sftpRef],
);
const onRenameFileAtPathLeft = useCallback(
(oldPath: string, newName: string) => sftpRef.current.renameFileAtPath("left", oldPath, newName),
[sftpRef],
);
const onRenameFileAtPathRight = useCallback(
(oldPath: string, newName: string) => sftpRef.current.renameFileAtPath("right", oldPath, newName),
[sftpRef],
);
const onMoveEntriesToPathLeft = useCallback(
(sourcePaths: string[], targetPath: string) => sftpRef.current.moveEntriesToPath("left", sourcePaths, targetPath),
[sftpRef],
);
const onMoveEntriesToPathRight = useCallback(
(sourcePaths: string[], targetPath: string) => sftpRef.current.moveEntriesToPath("right", sourcePaths, targetPath),
[sftpRef],
);
const dragCallbacks = useMemo<SftpDragCallbacks>(
() => ({
@@ -192,12 +287,16 @@ export const useSftpViewPaneActions = ({
onConnectRight,
onDisconnectLeft,
onDisconnectRight,
onPrepareSelectionLeft,
onPrepareSelectionRight,
onNavigateToLeft,
onNavigateToRight,
onNavigateUpLeft,
onNavigateUpRight,
onRefreshLeft,
onRefreshRight,
onRefreshTabLeft,
onRefreshTabRight,
onSetFilenameEncodingLeft,
onSetFilenameEncodingRight,
onToggleSelectionLeft,
@@ -210,12 +309,22 @@ export const useSftpViewPaneActions = ({
onSetFilterRight,
onCreateDirectoryLeft,
onCreateDirectoryRight,
onCreateDirectoryAtPathLeft,
onCreateDirectoryAtPathRight,
onCreateFileLeft,
onCreateFileRight,
onCreateFileAtPathLeft,
onCreateFileAtPathRight,
onDeleteFilesLeft,
onDeleteFilesRight,
onDeleteFilesAtPathLeft,
onDeleteFilesAtPathRight,
onRenameFileLeft,
onRenameFileRight,
onRenameFileAtPathLeft,
onRenameFileAtPathRight,
onMoveEntriesToPathLeft,
onMoveEntriesToPathRight,
onCopyToOtherPaneLeft,
onCopyToOtherPaneRight,
onReceiveFromOtherPaneLeft,

View File

@@ -1,11 +1,15 @@
import { useMemo } from "react";
import { useEffect, useMemo, useRef } from "react";
import type { MutableRefObject } from "react";
import type { SftpStateApi } from "../../../application/state/useSftpState";
import type { RemoteFile, SftpFilenameEncoding } from "../../../types";
import type { SftpPaneCallbacks } from "../SftpContext";
import type { SftpPane } from "../../../application/state/sftp/types";
import { useSftpViewPaneActions } from "./useSftpViewPaneActions";
import { useSftpViewFileOps } from "./useSftpViewFileOps";
import type { FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
import { formatFileSize, formatDate } from '../../../application/state/sftp/utils';
import { isSessionError } from "../../../application/state/sftp/errors";
import { filterHiddenFiles } from "../utils";
interface UseSftpViewPaneCallbacksParams {
sftpRef: MutableRefObject<SftpStateApi>;
@@ -21,8 +25,6 @@ interface UseSftpViewPaneCallbacksParams {
) => void;
t: (key: string, vars?: Record<string, string | number>) => string;
listSftp?: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<RemoteFile[]>;
mkdirLocal?: (path: string) => Promise<unknown>;
deleteLocalFile?: (path: string) => Promise<unknown>;
showSaveDialog?: (defaultPath: string, filters?: Array<{ name: string; extensions: string[] }>) => Promise<string | null>;
selectDirectory?: (title?: string, defaultPath?: string) => Promise<string | null>;
startStreamTransfer?: (
@@ -43,6 +45,7 @@ interface UseSftpViewPaneCallbacksParams {
onError?: (error: string) => void
) => Promise<{ transferId: string; totalBytes?: number; error?: string }>;
getSftpIdForConnection?: (connectionId: string) => string | undefined;
listLocalFiles: (path: string) => Promise<RemoteFile[]>;
}
export const useSftpViewPaneCallbacks = ({
@@ -53,12 +56,11 @@ export const useSftpViewPaneCallbacks = ({
setOpenerForExtension,
t,
listSftp,
mkdirLocal,
deleteLocalFile,
showSaveDialog,
selectDirectory,
startStreamTransfer,
getSftpIdForConnection,
listLocalFiles,
}: UseSftpViewPaneCallbacksParams) => {
const paneActions = useSftpViewPaneActions({ sftpRef });
const fileOps = useSftpViewFileOps({
@@ -68,23 +70,81 @@ export const useSftpViewPaneCallbacks = ({
getOpenerForFileRef,
setOpenerForExtension,
t,
listSftp,
mkdirLocal,
deleteLocalFile,
showSaveDialog,
selectDirectory,
startStreamTransfer,
getSftpIdForConnection,
});
const listLocalFilesRef = useRef(listLocalFiles);
const listSftpRef = useRef(listSftp);
const getSftpIdForConnectionRef = useRef(getSftpIdForConnection);
useEffect(() => {
listLocalFilesRef.current = listLocalFiles;
listSftpRef.current = listSftp;
getSftpIdForConnectionRef.current = getSftpIdForConnection;
}, [listLocalFiles, listSftp, getSftpIdForConnection]);
const makeListDirectory = (side: "left" | "right", getPane: () => SftpPane) =>
async (path: string) => {
const pane = getPane();
if (!pane.connection) return [];
const toSize = (raw: string) => parseInt(raw) || 0;
const toTs = (raw: string) => new Date(raw).getTime();
const normalizeEntries = (rawFiles: RemoteFile[]) =>
filterHiddenFiles(
rawFiles.map(f => {
const s = toSize(f.size);
const ms = toTs(f.lastModified);
return {
name: f.name,
type: f.type as 'file' | 'directory' | 'symlink',
size: s,
sizeFormatted: formatFileSize(s),
lastModified: ms,
lastModifiedFormatted: formatDate(ms),
permissions: f.permissions,
linkTarget: f.linkTarget as 'file' | 'directory' | null | undefined,
hidden: f.hidden,
};
}),
pane.showHiddenFiles,
);
if (pane.connection.isLocal) {
return normalizeEntries(await listLocalFilesRef.current(path));
}
const sftpId = getSftpIdForConnectionRef.current?.(pane.connection.id);
if (!sftpId) {
const error = new Error("SFTP session not found");
sftpRef.current.reportSessionError(side, error);
throw error;
}
let rawFiles: RemoteFile[] | undefined;
try {
rawFiles = await listSftpRef.current?.(sftpId, path, pane.filenameEncoding);
} catch (err) {
if (isSessionError(err)) {
sftpRef.current.reportSessionError(side, err as Error);
}
throw err;
}
if (!rawFiles) return [];
return normalizeEntries(rawFiles);
};
/* eslint-disable react-hooks/exhaustive-deps -- Handlers use refs, so they are stable */
const leftCallbacks = useMemo<SftpPaneCallbacks>(
() => ({
onConnect: paneActions.onConnectLeft,
onDisconnect: paneActions.onDisconnectLeft,
onPrepareSelection: paneActions.onPrepareSelectionLeft,
onNavigateTo: paneActions.onNavigateToLeft,
onNavigateUp: paneActions.onNavigateUpLeft,
onRefresh: paneActions.onRefreshLeft,
onRefreshTab: paneActions.onRefreshTabLeft,
onSetFilenameEncoding: paneActions.onSetFilenameEncodingLeft,
onOpenEntry: fileOps.onOpenEntryLeft,
onToggleSelection: paneActions.onToggleSelectionLeft,
@@ -92,9 +152,14 @@ export const useSftpViewPaneCallbacks = ({
onClearSelection: paneActions.onClearSelectionLeft,
onSetFilter: paneActions.onSetFilterLeft,
onCreateDirectory: paneActions.onCreateDirectoryLeft,
onCreateDirectoryAtPath: paneActions.onCreateDirectoryAtPathLeft,
onCreateFile: paneActions.onCreateFileLeft,
onCreateFileAtPath: paneActions.onCreateFileAtPathLeft,
onDeleteFiles: paneActions.onDeleteFilesLeft,
onDeleteFilesAtPath: paneActions.onDeleteFilesAtPathLeft,
onRenameFile: paneActions.onRenameFileLeft,
onRenameFileAtPath: paneActions.onRenameFileAtPathLeft,
onMoveEntriesToPath: paneActions.onMoveEntriesToPathLeft,
onCopyToOtherPane: paneActions.onCopyToOtherPaneLeft,
onReceiveFromOtherPane: paneActions.onReceiveFromOtherPaneLeft,
onEditPermissions: fileOps.onEditPermissionsLeft,
@@ -103,6 +168,7 @@ export const useSftpViewPaneCallbacks = ({
onOpenFileWith: fileOps.onOpenFileWithLeft,
onDownloadFile: fileOps.onDownloadFileLeft,
onUploadExternalFiles: fileOps.onUploadExternalFilesLeft,
onListDirectory: makeListDirectory("left", () => sftpRef.current.leftPane),
}),
[],
);
@@ -111,9 +177,11 @@ export const useSftpViewPaneCallbacks = ({
() => ({
onConnect: paneActions.onConnectRight,
onDisconnect: paneActions.onDisconnectRight,
onPrepareSelection: paneActions.onPrepareSelectionRight,
onNavigateTo: paneActions.onNavigateToRight,
onNavigateUp: paneActions.onNavigateUpRight,
onRefresh: paneActions.onRefreshRight,
onRefreshTab: paneActions.onRefreshTabRight,
onSetFilenameEncoding: paneActions.onSetFilenameEncodingRight,
onOpenEntry: fileOps.onOpenEntryRight,
onToggleSelection: paneActions.onToggleSelectionRight,
@@ -121,9 +189,14 @@ export const useSftpViewPaneCallbacks = ({
onClearSelection: paneActions.onClearSelectionRight,
onSetFilter: paneActions.onSetFilterRight,
onCreateDirectory: paneActions.onCreateDirectoryRight,
onCreateDirectoryAtPath: paneActions.onCreateDirectoryAtPathRight,
onCreateFile: paneActions.onCreateFileRight,
onCreateFileAtPath: paneActions.onCreateFileAtPathRight,
onDeleteFiles: paneActions.onDeleteFilesRight,
onDeleteFilesAtPath: paneActions.onDeleteFilesAtPathRight,
onRenameFile: paneActions.onRenameFileRight,
onRenameFileAtPath: paneActions.onRenameFileAtPathRight,
onMoveEntriesToPath: paneActions.onMoveEntriesToPathRight,
onCopyToOtherPane: paneActions.onCopyToOtherPaneRight,
onReceiveFromOtherPane: paneActions.onReceiveFromOtherPaneRight,
onEditPermissions: fileOps.onEditPermissionsRight,
@@ -132,6 +205,7 @@ export const useSftpViewPaneCallbacks = ({
onOpenFileWith: fileOps.onOpenFileWithRight,
onDownloadFile: fileOps.onDownloadFileRight,
onUploadExternalFiles: fileOps.onUploadExternalFilesRight,
onListDirectory: makeListDirectory("right", () => sftpRef.current.rightPane),
}),
[],
);

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

@@ -7,7 +7,7 @@
// Utilities
export {
formatBytes, formatDate,
formatSpeed, formatTransferBytes, getFileIcon, isNavigableDirectory, isHiddenFile, isWindowsHiddenFile, filterHiddenFiles, type ColumnWidths, type SortField,
formatSpeed, formatTransferBytes, getFileIcon, isNavigableDirectory, isHiddenFile, isWindowsHiddenFile, filterHiddenFiles, sortSftpEntries, type ColumnWidths, type SortField,
type SortOrder
} from './utils';

View File

@@ -22,8 +22,160 @@ import {
Terminal,
} from 'lucide-react';
import React from 'react';
import type { LucideIcon } from 'lucide-react';
import { SftpFileEntry } from '../../types';
// Pre-built icon maps for O(1) lookup in getFileIcon
type IconDef = [LucideIcon, string?];
const EXTENSION_ICON_MAP = new Map<string, IconDef>([
// Documents
['doc', [FileText, "text-blue-500"]],
['docx', [FileText, "text-blue-500"]],
['rtf', [FileText, "text-blue-500"]],
['odt', [FileText, "text-blue-500"]],
['xls', [FileSpreadsheet, "text-green-500"]],
['xlsx', [FileSpreadsheet, "text-green-500"]],
['csv', [FileSpreadsheet, "text-green-500"]],
['ods', [FileSpreadsheet, "text-green-500"]],
['ppt', [FileType, "text-orange-500"]],
['pptx', [FileType, "text-orange-500"]],
['odp', [FileType, "text-orange-500"]],
['pdf', [FileText, "text-red-500"]],
// Code/Scripts
['js', [FileCode, "text-yellow-500"]],
['jsx', [FileCode, "text-yellow-500"]],
['ts', [FileCode, "text-yellow-500"]],
['tsx', [FileCode, "text-yellow-500"]],
['mjs', [FileCode, "text-yellow-500"]],
['cjs', [FileCode, "text-yellow-500"]],
['py', [FileCode, "text-blue-400"]],
['pyc', [FileCode, "text-blue-400"]],
['pyw', [FileCode, "text-blue-400"]],
['sh', [Terminal, "text-green-400"]],
['bash', [Terminal, "text-green-400"]],
['zsh', [Terminal, "text-green-400"]],
['fish', [Terminal, "text-green-400"]],
['bat', [Terminal, "text-green-400"]],
['cmd', [Terminal, "text-green-400"]],
['ps1', [Terminal, "text-green-400"]],
['c', [FileCode, "text-blue-600"]],
['cpp', [FileCode, "text-blue-600"]],
['h', [FileCode, "text-blue-600"]],
['hpp', [FileCode, "text-blue-600"]],
['cc', [FileCode, "text-blue-600"]],
['cxx', [FileCode, "text-blue-600"]],
['java', [FileCode, "text-orange-600"]],
['class', [FileCode, "text-orange-600"]],
['jar', [FileCode, "text-orange-600"]],
['go', [FileCode, "text-cyan-500"]],
['rs', [FileCode, "text-orange-400"]],
['rb', [FileCode, "text-red-400"]],
['php', [FileCode, "text-purple-500"]],
['html', [Globe, "text-orange-500"]],
['htm', [Globe, "text-orange-500"]],
['xhtml', [Globe, "text-orange-500"]],
['css', [FileCode, "text-blue-500"]],
['scss', [FileCode, "text-blue-500"]],
['sass', [FileCode, "text-blue-500"]],
['less', [FileCode, "text-blue-500"]],
['vue', [FileCode, "text-green-500"]],
['svelte', [FileCode, "text-green-500"]],
// Config/Data
['json', [FileCode, "text-yellow-600"]],
['json5', [FileCode, "text-yellow-600"]],
['xml', [FileCode, "text-orange-400"]],
['xsl', [FileCode, "text-orange-400"]],
['xslt', [FileCode, "text-orange-400"]],
['yml', [Settings, "text-pink-400"]],
['yaml', [Settings, "text-pink-400"]],
['toml', [Settings, "text-gray-400"]],
['ini', [Settings, "text-gray-400"]],
['conf', [Settings, "text-gray-400"]],
['cfg', [Settings, "text-gray-400"]],
['config', [Settings, "text-gray-400"]],
['env', [Lock, "text-yellow-500"]],
['sql', [Database, "text-blue-400"]],
['sqlite', [Database, "text-blue-400"]],
['db', [Database, "text-blue-400"]],
// Images
['jpg', [FileImage, "text-purple-400"]],
['jpeg', [FileImage, "text-purple-400"]],
['png', [FileImage, "text-purple-400"]],
['gif', [FileImage, "text-purple-400"]],
['bmp', [FileImage, "text-purple-400"]],
['webp', [FileImage, "text-purple-400"]],
['svg', [FileImage, "text-purple-400"]],
['ico', [FileImage, "text-purple-400"]],
['tiff', [FileImage, "text-purple-400"]],
['tif', [FileImage, "text-purple-400"]],
['heic', [FileImage, "text-purple-400"]],
['heif', [FileImage, "text-purple-400"]],
['avif', [FileImage, "text-purple-400"]],
// Videos
['mp4', [FileVideo, "text-pink-500"]],
['mkv', [FileVideo, "text-pink-500"]],
['avi', [FileVideo, "text-pink-500"]],
['mov', [FileVideo, "text-pink-500"]],
['wmv', [FileVideo, "text-pink-500"]],
['flv', [FileVideo, "text-pink-500"]],
['webm', [FileVideo, "text-pink-500"]],
['m4v', [FileVideo, "text-pink-500"]],
['3gp', [FileVideo, "text-pink-500"]],
['mpeg', [FileVideo, "text-pink-500"]],
['mpg', [FileVideo, "text-pink-500"]],
// Audio
['mp3', [FileAudio, "text-green-400"]],
['wav', [FileAudio, "text-green-400"]],
['flac', [FileAudio, "text-green-400"]],
['aac', [FileAudio, "text-green-400"]],
['ogg', [FileAudio, "text-green-400"]],
['m4a', [FileAudio, "text-green-400"]],
['wma', [FileAudio, "text-green-400"]],
['opus', [FileAudio, "text-green-400"]],
['aiff', [FileAudio, "text-green-400"]],
// Archives
['zip', [FileArchive, "text-amber-500"]],
['rar', [FileArchive, "text-amber-500"]],
['7z', [FileArchive, "text-amber-500"]],
['tar', [FileArchive, "text-amber-500"]],
['gz', [FileArchive, "text-amber-500"]],
['bz2', [FileArchive, "text-amber-500"]],
['xz', [FileArchive, "text-amber-500"]],
['tgz', [FileArchive, "text-amber-500"]],
['tbz2', [FileArchive, "text-amber-500"]],
['lz', [FileArchive, "text-amber-500"]],
['lzma', [FileArchive, "text-amber-500"]],
['cab', [FileArchive, "text-amber-500"]],
['iso', [FileArchive, "text-amber-500"]],
['dmg', [FileArchive, "text-amber-500"]],
// Executables
['exe', [File, "text-red-400"]],
['msi', [File, "text-red-400"]],
['app', [File, "text-red-400"]],
['deb', [File, "text-red-400"]],
['rpm', [File, "text-red-400"]],
['apk', [File, "text-red-400"]],
['ipa', [File, "text-red-400"]],
['dll', [File, "text-gray-500"]],
['so', [File, "text-gray-500"]],
['dylib', [File, "text-gray-500"]],
// Keys/Certs
['pem', [Key, "text-yellow-400"]],
['crt', [Key, "text-yellow-400"]],
['cer', [Key, "text-yellow-400"]],
['key', [Key, "text-yellow-400"]],
['pub', [Key, "text-yellow-400"]],
['ppk', [Key, "text-yellow-400"]],
// Text/Markdown
['md', [FileText, "text-gray-400"]],
['markdown', [FileText, "text-gray-400"]],
['mdx', [FileText, "text-gray-400"]],
['txt', [FileText, "text-muted-foreground"]],
['log', [FileText, "text-muted-foreground"]],
['text', [FileText, "text-muted-foreground"]],
]);
/**
* Format bytes with appropriate unit (B, KB, MB, GB)
*/
@@ -70,7 +222,8 @@ export const formatSpeed = (bytesPerSecond: number): string => {
};
/**
* Comprehensive file icon helper - returns JSX element based on file type
* Comprehensive file icon helper - returns JSX element based on file type.
* Uses pre-built Map for O(1) extension lookup.
*/
export const getFileIcon = (entry: SftpFileEntry): React.ReactElement => {
if (entry.type === 'directory') return React.createElement(Folder, { size: 14 });
@@ -80,89 +233,13 @@ export const getFileIcon = (entry: SftpFileEntry): React.ReactElement => {
return React.createElement(ExternalLink, { size: 14, className: "text-cyan-500" });
}
const ext = entry.name.split('.').pop()?.toLowerCase() || '';
const ext = entry.name.includes('.') ? entry.name.split('.').pop()?.toLowerCase() ?? '' : '';
// Documents
if (['doc', 'docx', 'rtf', 'odt'].includes(ext))
return React.createElement(FileText, { size: 14, className: "text-blue-500" });
if (['xls', 'xlsx', 'csv', 'ods'].includes(ext))
return React.createElement(FileSpreadsheet, { size: 14, className: "text-green-500" });
if (['ppt', 'pptx', 'odp'].includes(ext))
return React.createElement(FileType, { size: 14, className: "text-orange-500" });
if (['pdf'].includes(ext))
return React.createElement(FileText, { size: 14, className: "text-red-500" });
// Code/Scripts
if (['js', 'jsx', 'ts', 'tsx', 'mjs', 'cjs'].includes(ext))
return React.createElement(FileCode, { size: 14, className: "text-yellow-500" });
if (['py', 'pyc', 'pyw'].includes(ext))
return React.createElement(FileCode, { size: 14, className: "text-blue-400" });
if (['sh', 'bash', 'zsh', 'fish', 'bat', 'cmd', 'ps1'].includes(ext))
return React.createElement(Terminal, { size: 14, className: "text-green-400" });
if (['c', 'cpp', 'h', 'hpp', 'cc', 'cxx'].includes(ext))
return React.createElement(FileCode, { size: 14, className: "text-blue-600" });
if (['java', 'class', 'jar'].includes(ext))
return React.createElement(FileCode, { size: 14, className: "text-orange-600" });
if (['go'].includes(ext))
return React.createElement(FileCode, { size: 14, className: "text-cyan-500" });
if (['rs'].includes(ext))
return React.createElement(FileCode, { size: 14, className: "text-orange-400" });
if (['rb'].includes(ext))
return React.createElement(FileCode, { size: 14, className: "text-red-400" });
if (['php'].includes(ext))
return React.createElement(FileCode, { size: 14, className: "text-purple-500" });
if (['html', 'htm', 'xhtml'].includes(ext))
return React.createElement(Globe, { size: 14, className: "text-orange-500" });
if (['css', 'scss', 'sass', 'less'].includes(ext))
return React.createElement(FileCode, { size: 14, className: "text-blue-500" });
if (['vue', 'svelte'].includes(ext))
return React.createElement(FileCode, { size: 14, className: "text-green-500" });
// Config/Data
if (['json', 'json5'].includes(ext))
return React.createElement(FileCode, { size: 14, className: "text-yellow-600" });
if (['xml', 'xsl', 'xslt'].includes(ext))
return React.createElement(FileCode, { size: 14, className: "text-orange-400" });
if (['yml', 'yaml'].includes(ext))
return React.createElement(Settings, { size: 14, className: "text-pink-400" });
if (['toml', 'ini', 'conf', 'cfg', 'config'].includes(ext))
return React.createElement(Settings, { size: 14, className: "text-gray-400" });
if (['env'].includes(ext))
return React.createElement(Lock, { size: 14, className: "text-yellow-500" });
if (['sql', 'sqlite', 'db'].includes(ext))
return React.createElement(Database, { size: 14, className: "text-blue-400" });
// Images
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg', 'ico', 'tiff', 'tif', 'heic', 'heif', 'avif'].includes(ext))
return React.createElement(FileImage, { size: 14, className: "text-purple-400" });
// Videos
if (['mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v', '3gp', 'mpeg', 'mpg'].includes(ext))
return React.createElement(FileVideo, { size: 14, className: "text-pink-500" });
// Audio
if (['mp3', 'wav', 'flac', 'aac', 'ogg', 'm4a', 'wma', 'opus', 'aiff'].includes(ext))
return React.createElement(FileAudio, { size: 14, className: "text-green-400" });
// Archives
if (['zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'tgz', 'tbz2', 'lz', 'lzma', 'cab', 'iso', 'dmg'].includes(ext))
return React.createElement(FileArchive, { size: 14, className: "text-amber-500" });
// Executables
if (['exe', 'msi', 'app', 'deb', 'rpm', 'apk', 'ipa'].includes(ext))
return React.createElement(File, { size: 14, className: "text-red-400" });
if (['dll', 'so', 'dylib'].includes(ext))
return React.createElement(File, { size: 14, className: "text-gray-500" });
// Keys/Certs
if (['pem', 'crt', 'cer', 'key', 'pub', 'ppk'].includes(ext))
return React.createElement(Key, { size: 14, className: "text-yellow-400" });
// Text/Markdown
if (['md', 'markdown', 'mdx'].includes(ext))
return React.createElement(FileText, { size: 14, className: "text-gray-400" });
if (['txt', 'log', 'text'].includes(ext))
return React.createElement(FileText, { size: 14, className: "text-muted-foreground" });
const iconDef = EXTENSION_ICON_MAP.get(ext);
if (iconDef) {
const [Icon, className] = iconDef;
return React.createElement(Icon, { size: 14, ...(className ? { className } : {}) });
}
// Default
return React.createElement(FileCode, { size: 14 });
@@ -180,6 +257,59 @@ export interface ColumnWidths {
type: number;
}
export const buildSftpColumnTemplate = (columnWidths: ColumnWidths): string => {
return [
`minmax(140px, ${columnWidths.name}fr)`,
`minmax(0, ${columnWidths.modified}fr)`,
`minmax(52px, ${columnWidths.size}fr)`,
`minmax(64px, ${columnWidths.type}fr)`,
].join(' ');
};
export const sortSftpEntries = (
entries: SftpFileEntry[],
sortField: SortField,
sortOrder: SortOrder,
): SftpFileEntry[] => {
if (!entries.length) return entries;
const sorted = [...entries].sort((a, b) => {
const aIsDir = isNavigableDirectory(a);
const bIsDir = isNavigableDirectory(b);
if (sortField !== 'type') {
if (aIsDir && !bIsDir) return -1;
if (!aIsDir && bIsDir) return 1;
}
let cmp = 0;
switch (sortField) {
case 'name':
cmp = a.name.localeCompare(b.name);
break;
case 'size':
cmp = (a.size || 0) - (b.size || 0);
break;
case 'modified':
cmp = (a.lastModified || 0) - (b.lastModified || 0);
break;
case 'type': {
const extA = aIsDir
? 'folder'
: a.name.split('.').pop()?.toLowerCase() || '';
const extB = bIsDir
? 'folder'
: b.name.split('.').pop()?.toLowerCase() || '';
cmp = extA.localeCompare(extB);
break;
}
}
return sortOrder === 'asc' ? cmp : -cmp;
});
return sorted;
};
/**
* Check if an entry is navigable like a directory
* This includes regular directories and symlinks that point to directories

View File

@@ -2,7 +2,7 @@
* Terminal Authentication Dialog
* Displays auth form with password/key selection for SSH connection
*/
import { AlertCircle, BadgeCheck, ChevronDown, Eye, EyeOff, Key, Lock } from 'lucide-react';
import { BadgeCheck, ChevronDown, Eye, EyeOff, Key, Lock, Unplug } from 'lucide-react';
import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
@@ -80,38 +80,42 @@ export const TerminalAuthDialog: React.FC<TerminalAuthDialogProps> = ({
return (
<>
{/* Auth method tabs */}
<div className="flex gap-1 p-1 bg-secondary/80 rounded-lg border border-border/60">
<div className="flex gap-1 p-1 bg-secondary/65 rounded-xl border border-border/50">
<button
className={cn(
"flex-1 flex items-center justify-center gap-2 py-2 text-sm font-medium rounded-md transition-all",
"flex-1 flex items-center justify-center gap-1.5 py-1.5 text-xs font-medium rounded-lg transition-all",
authMethod === 'password'
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-secondary"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/40"
)}
onClick={() => setAuthMethod('password')}
>
<Lock size={14} />
<Lock size={13} />
{t("terminal.auth.password")}
</button>
<button
className={cn(
"flex-1 flex items-center justify-center gap-2 py-2 text-sm font-medium rounded-md transition-all",
"flex-1 flex items-center justify-center gap-1.5 py-1.5 text-xs font-medium rounded-lg transition-all",
authMethod === 'key' || authMethod === 'certificate'
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-secondary"
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-background/40"
)}
onClick={() => setAuthMethod('key')}
>
<Key size={14} />
<Key size={13} />
{t("terminal.auth.sshKey")}
</button>
</div>
{/* Auth retry error message */}
{authRetryMessage && (
<div className="p-3 rounded-lg bg-destructive/10 border border-destructive/20 text-destructive text-sm flex items-center gap-2">
<AlertCircle size={16} />
{authRetryMessage}
<div className="flex items-center gap-2.5 rounded-xl border border-destructive/20 bg-destructive/7 px-3 py-2.5 text-xs text-foreground/90">
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-destructive/12 text-destructive">
<Unplug size={11} />
</div>
<div className="min-w-0 leading-4 text-destructive/95">
{authRetryMessage}
</div>
</div>
)}

View File

@@ -66,13 +66,15 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
const bg = themeColors?.background ?? '#0a0a0a';
const fg = themeColors?.foreground ?? '#d4d4d4';
const resolvedBg = 'var(--terminal-ui-bg, ' + bg + ')';
const resolvedFg = 'var(--terminal-ui-fg, ' + fg + ')';
return (
<div
className="flex-shrink-0"
style={{
background: `linear-gradient(to top, ${bg}, color-mix(in srgb, ${fg} 4%, ${bg} 96%))`,
borderTop: `1px solid color-mix(in srgb, ${fg} 10%, ${bg} 90%)`,
background: `linear-gradient(to top, ${resolvedBg}, color-mix(in srgb, ${resolvedFg} 4%, ${resolvedBg} 96%))`,
borderTop: `1px solid color-mix(in srgb, ${resolvedFg} 10%, ${resolvedBg} 90%)`,
borderRadius: '0 0 8px 8px',
padding: '6px 10px',
}}
@@ -97,24 +99,24 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
"placeholder:opacity-40",
)}
style={{
backgroundColor: `color-mix(in srgb, ${fg} 6%, ${bg} 94%)`,
color: fg,
border: `1px solid color-mix(in srgb, ${fg} 25%, ${bg} 75%)`,
backgroundColor: `color-mix(in srgb, ${resolvedFg} 6%, ${resolvedBg} 94%)`,
color: resolvedFg,
border: `1px solid color-mix(in srgb, ${resolvedFg} 25%, ${resolvedBg} 75%)`,
minHeight: '28px',
maxHeight: '120px',
boxShadow: `inset 0 1px 3px color-mix(in srgb, ${bg} 80%, transparent)`,
boxShadow: `inset 0 1px 3px color-mix(in srgb, ${resolvedBg} 80%, transparent)`,
}}
rows={1}
placeholder={t("terminal.composeBar.placeholder")}
onInput={handleInput}
onKeyDown={handleKeyDown}
onFocus={(e) => {
e.currentTarget.style.borderColor = `color-mix(in srgb, ${fg} 40%, ${bg} 60%)`;
e.currentTarget.style.boxShadow = `inset 0 1px 3px color-mix(in srgb, ${bg} 80%, transparent), 0 0 0 1px color-mix(in srgb, ${fg} 8%, transparent)`;
e.currentTarget.style.borderColor = `color-mix(in srgb, ${resolvedFg} 40%, ${resolvedBg} 60%)`;
e.currentTarget.style.boxShadow = `inset 0 1px 3px color-mix(in srgb, ${resolvedBg} 80%, transparent), 0 0 0 1px color-mix(in srgb, ${resolvedFg} 8%, transparent)`;
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = `color-mix(in srgb, ${fg} 25%, ${bg} 75%)`;
e.currentTarget.style.boxShadow = `inset 0 1px 3px color-mix(in srgb, ${bg} 80%, transparent)`;
e.currentTarget.style.borderColor = `color-mix(in srgb, ${resolvedFg} 25%, ${resolvedBg} 75%)`;
e.currentTarget.style.boxShadow = `inset 0 1px 3px color-mix(in srgb, ${resolvedBg} 80%, transparent)`;
}}
onCompositionStart={() => { isComposingRef.current = true; }}
onCompositionEnd={() => { isComposingRef.current = false; }}
@@ -125,14 +127,14 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
<button
className="h-7 w-7 flex items-center justify-center rounded-md transition-colors duration-150"
style={{
color: fg,
background: `color-mix(in srgb, ${fg} 20%, ${bg} 80%)`,
color: resolvedFg,
background: `color-mix(in srgb, ${resolvedFg} 20%, ${resolvedBg} 80%)`,
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = `color-mix(in srgb, ${fg} 30%, ${bg} 70%)`;
e.currentTarget.style.background = `color-mix(in srgb, ${resolvedFg} 30%, ${resolvedBg} 70%)`;
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = `color-mix(in srgb, ${fg} 20%, ${bg} 80%)`;
e.currentTarget.style.background = `color-mix(in srgb, ${resolvedFg} 20%, ${resolvedBg} 80%)`;
}}
onClick={handleSend}
title={t("terminal.composeBar.send")}
@@ -142,16 +144,16 @@ export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
<button
className="h-7 w-7 flex items-center justify-center rounded-md transition-colors duration-150"
style={{
color: `color-mix(in srgb, ${fg} 60%, ${bg} 40%)`,
background: `color-mix(in srgb, ${fg} 12%, ${bg} 88%)`,
color: `color-mix(in srgb, ${resolvedFg} 60%, ${resolvedBg} 40%)`,
background: `color-mix(in srgb, ${resolvedFg} 12%, ${resolvedBg} 88%)`,
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = `color-mix(in srgb, ${fg} 22%, ${bg} 78%)`;
e.currentTarget.style.color = fg;
e.currentTarget.style.background = `color-mix(in srgb, ${resolvedFg} 22%, ${resolvedBg} 78%)`;
e.currentTarget.style.color = resolvedFg;
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = `color-mix(in srgb, ${fg} 12%, ${bg} 88%)`;
e.currentTarget.style.color = `color-mix(in srgb, ${fg} 60%, ${bg} 40%)`;
e.currentTarget.style.background = `color-mix(in srgb, ${resolvedFg} 12%, ${resolvedBg} 88%)`;
e.currentTarget.style.color = `color-mix(in srgb, ${resolvedFg} 60%, ${resolvedBg} 40%)`;
}}
onClick={onClose}
title={t("terminal.composeBar.close")}

View File

@@ -84,14 +84,21 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
"absolute inset-0 z-20 flex items-center justify-center",
needsAuth ? "bg-black" : "bg-black/30"
)}>
<div className="w-[560px] max-w-[90vw] bg-background/95 border border-border/60 rounded-xl shadow-xl p-6 space-y-4">
<div
className="w-[480px] max-w-[88vw] rounded-xl shadow-xl p-4 space-y-3"
style={{
backgroundColor: 'color-mix(in srgb, var(--terminal-ui-bg, var(--background)) 95%, transparent)',
border: '1px solid color-mix(in srgb, var(--terminal-ui-fg, var(--foreground)) 12%, var(--terminal-ui-bg, var(--background)) 88%)',
color: 'var(--terminal-ui-fg, var(--foreground))',
}}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 min-w-0 flex-1">
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} className="h-10 w-10 rounded-lg shrink-0" />
<div className="flex items-center gap-2.5 min-w-0 flex-1">
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} className="h-8 w-8 rounded-md shrink-0" />
<div className="min-w-0">
{chainProgress ? (
<>
<div className="text-sm font-semibold truncate">
<div className="text-xs font-semibold truncate">
<span className="text-muted-foreground">
{t('terminal.connection.chainOf', {
current: chainProgress.currentHop,
@@ -101,14 +108,20 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
</span>
<span>{chainProgress.currentHostLabel}</span>
</div>
<div className="text-[11px] text-muted-foreground font-mono truncate">
<div
className="text-[10px] font-mono truncate"
style={{ color: 'color-mix(in srgb, var(--terminal-ui-fg, var(--foreground)) 58%, transparent)' }}
>
{t(protocolInfo.i18nKey)} {protocolInfo.showPort ? formatHostPort(host.hostname, protocolInfo.port) : host.hostname}
</div>
</>
) : (
<>
<div className="text-lg font-semibold truncate">{host.label}</div>
<div className="text-[11px] text-muted-foreground font-mono truncate">
<div className="text-base font-semibold truncate">{host.label}</div>
<div
className="text-[10px] font-mono truncate"
style={{ color: 'color-mix(in srgb, var(--terminal-ui-fg, var(--foreground)) 58%, transparent)' }}
>
{t(protocolInfo.i18nKey)} {protocolInfo.showPort ? formatHostPort(host.hostname, protocolInfo.port) : host.hostname}
</div>
</>
@@ -120,7 +133,7 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
<Button
size="sm"
variant="outline"
className="h-8 text-xs"
className="h-7 px-3 text-[11px]"
onClick={() => setShowLogs(!showLogs)}
>
{showLogs ? t('terminal.connection.hideLogs') : t('terminal.connection.showLogs')}
@@ -130,7 +143,7 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
<Button
size="sm"
variant="outline"
className="h-8 text-xs"
className="h-7 px-3 text-[11px]"
onClick={progressProps.onCancelConnect}
disabled={progressProps.isCancelling}
>
@@ -141,7 +154,7 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
className="h-7 w-7"
aria-label={t('terminal.connection.dismissDisconnectedDialog')}
title={t('terminal.connection.dismissDisconnectedDialog')}
onClick={onDismissDisconnected}
@@ -152,10 +165,10 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
</div>
</div>
<div className="space-y-2">
<div className="space-y-1.5">
<div className="flex items-center gap-3">
<div className={cn(
"h-8 w-8 rounded-lg flex items-center justify-center flex-shrink-0",
"h-7 w-7 rounded-md flex items-center justify-center flex-shrink-0",
needsAuth
? "bg-primary text-primary-foreground"
: hasError
@@ -164,7 +177,7 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
? "bg-primary/15 text-primary"
: "bg-muted text-muted-foreground"
)}>
<Plug size={14} />
<Plug size={13} />
</div>
<div className="flex-1 h-1.5 rounded-full bg-border/60 overflow-hidden relative">
<div
@@ -178,13 +191,13 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
/>
</div>
<div className={cn(
"h-8 w-8 rounded-lg flex items-center justify-center flex-shrink-0",
"h-7 w-7 rounded-md flex items-center justify-center flex-shrink-0",
hasError ? "bg-destructive/20 text-destructive" : "bg-muted text-muted-foreground"
)}>
{isConnecting ? (
<Loader2 size={14} className="animate-spin" />
<Loader2 size={13} className="animate-spin" />
) : (
<TerminalSquare size={14} />
<TerminalSquare size={13} />
)}
</div>
</div>

View File

@@ -35,7 +35,7 @@ export const TerminalConnectionProgress: React.FC<TerminalConnectionProgressProp
return (
<>
<div className="flex items-start justify-between gap-3 text-xs text-muted-foreground">
<div className="flex items-start justify-between gap-3 text-[11px] text-muted-foreground">
<div className="flex min-w-0 items-start gap-2">
{status === 'connecting' ? (
<>
@@ -57,8 +57,8 @@ export const TerminalConnectionProgress: React.FC<TerminalConnectionProgressProp
{showLogs && (
<div className="rounded-md border border-border/35 bg-background/40">
<ScrollArea className="max-h-52 p-3">
<div className="space-y-1 text-sm text-foreground/90">
<ScrollArea className="max-h-44 p-2.5">
<div className="space-y-1 text-xs text-foreground/90">
{progressLogs.map((line, idx) => (
<div key={idx} className="flex items-start gap-2">
<div className="mt-[0.4rem] h-1.5 w-1.5 flex-shrink-0 rounded-full bg-emerald-500" />
@@ -79,11 +79,11 @@ export const TerminalConnectionProgress: React.FC<TerminalConnectionProgressProp
<div className="flex justify-end gap-2">
{status !== 'connecting' && (
<>
<Button variant="ghost" size="sm" className="h-8" onClick={onCloseSession}>
<Button variant="ghost" size="sm" className="h-7 px-3 text-[11px]" onClick={onCloseSession}>
{t('terminal.toolbar.closeSession')}
</Button>
<Button size="sm" className="h-8" onClick={onRetry}>
<Play className="h-3 w-3 mr-2" /> {t('terminal.progress.startOver')}
<Button size="sm" className="h-7 px-3 text-[11px]" onClick={onRetry}>
<Play className="h-3 w-3 mr-1.5" /> {t('terminal.progress.startOver')}
</Button>
</>
)}

View File

@@ -73,12 +73,19 @@ export const TerminalSearchBar: React.FC<TerminalSearchBarProps> = ({
return (
<div
className="flex items-center gap-1.5 px-2 pt-0 pb-2 bg-black/50 backdrop-blur-sm"
style={{
backgroundColor: 'color-mix(in srgb, var(--terminal-ui-bg, #000000) 86%, transparent)',
}}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
{/* Search input */}
<div className="relative flex-1">
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-white/40" />
<Search
size={12}
className="absolute left-2 top-1/2 -translate-y-1/2"
style={{ color: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 40%, transparent)' }}
/>
<input
ref={inputRef}
type="text"
@@ -88,13 +95,20 @@ export const TerminalSearchBar: React.FC<TerminalSearchBarProps> = ({
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
placeholder={t("terminal.search.placeholder")}
className="w-full h-6 pl-7 pr-2 text-[11px] bg-white/5 border-none rounded text-white placeholder:text-white/30 focus:outline-none focus:bg-white/10"
className="w-full h-6 pl-7 pr-2 text-[11px] border-none rounded placeholder:opacity-40 focus:outline-none"
style={{
backgroundColor: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 5%, transparent)',
color: 'var(--terminal-ui-fg, #ffffff)',
}}
/>
</div>
{/* Match count indicator - only show when no results */}
{searchTerm.length > 0 && matchCount?.total === 0 && (
<span className="text-[10px] text-white/50 flex-shrink-0">
<span
className="text-[10px] flex-shrink-0"
style={{ color: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 50%, transparent)' }}
>
{t("terminal.search.noResults")}
</span>
)}
@@ -105,7 +119,10 @@ export const TerminalSearchBar: React.FC<TerminalSearchBarProps> = ({
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 text-white/60 hover:text-white hover:bg-white/10 disabled:opacity-30"
className="h-6 w-6 disabled:opacity-30"
style={{
color: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 60%, transparent)',
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
@@ -123,7 +140,10 @@ export const TerminalSearchBar: React.FC<TerminalSearchBarProps> = ({
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 text-white/60 hover:text-white hover:bg-white/10 disabled:opacity-30"
className="h-6 w-6 disabled:opacity-30"
style={{
color: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 60%, transparent)',
}}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();

View File

@@ -39,16 +39,20 @@ const ThemeItem = memo(({
onClick={() => onSelect(theme.id)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(theme.id); } }}
className={cn(
'w-full flex items-center gap-2.5 px-3 py-2 text-left group cursor-pointer',
isSelected
? 'bg-accent/50'
: 'hover:bg-accent/50'
'w-full flex items-center gap-2.5 px-3 py-2 text-left group cursor-pointer'
)}
style={{ backgroundColor: isSelected ? 'var(--terminal-panel-active)' : 'transparent' }}
onMouseEnter={(e) => {
if (!isSelected) e.currentTarget.style.backgroundColor = 'var(--terminal-panel-hover)';
}}
onMouseLeave={(e) => {
if (!isSelected) e.currentTarget.style.backgroundColor = 'transparent';
}}
>
{/* Color swatch */}
<div
className="w-6 h-6 rounded-md flex-shrink-0 flex flex-col justify-center items-start pl-0.5 gap-0.5 border border-border/50"
style={{ backgroundColor: theme.colors.background }}
className="h-6 w-8 rounded-[4px] flex-shrink-0 flex flex-col justify-center items-start pl-1 gap-0.5 border-[0.5px]"
style={{ backgroundColor: theme.colors.background, borderColor: 'var(--terminal-panel-border)' }}
>
<div className="h-0.5 w-2.5 rounded-full" style={{ backgroundColor: theme.colors.green }} />
<div className="h-0.5 w-4 rounded-full" style={{ backgroundColor: theme.colors.blue }} />
@@ -58,7 +62,7 @@ const ThemeItem = memo(({
<div className="text-xs font-medium truncate">
{theme.name}
</div>
<div className="text-[10px] text-muted-foreground capitalize">
<div className="text-[10px] capitalize" style={{ color: 'var(--terminal-panel-muted)' }}>
{theme.type}
{theme.isCustom && ' • custom'}
</div>
@@ -69,13 +73,14 @@ const ThemeItem = memo(({
tabIndex={0}
onClick={(e) => { e.stopPropagation(); onEdit(theme.id); }}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.stopPropagation(); e.preventDefault(); onEdit(theme.id); } }}
className="w-5 h-5 rounded flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted/80 opacity-0 group-hover:opacity-100 transition-all"
className="w-5 h-5 rounded flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all"
style={{ color: 'var(--terminal-panel-muted)' }}
>
<Pencil size={10} />
</div>
)}
{isSelected && !onEdit && (
<Check size={12} className="text-primary flex-shrink-0" />
<Check size={12} className="flex-shrink-0" style={{ color: 'var(--terminal-panel-fg)' }} />
)}
</div>
));
@@ -94,11 +99,15 @@ const FontItem = memo(({
<button
onClick={() => onSelect(font.id)}
className={cn(
'w-full flex items-center gap-2.5 px-3 py-2 text-left transition-colors',
isSelected
? 'bg-accent/50'
: 'hover:bg-accent/50'
'w-full flex items-center gap-2.5 px-3 py-2 text-left transition-colors'
)}
style={{ backgroundColor: isSelected ? 'var(--terminal-panel-active)' : 'transparent' }}
onMouseEnter={(e) => {
if (!isSelected) e.currentTarget.style.backgroundColor = 'var(--terminal-panel-hover)';
}}
onMouseLeave={(e) => {
if (!isSelected) e.currentTarget.style.backgroundColor = 'transparent';
}}
>
<div className="flex-1 min-w-0">
<div
@@ -107,10 +116,10 @@ const FontItem = memo(({
>
{font.name}
</div>
<div className="text-[10px] text-muted-foreground truncate">{font.description}</div>
<div className="text-[10px] truncate" style={{ color: 'var(--terminal-panel-muted)' }}>{font.description}</div>
</div>
{isSelected && (
<Check size={12} className="text-primary flex-shrink-0" />
<Check size={12} className="flex-shrink-0" style={{ color: 'var(--terminal-panel-fg)' }} />
)}
</button>
));
@@ -132,6 +141,10 @@ interface ThemeSidePanelProps {
onFontSizeChange: (fontSize: number) => void;
onFontSizeReset?: () => void;
isVisible?: boolean;
previewColors?: {
background: string;
foreground: string;
};
}
const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
@@ -150,6 +163,7 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
onFontSizeChange,
onFontSizeReset,
isVisible = true,
previewColors,
}) => {
const { t } = useI18n();
const availableFonts = useAvailableFonts();
@@ -245,44 +259,57 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
if (!isVisible) return null;
const builtinThemes = TERMINAL_THEMES;
const panelVars = {
['--terminal-panel-bg' as never]: previewColors?.background ?? 'var(--background)',
['--terminal-panel-fg' as never]: previewColors?.foreground ?? 'var(--foreground)',
['--terminal-panel-muted' as never]: 'color-mix(in srgb, var(--terminal-panel-fg) 58%, var(--terminal-panel-bg) 42%)',
['--terminal-panel-border' as never]: 'color-mix(in srgb, var(--terminal-panel-fg) 12%, var(--terminal-panel-bg) 88%)',
['--terminal-panel-hover' as never]: 'color-mix(in srgb, var(--terminal-panel-fg) 12%, var(--terminal-panel-bg) 88%)',
['--terminal-panel-active' as never]: 'color-mix(in srgb, var(--terminal-panel-fg) 16%, var(--terminal-panel-bg) 84%)',
} as React.CSSProperties;
return (
<>
<div className="h-full flex flex-col bg-background overflow-hidden">
<div
className="h-full flex flex-col overflow-hidden"
style={{
...panelVars,
backgroundColor: 'var(--terminal-panel-bg)',
color: 'var(--terminal-panel-fg)',
borderColor: 'var(--terminal-panel-border)',
}}
>
{/* Tab Bar */}
<div className="flex p-1.5 gap-0.5 shrink-0 border-b border-border/50">
<div className="flex p-1.5 gap-0.5 shrink-0 border-b" style={{ borderColor: 'var(--terminal-panel-border)' }}>
<button
onClick={() => { setActiveTab('theme'); setEditingTheme(null); }}
className={cn(
'flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all',
activeTab === 'theme'
? 'bg-primary/15 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
)}
className="flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all"
style={{
backgroundColor: activeTab === 'theme' ? 'var(--terminal-panel-active)' : 'transparent',
color: activeTab === 'theme' ? 'var(--terminal-panel-fg)' : 'var(--terminal-panel-muted)',
}}
>
<Palette size={12} />
{t('terminal.themeModal.tab.theme')}
</button>
<button
onClick={() => setActiveTab('font')}
className={cn(
'flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all',
activeTab === 'font'
? 'bg-primary/15 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
)}
className="flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all"
style={{
backgroundColor: activeTab === 'font' ? 'var(--terminal-panel-active)' : 'transparent',
color: activeTab === 'font' ? 'var(--terminal-panel-fg)' : 'var(--terminal-panel-muted)',
}}
>
<Type size={12} />
{t('terminal.themeModal.tab.font')}
</button>
<button
onClick={() => setActiveTab('custom')}
className={cn(
'flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all',
activeTab === 'custom'
? 'bg-primary/15 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
)}
className="flex-1 flex items-center justify-center gap-1 px-1.5 py-1.5 rounded-md text-[11px] font-medium transition-all"
style={{
backgroundColor: activeTab === 'custom' ? 'var(--terminal-panel-active)' : 'transparent',
color: activeTab === 'custom' ? 'var(--terminal-panel-fg)' : 'var(--terminal-panel-muted)',
}}
>
<Sparkles size={12} />
{t('terminal.themeModal.tab.custom')}
@@ -304,7 +331,7 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
))}
{customThemes.length > 0 && (
<>
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mt-2 mb-1 px-1 font-semibold">
<div className="text-[9px] uppercase tracking-wider mt-2 mb-1 px-1 font-semibold" style={{ color: 'var(--terminal-panel-muted)' }}>
{t('terminal.customTheme.section')}
</div>
{customThemes.map(theme => (
@@ -320,7 +347,7 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
)}
{canResetTheme && (
<>
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mt-2 mb-1 px-1 font-semibold">
<div className="text-[9px] uppercase tracking-wider mt-2 mb-1 px-1 font-semibold" style={{ color: 'var(--terminal-panel-muted)' }}>
{t('terminal.themeModal.globalTheme')}
</div>
<ThemeItem
@@ -344,7 +371,7 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
))}
{canResetFontFamily && (
<>
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mt-2 mb-1 px-1 font-semibold">
<div className="text-[9px] uppercase tracking-wider mt-2 mb-1 px-1 font-semibold" style={{ color: 'var(--terminal-panel-muted)' }}>
{t('terminal.themeModal.globalFont')}
</div>
<FontItem
@@ -360,26 +387,36 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
<div>
<button
onClick={handleNewTheme}
className="w-full flex items-center gap-2.5 px-3 py-2 text-left hover:bg-accent/50 transition-colors"
className="w-full flex items-center gap-2.5 px-3 py-2 text-left transition-colors"
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = 'var(--terminal-panel-hover)'; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; }}
>
<div className="w-6 h-6 rounded-md flex items-center justify-center bg-primary/10 text-primary shrink-0">
<Plus size={12} />
</div>
<div
className="w-6 h-6 rounded-md flex items-center justify-center shrink-0"
style={{
backgroundColor: 'color-mix(in srgb, var(--terminal-panel-fg) 10%, transparent)',
color: 'var(--terminal-panel-fg)',
}}
>
<Plus size={12} />
</div>
<div>
<div className="text-xs font-medium text-foreground">{t('terminal.customTheme.new')}</div>
<div className="text-[10px] text-muted-foreground">{t('terminal.customTheme.newDesc')}</div>
<div className="text-xs font-medium">{t('terminal.customTheme.new')}</div>
<div className="text-[10px]" style={{ color: 'var(--terminal-panel-muted)' }}>{t('terminal.customTheme.newDesc')}</div>
</div>
</button>
<button
onClick={handleImportFile}
className="w-full flex items-center gap-2.5 px-3 py-2 text-left hover:bg-accent/50 transition-colors"
className="w-full flex items-center gap-2.5 px-3 py-2 text-left transition-colors"
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = 'var(--terminal-panel-hover)'; }}
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; }}
>
<div className="w-6 h-6 rounded-md flex items-center justify-center bg-blue-500/10 text-blue-500 shrink-0">
<Download size={12} />
</div>
<div>
<div className="text-xs font-medium text-foreground">{t('terminal.customTheme.import')}</div>
<div className="text-[10px] text-muted-foreground">{t('terminal.customTheme.importDesc')}</div>
<div className="text-xs font-medium">{t('terminal.customTheme.import')}</div>
<div className="text-[10px]" style={{ color: 'var(--terminal-panel-muted)' }}>{t('terminal.customTheme.importDesc')}</div>
</div>
</button>
<input
@@ -391,7 +428,7 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
/>
{customThemes.length > 0 && (
<>
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mt-2 mb-1 px-1 font-semibold">
<div className="text-[9px] uppercase tracking-wider mt-2 mb-1 px-1 font-semibold" style={{ color: 'var(--terminal-panel-muted)' }}>
{t('terminal.customTheme.yourThemes')}
</div>
{customThemes.map(theme => (
@@ -412,36 +449,47 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
{/* Font Size Control (only in font tab) */}
{activeTab === 'font' && (
<div className="p-2.5 border-t border-border/50 shrink-0">
<div className="p-2.5 border-t shrink-0" style={{ borderColor: 'var(--terminal-panel-border)' }}>
<div className="flex items-center justify-between gap-2 mb-1.5">
<div className="text-[9px] uppercase tracking-wider text-muted-foreground font-semibold">
<div className="text-[9px] uppercase tracking-wider font-semibold" style={{ color: 'var(--terminal-panel-muted)' }}>
{t('terminal.themeModal.fontSize')}
</div>
{canResetFontSize && (
<button
onClick={onFontSizeReset}
className="text-[10px] font-medium text-primary hover:opacity-80 transition-opacity"
className="text-[10px] font-medium hover:opacity-80 transition-opacity"
style={{ color: 'var(--terminal-panel-fg)' }}
>
{t('common.useGlobal')}
</button>
)}
</div>
<div className="flex items-center justify-between gap-2 bg-muted/30 rounded-lg p-1.5">
<div className="flex items-center justify-between gap-2 rounded-lg p-1.5" style={{ backgroundColor: 'var(--terminal-panel-hover)' }}>
<button
onClick={() => handleFontSizeChange(-1)}
disabled={currentFontSize <= MIN_FONT_SIZE}
className="w-7 h-7 rounded-md flex items-center justify-center bg-background hover:bg-accent text-foreground disabled:opacity-30 disabled:cursor-not-allowed transition-colors border border-border"
className="w-7 h-7 rounded-md flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed transition-colors border"
style={{
backgroundColor: 'var(--terminal-panel-bg)',
color: 'var(--terminal-panel-fg)',
borderColor: 'var(--terminal-panel-border)',
}}
>
<Minus size={12} />
</button>
<div className="flex items-baseline gap-1">
<span className="text-lg font-bold text-foreground tabular-nums">{currentFontSize}</span>
<span className="text-[9px] text-muted-foreground">px</span>
<span className="text-lg font-bold tabular-nums">{currentFontSize}</span>
<span className="text-[9px]" style={{ color: 'var(--terminal-panel-muted)' }}>px</span>
</div>
<button
onClick={() => handleFontSizeChange(1)}
disabled={currentFontSize >= MAX_FONT_SIZE}
className="w-7 h-7 rounded-md flex items-center justify-center bg-background hover:bg-accent text-foreground disabled:opacity-30 disabled:cursor-not-allowed transition-colors border border-border"
className="w-7 h-7 rounded-md flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed transition-colors border"
style={{
backgroundColor: 'var(--terminal-panel-bg)',
color: 'var(--terminal-panel-fg)',
borderColor: 'var(--terminal-panel-border)',
}}
>
<Plus size={12} />
</button>
@@ -450,8 +498,8 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
)}
{/* Current selection info */}
<div className="px-2.5 py-1.5 border-t border-border/50 shrink-0">
<div className="text-[9px] text-muted-foreground truncate">
<div className="px-2.5 py-1.5 border-t shrink-0" style={{ borderColor: 'var(--terminal-panel-border)' }}>
<div className="text-[9px] truncate" style={{ color: 'var(--terminal-panel-muted)' }}>
{allThemes.find(t => t.id === currentThemeId)?.name ?? currentThemeId} {availableFonts.find(f => f.id === currentFontFamilyId)?.name ?? currentFontFamilyId} {currentFontSize}px
</div>
</div>

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

@@ -0,0 +1,456 @@
/**
* Popup autocomplete menu for terminal.
* Renders a floating list of completion suggestions near the terminal cursor.
* Shows a detail tooltip for the selected/hovered item with full description.
* Colors are derived from the active terminal theme for visual consistency.
*/
import React, { useEffect, useRef, useState, memo } from "react";
import { Folder, File, Link } from "lucide-react";
import type { CompletionSuggestion, SuggestionSource } from "./completionEngine";
export interface AutocompleteThemeColors {
background: string;
foreground: string;
selection: string;
cursor: string;
}
export interface SubDirEntry {
name: string;
type: "file" | "directory" | "symlink";
}
export interface SubDirPanel {
entries: SubDirEntry[];
selectedIndex: number;
dirPath: string;
}
interface AutocompletePopupProps {
suggestions: CompletionSuggestion[];
selectedIndex: number;
/** Position relative to the terminal container (not viewport) */
position: { x: number; y: number };
/** Current input line bounds relative to the terminal container */
cursorLineTop: number;
cursorLineBottom: number;
visible: boolean;
expandUpward?: boolean;
themeColors?: AutocompleteThemeColors;
onSelect: (suggestion: CompletionSuggestion) => void;
maxHeight?: number;
subDirPanels?: SubDirPanel[];
subDirFocusLevel?: number;
/** Reference to the terminal container for calculating fixed position */
containerRef?: React.RefObject<HTMLDivElement | null>;
/** Ask the autocomplete controller to recompute cursor-relative popup position */
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 }> = {
history: { label: "h", fullLabel: "History", fallbackColor: "#FBBF24" },
command: { label: "c", fullLabel: "Command", fallbackColor: "#34D399" },
subcommand: { label: "s", fullLabel: "Subcommand", fallbackColor: "#60A5FA" },
option: { label: "o", fullLabel: "Option", fallbackColor: "#A78BFA" },
arg: { label: "a", fullLabel: "Argument", fallbackColor: "#F87171" },
path: { label: "p", fullLabel: "Path", fallbackColor: "#38BDF8" },
};
/** Lucide icon components for file types in path suggestions */
const FILE_TYPE_CONFIG: Record<string, { Icon: React.FC<{ size?: number; color?: string }>; color: string }> = {
directory: { Icon: Folder, color: "#38BDF8" },
file: { Icon: File, color: "#94A3B8" },
symlink: { Icon: Link, color: "#A78BFA" },
};
const FileTypeIcon: React.FC<{ fileType: string }> = ({ fileType }) => {
const cfg = FILE_TYPE_CONFIG[fileType] ?? FILE_TYPE_CONFIG.file;
return (
<span
style={{
width: "18px",
height: "18px",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<cfg.Icon size={14} color={cfg.color} />
</span>
);
};
/** Chevron indicator for expandable directory items */
const DirExpandIndicator: React.FC<{ visible: boolean; color: string }> = ({ visible, color }) => (
<span style={{ fontSize: "10px", color, opacity: visible ? 0.6 : 0, flexShrink: 0, marginLeft: "2px" }}></span>
);
const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
suggestions,
selectedIndex,
position,
cursorLineTop,
cursorLineBottom,
visible,
expandUpward = false,
themeColors,
onSelect,
maxHeight = 240,
subDirPanels = [],
subDirFocusLevel = -1,
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);
useEffect(() => {
if (selectedRef.current && listRef.current) {
selectedRef.current.scrollIntoView({
block: "nearest",
behavior: "instant" as ScrollBehavior,
});
}
}, [selectedIndex]);
// Reset hover when suggestions change
useEffect(() => {
setHoveredIndex(-1);
}, [suggestions]);
useEffect(() => {
if (!visible || !onRequestReposition) return;
let frameId = 0;
const requestReposition = () => {
if (frameId) cancelAnimationFrame(frameId);
frameId = requestAnimationFrame(() => {
frameId = 0;
onRequestReposition();
});
};
const container = containerRef?.current;
const observer = container ? new ResizeObserver(requestReposition) : null;
observer?.observe(container);
window.addEventListener("resize", requestReposition);
return () => {
if (frameId) cancelAnimationFrame(frameId);
observer?.disconnect();
window.removeEventListener("resize", requestReposition);
};
}, [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";
const fg = themeColors?.foreground ?? "#cdd6f4";
const popupBg = `color-mix(in srgb, ${bg} 92%, ${fg} 8%)`;
const popupBorder = `color-mix(in srgb, ${bg} 75%, ${fg} 25%)`;
const selectedBg = `color-mix(in srgb, ${bg} 78%, ${fg} 22%)`;
const hoverBg = `color-mix(in srgb, ${bg} 85%, ${fg} 15%)`;
const textColor = fg;
const dimTextColor = `color-mix(in srgb, ${fg} 50%, ${bg} 50%)`;
// Determine which item to show the detail tooltip for
const detailIndex = hoveredIndex >= 0 ? hoveredIndex : selectedIndex;
const detailItem = detailIndex >= 0 ? suggestions[detailIndex] : null;
const showDetail = detailItem?.description && detailItem.description.length > 0;
// Calculate fixed viewport position from container rect + relative cursor position.
// containerRef already has top offset for toolbar/search bar, so don't add it again.
const containerRect = containerRef?.current?.getBoundingClientRect();
const fixedLeft = (containerRect?.left ?? 0) + position.x;
const fixedLineTop = (containerRect?.top ?? 0) + cursorLineTop;
const fixedLineBottom = (containerRect?.top ?? 0) + cursorLineBottom;
const viewportPadding = 8;
const anchorGap = 8;
const viewportHeight = typeof window !== "undefined" ? window.innerHeight : 800;
const viewportWidth = typeof window !== "undefined" ? window.innerWidth : 1200;
const estimatedPopupHeight = Math.min(maxHeight, suggestions.length * 28 + 8);
const estimatedDetailHeight = showDetail && detailItem && detailItem.source !== "path" ? 96 : 0;
const desiredContentHeight = Math.min(
maxHeight,
Math.max(estimatedPopupHeight, estimatedDetailHeight),
);
const spaceAbove = Math.max(0, fixedLineTop - viewportPadding - anchorGap);
const spaceBelow = Math.max(0, viewportHeight - fixedLineBottom - viewportPadding - anchorGap);
const canFullyRenderAbove = spaceAbove >= desiredContentHeight;
const canFullyRenderBelow = spaceBelow >= desiredContentHeight;
const renderUpward = canFullyRenderBelow
? false
: canFullyRenderAbove
? true
: expandUpward
? spaceAbove >= Math.min(spaceBelow, 80)
: spaceAbove > spaceBelow;
const availableVerticalSpace = renderUpward ? spaceAbove : spaceBelow;
const effectiveMaxHeight = Math.max(0, Math.min(maxHeight, availableVerticalSpace));
const contentHeightForPlacement = Math.min(
effectiveMaxHeight,
desiredContentHeight,
);
const anchoredTop = renderUpward
? Math.max(viewportPadding, fixedLineTop - anchorGap - contentHeightForPlacement)
: Math.min(fixedLineBottom + anchorGap, viewportHeight - viewportPadding - contentHeightForPlacement);
const clampedLeft = Math.max(viewportPadding, Math.min(fixedLeft, viewportWidth - viewportPadding - 400));
const sharedBoxStyle = {
backgroundColor: popupBg,
border: `1px solid ${popupBorder}`,
borderRadius: "6px",
boxShadow: renderUpward
? "0 -2px 6px rgba(0, 0, 0, 0.15)"
: "0 2px 6px rgba(0, 0, 0, 0.15)",
fontFamily: "inherit",
fontSize: "13px",
color: textColor,
};
return (
<div
ref={wrapperRef}
style={{
position: "fixed",
left: `${clampedLeft}px`,
top: `${anchoredTop}px`,
zIndex: 10000,
display: "flex",
alignItems: renderUpward ? "flex-end" : "flex-start",
gap: "4px",
pointerEvents: "auto", // Re-enable on popup itself (parent is pointer-events-none)
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
{/* Main suggestion list */}
<div
ref={listRef}
className="xterm-autocomplete-popup"
style={{
...sharedBoxStyle,
maxHeight: `${effectiveMaxHeight}px`,
minWidth: "180px",
maxWidth: "400px",
overflowY: "auto",
overflowX: "hidden",
padding: "4px 0",
userSelect: "none",
}}
>
{suggestions.map((suggestion, index) => {
const isSelected = index === selectedIndex;
const isHovered = index === hoveredIndex;
const sourceInfo = SOURCE_LABELS[suggestion.source];
return (
<div
key={`${suggestion.text}-${index}`}
ref={isSelected ? selectedRef : undefined}
style={{
display: "flex",
alignItems: "center",
padding: "5px 10px",
cursor: "pointer",
backgroundColor: isSelected ? selectedBg : isHovered ? hoverBg : "transparent",
gap: "8px",
lineHeight: "1.4",
}}
onMouseEnter={() => setHoveredIndex(index)}
onMouseLeave={() => setHoveredIndex(-1)}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
onSelect(suggestion);
}}
>
{/* Source / file type indicator */}
{suggestion.source === "path" && suggestion.fileType ? (
<FileTypeIcon fileType={suggestion.fileType} />
) : (
<span
style={{
width: "18px",
height: "18px",
borderRadius: "3px",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "10px",
fontWeight: 600,
color: sourceInfo.fallbackColor,
backgroundColor: `${sourceInfo.fallbackColor}15`,
flexShrink: 0,
}}
>
{sourceInfo.label}
</span>
)}
{/* Command text */}
<span
style={{
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
color: textColor,
fontWeight: isSelected ? 500 : 400,
}}
>
{suggestion.displayText}
</span>
{/* Inline description (truncated) */}
{suggestion.description && (
<span
style={{
fontSize: "11px",
color: dimTextColor,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
maxWidth: "160px",
flexShrink: 0,
}}
>
{suggestion.description}
</span>
)}
{/* Frequency badge for history */}
{suggestion.frequency && suggestion.frequency > 1 && (
<span
style={{
fontSize: "10px",
color: dimTextColor,
flexShrink: 0,
}}
>
×{suggestion.frequency}
</span>
)}
{/* Expand indicator for directories */}
{suggestion.source === "path" && suggestion.fileType === "directory" && (
<DirExpandIndicator visible={isSelected || isHovered} color={dimTextColor} />
)}
</div>
);
})}
</div>
{/* Cascading sub-directory panels */}
{subDirPanels.map((panel, level) => (
<div
key={panel.dirPath}
style={{
...sharedBoxStyle,
maxHeight: `${effectiveMaxHeight}px`,
minWidth: "150px",
maxWidth: "240px",
overflowY: "auto",
overflowX: "hidden",
padding: "4px 0",
userSelect: "none",
alignSelf: "flex-start",
}}
>
{panel.entries.map((entry, idx) => {
const isFocused = level === subDirFocusLevel;
const isSubSelected = isFocused && idx === panel.selectedIndex;
return (
<div
key={entry.name}
ref={isSubSelected ? (el) => { el?.scrollIntoView({ block: "nearest" }); } : undefined}
style={{
display: "flex",
alignItems: "center",
padding: "4px 10px",
cursor: "pointer",
backgroundColor: isSubSelected ? selectedBg
: (idx === panel.selectedIndex && level < subDirFocusLevel) ? hoverBg
: "transparent",
gap: "8px",
lineHeight: "1.4",
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<FileTypeIcon fileType={entry.type} />
<span style={{
flex: 1, overflow: "hidden", textOverflow: "ellipsis",
whiteSpace: "nowrap", color: textColor,
}}>
{entry.name}{entry.type === "directory" ? "/" : ""}
</span>
{entry.type === "directory" && (
<DirExpandIndicator visible={isSubSelected || (idx === panel.selectedIndex && level < subDirFocusLevel)} color={dimTextColor} />
)}
</div>
);
})}
</div>
))}
{/* Detail tooltip panel — shows full description for non-path items */}
{showDetail && detailItem && detailItem.source !== "path" && (
<div
style={{
...sharedBoxStyle,
padding: "10px 12px",
maxWidth: "280px",
minWidth: "160px",
alignSelf: renderUpward ? "flex-end" : "flex-start",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "6px", marginBottom: "6px" }}>
<span style={{ fontWeight: 600, fontSize: "13px" }}>{detailItem.displayText}</span>
<span style={{
fontSize: "10px",
color: SOURCE_LABELS[detailItem.source].fallbackColor,
padding: "1px 5px",
borderRadius: "3px",
backgroundColor: `${SOURCE_LABELS[detailItem.source].fallbackColor}15`,
}}>
{SOURCE_LABELS[detailItem.source].fullLabel}
</span>
</div>
<div style={{ fontSize: "12px", color: dimTextColor, lineHeight: "1.5", wordBreak: "break-word" }}>
{detailItem.description}
</div>
</div>
)}
</div>
);
};
export default memo(AutocompletePopup);

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