Compare commits

...
81 Commits
Author SHA1 Message Date
bincxz 27829d7a4b fix: include local shell helper in packaged app 2026-03-21 04:39:02 +08:00
bincxz 4d09227bed fix: resolve native module path in linux packaging check 2026-03-21 04:15:45 +08:00
bincxz 16415299ae fix: repair linux node-pty packaging workflow 2026-03-21 04:13:31 +08:00
bincxzandClaude Opus 4.6 dfc9a4efdd fix: use electron-rebuild CLI directly instead of install-app-deps
electron-builder install-app-deps forks a child process via
remote-rebuild.js to run @electron/rebuild. The child's main()
has no .catch() handler, causing unhandled promise rejections
that exit with code 1 even after successful rebuilds.

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

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

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

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

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

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

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

* Refine terminal connection dialog visuals

* Polish terminal connection dialog layout

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

---------

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

* feat: add reset to global terminal appearance

* fix: preserve legacy host appearance overrides

* fix: show legacy appearance reset controls

* refactor: reorder terminal global reset actions

* refactor: present global theme as theme option

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

* refactor: remove unused legacy SFTP modal

* fix: use directory picker for SFTP folder downloads

* fix: wire folder downloads through SFTP side panel

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

* feat: show hybrid progress for SFTP folder downloads

* feat: parallelize SFTP folder downloads

* feat: adapt SFTP folder download concurrency by file size

* feat: pool isolated channels for fast SFTP downloads

* fix: address SFTP download review findings

* fix: wait for in-flight fast download channels

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

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

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

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

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

* chore: remove old approval flow code

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: eliminate PTY wrapper echo leakage and duplicate prompts

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

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

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

* fix: surface denied inline approvals as errors in UI

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

* Fix local AI session metadata and shell safety

* Fix local session cloning and multi-exec errors

* Refactor local shell detection helpers

* Fix local shell helper import path

* Fix CJS imports in renderer

* Use ESM local shell helpers in renderer

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

* Fix local AI session metadata and shell safety

* Fix local session cloning and multi-exec errors

* Refactor local shell detection helpers

* Fix local shell helper import path

* Fix CJS imports in renderer

* Use ESM local shell helpers in renderer

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

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

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

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

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

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

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

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

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

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

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

* fix: use transform scale for smooth zoom animation

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

Closes #400

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 16:24:29 +08:00
bincxzandClaude Opus 4.6 be7aa4ae52 fix: resolve eslint warnings in App.tsx and VaultView.tsx
- Remove unused sessionLog deps from useCallback in App.tsx
- Wrap countAllHostsInNode in useCallback and add to useMemo deps

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

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

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

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

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

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

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

---------

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

Closes #396

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

Closes #398

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

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

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

---------

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

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

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

Closes #294 (AI file upload issues)

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

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

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

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

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

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

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

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

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

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

* fix: use managed temp dir for pasted ACP attachments

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

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

---------

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 01:59:42 +08:00
陈大猫 7ce110c3fb Update asset links in README.md
Updated asset links for various features in the README.
2026-03-19 01:52:27 +08:00
bincxzandClaude Opus 4.6 667ee18ed3 Compress demo mp4 files (~52MB → ~2.5MB)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 01:50:23 +08:00
陈大猫 f969b1b73d Add links for SFTP and drag file upload sections
Updated README to include links for SFTP and drag file upload.
2026-03-19 01:43:47 +08:00
陈大猫 58a4bf892a Update video references in README.md
Replaced video tags with links to video assets for better accessibility.
2026-03-19 01:39:38 +08:00
bincxzandClaude Opus 4.6 5052a8231f Improve README: mp4 demos, contributor avatars, star history
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 01:34:00 +08:00
bincxzandClaude Opus 4.6 13c9cf16fd Update screenshots and add demo GIFs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 01:26:16 +08:00
陈大猫 63558b5301 Remove HTTP localhost-only restriction for AI requests (#393)
Remove the restriction that blocked non-localhost HTTP URLs for AI
provider requests. Users with HTTP-based AI services on internal
networks can now configure http:// provider base URLs.

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

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

Fixes #390

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

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

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

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

* fix: refresh catty workspace tool context

* fix: preserve AI stream state across tab switches

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

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

* Block ACP tool writes after stop

* Kill ACP child processes on cleanup

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

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

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

Closes #378

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

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

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

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

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

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

* fix: address review feedback for auto-close feature

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

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

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

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

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

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

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

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

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

* fix: classify mosh non-zero exits as errors

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

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

* fix: treat SSH signal-terminated exits as disconnects

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

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

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

* fix: distinguish abnormal local PTY exits from user exits

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 23:45:56 +08:00
陈大猫 6303eef3a2 fix: make global and host-level keyword highlight independent (#379) 2026-03-17 22:59:02 +08:00
yuzifu a9a648039f Merge branch 'main' into fix-host-count-in-tree-view 2026-03-17 21:53:30 +08:00
yuzifu 1d4ec7afb9 Merge remote-tracking branch 'origin/fix-host-count-in-tree-view' into fix-host-count-in-tree-view 2026-03-17 17:25:00 +08:00
yuzifu a1899951e0 fix: show hosts count(update)
Avoid recalculating the number of hosts during re-rendering
2026-03-17 17:24:16 +08:00
bincxzandClaude Opus 4.6 d84668aa0f perf: memoize subtree host count to avoid repeated traversals
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 16:52:41 +08:00
yuzifu 68d0f4574c update show hosts count in tree view 2026-03-17 16:40:45 +08:00
yuzifu bedf59bb48 update show host count in tree view 2026-03-17 10:17:57 +08:00
yuzifu 793ea94078 fix: show host count in tree view 2026-03-17 09:16:01 +08:00
131 changed files with 5898 additions and 7126 deletions

No files matched your search

+17 -1
View File
@@ -121,11 +121,17 @@ jobs:
echo "Setting version to ${VERSION}"
npm pkg set version="${VERSION}"
- name: Prepare node-pty Linux runtime
run: bash scripts/ensure-node-pty-linux.sh prepare x64
- name: Build package
env:
ELECTRON_BUILDER_PUBLISH: "never"
run: npm run pack:linux-x64
- name: Verify packaged node-pty Linux runtime
run: bash scripts/ensure-node-pty-linux.sh verify x64
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
@@ -155,7 +161,9 @@ jobs:
- name: Install build dependencies
run: |
apt-get update
apt-get install -y curl build-essential python3 git libfuse2 file rpm
apt-get install -y curl build-essential python3 git libfuse2 file rpm \
libglib2.0-0 libgtk-3-0 libnss3 libxss1 libxtst6 libasound2 \
libatk-bridge2.0-0 libdrm2 libgbm1 libx11-xcb1 libxcb-dri3-0
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y nodejs
@@ -176,12 +184,20 @@ jobs:
echo "Setting version to ${VERSION}"
npm pkg set version="${VERSION}"
- name: Prepare node-pty Linux runtime
env:
npm_config_arch: arm64
run: bash scripts/ensure-node-pty-linux.sh prepare arm64
- name: Build package
env:
npm_config_arch: arm64
ELECTRON_BUILDER_PUBLISH: "never"
run: npm run pack:linux-arm64
- name: Verify packaged node-pty Linux runtime
run: bash scripts/ensure-node-pty-linux.sh verify arm64
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
+3
View File
@@ -37,6 +37,9 @@ coverage
# Claude Code
/.claude/
# Codex
/.codex/
/CLAUDE.md
# AI / Superpowers generated docs (local only)
+35 -30
View File
@@ -28,6 +28,7 @@ import { VaultView, VaultSection } from './components/VaultView';
import { KeyboardInteractiveModal, KeyboardInteractiveRequest } from './components/KeyboardInteractiveModal';
import { PassphraseModal, PassphraseRequest } from './components/PassphraseModal';
import { cn } from './lib/utils';
import { classifyLocalShellType } from './lib/localShell';
import { ConnectionLog, Host, HostProtocol, SerialConfig, TerminalTheme } from './types';
import { LogView as LogViewType } from './application/state/useSessionState';
import type { SftpView as SftpViewComponent } from './components/SftpView';
@@ -185,6 +186,7 @@ function App({ settings }: { settings: SettingsState }) {
sftpAutoSync,
sftpShowHiddenFiles,
sftpUseCompressedUpload,
sftpAutoOpenSidebar,
editorWordWrap,
setEditorWordWrap,
sessionLogsEnabled,
@@ -656,6 +658,24 @@ function App({ settings }: { settings: SettingsState }) {
const addConnectionLogRef = useRef(addConnectionLog);
addConnectionLogRef.current = addConnectionLog;
const createLocalTerminalWithCurrentShell = useCallback(() => {
return createLocalTerminal({
shellType: classifyLocalShellType(terminalSettings.localShell, navigator.userAgent),
});
}, [createLocalTerminal, terminalSettings.localShell]);
const splitSessionWithCurrentShell = useCallback((sessionId: string, direction: 'horizontal' | 'vertical') => {
return splitSession(sessionId, direction, {
localShellType: classifyLocalShellType(terminalSettings.localShell, navigator.userAgent),
});
}, [splitSession, terminalSettings.localShell]);
const copySessionWithCurrentShell = useCallback((sessionId: string) => {
return copySession(sessionId, {
localShellType: classifyLocalShellType(terminalSettings.localShell, navigator.userAgent),
});
}, [copySession, terminalSettings.localShell]);
// Shared hotkey action handler - used by both global handler and terminal callback
const executeHotkeyAction = useCallback((action: string, e: KeyboardEvent) => {
switch (action) {
@@ -727,7 +747,7 @@ function App({ settings }: { settings: SettingsState }) {
localHostname: systemInfoRef.current.hostname,
saved: false,
});
createLocalTerminal();
createLocalTerminalWithCurrentShell();
break;
case 'openHosts':
setActiveTabId('vault');
@@ -766,7 +786,7 @@ function App({ settings }: { settings: SettingsState }) {
const activeWs = workspaces.find(w => w.id === currentId);
if (activeSession && !activeSession.workspaceId) {
// Standalone session - split it
splitSession(activeSession.id, 'horizontal');
splitSessionWithCurrentShell(activeSession.id, 'horizontal');
} else if (activeWs) {
// In a workspace - need to determine focused session
// For now, we'll need the terminal to handle this via context menu
@@ -781,7 +801,7 @@ function App({ settings }: { settings: SettingsState }) {
const activeWs = workspaces.find(w => w.id === currentId);
if (activeSession && !activeSession.workspaceId) {
// Standalone session - split it
splitSession(activeSession.id, 'vertical');
splitSessionWithCurrentShell(activeSession.id, 'vertical');
} else if (activeWs) {
// In a workspace - need to determine focused session
if (IS_DEV) console.log('[Hotkey] Split vertical in workspace - use context menu on specific terminal');
@@ -821,7 +841,7 @@ function App({ settings }: { settings: SettingsState }) {
break;
}
}
}, [orderedTabs, sessions, workspaces, setActiveTabId, closeSession, closeWorkspace, createLocalTerminal, splitSession, moveFocusInWorkspace, toggleBroadcast]);
}, [orderedTabs, sessions, workspaces, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast]);
// Callback for terminal to invoke app-level hotkey actions
const handleHotkeyAction = useCallback((action: string, e: KeyboardEvent) => {
@@ -967,7 +987,7 @@ function App({ settings }: { settings: SettingsState }) {
// Wrapper to create local terminal with logging
const handleCreateLocalTerminal = useCallback(() => {
const { username, hostname } = systemInfoRef.current;
const sessionId = createLocalTerminal();
const sessionId = createLocalTerminalWithCurrentShell();
addConnectionLog({
sessionId,
hostId: '',
@@ -980,7 +1000,7 @@ function App({ settings }: { settings: SettingsState }) {
localHostname: hostname,
saved: false,
});
}, [addConnectionLog, createLocalTerminal]);
}, [addConnectionLog, createLocalTerminalWithCurrentShell]);
// Wrapper to connect to host with logging
const handleConnectToHost = useCallback((host: Host) => {
@@ -1067,31 +1087,12 @@ function App({ settings }: { settings: SettingsState }) {
});
if (IS_DEV) console.log('[handleTerminalDataCapture] Updated log with terminalData');
// Auto-save session log if enabled
if (sessionLogsEnabled && sessionLogsDir && data) {
import('./infrastructure/services/netcattyBridge').then(({ netcattyBridge }) => {
const bridge = netcattyBridge.get();
if (bridge?.autoSaveSessionLog) {
bridge.autoSaveSessionLog({
terminalData: data,
hostLabel: matchingLog.hostLabel,
hostname: matchingLog.hostname,
hostId: matchingLog.hostId,
startTime: matchingLog.startTime,
format: sessionLogsFormat,
directory: sessionLogsDir,
}).then(result => {
if (IS_DEV) console.log('[handleTerminalDataCapture] Auto-save result:', result);
}).catch(err => {
console.error('[handleTerminalDataCapture] Auto-save failed:', err);
});
}
});
}
// Auto-save is now handled by real-time streaming in the main process
// via sessionLogStreamManager. No renderer-side fallback needed.
} else {
if (IS_DEV) console.log('[handleTerminalDataCapture] No matching log found!');
}
}, [sessions, connectionLogs, updateConnectionLog, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat]);
}, [sessions, connectionLogs, updateConnectionLog]);
// Check if host has multiple protocols enabled
const hasMultipleProtocols = useCallback((host: Host) => {
@@ -1222,7 +1223,7 @@ function App({ settings }: { settings: SettingsState }) {
isMacClient={isMacClient}
onCloseSession={closeSession}
onRenameSession={startSessionRename}
onCopySession={copySession}
onCopySession={copySessionWithCurrentShell}
onRenameWorkspace={startWorkspaceRename}
onCloseWorkspace={closeWorkspace}
onCloseLogView={closeLogView}
@@ -1316,7 +1317,7 @@ function App({ settings }: { settings: SettingsState }) {
onSetDraggingSessionId={setDraggingSessionId}
onToggleWorkspaceViewMode={toggleWorkspaceViewMode}
onSetWorkspaceFocusedSession={setWorkspaceFocusedSession}
onSplitSession={splitSession}
onSplitSession={splitSessionWithCurrentShell}
isBroadcastEnabled={isBroadcastEnabled}
onToggleBroadcast={toggleBroadcast}
updateHosts={updateHosts}
@@ -1324,8 +1325,12 @@ function App({ settings }: { settings: SettingsState }) {
sftpAutoSync={sftpAutoSync}
sftpShowHiddenFiles={sftpShowHiddenFiles}
sftpUseCompressedUpload={sftpUseCompressedUpload}
sftpAutoOpenSidebar={sftpAutoOpenSidebar}
editorWordWrap={editorWordWrap}
setEditorWordWrap={setEditorWordWrap}
sessionLogsEnabled={sessionLogsEnabled}
sessionLogsDir={sessionLogsDir}
sessionLogsFormat={sessionLogsFormat}
/>
{/* Log Views - readonly terminal replays */}
+34 -7
View File
@@ -59,6 +59,8 @@
- [ビルドとパッケージ](#ビルドとパッケージ)
- [技術スタック](#技術スタック)
- [コントリビューション](#コントリビューション)
- [コントリビューター](#コントリビューター)
- [Star 履歴](#star-履歴)
- [ライセンス](#ライセンス)
---
@@ -110,37 +112,37 @@
<a name="デモ"></a>
# デモ
GIF で機能をさっと確認できます(素材は `screenshots/gifs/`):
動画で機能をさっと確認できます(素材は `screenshots/gifs/`):
### Vault ビュー:グリッド / リスト / ツリー
状況に合わせて見え方を切り替え。グリッドで全体像、リストで密度、ツリーで階層を扱えます。
![Vault ビュー:グリッド/リスト/ツリー](screenshots/gifs/gird-list-tre-views.gif)
https://github.com/user-attachments/assets/e2742987-3131-404d-bd4b-06423e5bfd99
### 分割ターミナル + セッション管理
複数セッションを分割ペインで並べて作業。関連タスクを横並びにしてコンテキストスイッチを減らします。
![分割ターミナル + セッション管理](screenshots/gifs/dual-terminal--split-manage.gif)
https://github.com/user-attachments/assets/377d0c46-cc5a-4382-aa31-5acfd412ce62
### SFTP:ドラッグ&ドロップ + 内蔵エディタ
ドラッグ&ドロップでファイルを移動し、内蔵エディタでそのまま編集できます。
![SFTP:ドラッグ&ドロップ + 内蔵エディタ](screenshots/gifs/sftpview-with-drag-and-built-in-editor.gif)
https://github.com/user-attachments/assets/c6e06af4-b0d5-461c-b0c7-9d6f655af6c7
### ドラッグでアップロード
ファイルをそのままドロップしてアップロードを開始。ダイアログ操作を減らせます。
![ドラッグでアップロード](screenshots/gifs/drag-file-upload.gif)
https://github.com/user-attachments/assets/c8e0c4ff-f020-4e18-9b09-681ec97b003f
### カスタムテーマ
テーマを調整して自分の好みに合わせた見た目に。
![カスタムテーマ](screenshots/gifs/custom-themes.gif)
https://github.com/user-attachments/assets/77e2a693-4ef2-4823-8ca1-9bcbf14ed98b
### キーワードハイライト
重要な出力(エラー/警告/マーカーなど)を見つけやすくするために、ハイライトをカスタマイズできます。
![キーワードハイライト](screenshots/gifs/custom-highlight.gif)
https://github.com/user-attachments/assets/e6516993-ad66-4594-8c28-57426082339b
---
@@ -196,6 +198,7 @@ Netcatty は接続したホストの OS を検出し、ホスト一覧でアイ
<img src="public/distro/opensuse.svg" width="48" alt="openSUSE" title="openSUSE">
<img src="public/distro/oracle.svg" width="48" alt="Oracle Linux" title="Oracle Linux">
<img src="public/distro/kali.svg" width="48" alt="Kali Linux" title="Kali Linux">
<img src="public/distro/almalinux.svg" width="48" alt="AlmaLinux" title="AlmaLinux">
</p>
---
@@ -305,6 +308,17 @@ npm run pack:linux # Linux (AppImage + DEB + RPM)
---
<a name="コントリビューター"></a>
# コントリビューター
貢献してくださったすべての方に感謝します!
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" />
</a>
---
<a name="ライセンス"></a>
# ライセンス
@@ -312,6 +326,19 @@ npm run pack:linux # Linux (AppImage + DEB + RPM)
---
<a name="star-履歴"></a>
# Star 履歴
<a href="https://star-history.com/#binaricat/Netcatty&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date" />
</picture>
</a>
---
<p align="center">
❤️ を込めて作成 by <a href="https://ko-fi.com/binaricat">binaricat</a>
</p>
+86 -10
View File
@@ -5,13 +5,13 @@
<h1 align="center">Netcatty</h1>
<p align="center">
<strong>Modern SSH Client, SFTP Browser & Terminal Manager</strong><br/>
<strong>🔥 AI-Powered SSH Client, SFTP Browser & Terminal Manager 🚀</strong><br/>
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
</p>
<p align="center">
A beautiful, feature-rich SSH workspace built with Electron, React, and xterm.js.<br/>
Split terminals, Vault views, SFTP workflows, custom themes, and keyword highlighting — all in one.
🔥 Built-in AI Agent · Split terminals · Vault views · SFTP workflows · Custom themes — all in one.
</p>
<p align="center">
@@ -42,10 +42,52 @@
[![Netcatty Main Interface](screenshots/main-window-dark.png)](screenshots/main-window-dark.png)
---
<a name="catty-agent"></a>
# 🔥 Catty Agent — Your IT Ops AI Partner
> 🚀 **Boost your IT ops daily work with AI power.** Catty Agent is the built-in AI assistant that understands your servers, executes commands, and handles complex multi-host operations — all through natural conversation.
<p align="center">
<img src="screenshots/ai-feature.png" alt="Catty Agent Interface" width="800">
</p>
### 🔥 What can Catty Agent do?
- 🚀 **Natural language server management** — just tell it what you need, no more memorizing commands
- 🔥 **Real-time server diagnostics** — check status, inspect logs, monitor resources through conversation
- 🚀 **Multi-host orchestration** — coordinate tasks across multiple servers simultaneously
- 🔥 **Intelligent context awareness** — understands your server environment and provides tailored responses
- 🚀 **One-click complex operations** — set up clusters, deploy services, and more with simple instructions
### 🎬 AI in Action
#### 🔥 Single Host — Intelligent Server Diagnostics
Ask Catty Agent to check a server's health, and it runs the right commands, analyzes the output, and gives you a clear summary — all in seconds.
https://github.com/user-attachments/assets/eecf08f1-80bd-49db-886d-b36e93388865
#### 🚀 Multi-Host — Docker Swarm Cluster Setup
Watch Catty Agent orchestrate a Docker Swarm cluster across two servers in one conversation. It handles the init, token exchange, and node joining — you just tell it what you want.
https://github.com/user-attachments/assets/282027aa-5c9e-4bb1-b2c3-5eea9df2b203
---
# Contents <!-- omit in toc -->
- [🔥 Catty Agent — AI Partner](#catty-agent)
- [What is Netcatty](#what-is-netcatty)
- [Why Netcatty](#why-netcatty)
- [Features](#features)
@@ -59,6 +101,8 @@
- [Build & Package](#build--package)
- [Tech Stack](#tech-stack)
- [Contributing](#contributing)
- [Contributors](#contributors)
- [Star History](#star-history)
- [License](#license)
---
@@ -111,37 +155,53 @@ If you regularly work with a fleet of servers, Netcatty is built for speed and f
<a name="demos"></a>
# Demos
GIF previews (stored in `screenshots/gifs/`), rendered inline on GitHub:
Video previews (stored in `screenshots/gifs/`), rendered inline on GitHub:
### Vault views: grid / list / tree
Switch between different Vault views to match your workflow: overview in grid, dense scanning in list, and hierarchical navigation in tree.
![Vault views: grid/list/tree](screenshots/gifs/gird-list-tre-views.gif)
https://github.com/user-attachments/assets/e2742987-3131-404d-bd4b-06423e5bfd99
### Split terminals + session management
Work in multiple sessions at once with split panes. Keep related tasks side-by-side and reduce context switching.
![Split terminals + session management](screenshots/gifs/dual-terminal--split-manage.gif)
https://github.com/user-attachments/assets/377d0c46-cc5a-4382-aa31-5acfd412ce62
### SFTP: drag & drop + built-in editor
Move files with drag & drop, then edit quickly using the built-in editor without leaving the app.
![SFTP: drag & drop + built-in editor](screenshots/gifs/sftpview-with-drag-and-built-in-editor.gif)
https://github.com/user-attachments/assets/c6e06af4-b0d5-461c-b0c7-9d6f655af6c7
### Drag file upload
Drop files into the app to kick off uploads without hunting through dialogs.
![Drag file upload](screenshots/gifs/drag-file-upload.gif)
https://github.com/user-attachments/assets/c8e0c4ff-f020-4e18-9b09-681ec97b003f
### Custom themes
Make Netcatty yours: customize themes and UI appearance.
![Custom themes](screenshots/gifs/custom-themes.gif)
https://github.com/user-attachments/assets/77e2a693-4ef2-4823-8ca1-9bcbf14ed98b
### Keyword highlighting
Highlight important terminal output so errors, warnings, and key events stand out at a glance.
![Keyword highlighting](screenshots/gifs/custom-highlight.gif)
https://github.com/user-attachments/assets/e6516993-ad66-4594-8c28-57426082339b
---
@@ -197,6 +257,7 @@ Netcatty automatically detects and displays OS icons for connected hosts:
<img src="public/distro/opensuse.svg" width="48" alt="openSUSE" title="openSUSE">
<img src="public/distro/oracle.svg" width="48" alt="Oracle Linux" title="Oracle Linux">
<img src="public/distro/kali.svg" width="48" alt="Kali Linux" title="Kali Linux">
<img src="public/distro/almalinux.svg" width="48" alt="AlmaLinux" title="AlmaLinux">
</p>
<a name="getting-started"></a>
@@ -309,7 +370,9 @@ See [agents.md](agents.md) for architecture overview and coding conventions.
Thanks to all the people who contribute!
See: https://github.com/binaricat/Netcatty/graphs/contributors
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" />
</a>
---
@@ -320,6 +383,19 @@ This project is licensed under the **GPL-3.0 License** - see the [LICENSE](LICEN
---
<a name="star-history"></a>
# Star History
<a href="https://star-history.com/#binaricat/Netcatty&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date" />
</picture>
</a>
---
<p align="center">
Made with ❤️ by <a href="https://ko-fi.com/binaricat">binaricat</a>
</p>
+26 -8
View File
@@ -59,6 +59,8 @@
- [构建与打包](#构建与打包)
- [技术栈](#技术栈)
- [参与贡献](#参与贡献)
- [贡献者](#贡献者)
- [Star 历史](#star-历史)
- [开源协议](#开源协议)
---
@@ -111,37 +113,37 @@
<a name="演示"></a>
# 演示
GIF 预览(素材均在 `screenshots/gifs/`),在 GitHub README 中可直接观看:
视频预览(素材均在 `screenshots/gifs/`),在 GitHub README 中可直接观看:
### Vault 视图:网格 / 列表 / 树形
根据不同场景自由切换视图:网格适合总览,列表适合密集浏览,树形适合层级导航与整理。
![Vault 视图:网格/列表/树形](screenshots/gifs/gird-list-tre-views.gif)
https://github.com/user-attachments/assets/e2742987-3131-404d-bd4b-06423e5bfd99
### 分屏终端 + 会话管理
用分屏把多个会话并排放在同一个工作区里,降低来回切换窗口/标签页的成本。
![分屏终端 + 会话管理](screenshots/gifs/dual-terminal--split-manage.gif)
https://github.com/user-attachments/assets/377d0c46-cc5a-4382-aa31-5acfd412ce62
### SFTP:拖拽 + 内置编辑器
通过拖拽完成文件传输,并用内置编辑器快速修改文件内容,不用来回切换工具。
![SFTP:拖拽 + 内置编辑器](screenshots/gifs/sftpview-with-drag-and-built-in-editor.gif)
https://github.com/user-attachments/assets/c6e06af4-b0d5-461c-b0c7-9d6f655af6c7
### 拖拽文件上传
把文件直接拖进应用即可触发上传流程,省去多层对话框与路径选择。
![拖拽文件上传](screenshots/gifs/drag-file-upload.gif)
https://github.com/user-attachments/assets/c8e0c4ff-f020-4e18-9b09-681ec97b003f
### 自定义主题
按自己的审美与习惯定制主题与界面外观,让日常使用更顺手。
![自定义主题](screenshots/gifs/custom-themes.gif)
https://github.com/user-attachments/assets/77e2a693-4ef2-4823-8ca1-9bcbf14ed98b
### 关键词高亮
让关键输出一眼可见:错误、告警或特定标记被高亮后更容易扫到与定位。
![关键词高亮](screenshots/gifs/custom-highlight.gif)
https://github.com/user-attachments/assets/e6516993-ad66-4594-8c28-57426082339b
---
@@ -197,6 +199,7 @@ Netcatty 会自动识别并在主机列表中展示对应的系统图标:
<img src="public/distro/opensuse.svg" width="48" alt="openSUSE" title="openSUSE">
<img src="public/distro/oracle.svg" width="48" alt="Oracle Linux" title="Oracle Linux">
<img src="public/distro/kali.svg" width="48" alt="Kali Linux" title="Kali Linux">
<img src="public/distro/almalinux.svg" width="48" alt="AlmaLinux" title="AlmaLinux">
</p>
<a name="快速开始"></a>
@@ -309,7 +312,9 @@ npm run pack:linux # Linux (AppImage, deb, rpm)
感谢所有参与贡献的人!
查看:https://github.com/binaricat/Netcatty/graphs/contributors
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" />
</a>
---
@@ -320,6 +325,19 @@ npm run pack:linux # Linux (AppImage, deb, rpm)
---
<a name="star-历史"></a>
# Star 历史
<a href="https://star-history.com/#binaricat/Netcatty&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=binaricat/Netcatty&type=Date" />
</picture>
</a>
---
<p align="center">
用 ❤️ 制作,作者 <a href="https://ko-fi.com/binaricat">binaricat</a>
</p>
+39 -2
View File
@@ -5,6 +5,9 @@ const en: Messages = {
'common.save': 'Save',
'common.cancel': 'Cancel',
'common.close': 'Close',
'common.reset': 'Reset',
'common.zoomIn': 'Zoom in',
'common.zoomOut': 'Zoom out',
'common.settings': 'Settings',
'common.search': 'Search',
'common.searchPlaceholder': 'Search...',
@@ -30,6 +33,7 @@ const en: Messages = {
'common.back': 'Back',
'common.apply': 'Apply',
'common.use': 'Use',
'common.useGlobal': 'Use global',
'common.saveChanges': 'Save Changes',
'common.advanced': 'Advanced',
'common.left': 'Left',
@@ -615,6 +619,7 @@ const en: Messages = {
'sftp.transfers': 'Transfers',
'sftp.transfers.active': '{count} active',
'sftp.transfers.clearCompleted': 'Clear completed',
'sftp.transfers.calculatingTotal': 'Calculating total size...',
'sftp.goUp': 'Go up',
'sftp.goToTerminalCwd': 'Go to terminal directory',
'sftp.encoding.label': 'Filename Encoding',
@@ -745,6 +750,13 @@ const en: Messages = {
'settings.sftp.autoSync.desc': 'Automatically sync file changes back to the remote server when opening files with external applications',
'settings.sftp.autoSync.enable': 'Enable auto-sync',
'settings.sftp.autoSync.enableDesc': 'When you save a file in an external application, changes will be automatically uploaded to the remote server',
// Settings > SFTP Auto Open Sidebar
'settings.sftp.autoOpenSidebar': 'Auto-open sidebar on connect',
'settings.sftp.autoOpenSidebar.desc': 'Automatically open the SFTP file browser sidebar when connecting to a host',
'settings.sftp.autoOpenSidebar.enable': 'Enable auto-open sidebar',
'settings.sftp.autoOpenSidebar.enableDesc': 'The SFTP sidebar will open automatically when a terminal session connects to a remote host',
'sftp.autoSync.success': 'File synced to remote: {fileName}',
'sftp.autoSync.error': 'Failed to sync file: {error}',
@@ -832,6 +844,29 @@ const en: Messages = {
'hostDetails.section.credentials': 'Credentials',
'hostDetails.section.portCredentials': 'Port & Credentials',
'hostDetails.section.appearance': 'Appearance',
'hostDetails.distro.title': 'Linux Distribution',
'hostDetails.distro.desc': 'Auto-detect on connect, or override the distro icon manually.',
'hostDetails.distro.mode': 'Source',
'hostDetails.distro.mode.auto': 'Auto-detect',
'hostDetails.distro.mode.manual': 'Manual override',
'hostDetails.distro.detectedLabel': 'Current',
'hostDetails.distro.manualLabel': 'Override',
'hostDetails.distro.pending': 'Detect after first connection',
'hostDetails.distro.unknown': 'Unknown',
'hostDetails.distro.option.linux': 'Generic Linux',
'hostDetails.distro.option.ubuntu': 'Ubuntu',
'hostDetails.distro.option.debian': 'Debian',
'hostDetails.distro.option.centos': 'CentOS',
'hostDetails.distro.option.rocky': 'Rocky Linux',
'hostDetails.distro.option.fedora': 'Fedora',
'hostDetails.distro.option.arch': 'Arch Linux',
'hostDetails.distro.option.alpine': 'Alpine',
'hostDetails.distro.option.amazon': 'Amazon Linux',
'hostDetails.distro.option.opensuse': 'openSUSE / SLES',
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
'hostDetails.distro.option.almalinux': 'AlmaLinux',
'hostDetails.distro.option.oracle': 'Oracle Linux',
'hostDetails.distro.option.kali': 'Kali Linux',
'hostDetails.section.mosh': 'Mosh',
'hostDetails.username.placeholder': 'Username',
'hostDetails.password.placeholder': 'Password',
@@ -1048,6 +1083,7 @@ const en: Messages = {
'terminal.progress.disconnected': 'Disconnected',
'terminal.progress.cancelling': 'Cancelling...',
'terminal.progress.startOver': 'Start over',
'terminal.connection.dismissDisconnectedDialog': 'Dismiss disconnected notice',
'terminal.connection.chainOf': 'Chain {current} of {total}',
'terminal.connection.showLogs': 'Show logs',
'terminal.connection.hideLogs': 'Hide logs',
@@ -1060,6 +1096,8 @@ const en: Messages = {
'terminal.themeModal.tab.theme': 'Theme',
'terminal.themeModal.tab.font': 'Font',
'terminal.themeModal.tab.custom': 'Custom',
'terminal.themeModal.globalTheme': 'Global Theme',
'terminal.themeModal.globalFont': 'Global Font',
'terminal.themeModal.fontSize': 'Font Size',
'terminal.themeModal.livePreview': 'Live Preview',
'terminal.themeModal.themeType': '{type} theme',
@@ -1548,7 +1586,7 @@ const en: Messages = {
// AI Claude Code
'ai.claude.title': 'Claude Code',
'ai.claude.description': "Anthropic's agentic coding assistant. Uses claude-code-acp for ACP protocol streaming.",
'ai.claude.description': "Anthropic's agentic coding assistant. Uses claude-agent-acp for ACP protocol streaming.",
'ai.claude.detecting': 'Detecting...',
'ai.claude.detected': 'Detected',
'ai.claude.notFound': 'Not found',
@@ -1565,7 +1603,6 @@ const en: Messages = {
// AI Chat
'ai.chat.noProvider': 'No AI provider is configured. Go to **Settings → AI → Providers** to add and enable a provider.',
'ai.chat.toolDenied': 'Action was rejected by the user.',
'ai.chat.toolApprovalTitle': 'Permission Required',
'ai.chat.toolApproved': 'Approved',
'ai.chat.toolApprovalHint': 'Press Enter to approve, Escape to reject',
'ai.chat.approve': 'Approve',
+39 -2
View File
@@ -5,6 +5,9 @@ const zhCN: Messages = {
'common.save': '保存',
'common.cancel': '取消',
'common.close': '关闭',
'common.reset': '重置',
'common.zoomIn': '放大',
'common.zoomOut': '缩小',
'common.settings': '设置',
'common.search': '搜索',
'common.connect': '连接',
@@ -20,6 +23,7 @@ const zhCN: Messages = {
'common.back': '返回',
'common.apply': '应用',
'common.use': '使用',
'common.useGlobal': '跟随全局',
'common.left': '左侧',
'common.right': '右侧',
'common.more': '更多',
@@ -442,6 +446,7 @@ const zhCN: Messages = {
'sftp.transfers': '传输',
'sftp.transfers.active': '{count} 个进行中',
'sftp.transfers.clearCompleted': '清除已完成',
'sftp.transfers.calculatingTotal': '正在统计总大小...',
'sftp.goUp': '上一级',
'sftp.goToTerminalCwd': '定位到终端当前目录',
'sftp.encoding.label': '文件名编码',
@@ -531,6 +536,29 @@ const zhCN: Messages = {
'hostDetails.section.credentials': '凭据',
'hostDetails.section.portCredentials': '端口与凭据',
'hostDetails.section.appearance': '外观',
'hostDetails.distro.title': 'Linux 发行版',
'hostDetails.distro.desc': '可在连接后自动探测,也可以手动覆盖图标所用的发行版。',
'hostDetails.distro.mode': '来源',
'hostDetails.distro.mode.auto': '自动探测',
'hostDetails.distro.mode.manual': '手动覆盖',
'hostDetails.distro.detectedLabel': '当前值',
'hostDetails.distro.manualLabel': '手动指定',
'hostDetails.distro.pending': '首次连接后自动探测',
'hostDetails.distro.unknown': '未知',
'hostDetails.distro.option.linux': '通用 Linux',
'hostDetails.distro.option.ubuntu': 'Ubuntu',
'hostDetails.distro.option.debian': 'Debian',
'hostDetails.distro.option.centos': 'CentOS',
'hostDetails.distro.option.rocky': 'Rocky Linux',
'hostDetails.distro.option.fedora': 'Fedora',
'hostDetails.distro.option.arch': 'Arch Linux',
'hostDetails.distro.option.alpine': 'Alpine',
'hostDetails.distro.option.amazon': 'Amazon Linux',
'hostDetails.distro.option.opensuse': 'openSUSE / SLES',
'hostDetails.distro.option.redhat': 'Red Hat / RHEL',
'hostDetails.distro.option.almalinux': 'AlmaLinux',
'hostDetails.distro.option.oracle': 'Oracle Linux',
'hostDetails.distro.option.kali': 'Kali Linux',
'hostDetails.section.mosh': 'Mosh',
'hostDetails.username.placeholder': '用户名',
'hostDetails.password.placeholder': '密码',
@@ -719,6 +747,7 @@ const zhCN: Messages = {
'terminal.progress.disconnected': '已断开',
'terminal.progress.cancelling': '正在取消...',
'terminal.progress.startOver': '重新开始',
'terminal.connection.dismissDisconnectedDialog': '关闭断连提示',
'terminal.connection.chainOf': 'Chain {current} / {total}',
'terminal.connection.showLogs': '显示日志',
'terminal.connection.hideLogs': '隐藏日志',
@@ -731,6 +760,8 @@ const zhCN: Messages = {
'terminal.themeModal.tab.theme': '主题',
'terminal.themeModal.tab.font': '字体',
'terminal.themeModal.tab.custom': '自定义',
'terminal.themeModal.globalTheme': '全局主题',
'terminal.themeModal.globalFont': '全局字体',
'terminal.themeModal.fontSize': '字体大小',
'terminal.themeModal.livePreview': '实时预览',
'terminal.themeModal.themeType': '{type} 主题',
@@ -1060,6 +1091,13 @@ const zhCN: Messages = {
'settings.sftp.autoSync.desc': '使用外部应用程序打开文件时,自动将文件更改同步回远程服务器',
'settings.sftp.autoSync.enable': '启用自动同步',
'settings.sftp.autoSync.enableDesc': '在外部应用程序中保存文件时,更改将自动上传到远程服务器',
// Settings > SFTP 自动打开侧栏
'settings.sftp.autoOpenSidebar': '连接时自动打开侧栏',
'settings.sftp.autoOpenSidebar.desc': '连接到主机时自动打开 SFTP 文件浏览器侧栏',
'settings.sftp.autoOpenSidebar.enable': '启用自动打开侧栏',
'settings.sftp.autoOpenSidebar.enableDesc': '当终端会话连接到远程主机时,SFTP 侧栏将自动打开',
'sftp.autoSync.success': '文件已同步到远程:{fileName}',
'sftp.autoSync.error': '同步文件失败:{error}',
@@ -1563,7 +1601,7 @@ const zhCN: Messages = {
// AI Claude Code
'ai.claude.title': 'Claude Code',
'ai.claude.description': 'Anthropic 的智能编程助手。使用 claude-code-acp 进行 ACP 协议流式传输。',
'ai.claude.description': 'Anthropic 的智能编程助手。使用 claude-agent-acp 进行 ACP 协议流式传输。',
'ai.claude.detecting': '检测中...',
'ai.claude.detected': '已检测到',
'ai.claude.notFound': '未找到',
@@ -1580,7 +1618,6 @@ const zhCN: Messages = {
// AI Chat
'ai.chat.noProvider': '尚未配置 AI 提供商。请前往 **设置 → AI → 提供商** 添加并启用一个提供商。',
'ai.chat.toolDenied': '操作已被用户拒绝。',
'ai.chat.toolApprovalTitle': '需要权限确认',
'ai.chat.toolApproved': '已批准',
'ai.chat.toolApprovalHint': '按回车批准,按 Esc 拒绝',
'ai.chat.approve': '批准',
+30
View File
@@ -32,6 +32,14 @@ function getAIBridge() {
return (window as unknown as { netcatty?: Record<string, (...args: unknown[]) => unknown> }).netcatty;
}
function cleanupAcpSessions(sessionIds: string[]) {
const bridge = getAIBridge();
if (!bridge?.aiAcpCleanup || sessionIds.length === 0) return;
for (const sessionId of sessionIds) {
void bridge.aiAcpCleanup(sessionId).catch(() => {});
}
}
/** Maximum number of sessions to keep in localStorage. */
const MAX_STORED_SESSIONS = 50;
@@ -376,6 +384,7 @@ export function useAIState() {
}, [defaultAgentId, persistSessions, setActiveSessionId]);
const deleteSession = useCallback((sessionId: string, scopeKey?: string) => {
cleanupAcpSessions([sessionId]);
if (persistTimerRef.current) {
clearTimeout(persistTimerRef.current);
persistTimerRef.current = null;
@@ -394,6 +403,10 @@ export function useAIState() {
}, [persistSessions]);
const deleteSessionsByTarget = useCallback((scopeType: 'terminal' | 'workspace', targetId: string) => {
const removedSessionIds = sessionsRef.current
.filter(s => s.scope.type === scopeType && s.scope.targetId === targetId)
.map(s => s.id);
cleanupAcpSessions(removedSessionIds);
if (persistTimerRef.current) {
clearTimeout(persistTimerRef.current);
persistTimerRef.current = null;
@@ -420,6 +433,18 @@ export function useAIState() {
});
}, [persistSessions]);
const updateSessionExternalSessionId = useCallback((sessionId: string, externalSessionId: string | undefined) => {
setSessionsRaw(prev => {
const next = prev.map(s => (
s.id === sessionId
? { ...s, externalSessionId, updatedAt: Date.now() }
: s
));
debouncedPersistSessions();
return next;
});
}, [debouncedPersistSessions]);
// Maximum messages per session to prevent unbounded memory growth
const MAX_MESSAGES_PER_SESSION = 500;
@@ -484,6 +509,10 @@ export function useAIState() {
}, [persistSessions]);
const cleanupOrphanedSessions = useCallback((activeTargetIds: Set<string>) => {
const removedSessionIds = sessionsRef.current
.filter(s => s.scope.targetId && !activeTargetIds.has(s.scope.targetId))
.map(s => s.id);
cleanupAcpSessions(removedSessionIds);
setSessionsRaw(prev => {
const next = prev.filter(s => {
// Keep sessions without a targetId (global scope)
@@ -572,6 +601,7 @@ export function useAIState() {
deleteSession,
deleteSessionsByTarget,
updateSessionTitle,
updateSessionExternalSessionId,
addMessageToSession,
updateLastMessage,
updateMessageById,
+35 -7
View File
@@ -52,11 +52,13 @@ interface SyncNowOptions {
export const useAutoSync = (config: AutoSyncConfig) => {
const { t } = useI18n();
const sync = useCloudSync();
const { onApplyPayload } = config;
const syncTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastSyncedDataRef = useRef<string>('');
const hasCheckedRemoteRef = useRef(false);
const isInitializedRef = useRef(false);
const isSyncRunningRef = useRef(false);
const skipNextSyncRef = useRef(false);
const getSyncSnapshot = useCallback(() => {
let effectivePFRules = config.portForwardingRules;
@@ -162,6 +164,16 @@ export const useAutoSync = (config: AutoSyncConfig) => {
const results = await sync.syncNow(payload);
// Apply merged payloads first (before checking for failures) so local
// state gets updated even when some providers failed
for (const result of results.values()) {
if (result.mergedPayload) {
onApplyPayload(result.mergedPayload);
skipNextSyncRef.current = true;
break; // All providers share the same merged payload
}
}
for (const result of results.values()) {
if (!result.success) {
if (result.conflictDetected) {
@@ -184,7 +196,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
} finally {
isSyncRunningRef.current = false;
}
}, [sync, buildPayload, getDataHash, t]);
}, [sync, buildPayload, getDataHash, onApplyPayload, t]);
// Check remote version and pull if newer (on startup)
const checkRemoteVersion = useCallback(async () => {
@@ -207,18 +219,26 @@ export const useAutoSync = (config: AutoSyncConfig) => {
try {
console.log('[AutoSync] Checking remote version...');
// Load base BEFORE downloading (downloadFromProvider overwrites the base)
const base = await manager.loadSyncBase(connectedProvider);
const remotePayload = await sync.downloadFromProvider(connectedProvider);
if (remotePayload && remotePayload.syncedAt > state.localUpdatedAt) {
console.log('[AutoSync] Remote is newer, applying...');
config.onApplyPayload(remotePayload);
const { mergeSyncPayloads } = await import('../../domain/syncMerge');
const localPayload = buildPayload();
const mergeResult = mergeSyncPayloads(base, localPayload, remotePayload);
console.log('[AutoSync] Remote is newer, merged:', mergeResult.summary);
config.onApplyPayload(mergeResult.payload);
// Don't save base or skip auto-sync — let the data-change effect
// naturally trigger an upload of the merged payload (which will
// go through syncAllProviders and save base on success).
toast.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle'));
}
} catch (error) {
console.error('[AutoSync] Failed to check remote version:', error);
// Don't show error toast for initial check - it's not critical
}
}, [sync, config, t]);
}, [sync, config, buildPayload, t]);
// Debounced auto-sync when data changes
useEffect(() => {
@@ -235,7 +255,15 @@ export const useAutoSync = (config: AutoSyncConfig) => {
}
const currentHash = getDataHash();
// After a merge, onApplyPayload changes local state which triggers
// this effect. Skip that cycle and just update the hash baseline.
if (skipNextSyncRef.current) {
skipNextSyncRef.current = false;
lastSyncedDataRef.current = currentHash;
return;
}
// Skip if data hasn't changed
if (currentHash === lastSyncedDataRef.current) {
return;
+79
View File
@@ -0,0 +1,79 @@
/**
* useFileUpload - Handle file paste/drop with base64 conversion
*
* Supports images, PDFs, and other document types.
* Ported from 1code's use-agents-file-upload.ts
*/
import { useCallback, useState } from 'react';
import { getPathForFile } from '../../lib/sftpFileUtils';
export interface UploadedFile {
id: string;
filename: string;
dataUrl: string; // data:...;base64,... for preview
base64Data: string; // raw base64 for API
mediaType: string; // MIME type e.g. "image/png", "application/pdf"
filePath?: string; // original filesystem path (Electron only)
}
/** Reject only known binary blobs that AI models can't process */
const REJECTED_MIME_PREFIXES = ['video/', 'audio/'];
function isSupportedFile(file: File): boolean {
// Allow files with empty MIME (common in Electron for .sh, .yaml, etc.)
if (!file.type) return true;
return !REJECTED_MIME_PREFIXES.some(prefix => file.type.startsWith(prefix));
}
async function fileToDataUrl(file: File): Promise<{ dataUrl: string; base64: string }> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
const dataUrl = reader.result as string;
const base64 = dataUrl.split(',')[1] || '';
resolve({ dataUrl, base64 });
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
export function useFileUpload() {
const [files, setFiles] = useState<UploadedFile[]>([]);
const addFiles = useCallback(async (inputFiles: File[]) => {
const supported = inputFiles.filter(isSupportedFile);
if (supported.length === 0) return;
const newFiles: UploadedFile[] = await Promise.all(
supported.map(async (file) => {
const id = crypto.randomUUID();
const filename = file.name || `file-${Date.now()}`;
const mediaType = file.type || 'application/octet-stream';
let dataUrl = '';
let base64Data = '';
try {
const result = await fileToDataUrl(file);
dataUrl = result.dataUrl;
base64Data = result.base64;
} catch (err) {
console.error('[useFileUpload] Failed to convert:', err);
}
const filePath = getPathForFile(file);
return { id, filename, dataUrl, base64Data, mediaType, filePath };
}),
);
setFiles((prev) => [...prev, ...newFiles]);
}, []);
const removeFile = useCallback((id: string) => {
setFiles((prev) => prev.filter((f) => f.id !== id));
}, []);
const clearFiles = useCallback(() => {
setFiles([]);
}, []);
return { files, addFiles, removeFile, clearFiles };
}
-66
View File
@@ -1,66 +0,0 @@
/**
* useImageUpload - Handle image paste/drop with base64 conversion
*
* Ported from 1code's use-agents-file-upload.ts
*/
import { useCallback, useState } from 'react';
export interface UploadedImage {
id: string;
filename: string;
dataUrl: string; // data:image/...;base64,... for preview
base64Data: string; // raw base64 for API
mediaType: string; // MIME type e.g. "image/png"
}
async function fileToDataUrl(file: File): Promise<{ dataUrl: string; base64: string }> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => {
const dataUrl = reader.result as string;
const base64 = dataUrl.split(',')[1] || '';
resolve({ dataUrl, base64 });
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
export function useImageUpload() {
const [images, setImages] = useState<UploadedImage[]>([]);
const addImages = useCallback(async (files: File[]) => {
const imageFiles = files.filter((f) => f.type.startsWith('image/'));
if (imageFiles.length === 0) return;
const newImages: UploadedImage[] = await Promise.all(
imageFiles.map(async (file) => {
const id = crypto.randomUUID();
const filename = file.name || `screenshot-${Date.now()}.png`;
const mediaType = file.type || 'image/png';
let dataUrl = '';
let base64Data = '';
try {
const result = await fileToDataUrl(file);
dataUrl = result.dataUrl;
base64Data = result.base64;
} catch (err) {
console.error('[useImageUpload] Failed to convert:', err);
}
return { id, filename, dataUrl, base64Data, mediaType };
}),
);
setImages((prev) => [...prev, ...newImages]);
}, []);
const removeImage = useCallback((id: string) => {
setImages((prev) => prev.filter((i) => i.id !== id));
}, []);
const clearImages = useCallback(() => {
setImages([]);
}, []);
return { images, addImages, removeImage, clearImages };
}
+21 -3
View File
@@ -38,7 +38,9 @@ export const useSessionState = () => {
// Log views: stores open log replay tabs
const [logViews, setLogViews] = useState<LogView[]>([]);
const createLocalTerminal = useCallback(() => {
const createLocalTerminal = useCallback((options?: {
shellType?: TerminalSession['shellType'];
}) => {
const sessionId = crypto.randomUUID();
const localHostId = `local-${sessionId}`;
const newSession: TerminalSession = {
@@ -48,6 +50,8 @@ export const useSessionState = () => {
hostname: 'localhost',
username: 'local',
status: 'connecting',
protocol: 'local',
shellType: options?.shellType,
};
setSessions(prev => [...prev, newSession]);
setActiveTabId(sessionId);
@@ -414,11 +418,17 @@ export const useSessionState = () => {
// direction: 'horizontal' = split top/bottom, 'vertical' = split left/right
const splitSession = useCallback((
sessionId: string,
direction: SplitDirection
direction: SplitDirection,
options?: {
localShellType?: TerminalSession['shellType'];
},
) => {
setSessions(prevSessions => {
const session = prevSessions.find(s => s.id === sessionId);
if (!session) return prevSessions;
const nextShellType = session.protocol === 'local'
? options?.localShellType
: session.shellType;
// If session is already in a workspace, split within that workspace
if (session.workspaceId) {
@@ -434,6 +444,7 @@ export const useSessionState = () => {
protocol: session.protocol,
port: session.port,
moshEnabled: session.moshEnabled,
shellType: nextShellType,
};
// Add pane to existing workspace
@@ -464,6 +475,7 @@ export const useSessionState = () => {
protocol: session.protocol,
port: session.port,
moshEnabled: session.moshEnabled,
shellType: nextShellType,
};
const hint: SplitHint = {
@@ -615,10 +627,15 @@ export const useSessionState = () => {
}, [setActiveTabId]);
// Copy a session - creates a new session with the same host connection
const copySession = useCallback((sessionId: string) => {
const copySession = useCallback((sessionId: string, options?: {
localShellType?: TerminalSession['shellType'];
}) => {
setSessions(prevSessions => {
const session = prevSessions.find(s => s.id === sessionId);
if (!session) return prevSessions;
const nextShellType = session.protocol === 'local'
? options?.localShellType
: session.shellType;
// Create a new session with the same connection info
const newSession: TerminalSession = {
@@ -631,6 +648,7 @@ export const useSessionState = () => {
protocol: session.protocol,
port: session.port,
moshEnabled: session.moshEnabled,
shellType: nextShellType,
serialConfig: session.serialConfig,
};
+28 -2
View File
@@ -21,6 +21,7 @@ import {
STORAGE_KEY_SFTP_AUTO_SYNC,
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_EDITOR_WORD_WRAP,
STORAGE_KEY_SESSION_LOGS_ENABLED,
STORAGE_KEY_SESSION_LOGS_DIR,
@@ -63,6 +64,7 @@ const DEFAULT_SFTP_DOUBLE_CLICK_BEHAVIOR: 'open' | 'transfer' = 'open';
const DEFAULT_SFTP_AUTO_SYNC = false;
const DEFAULT_SFTP_SHOW_HIDDEN_FILES = false;
const DEFAULT_SFTP_USE_COMPRESSED_UPLOAD = true;
const DEFAULT_SFTP_AUTO_OPEN_SIDEBAR = false;
// Editor defaults
const DEFAULT_EDITOR_WORD_WRAP = false;
@@ -231,6 +233,10 @@ export const useSettingsState = () => {
if (stored === 'false' || stored === 'disabled') return false;
return DEFAULT_SFTP_USE_COMPRESSED_UPLOAD;
});
const [sftpAutoOpenSidebar, setSftpAutoOpenSidebar] = useState<boolean>(() => {
const stored = readStoredString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
return stored === 'true' ? true : DEFAULT_SFTP_AUTO_OPEN_SIDEBAR;
});
// Editor Settings
const [editorWordWrap, setEditorWordWrapState] = useState<boolean>(() => {
@@ -393,6 +399,8 @@ export const useSettingsState = () => {
if (storedHidden === 'true' || storedHidden === 'false') setSftpShowHiddenFiles(storedHidden === 'true');
const storedCompress = readStoredString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD);
if (storedCompress === 'true' || storedCompress === 'false') setSftpUseCompressedUpload(storedCompress === 'true');
const storedAutoOpenSidebar = readStoredString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
if (storedAutoOpenSidebar === 'true' || storedAutoOpenSidebar === 'false') setSftpAutoOpenSidebar(storedAutoOpenSidebar === 'true');
// Custom terminal themes
customThemeStore.loadFromStorage();
@@ -529,6 +537,9 @@ export const useSettingsState = () => {
if (key === STORAGE_KEY_AUTO_UPDATE_ENABLED && typeof value === 'boolean') {
setAutoUpdateEnabled((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && typeof value === 'boolean') {
setSftpAutoOpenSidebar((prev) => (prev === value ? prev : value));
}
});
return () => {
try {
@@ -694,6 +705,13 @@ export const useSettingsState = () => {
setSftpUseCompressedUpload(newValue);
}
}
// Sync SFTP auto-open sidebar setting from other windows
if (e.key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== sftpAutoOpenSidebar) {
setSftpAutoOpenSidebar(newValue);
}
}
// Sync global hotkey enabled setting from other windows
if (e.key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && e.newValue !== null) {
const newValue = e.newValue === 'true';
@@ -712,7 +730,7 @@ export const useSettingsState = () => {
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, globalHotkeyEnabled, autoUpdateEnabled, mergeIncomingTerminalSettings]);
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, globalHotkeyEnabled, autoUpdateEnabled, mergeIncomingTerminalSettings]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
@@ -797,6 +815,12 @@ export const useSettingsState = () => {
notifySettingsChanged(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, sftpUseCompressedUpload);
}, [sftpUseCompressedUpload, notifySettingsChanged]);
// Persist SFTP auto-open sidebar setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, sftpAutoOpenSidebar ? 'true' : 'false');
notifySettingsChanged(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, sftpAutoOpenSidebar);
}, [sftpAutoOpenSidebar, notifySettingsChanged]);
// Persist Session Logs settings
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_ENABLED, sessionLogsEnabled ? 'true' : 'false');
@@ -1019,6 +1043,8 @@ export const useSettingsState = () => {
setSftpShowHiddenFiles,
sftpUseCompressedUpload,
setSftpUseCompressedUpload,
sftpAutoOpenSidebar,
setSftpAutoOpenSidebar,
// Editor Settings
editorWordWrap,
setEditorWordWrap: useCallback((enabled: boolean) => {
@@ -1052,7 +1078,7 @@ export const useSettingsState = () => {
uiFontFamilyId, uiLanguage, customCSS,
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
customKeyBindings, editorWordWrap,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar,
customThemes,
]),
};
+7
View File
@@ -197,6 +197,12 @@ export const useSftpBackend = () => {
return bridge.showSaveDialog(defaultPath, filters);
}, []);
const selectDirectory = async (title?: string, defaultPath?: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.selectDirectory) return null;
return bridge.selectDirectory(title, defaultPath);
};
const downloadSftpToTempAndOpen = useCallback(async (
sftpId: string,
remotePath: string,
@@ -278,6 +284,7 @@ export const useSftpBackend = () => {
onTransferProgress,
selectApplication,
showSaveDialog,
selectDirectory,
downloadSftpToTempAndOpen,
};
};
+1 -1
View File
@@ -90,7 +90,7 @@ export const useTerminalBackend = () => {
return bridge.onSessionData(sessionId, cb);
}, []);
const onSessionExit = useCallback((sessionId: string, cb: (evt: { exitCode?: number; signal?: number }) => void) => {
const onSessionExit = useCallback((sessionId: string, cb: (evt: { exitCode?: number; signal?: number; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => void) => {
const bridge = netcattyBridge.get();
if (!bridge?.onSessionExit) throw new Error("onSessionExit unavailable");
return bridge.onSessionExit(sessionId, cb);
+93 -67
View File
@@ -6,7 +6,6 @@
*
* Core logic is decomposed into focused hooks:
* - useAIChatStreaming: stream processing, abort management, agent sub-flows
* - useToolApproval: tool approval workflow, timeouts, resume logic
* - useConversationExport: export formats & object URL lifecycle
*/
@@ -20,7 +19,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { cn } from '../lib/utils';
import { useI18n } from '../application/i18n/I18nProvider';
import { useWindowControls } from '../application/state/useWindowControls';
import { useImageUpload } from '../application/state/useImageUpload';
import { useFileUpload } from '../application/state/useFileUpload';
import type {
AIPermissionMode,
AISession,
@@ -40,8 +39,9 @@ import ChatInput from './ai/ChatInput';
import ChatMessageList from './ai/ChatMessageList';
import ConversationExport from './ai/ConversationExport';
import { useAIChatStreaming, getNetcattyBridge } from './ai/hooks/useAIChatStreaming';
import { useToolApproval } from './ai/hooks/useToolApproval';
import { clearAllPendingApprovals } from '../infrastructure/ai/shared/approvalGate';
import { useConversationExport } from './ai/hooks/useConversationExport';
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
// -------------------------------------------------------------------
// Props
@@ -55,6 +55,7 @@ interface AIChatSidePanelProps {
createSession: (scope: AISessionScope, agentId?: string) => AISession;
deleteSession: (sessionId: string, scopeKey?: string) => void;
updateSessionTitle: (sessionId: string, title: string) => void;
updateSessionExternalSessionId: (sessionId: string, externalSessionId: string | undefined) => void;
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
updateLastMessage: (
sessionId: string,
@@ -100,8 +101,15 @@ interface AIChatSidePanelProps {
label: string;
os?: string;
username?: string;
protocol?: string;
shellType?: string;
connected: boolean;
}>;
resolveExecutorContext?: (scope: {
type: 'terminal' | 'workspace';
targetId?: string;
label?: string;
}) => ExecutorContext;
// Visibility
isVisible?: boolean;
@@ -115,6 +123,35 @@ function generateId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function buildAcpHistoryMessages(messages: ChatMessage[]): Array<{ role: 'user' | 'assistant'; content: string }> {
return messages.flatMap((message) => {
if (message.role === 'system') return [];
if (message.role === 'user') {
return message.content ? [{ role: 'user' as const, content: message.content }] : [];
}
if (message.role === 'assistant') {
const parts: string[] = [];
if (message.content) parts.push(message.content);
if (message.toolCalls?.length) {
parts.push(...message.toolCalls.map((tc) => `Tool call: ${tc.name}(${JSON.stringify(tc.arguments ?? {})})`));
}
if (!parts.length) return [];
return [{ role: 'assistant' as const, content: parts.join('\n\n') }];
}
if (message.role === 'tool' && message.toolResults?.length) {
return message.toolResults.map((tr) => ({
role: 'assistant' as const,
content: `Tool result:\n${tr.content}`,
}));
}
return [];
});
}
// -------------------------------------------------------------------
// Component
// -------------------------------------------------------------------
@@ -126,6 +163,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
createSession,
deleteSession,
updateSessionTitle,
updateSessionExternalSessionId,
addMessageToSession,
updateLastMessage,
updateMessageById,
@@ -147,6 +185,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
scopeHostIds,
scopeLabel,
terminalSessions = [],
resolveExecutorContext,
isVisible = true,
}) => {
const { t } = useI18n();
@@ -164,15 +203,18 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
const [showHistory, setShowHistory] = useState(false);
const [currentAgentId, setCurrentAgentId] = useState(defaultAgentId);
const { images, addImages, removeImage, clearImages } = useImageUpload();
const { files, addFiles, removeFile, clearFiles } = useFileUpload();
const { openSettingsWindow } = useWindowControls();
const terminalSessionsRef = useRef(terminalSessions);
terminalSessionsRef.current = terminalSessions;
const resolveExecutorContextRef = useRef(resolveExecutorContext);
resolveExecutorContextRef.current = resolveExecutorContext;
// ── Streaming hook ──
const {
streamingSessionIds,
setStreamingForScope,
abortControllersRef,
processCattyStream,
sendToCattyAgent,
sendToExternalAgent,
reportStreamError,
@@ -183,20 +225,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
updateMessageById,
});
// ── Tool approval hook ──
const {
pendingApprovalContextRef,
setPendingApproval,
handleApprovalResponse,
} = useToolApproval({
addMessageToSession,
updateLastMessage,
updateMessageById,
setStreamingForScope,
abortControllersRef,
processCattyStream,
t,
});
// Per-scope active session ID
const activeSessionId = activeSessionIdMap[scopeKey] ?? null;
@@ -218,7 +246,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
// Proactively sync terminal session metadata to main process whenever scope or sessions change
useEffect(() => {
const bridge = getNetcattyBridge();
if (bridge?.aiMcpUpdateSessions && terminalSessions.length > 0) {
if (bridge?.aiMcpUpdateSessions) {
void bridge.aiMcpUpdateSessions(terminalSessions, activeSessionId ?? undefined);
}
}, [terminalSessions, scopeKey, activeSessionId]);
@@ -242,16 +270,13 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
}
}, [webSearchConfig?.apiHost, webSearchConfig?.apiKey, webSearchConfig?.enabled]);
// Abort all active streams and clean up on unmount
// Preserve active streams across tab switches. The panel is conditionally
// mounted per tab, so unmounting here should not cancel in-flight work.
useEffect(() => {
const controllers = abortControllersRef.current;
return () => {
controllers.forEach(c => c.abort());
controllers.clear();
// Clear pending approval (clears timeout too via setPendingApproval)
setPendingApproval(null);
// no-op: stream lifecycle is managed by explicit stop/delete actions
};
}, [abortControllersRef, setPendingApproval]);
}, []);
// Agent discovery
const {
@@ -368,8 +393,8 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
/** Refs to avoid re-creating handleSend on every keystroke / image change. */
const inputValueRef = useRef(inputValue);
inputValueRef.current = inputValue;
const imagesRef = useRef(images);
imagesRef.current = images;
const filesRef = useRef(files);
filesRef.current = files;
/** Auto-title a session from the first user message if untitled. */
const autoTitleSession = useCallback((sessionId: string, text: string) => {
@@ -379,6 +404,20 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
}
}, [updateSessionTitle]);
const buildExecutorContextForScope = useCallback((scope: {
type: 'terminal' | 'workspace';
targetId?: string;
label?: string;
}): ExecutorContext => {
const resolved = resolveExecutorContextRef.current?.(scope);
if (resolved) return resolved;
return {
sessions: terminalSessionsRef.current,
workspaceId: scope.type === 'workspace' ? scope.targetId : undefined,
workspaceName: scope.type === 'workspace' ? scope.label : undefined,
};
}, []);
/** Ensure a session exists for the current scope and return its ID. */
const ensureSession = useCallback((): string => {
if (activeSessionId) return activeSessionId;
@@ -412,16 +451,16 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
const sessionId = ensureSession();
// Capture images before clearing
const attachedImages = imagesRef.current.map(img => ({ base64Data: img.base64Data, mediaType: img.mediaType, filename: img.filename }));
const attachments = filesRef.current.map(f => ({ base64Data: f.base64Data, mediaType: f.mediaType, filename: f.filename, filePath: f.filePath }));
// Add user message
addMessageToSession(sessionId, {
id: generateId(), role: 'user', content: trimmed,
...(attachedImages.length > 0 ? { images: attachedImages } : {}),
...(attachments.length > 0 ? { attachments } : {}),
timestamp: Date.now(),
});
setInputValue('');
clearImages();
clearFiles();
setStreamingForScope(sessionId, true);
// Create assistant message placeholder with a tracked ID
@@ -444,7 +483,10 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
return;
}
try {
await sendToExternalAgent(sessionId, trimmed, agentConfig, abortController, attachedImages, {
await sendToExternalAgent(sessionId, trimmed, agentConfig, abortController, attachments, {
existingSessionId: currentSession?.externalSessionId,
updateExternalSessionId: updateSessionExternalSessionId,
historyMessages: buildAcpHistoryMessages(currentSession?.messages ?? []),
terminalSessions,
providers,
selectedAgentModel,
@@ -458,6 +500,11 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
abortControllersRef.current.delete(sessionId);
autoTitleSession(sessionId, trimmed);
} else {
const toolScope = {
type: scopeType,
targetId: scopeTargetId,
label: scopeLabel,
} as const;
await sendToCattyAgent(sessionId, sendScopeKey, trimmed, abortController, currentSession ?? undefined, assistantMsgId, {
activeProvider,
activeModelId,
@@ -468,18 +515,18 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
commandBlocklist,
terminalSessions,
webSearchConfig,
setPendingApproval,
getExecutorContext: () => buildExecutorContextForScope(toolScope),
autoTitleSession,
});
}, attachments.length > 0 ? attachments : undefined);
}
}, [
isStreaming, activeProvider, scopeKey, currentAgentId,
activeModelId, externalAgents,
ensureSession, addMessageToSession, updateMessageById, updateLastMessage,
setStreamingForScope, setInputValue, clearImages,
setStreamingForScope, setInputValue, clearFiles,
sendToExternalAgent, sendToCattyAgent, reportStreamError, autoTitleSession, t,
abortControllersRef, terminalSessions, providers, selectedAgentModel,
scopeType, scopeTargetId, scopeLabel, globalPermissionMode, commandBlocklist, webSearchConfig, setPendingApproval,
abortControllersRef, terminalSessions, providers, selectedAgentModel, updateSessionExternalSessionId,
scopeType, scopeTargetId, scopeLabel, globalPermissionMode, commandBlocklist, webSearchConfig, buildExecutorContextForScope,
]);
const handleStop = useCallback(() => {
@@ -492,13 +539,11 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
updateLastMessage(activeSessionId, msg => ({
...msg,
statusText: '',
executionStatus: msg.executionStatus === 'running' ? 'completed' : msg.executionStatus,
executionStatus: msg.executionStatus === 'running' ? 'cancelled' : msg.executionStatus,
}));
// Also clear any pending approval (clears timeout too via setPendingApproval)
if (pendingApprovalContextRef.current?.sessionId === activeSessionId) {
setPendingApproval(null);
}
}, [activeSessionId, setStreamingForScope, updateLastMessage, setPendingApproval, abortControllersRef, pendingApprovalContextRef]);
// Clear pending approvals for this session (so tool execute functions don't hang)
clearAllPendingApprovals(activeSessionId);
}, [activeSessionId, setStreamingForScope, updateLastMessage, abortControllersRef]);
const handleSelectSession = useCallback(
(sessionId: string) => {
@@ -516,8 +561,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
const handleDeleteSession = useCallback(
(e: React.MouseEvent, sessionId: string) => {
e.stopPropagation();
const bridge = getNetcattyBridge();
void bridge?.aiAcpCleanup?.(sessionId).catch(() => {});
deleteSession(sessionId, scopeKey);
// Active session clearing is handled by deleteSession with scopeKey
},
@@ -593,24 +636,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
<ChatMessageList
messages={messages}
isStreaming={isStreaming}
onApprove={(messageId) => void handleApprovalResponse(messageId, true, {
terminalSessions,
scopeType,
scopeTargetId,
scopeLabel,
globalPermissionMode,
commandBlocklist,
webSearchConfig,
})}
onReject={(messageId) => void handleApprovalResponse(messageId, false, {
terminalSessions,
scopeType,
scopeTargetId,
scopeLabel,
globalPermissionMode,
commandBlocklist,
webSearchConfig,
})}
activeSessionId={activeSessionId}
/>
{/* Recent sessions (Zed-style, shown when no messages) */}
@@ -655,9 +681,9 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
modelPresets={agentModelPresets}
selectedModelId={selectedAgentModel}
onModelSelect={handleAgentModelSelect}
images={images}
onAddImages={addImages}
onRemoveImage={removeImage}
files={files}
onAddFiles={addFiles}
onRemoveFile={removeFile}
hosts={terminalSessions.map(s => ({ sessionId: s.sessionId, hostname: s.hostname, label: s.label, connected: s.connected }))}
permissionMode={globalPermissionMode}
onPermissionModeChange={setGlobalPermissionMode}
+4
View File
@@ -978,6 +978,10 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
const result = await sync.syncToProvider(provider, payload);
if (result.success) {
// Apply merged data if a three-way merge happened
if (result.mergedPayload && onApplyPayload) {
onApplyPayload(result.mergedPayload);
}
toast.success(t('cloudSync.sync.success', { provider }));
} else if (result.conflictDetected) {
// Conflict modal will show automatically
+3 -4
View File
@@ -1,6 +1,6 @@
import { Server, Usb } from "lucide-react";
import React, { memo } from "react";
import { normalizeDistroId } from "../domain/host";
import { getEffectiveHostDistro } from "../domain/host";
import { cn } from "../lib/utils";
import { Host } from "../types";
@@ -58,8 +58,7 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
className,
size = "md",
}) => {
const distro =
normalizeDistroId(host.distro) || (host.distro || "").toLowerCase();
const distro = getEffectiveHostDistro(host);
const logo = DISTRO_LOGOS[distro];
const [errored, setErrored] = React.useState(false);
const bg = DISTRO_COLORS[distro] || DISTRO_COLORS.default;
@@ -106,7 +105,7 @@ const DistroAvatarInner: React.FC<DistroAvatarProps> = ({
>
<img
src={logo}
alt={host.distro || host.os}
alt={distro || host.os}
className={cn("object-contain invert brightness-0", iconSize)}
onError={() => setErrored(true)}
/>
+242 -31
View File
@@ -28,10 +28,20 @@ import React, { useEffect, useMemo, useState, useCallback } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { useApplicationBackend } from "../application/state/useApplicationBackend";
import { useSettingsState } from "../application/state/useSettingsState";
import { getEffectiveHostDistro, LINUX_DISTRO_OPTIONS } from "../domain/host";
import { customThemeStore } from "../application/state/customThemeStore";
import {
clearHostFontSizeOverride,
clearHostThemeOverride,
hasHostFontSizeOverride,
hasHostThemeOverride,
resolveHostTerminalFontSize,
resolveHostTerminalThemeId,
} from "../domain/terminalAppearance";
import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "../infrastructure/config/fonts";
import { cn } from "../lib/utils";
import { EnvVar, Host, Identity, ManagedSource, ProxyConfig, SSHKey } from "../types";
import { DISTRO_COLORS, DISTRO_LOGOS } from "./DistroAvatar";
import { DistroAvatar } from "./DistroAvatar";
import ThemeSelectPanel from "./ThemeSelectPanel";
import {
@@ -69,6 +79,8 @@ type SubPanel =
| "theme-select"
| "telnet-theme-select";
const LINUX_DISTRO_OPTION_IDS = [...LINUX_DISTRO_OPTIONS];
interface HostDetailsPanelProps {
initialData?: Host | null;
availableKeys: SSHKey[];
@@ -117,6 +129,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
charset: "UTF-8",
theme: terminalThemeId,
fontSize: terminalFontSize,
distroMode: "auto",
createdAt: Date.now(),
group: defaultGroup || undefined, // Pre-fill with current navigation group
} as Host),
@@ -179,6 +192,56 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
setForm((prev) => ({ ...prev, [key]: value }));
};
const effectiveThemeId = useMemo(
() => resolveHostTerminalThemeId(form, terminalThemeId),
[form, terminalThemeId],
);
const effectiveFontSize = useMemo(
() => resolveHostTerminalFontSize(form, terminalFontSize),
[form, terminalFontSize],
);
const hasEffectiveThemeOverride = useMemo(
() => hasHostThemeOverride(form),
[form],
);
const hasEffectiveFontSizeOverride = useMemo(
() => hasHostFontSizeOverride(form),
[form],
);
const effectiveTelnetThemeId =
form.protocols?.find((p) => p.protocol === "telnet")?.theme || effectiveThemeId;
const distroOptions = useMemo(
() =>
LINUX_DISTRO_OPTION_IDS.map((value) => ({
value,
label: t(`hostDetails.distro.option.${value}`),
icon: DISTRO_LOGOS[value],
bgClass: DISTRO_COLORS[value] || DISTRO_COLORS.default,
})),
[t],
);
const getDistroOptionLabel = useCallback(
(value?: string) =>
distroOptions.find((option) => option.value === value)?.label ||
value ||
t("hostDetails.distro.pending"),
[distroOptions, t],
);
const effectiveFormDistro = getEffectiveHostDistro(form);
const handleDistroModeChange = useCallback((mode: "auto" | "manual") => {
setForm((prev) => ({
...prev,
distroMode: mode,
manualDistro:
mode === "manual"
? prev.manualDistro || getEffectiveHostDistro(prev) || "linux"
: prev.manualDistro,
}));
}, []);
const updateProxyConfig = useCallback(
(field: keyof ProxyConfig, value: string | number) => {
setForm((prev) => ({
@@ -298,6 +361,27 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
password: form.savePassword === false ? undefined : form.password,
managedSourceId: finalManagedSourceId,
};
const preserveLegacyTheme = initialData?.theme != null && cleaned.themeOverride !== false;
const preserveLegacyFontFamily = initialData?.fontFamily != null && cleaned.fontFamilyOverride !== false;
const preserveLegacyFontSize = initialData?.fontSize != null && cleaned.fontSizeOverride !== false;
if (cleaned.themeOverride === false) {
delete cleaned.theme;
} else if (preserveLegacyTheme && cleaned.theme == null) {
cleaned.theme = initialData?.theme;
}
if (cleaned.fontFamilyOverride === false) {
delete cleaned.fontFamily;
} else if (preserveLegacyFontFamily && cleaned.fontFamily == null) {
cleaned.fontFamily = initialData?.fontFamily;
}
if (cleaned.fontSizeOverride === false) {
delete cleaned.fontSize;
} else if (preserveLegacyFontSize && cleaned.fontSize == null) {
cleaned.fontSize = initialData?.fontSize;
}
onSave(cleaned);
};
@@ -478,9 +562,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
return (
<ThemeSelectPanel
open={true}
selectedThemeId={form.theme || "flexoki-dark"}
selectedThemeId={effectiveThemeId}
onSelect={(themeId) => {
update("theme", themeId);
setForm((prev) => ({ ...prev, theme: themeId, themeOverride: true }));
setActiveSubPanel("none");
}}
onClose={onCancel}
@@ -495,11 +579,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
return (
<ThemeSelectPanel
open={true}
selectedThemeId={
form.protocols?.find((p) => p.protocol === "telnet")?.theme ||
form.theme ||
"flexoki-dark"
}
selectedThemeId={effectiveTelnetThemeId}
onSelect={(themeId) => {
// Update telnet protocol theme
const telnetConfig = form.protocols?.find(
@@ -1103,6 +1183,113 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</p>
</div>
{form.os === "linux" && (
<div className="space-y-2 rounded-lg border border-border/70 bg-secondary/30 p-3">
<div className="flex items-start gap-2">
<Globe size={14} className="mt-0.5 text-muted-foreground" />
<div className="space-y-0.5">
<p className="text-xs font-semibold">{t("hostDetails.distro.title")}</p>
<p className="text-xs text-muted-foreground">{t("hostDetails.distro.desc")}</p>
</div>
</div>
<div className="grid gap-2 md:grid-cols-2">
<div className="space-y-1">
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.mode")}</span>
<Select
value={form.distroMode || "auto"}
onValueChange={(val) => handleDistroModeChange(val as "auto" | "manual")}
>
<SelectTrigger className="h-8" aria-label={t("hostDetails.distro.mode")}>
<span className="truncate whitespace-nowrap pr-2 text-left">
{form.distroMode === "manual"
? t("hostDetails.distro.mode.manual")
: t("hostDetails.distro.mode.auto")}
</span>
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{t("hostDetails.distro.mode.auto")}</SelectItem>
<SelectItem value="manual">{t("hostDetails.distro.mode.manual")}</SelectItem>
</SelectContent>
</Select>
</div>
{form.distroMode === "manual" ? (
<div className="space-y-1">
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.manualLabel")}</span>
<Select
value={form.manualDistro}
onValueChange={(val) => update("manualDistro", val)}
>
<SelectTrigger className="h-8" aria-label={t("hostDetails.distro.manualLabel")}>
{(() => {
const selectedOption = distroOptions.find((option) => option.value === form.manualDistro);
return selectedOption ? (
<div className="flex min-w-0 items-center gap-2 pr-2">
<div
className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px]",
selectedOption.bgClass,
)}
>
{selectedOption.icon ? (
<img
src={selectedOption.icon}
alt={selectedOption.label}
className="h-3 w-3 object-contain invert brightness-0"
/>
) : (
<div className="h-2 w-2 rounded-full bg-white/70" />
)}
</div>
<span className="truncate whitespace-nowrap">{selectedOption.label}</span>
</div>
) : (
<SelectValue placeholder={t("hostDetails.distro.unknown")} />
);
})()}
</SelectTrigger>
<SelectContent className="min-w-[14rem]">
{distroOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className="flex items-center gap-2">
<div
className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center overflow-hidden rounded-[2px]",
option.bgClass,
)}
>
{option.icon ? (
<img
src={option.icon}
alt={option.label}
className="h-3 w-3 object-contain invert brightness-0"
/>
) : (
<div className="h-2 w-2 rounded-full bg-white/70" />
)}
</div>
<span className="whitespace-nowrap">{option.label}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : (
<div className="space-y-1">
<span className="text-xs text-muted-foreground">{t("hostDetails.distro.detectedLabel")}</span>
<div className="flex h-8 items-center rounded-md border border-border/60 bg-background/50 px-3 text-sm">
{effectiveFormDistro
? getDistroOptionLabel(effectiveFormDistro)
: t("hostDetails.distro.unknown")}
</div>
</div>
)}
</div>
</div>
)}
{/* SSH Theme Selection */}
<button
type="button"
@@ -1113,15 +1300,15 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
className="w-12 h-8 rounded-md border border-border/60 flex items-center justify-center text-[6px] font-mono overflow-hidden"
style={{
backgroundColor:
customThemeStore.getThemeById(form.theme || "flexoki-dark")?.colors.background || "#100F0F",
customThemeStore.getThemeById(effectiveThemeId)?.colors.background || "#100F0F",
color:
customThemeStore.getThemeById(form.theme || "flexoki-dark")?.colors.foreground || "#CECDC3",
customThemeStore.getThemeById(effectiveThemeId)?.colors.foreground || "#CECDC3",
}}
>
<div className="p-0.5">
<div
style={{
color: customThemeStore.getThemeById(form.theme || "flexoki-dark")?.colors.green,
color: customThemeStore.getThemeById(effectiveThemeId)?.colors.green,
}}
>
$
@@ -1129,9 +1316,19 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</div>
</div>
<span className="text-sm flex-1">
{customThemeStore.getThemeById(form.theme || "flexoki-dark")?.name || "Flexoki Dark"}
{customThemeStore.getThemeById(effectiveThemeId)?.name || "Flexoki Dark"}
</span>
</button>
{hasEffectiveThemeOverride && (
<Button
variant="ghost"
size="sm"
className="w-full justify-start text-primary"
onClick={() => setForm((prev) => clearHostThemeOverride(prev))}
>
{t("common.useGlobal")}
</Button>
)}
{/* Font Size */}
<div className="flex items-center gap-2">
@@ -1140,11 +1337,15 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
variant="outline"
size="sm"
onClick={() => {
if ((form.fontSize || 14) > MIN_FONT_SIZE) {
update("fontSize", (form.fontSize || 14) - 1);
if (effectiveFontSize > MIN_FONT_SIZE) {
setForm((prev) => ({
...prev,
fontSize: effectiveFontSize - 1,
fontSizeOverride: true,
}));
}
}}
disabled={(form.fontSize || 14) <= MIN_FONT_SIZE}
disabled={effectiveFontSize <= MIN_FONT_SIZE}
className="px-2 h-8"
>
-
@@ -1153,25 +1354,43 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
type="number"
min={MIN_FONT_SIZE}
max={MAX_FONT_SIZE}
value={form.fontSize || 14}
value={effectiveFontSize}
onChange={(e) => {
const val = parseInt(e.target.value);
if (val >= MIN_FONT_SIZE && val <= MAX_FONT_SIZE) {
update("fontSize", val);
setForm((prev) => ({
...prev,
fontSize: val,
fontSizeOverride: true,
}));
}
}}
className="w-16 text-center h-8"
/>
<span className="text-sm text-muted-foreground">pt</span>
{hasEffectiveFontSizeOverride && (
<Button
variant="ghost"
size="sm"
className="ml-auto h-8 text-primary"
onClick={() => setForm((prev) => clearHostFontSizeOverride(prev))}
>
{t("common.useGlobal")}
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => {
if ((form.fontSize || 14) < MAX_FONT_SIZE) {
update("fontSize", (form.fontSize || 14) + 1);
if (effectiveFontSize < MAX_FONT_SIZE) {
setForm((prev) => ({
...prev,
fontSize: effectiveFontSize + 1,
fontSizeOverride: true,
}));
}
}}
disabled={(form.fontSize || 14) >= MAX_FONT_SIZE}
disabled={effectiveFontSize >= MAX_FONT_SIZE}
className="px-2 h-8"
>
+
@@ -1494,21 +1713,15 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
className="w-12 h-8 rounded-md border border-border/60 flex items-center justify-center text-[6px] font-mono overflow-hidden"
style={{
backgroundColor:
customThemeStore.getThemeById(
form.protocols?.find((p) => p.protocol === "telnet")?.theme || form.theme || "flexoki-dark"
)?.colors.background || "#100F0F",
customThemeStore.getThemeById(effectiveTelnetThemeId)?.colors.background || "#100F0F",
color:
customThemeStore.getThemeById(
form.protocols?.find((p) => p.protocol === "telnet")?.theme || form.theme || "flexoki-dark"
)?.colors.foreground || "#CECDC3",
customThemeStore.getThemeById(effectiveTelnetThemeId)?.colors.foreground || "#CECDC3",
}}
>
<div className="p-0.5">
<div
style={{
color: customThemeStore.getThemeById(
form.protocols?.find((p) => p.protocol === "telnet")?.theme || form.theme || "flexoki-dark"
)?.colors.green,
color: customThemeStore.getThemeById(effectiveTelnetThemeId)?.colors.green,
}}
>
$
@@ -1516,9 +1729,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</div>
</div>
<span className="text-sm flex-1">
{customThemeStore.getThemeById(
form.protocols?.find((p) => p.protocol === "telnet")?.theme || form.theme || "flexoki-dark"
)?.name || "Flexoki Dark"}
{customThemeStore.getThemeById(effectiveTelnetThemeId)?.name || "Flexoki Dark"}
</span>
</button>
</Card>
+1 -11
View File
@@ -61,16 +61,6 @@ interface TreeNodeProps {
toggleHostSelection?: (hostId: string) => void;
}
// Helper function to recursively count all hosts in a node and its children
const countAllHostsInNode = (node: GroupNode): number => {
let count = node.hosts.length;
if (node.children) {
Object.values(node.children).forEach((child) => {
count += countAllHostsInNode(child);
});
}
return count;
};
const TreeNode: React.FC<TreeNodeProps> = ({
node,
@@ -100,7 +90,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
const hasChildren = node.children && Object.keys(node.children).length > 0;
const paddingLeft = `${depth * 20 + 12}px`;
const isManaged = managedGroupPaths?.has(node.path) ?? false;
const hostsCountInNode = useMemo(() => countAllHostsInNode(node), [node]);
const hostsCountInNode = node.totalHostCount ?? node.hosts.length;
const childNodes = useMemo(() => {
if (!node.children) return [];
+1 -1
View File
@@ -621,7 +621,7 @@ echo $3 >> "$FILE"`);
</Button>
</DropdownTrigger>
</div>
<DropdownContent className="w-44" align="start" alignToParent>
<DropdownContent className="w-48" align="start" alignToParent>
<Button
variant="ghost"
className="w-full justify-start gap-2"
+11 -12
View File
@@ -254,25 +254,25 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
const RENDER_LIMIT = 100; // Limit rendered items for performance
// Define handleScanSystem before useEffect that depends on it
const handleScanSystem = useCallback(async () => {
const handleScanSystem = useCallback(async (silent = false) => {
setIsScanning(true);
try {
const content = await readKnownHosts();
if (content === undefined) {
toast.error(
if (!silent) toast.error(
t("knownHosts.toast.scanUnavailable"),
t("vault.nav.knownHosts"),
);
return;
}
if (!content) {
toast.info(t("knownHosts.toast.scanNoFile"), t("vault.nav.knownHosts"));
if (!silent) toast.info(t("knownHosts.toast.scanNoFile"), t("vault.nav.knownHosts"));
return;
}
const parsed = parseKnownHostsFile(content);
if (parsed.length === 0) {
toast.info(
if (!silent) toast.info(
t("knownHosts.toast.scanNoEntries"),
t("vault.nav.knownHosts"),
);
@@ -288,16 +288,16 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
if (newHosts.length > 0) {
onImportFromFile(newHosts);
toast.success(
if (!silent) toast.success(
t("knownHosts.toast.scanImported", { count: newHosts.length }),
t("vault.nav.knownHosts"),
);
} else {
toast.info(t("knownHosts.toast.scanNoNew"), t("vault.nav.knownHosts"));
if (!silent) toast.info(t("knownHosts.toast.scanNoNew"), t("vault.nav.knownHosts"));
}
} catch (err) {
logger.error("Failed to scan system known_hosts:", err);
toast.error(
if (!silent) toast.error(
err instanceof Error ? err.message : t("knownHosts.toast.scanFailed"),
t("vault.nav.knownHosts"),
);
@@ -307,13 +307,12 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
}
}, [knownHosts, onRefresh, onImportFromFile, readKnownHosts, t]);
// Auto-scan on first mount
// Auto-scan on first mount (silent — don't show toasts for missing known_hosts)
useEffect(() => {
if (!hasScannedRef.current) {
hasScannedRef.current = true;
// Delay scan slightly to not block initial render
const timer = setTimeout(() => {
handleScanSystem();
handleScanSystem(true);
}, 100);
return () => clearTimeout(timer);
}
@@ -515,7 +514,7 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
variant="ghost"
size="sm"
className="h-9 px-3 text-xs"
onClick={handleScanSystem}
onClick={() => handleScanSystem()}
disabled={isScanning}
>
<RefreshCw
@@ -572,7 +571,7 @@ const KnownHostsManager: React.FC<KnownHostsManagerProps> = ({
<div className="flex gap-2">
<Button
variant="secondary"
onClick={handleScanSystem}
onClick={() => handleScanSystem()}
disabled={isScanning}
>
<RefreshCw
-817
View File
@@ -1,817 +0,0 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { useSftpBackend } from "../application/state/useSftpBackend";
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
import { useSettingsState } from "../application/state/useSettingsState";
import { useSftpModalTransfers } from "./sftp-modal/hooks/useSftpModalTransfers";
import { Host, RemoteFile, SftpFilenameEncoding } from "../types";
import { filterHiddenFiles } from "./sftp";
import { DropEntry } from "../lib/sftpFileUtils";
import FileOpenerDialog from "./FileOpenerDialog";
import TextEditorModal from "./TextEditorModal";
import { SftpModalFileList } from "./sftp-modal/SftpModalFileList";
import { SftpModalDialogs } from "./sftp-modal/SftpModalDialogs";
import { SftpModalFooter } from "./sftp-modal/SftpModalFooter";
import { SftpModalHeader } from "./sftp-modal/SftpModalHeader";
import { SftpModalUploadTasks } from "./sftp-modal/SftpModalUploadTasks";
import { formatBytes, formatDate } from "./sftp-modal/utils";
import { useSftpModalSorting } from "./sftp-modal/hooks/useSftpModalSorting";
import { useSftpModalVirtualList } from "./sftp-modal/hooks/useSftpModalVirtualList";
import { useSftpModalPath } from "./sftp-modal/hooks/useSftpModalPath";
import { useSftpModalSelection } from "./sftp-modal/hooks/useSftpModalSelection";
import { useSftpModalSession } from "./sftp-modal/hooks/useSftpModalSession";
import { useSftpModalFileActions } from "./sftp-modal/hooks/useSftpModalFileActions";
import { useSftpModalKeyboardShortcuts } from "./sftp-modal/hooks/useSftpModalKeyboardShortcuts";
import { joinPath, isRootPath, getParentPath } from "./sftp-modal/pathUtils";
import { toast } from "./ui/toast";
interface SFTPModalProps {
host: Host;
credentials: {
username?: string;
hostname: string;
port?: number;
password?: string;
privateKey?: string;
certificate?: string;
passphrase?: string;
publicKey?: string;
keyId?: string;
keySource?: 'generated' | 'imported';
proxy?: NetcattyProxyConfig;
jumpHosts?: NetcattyJumpHost[];
sftpSudo?: boolean;
legacyAlgorithms?: boolean;
};
open: boolean;
onClose: () => void;
/** Initial path to open in SFTP. If not accessible, falls back to home directory. */
initialPath?: string;
/** Initial entries to upload when SFTP modal opens. Used for drag-and-drop to terminal. */
initialEntriesToUpload?: DropEntry[];
/** Callback to update the host (e.g. for bookmark persistence). */
onUpdateHost?: (host: Host) => void;
}
const SFTPModal: React.FC<SFTPModalProps> = ({
host,
credentials,
open,
onClose,
initialPath,
initialEntriesToUpload,
onUpdateHost,
}) => {
const {
openSftp,
closeSftp: closeSftpBackend,
listSftp,
readSftp,
writeSftpBinaryWithProgress,
writeSftpBinary,
writeSftp,
deleteSftp,
mkdirSftp,
renameSftp,
chmodSftp,
statSftp,
listLocalDir,
readLocalFile,
writeLocalFile,
deleteLocalFile,
mkdirLocal,
getHomeDir,
selectApplication,
downloadSftpToTempAndOpen,
cancelSftpUpload,
startStreamTransfer,
cancelTransfer,
showSaveDialog,
} = useSftpBackend();
const { t } = useI18n();
const {
sftpAutoSync,
sftpShowHiddenFiles,
setSftpShowHiddenFiles,
sftpUseCompressedUpload,
hotkeyScheme,
keyBindings,
editorWordWrap,
setEditorWordWrap,
} = useSettingsState();
const isLocalSession = host.protocol === "local";
const [filenameEncoding, setFilenameEncoding] = useState<SftpFilenameEncoding>(
host.sftpEncoding ?? "auto"
);
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const inputRef = useRef<HTMLInputElement>(null);
const folderInputRef = useRef<HTMLInputElement>(null);
const navigatingRef = useRef(false);
const clearSelection = useCallback(() => setSelectedFiles(new Set()), []);
// Update filenameEncoding when host changes
useEffect(() => {
setFilenameEncoding(host.sftpEncoding ?? "auto");
}, [host.id, host.sftpEncoding]);
const listSftpWithEncoding = useCallback(
(sftpId: string, path: string) => listSftp(sftpId, path, filenameEncoding),
[listSftp, filenameEncoding],
);
const readSftpWithEncoding = useCallback(
(sftpId: string, path: string) => readSftp(sftpId, path, filenameEncoding),
[readSftp, filenameEncoding],
);
const writeSftpWithEncoding = useCallback(
(sftpId: string, path: string, data: string) =>
writeSftp(sftpId, path, data, filenameEncoding),
[writeSftp, filenameEncoding],
);
const writeSftpBinaryWithEncoding = useCallback(
(sftpId: string, path: string, data: ArrayBuffer) =>
writeSftpBinary(sftpId, path, data, filenameEncoding),
[writeSftpBinary, filenameEncoding],
);
const writeSftpBinaryWithProgressWithEncoding = useCallback(
(
sftpId: string,
path: string,
data: ArrayBuffer,
transferId: string,
onProgress?: (transferred: number, total: number, speed: number) => void,
onComplete?: () => void,
onError?: (error: string) => void,
) =>
writeSftpBinaryWithProgress(
sftpId,
path,
data,
transferId,
filenameEncoding,
onProgress,
onComplete,
onError,
),
[writeSftpBinaryWithProgress, filenameEncoding],
);
const deleteSftpWithEncoding = useCallback(
(sftpId: string, path: string) => deleteSftp(sftpId, path, filenameEncoding),
[deleteSftp, filenameEncoding],
);
const mkdirSftpWithEncoding = useCallback(
(sftpId: string, path: string) => mkdirSftp(sftpId, path, filenameEncoding),
[mkdirSftp, filenameEncoding],
);
const renameSftpWithEncoding = useCallback(
(sftpId: string, oldPath: string, newPath: string) =>
renameSftp(sftpId, oldPath, newPath, filenameEncoding),
[renameSftp, filenameEncoding],
);
const chmodSftpWithEncoding = useCallback(
(sftpId: string, path: string, mode: string) =>
chmodSftp(sftpId, path, mode, filenameEncoding),
[chmodSftp, filenameEncoding],
);
const statSftpWithEncoding = useCallback(
(sftpId: string, path: string) => statSftp(sftpId, path, filenameEncoding),
[statSftp, filenameEncoding],
);
const downloadSftpToTempAndOpenWithEncoding = useCallback(
(
sftpId: string,
remotePath: string,
fileName: string,
appPath: string,
options?: { enableWatch?: boolean },
) =>
downloadSftpToTempAndOpen(sftpId, remotePath, fileName, appPath, {
...options,
encoding: filenameEncoding,
}),
[downloadSftpToTempAndOpen, filenameEncoding],
);
const {
currentPath,
setCurrentPath,
currentPathRef,
files,
loading,
setLoading,
reconnecting,
sessionVersion,
ensureSftp,
loadFiles,
closeSftpSession,
localHomeRef,
} = useSftpModalSession({
open,
host,
credentials,
initialPath,
isLocalSession,
t,
openSftp,
closeSftp: closeSftpBackend,
listSftp: listSftpWithEncoding,
listLocalDir,
getHomeDir,
onClearSelection: clearSelection,
});
// Track previous encoding to detect changes
const prevEncodingRef = useRef(filenameEncoding);
// Force reload only when filenameEncoding changes (not on every path change)
useEffect(() => {
if (!open || isLocalSession) return;
// Only force reload if encoding actually changed
if (prevEncodingRef.current !== filenameEncoding) {
prevEncodingRef.current = filenameEncoding;
loadFiles(currentPath, { force: true });
}
}, [currentPath, filenameEncoding, isLocalSession, loadFiles, open]);
const { getOpenerForFile, setOpenerForExtension } = useSftpFileAssociations();
const { sortField, sortOrder, columnWidths, handleSort, handleResizeStart } =
useSftpModalSorting();
const joinPathForSession = useCallback(
(base: string, name: string) => joinPath(base, name, isLocalSession),
[isLocalSession],
);
const isRootPathForSession = useCallback(
(path: string) => isRootPath(path, isLocalSession),
[isLocalSession],
);
const getParentPathForSession = useCallback(
(path: string) => getParentPath(path, isLocalSession),
[isLocalSession],
);
const handleNavigate = useCallback((path: string) => {
// Prevent double navigation (e.g., from double-click race condition)
if (navigatingRef.current) return;
navigatingRef.current = true;
setCurrentPath(path);
// Reset lock after a short delay
setTimeout(() => {
navigatingRef.current = false;
}, 300);
}, [navigatingRef, setCurrentPath]);
const handleUp = () => {
if (isRootPathForSession(currentPath)) return;
setCurrentPath(getParentPathForSession(currentPath));
};
const {
isEditingPath,
editingPathValue,
setEditingPathValue,
pathInputRef,
handlePathDoubleClick,
handlePathSubmit,
handlePathKeyDown,
breadcrumbs,
visibleBreadcrumbs,
hiddenBreadcrumbs,
needsBreadcrumbTruncation,
breadcrumbPathAtForIndex,
rootLabel,
rootPath,
} = useSftpModalPath({
currentPath,
isLocalSession,
localHomePath: localHomeRef.current,
onNavigate: handleNavigate,
});
const {
handleDelete,
handleCreateFolder,
handleCreateFile,
showCreateDialog,
setShowCreateDialog,
createType,
createName,
setCreateName,
isCreating,
handleCreateSubmit,
showRenameDialog,
setShowRenameDialog,
renameTarget,
renameName,
setRenameName,
isRenaming,
openRenameDialog,
handleRename,
showPermissionsDialog,
setShowPermissionsDialog,
permissionsTarget,
permissions,
isChangingPermissions,
openPermissionsDialog,
togglePermission,
getOctalPermissions,
getSymbolicPermissions,
handleSavePermissions,
showFileOpenerDialog,
setShowFileOpenerDialog,
fileOpenerTarget,
setFileOpenerTarget,
openFileOpenerDialog,
handleFileOpenerSelect,
handleSelectSystemApp,
showTextEditor,
setShowTextEditor,
textEditorTarget,
setTextEditorTarget,
textEditorContent,
setTextEditorContent,
loadingTextContent,
handleEditFile,
handleSaveTextFile,
handleOpenFile,
} = useSftpModalFileActions({
currentPath,
isLocalSession,
joinPath: joinPathForSession,
ensureSftp,
loadFiles,
readLocalFile,
readSftp: readSftpWithEncoding,
writeLocalFile,
writeSftp: writeSftpWithEncoding,
writeSftpBinary: writeSftpBinaryWithEncoding,
deleteLocalFile,
deleteSftp: deleteSftpWithEncoding,
mkdirLocal,
mkdirSftp: mkdirSftpWithEncoding,
renameSftp: renameSftpWithEncoding,
chmodSftp: chmodSftpWithEncoding,
statSftp: statSftpWithEncoding,
t,
sftpAutoSync,
getOpenerForFile,
setOpenerForExtension,
downloadSftpToTempAndOpen: downloadSftpToTempAndOpenWithEncoding,
selectApplication,
});
const {
uploading,
uploadTasks,
dragActive,
handleDownload,
handleUploadEntries,
handleFileSelect,
handleFolderSelect,
handleDrag,
handleDrop,
cancelUpload,
cancelTask,
dismissTask,
} = useSftpModalTransfers({
currentPath,
currentPathRef,
isLocalSession,
joinPath: joinPathForSession,
ensureSftp,
loadFiles,
readLocalFile,
readSftp: readSftpWithEncoding,
writeLocalFile,
writeSftpBinaryWithProgress: writeSftpBinaryWithProgressWithEncoding,
writeSftpBinary: writeSftpBinaryWithEncoding,
writeSftp: writeSftpWithEncoding,
mkdirLocal,
mkdirSftp: mkdirSftpWithEncoding,
cancelSftpUpload,
startStreamTransfer,
cancelTransfer,
showSaveDialog,
setLoading,
t,
useCompressedUpload: sftpUseCompressedUpload,
listSftp: listSftpWithEncoding,
deleteLocalFile,
});
const hasEverOpenedRef = useRef(false);
const hasActiveTransferTasks = useMemo(
() =>
uploadTasks.some(
(task) =>
task.status === "pending" ||
task.status === "uploading" ||
task.status === "downloading",
),
[uploadTasks],
);
useEffect(() => {
if (open) {
hasEverOpenedRef.current = true;
return;
}
if (!hasEverOpenedRef.current) return;
if (uploading || hasActiveTransferTasks) return;
void closeSftpSession();
}, [closeSftpSession, hasActiveTransferTasks, open, sessionVersion, uploading]);
const handleClose = async () => {
if (uploading || hasActiveTransferTasks) {
onClose();
return;
}
await closeSftpSession();
onClose();
};
// Handle initial entries to upload (from drag-and-drop to terminal)
const initialUploadTriggeredRef = useRef(false);
const prevLoadingRef = useRef(loading);
const prevEntriesRef = useRef<DropEntry[] | undefined>(undefined);
useEffect(() => {
// Detect when loading transitions from true to false (initial load complete)
const wasLoading = prevLoadingRef.current;
prevLoadingRef.current = loading;
const justFinishedLoading = wasLoading && !loading;
// Reset the flag when initialEntriesToUpload is cleared
if (!initialEntriesToUpload || initialEntriesToUpload.length === 0) {
initialUploadTriggeredRef.current = false;
prevEntriesRef.current = undefined;
return;
}
// Reset the flag when new entries arrive (different reference = new drop)
if (initialEntriesToUpload !== prevEntriesRef.current) {
initialUploadTriggeredRef.current = false;
prevEntriesRef.current = initialEntriesToUpload;
}
// Prevent duplicate uploads
if (initialUploadTriggeredRef.current) return;
// Wait for SFTP connection to be established
// Trigger when: modal is open AND loading just finished (works for empty directories too)
if (!open || loading) return;
if (!justFinishedLoading) return;
initialUploadTriggeredRef.current = true;
// Trigger upload with full DropEntry data (preserves directory structure)
void handleUploadEntries(initialEntriesToUpload);
}, [handleUploadEntries, initialEntriesToUpload, loading, open]);
// Display files with parent entry (like SftpView)
const displayFiles = useMemo(() => {
// Filter hidden files using utility function
const visibleFiles = filterHiddenFiles(files, sftpShowHiddenFiles);
// Check if we're at root
const atRoot = isRootPathForSession(currentPath);
if (atRoot) return visibleFiles;
// Add ".." parent directory entry at the top (only if not at root)
const parentEntry: RemoteFile = {
name: "..",
type: "directory",
size: "--",
lastModified: undefined,
};
return [parentEntry, ...visibleFiles.filter((f) => f.name !== "..")];
}, [files, currentPath, isRootPathForSession, sftpShowHiddenFiles]);
// Sorted files
const sortedFiles = useMemo(() => {
if (!displayFiles.length) return displayFiles;
// Keep ".." at the top, sort the rest
const parentEntry = displayFiles.find((f) => f.name === "..");
const otherFiles = displayFiles.filter((f) => f.name !== "..");
const sorted = [...otherFiles].sort((a, b) => {
// Directories and symlinks pointing to directories come first
const aIsDir = a.type === "directory" || (a.type === "symlink" && a.linkTarget === "directory");
const bIsDir = b.type === "directory" || (b.type === "symlink" && b.linkTarget === "directory");
if (aIsDir && !bIsDir) return -1;
if (!aIsDir && bIsDir) return 1;
let cmp = 0;
switch (sortField) {
case "name":
cmp = a.name.localeCompare(b.name);
break;
case "size": {
const sizeA =
typeof a.size === "number"
? a.size
: parseInt(String(a.size), 10) || 0;
const sizeB =
typeof b.size === "number"
? b.size
: parseInt(String(b.size), 10) || 0;
cmp = sizeA - sizeB;
break;
}
case "modified": {
const dateA = new Date(a.lastModified || 0).getTime();
const dateB = new Date(b.lastModified || 0).getTime();
cmp = dateA - dateB;
break;
}
}
return sortOrder === "asc" ? cmp : -cmp;
});
return parentEntry ? [parentEntry, ...sorted] : sorted;
}, [displayFiles, sortField, sortOrder]);
const hasFiles = files.length > 0;
const hasDisplayFiles = sortedFiles.length > 0;
const {
fileListRef,
handleFileListScroll,
shouldVirtualize,
totalHeight,
visibleRows,
} = useSftpModalVirtualList({ open, sortedFiles });
const { handleFileClick, handleFileDoubleClick } = useSftpModalSelection({
files,
setSelectedFiles,
currentPath,
joinPath: joinPathForSession,
onNavigate: handleNavigate,
onOpenFile: handleOpenFile,
onNavigateUp: handleUp,
});
// Keyboard shortcuts for modal
const handleKeyboardRename = useCallback((file: RemoteFile) => {
openRenameDialog(file);
}, [openRenameDialog]);
const handleKeyboardDelete = useCallback((fileNames: string[]) => {
// Find the files to pass to confirm dialog
if (fileNames.length === 0) return;
if (!confirm(t("sftp.deleteConfirm.title", { count: fileNames.length }))) return;
// Delete files
(async () => {
try {
for (const fileName of fileNames) {
const fullPath = joinPathForSession(currentPath, fileName);
if (isLocalSession) {
await deleteLocalFile(fullPath);
} else {
await deleteSftpWithEncoding(await ensureSftp(), fullPath);
}
}
await loadFiles(currentPath, { force: true });
setSelectedFiles(new Set());
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.deleteFailed"),
"SFTP",
);
}
})();
}, [currentPath, isLocalSession, deleteLocalFile, deleteSftpWithEncoding, ensureSftp, loadFiles, setSelectedFiles, t, joinPathForSession]);
const handleKeyboardNewFolder = useCallback(() => {
handleCreateFolder();
}, [handleCreateFolder]);
useSftpModalKeyboardShortcuts({
keyBindings,
hotkeyScheme,
open,
files,
visibleFiles: displayFiles,
selectedFiles,
setSelectedFiles,
onRefresh: () => loadFiles(currentPath, { force: true }),
onRename: handleKeyboardRename,
onDelete: handleKeyboardDelete,
onNewFolder: handleKeyboardNewFolder,
});
const handleDeleteSelected = async () => {
if (selectedFiles.size === 0) return;
const fileNames = Array.from(selectedFiles);
if (!confirm(t("sftp.deleteConfirm.title", { count: fileNames.length }))) return;
try {
for (const fileName of fileNames) {
const fullPath = joinPathForSession(currentPath, fileName);
if (isLocalSession) {
await deleteLocalFile(fullPath);
} else {
await deleteSftpWithEncoding(await ensureSftp(), fullPath);
}
}
await loadFiles(currentPath, { force: true });
setSelectedFiles(new Set());
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.deleteFailed"),
"SFTP",
);
}
};
const handleDownloadSelected = async () => {
if (selectedFiles.size === 0) return;
for (const fileName of selectedFiles) {
const file = files.find((f) => f.name === fileName);
if (file && file.type === "file") {
await handleDownload(file);
}
}
};
if (!open) return null;
return (
<>
<div className="h-full flex flex-col bg-background border-r border-border/60 overflow-hidden">
<SftpModalHeader
onClose={handleClose}
t={t}
host={host}
credentials={credentials}
showEncoding={!isLocalSession}
filenameEncoding={filenameEncoding}
onFilenameEncodingChange={setFilenameEncoding}
currentPath={currentPath}
isEditingPath={isEditingPath}
editingPathValue={editingPathValue}
setEditingPathValue={setEditingPathValue}
handlePathSubmit={handlePathSubmit}
handlePathKeyDown={handlePathKeyDown}
handlePathDoubleClick={handlePathDoubleClick}
isAtRoot={isRootPathForSession(currentPath)}
rootLabel={rootLabel}
isRefreshing={loading || reconnecting}
onUp={handleUp}
onHome={() =>
setCurrentPath((isLocalSession && localHomeRef.current) || rootPath)
}
onRefresh={() => loadFiles(currentPath, { force: true })}
visibleBreadcrumbs={visibleBreadcrumbs}
hiddenBreadcrumbs={hiddenBreadcrumbs}
needsBreadcrumbTruncation={needsBreadcrumbTruncation}
breadcrumbs={breadcrumbs}
onBreadcrumbSelect={(index) => setCurrentPath(breadcrumbPathAtForIndex(index))}
onRootSelect={() => setCurrentPath(rootPath)}
inputRef={inputRef}
folderInputRef={folderInputRef}
pathInputRef={pathInputRef}
uploading={uploading}
onTriggerUpload={() => inputRef.current?.click()}
onTriggerFolderUpload={() => folderInputRef.current?.click()}
onCreateFolder={handleCreateFolder}
onCreateFile={handleCreateFile}
onFileSelect={handleFileSelect}
onFolderSelect={handleFolderSelect}
showHiddenFiles={sftpShowHiddenFiles}
onToggleShowHiddenFiles={() =>
setSftpShowHiddenFiles(!sftpShowHiddenFiles)
}
onUpdateHost={onUpdateHost}
onNavigateToBookmark={(path) => setCurrentPath(path)}
/>
<SftpModalFileList
t={t}
currentPath={currentPath}
isLocalSession={isLocalSession}
hasFiles={hasFiles}
hasDisplayFiles={hasDisplayFiles}
selectedFiles={selectedFiles}
dragActive={dragActive}
loading={loading}
loadingTextContent={loadingTextContent}
reconnecting={reconnecting}
columnWidths={columnWidths}
sortField={sortField}
sortOrder={sortOrder}
shouldVirtualize={shouldVirtualize}
totalHeight={totalHeight}
visibleRows={visibleRows}
fileListRef={fileListRef}
inputRef={inputRef}
folderInputRef={folderInputRef}
handleSort={handleSort}
handleResizeStart={handleResizeStart}
handleFileListScroll={handleFileListScroll}
handleDrag={handleDrag}
handleDrop={handleDrop}
handleFileClick={handleFileClick}
handleFileDoubleClick={handleFileDoubleClick}
handleDownload={handleDownload}
handleDelete={handleDelete}
handleOpenFile={handleOpenFile}
openFileOpenerDialog={openFileOpenerDialog}
handleEditFile={handleEditFile}
openRenameDialog={openRenameDialog}
openPermissionsDialog={openPermissionsDialog}
handleNavigate={handleNavigate}
handleCreateFolder={handleCreateFolder}
handleCreateFile={handleCreateFile}
handleDownloadSelected={handleDownloadSelected}
handleDeleteSelected={handleDeleteSelected}
loadFiles={loadFiles}
formatBytes={formatBytes}
formatDate={formatDate}
/>
<SftpModalUploadTasks tasks={uploadTasks} t={t} onCancel={cancelUpload} onCancelTask={cancelTask} onDismiss={dismissTask} />
<SftpModalFooter
t={t}
files={files}
selectedFiles={selectedFiles}
loading={loading}
uploading={uploading}
onDownloadSelected={handleDownloadSelected}
onDeleteSelected={handleDeleteSelected}
/>
</div>
<SftpModalDialogs
t={t}
showRenameDialog={showRenameDialog}
setShowRenameDialog={setShowRenameDialog}
renameTarget={renameTarget}
renameName={renameName}
setRenameName={setRenameName}
handleRename={handleRename}
isRenaming={isRenaming}
showPermissionsDialog={showPermissionsDialog}
setShowPermissionsDialog={setShowPermissionsDialog}
permissionsTarget={permissionsTarget}
permissions={permissions}
togglePermission={togglePermission}
getOctalPermissions={getOctalPermissions}
getSymbolicPermissions={getSymbolicPermissions}
handleSavePermissions={handleSavePermissions}
isChangingPermissions={isChangingPermissions}
showCreateDialog={showCreateDialog}
setShowCreateDialog={setShowCreateDialog}
createType={createType}
createName={createName}
setCreateName={setCreateName}
isCreating={isCreating}
handleCreateSubmit={handleCreateSubmit}
/>
{/* File Opener Dialog */}
<FileOpenerDialog
open={showFileOpenerDialog}
onClose={() => {
setShowFileOpenerDialog(false);
setFileOpenerTarget(null);
}}
fileName={fileOpenerTarget?.name || ""}
onSelect={handleFileOpenerSelect}
onSelectSystemApp={handleSelectSystemApp}
/>
{/* Text Editor Modal */}
<TextEditorModal
open={showTextEditor}
onClose={() => {
setShowTextEditor(false);
setTextEditorTarget(null);
setTextEditorContent("");
}}
fileName={textEditorTarget?.name || ""}
initialContent={textEditorContent}
onSave={handleSaveTextFile}
editorWordWrap={editorWordWrap}
onToggleWordWrap={() => setEditorWordWrap(!editorWordWrap)}
/>
</>
);
};
export default SFTPModal;
+12 -1
View File
@@ -101,7 +101,14 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles]);
const sftp = useSftpState(hosts, keys, identities, sftpOptions);
const { showSaveDialog, startStreamTransfer } = useSftpBackend();
const {
showSaveDialog,
selectDirectory,
startStreamTransfer,
listSftp,
mkdirLocal,
deleteLocalFile,
} = useSftpBackend();
const sftpRef = useRef(sftp);
sftpRef.current = sftp;
@@ -153,7 +160,11 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
getOpenerForFileRef,
setOpenerForExtension,
t,
listSftp,
mkdirLocal,
deleteLocalFile,
showSaveDialog,
selectDirectory,
startStreamTransfer,
getSftpIdForConnection: sftp.getSftpIdForConnection,
});
+13 -2
View File
@@ -86,8 +86,15 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
const sftp = useSftpState(hosts, keys, identities, sftpOptions);
// Get stream transfer functions for optimized downloads
const { showSaveDialog, startStreamTransfer } = useSftpBackend();
// Get backend helpers for file downloads and local filesystem writes.
const {
showSaveDialog,
selectDirectory,
startStreamTransfer,
listSftp,
mkdirLocal,
deleteLocalFile,
} = useSftpBackend();
// Store sftp in a ref so callbacks can access the latest instance
// without needing to re-create when sftp changes
@@ -176,7 +183,11 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
getOpenerForFileRef,
setOpenerForExtension,
t,
listSftp,
mkdirLocal,
deleteLocalFile,
showSaveDialog,
selectDirectory,
startStreamTransfer,
getSftpIdForConnection: sftp.getSftpIdForConnection,
});
+59 -20
View File
@@ -25,6 +25,11 @@ import {
shouldEnableNativeUserInputAutoScroll,
shouldScrollOnTerminalInput,
} from "../domain/terminalScroll";
import {
resolveHostTerminalFontFamilyId,
resolveHostTerminalFontSize,
resolveHostTerminalThemeId,
} from "../domain/terminalAppearance";
import { resolveHostAuth } from "../domain/sshAuth";
import { useTerminalBackend } from "../application/state/useTerminalBackend";
import KnownHostConfirmDialog, { HostKeyInfo } from "./KnownHostConfirmDialog";
@@ -124,7 +129,7 @@ interface TerminalProps {
keyBindings?: KeyBinding[];
onHotkeyAction?: (action: string, event: KeyboardEvent) => void;
onStatusChange?: (sessionId: string, status: TerminalSession["status"]) => void;
onSessionExit?: (sessionId: string) => void;
onSessionExit?: (sessionId: string, evt: { exitCode?: number; signal?: number; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => void;
onTerminalDataCapture?: (sessionId: string, data: string) => void;
onOsDetected?: (hostId: string, distro: string) => void;
onCloseSession?: (sessionId: string) => void;
@@ -152,6 +157,8 @@ interface TerminalProps {
onToggleComposeBar?: () => void;
isWorkspaceComposeBarOpen?: boolean;
onBroadcastInput?: (data: string, sourceSessionId: string) => void;
// Session log configuration for real-time streaming
sessionLog?: { enabled: boolean; directory: string; format: string };
}
// Helper function to format network speed (bytes/sec) to human-readable format
@@ -209,6 +216,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onToggleComposeBar,
isWorkspaceComposeBarOpen,
onBroadcastInput,
sessionLog,
}) => {
// Timeout for connection - increased to 120s to allow time for keyboard-interactive (2FA) authentication
const CONNECTION_TIMEOUT = 120000;
@@ -247,8 +255,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// Host-level toggle: undefined = inherit global, true/false = explicit override
const hostEnabled = host?.keywordHighlightEnabled;
// If host explicitly disabled highlighting, disable everything for this terminal
const effectiveGlobalEnabled = hostEnabled === false ? false : globalEnabled;
// Global and host-level highlights are independent:
// global toggle controls global rules, host toggle controls host-specific rules
const effectiveGlobalEnabled = globalEnabled;
const effectiveHostEnabled = hostEnabled ?? false;
const mergedRules = [
@@ -298,6 +307,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const [showSFTP, setShowSFTP] = useState(false);
const [progressValue, setProgressValue] = useState(15);
const [hasSelection, setHasSelection] = useState(false);
const [isDisconnectedDialogDismissed, setIsDisconnectedDialogDismissed] = useState(false);
const statusRef = useRef<TerminalSession["status"]>(status);
statusRef.current = status;
@@ -398,13 +408,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const customThemes = useCustomThemes();
const effectiveTheme = useMemo(() => {
if (host.theme) {
const hostTheme = TERMINAL_THEMES.find((t) => t.id === host.theme)
|| customThemes.find((t) => t.id === host.theme);
const themeId = resolveHostTerminalThemeId(host, terminalTheme.id);
if (themeId) {
const hostTheme = TERMINAL_THEMES.find((t) => t.id === themeId)
|| customThemes.find((t) => t.id === themeId);
if (hostTheme) return hostTheme;
}
return terminalTheme;
}, [host.theme, terminalTheme, customThemes]);
}, [host, terminalTheme, customThemes]);
const resolvedChainHosts =
(host.hostChain?.hostIds
@@ -486,6 +497,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onTerminalDataCapture,
onOsDetected,
onCommandExecuted,
sessionLog,
});
sessionStartersRef.current = sessionStarters;
@@ -497,6 +509,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
setProgressLogs([]);
setShowLogs(false);
setIsCancelling(false);
setIsDisconnectedDialogDismissed(false);
const boot = async () => {
try {
@@ -542,7 +555,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const hostRules = host?.keywordHighlightRules ?? [];
const globalEnabled = terminalSettingsRef.current?.keywordHighlightEnabled ?? false;
const hostEnabled = host?.keywordHighlightEnabled;
const effectiveGlobalEnabled = hostEnabled === false ? false : globalEnabled;
const effectiveGlobalEnabled = globalEnabled;
const effectiveHostEnabled = hostEnabled ?? false;
const mergedRules = [
...(effectiveGlobalEnabled ? globalRules : []),
@@ -674,6 +687,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps -- updateStatus is a stable internal helper
}, [status, auth.needsAuth, host.protocol, host.hostname]);
useEffect(() => {
if (status === "connecting") {
setIsDisconnectedDialogDismissed(false);
}
}, [status]);
const safeFit = (options?: { force?: boolean; requireVisible?: boolean }) => {
const fitAddon = fitAddonRef.current;
if (!fitAddon) return;
@@ -720,7 +739,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
useEffect(() => {
if (termRef.current) {
const effectiveFontSize = host.fontSize || fontSize;
const effectiveFontSize = resolveHostTerminalFontSize(host, fontSize);
termRef.current.options.fontSize = effectiveFontSize;
termRef.current.options.theme = {
@@ -777,14 +796,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
setTimeout(() => safeFit({ force: true }), 50);
}
}, [fontSize, effectiveTheme, terminalSettings, host.fontSize]);
}, [fontSize, effectiveTheme, terminalSettings, host]);
useEffect(() => {
if (termRef.current) {
const effectiveFontSize = host.fontSize || fontSize;
const effectiveFontSize = resolveHostTerminalFontSize(host, fontSize);
termRef.current.options.fontSize = effectiveFontSize;
const hostFontId = host.fontFamily || fontFamilyId || "menlo";
const hostFontId = resolveHostTerminalFontFamilyId(host, fontFamilyId) || "menlo";
const fontObj = availableFonts.find((f) => f.id === hostFontId) || availableFonts[0];
termRef.current.options.fontFamily = fontObj.family;
@@ -795,7 +814,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
setTimeout(() => safeFit({ force: true }), 50);
}
}, [host.fontSize, host.fontFamily, host.theme, fontFamilyId, fontSize, effectiveTheme, availableFonts]);
}, [host, fontFamilyId, fontSize, effectiveTheme, availableFonts]);
useEffect(() => {
if (!isVisible) return;
@@ -843,7 +862,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (terminalSettings && termRef.current) {
const fontFamily = termRef.current.options?.fontFamily || "";
const effectiveFontSize = host.fontSize || fontSize;
const effectiveFontSize = resolveHostTerminalFontSize(host, fontSize);
if (typeof document !== "undefined" && document.fonts?.check) {
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${fontFamily}`;
const resolvedBold = document.fonts.check(weightSpec)
@@ -879,7 +898,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return () => {
cancelled = true;
};
}, [host.id, host.fontFamily, host.fontSize, fontFamilyId, fontSize, resizeSession, sessionId, terminalSettings]);
}, [host, fontFamilyId, fontSize, resizeSession, sessionId, terminalSettings]);
useEffect(() => {
if (!isVisible || !containerRef.current || !fitAddonRef.current) return;
@@ -1105,6 +1124,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onCloseSession?.(sessionId);
};
const handleDismissDisconnectedDialog = () => {
setIsDisconnectedDialogDismissed(true);
};
const handleCloseDisconnectedSession = () => {
onCloseSession?.(sessionId);
};
const handleHostKeyClose = () => {
setNeedsHostKeyVerification(false);
setPendingHostKeyInfo(null);
@@ -1145,17 +1172,29 @@ const TerminalComponent: React.FC<TerminalProps> = ({
cleanupSession();
auth.resetForRetry();
hasRunStartupCommandRef.current = false;
setIsDisconnectedDialogDismissed(false);
setStatus("connecting");
setError(null);
setProgressLogs(["Retrying secure channel..."]);
setShowLogs(true);
if (host.protocol === "local" || host.hostname === "localhost") {
if (host.protocol === "serial") {
sessionStarters.startSerial(termRef.current);
} else if (host.protocol === "local" || host.hostname === "localhost") {
sessionStarters.startLocal(termRef.current);
} else if (host.protocol === "telnet") {
sessionStarters.startTelnet(termRef.current);
} else if (host.moshEnabled) {
sessionStarters.startMosh(termRef.current);
} else {
sessionStarters.startSSH(termRef.current);
}
};
const shouldShowConnectionDialog = status !== "connected"
&& !needsHostKeyVerification
&& !((isLocalConnection || isSerialConnection) && status === "connecting")
&& !(status === "disconnected" && isDisconnectedDialogDismissed);
// Drag and drop handlers
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
@@ -1729,9 +1768,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
)}
{/* Connection dialog: skip for local/serial during connecting phase, but show on error */}
{status !== "connected" && !needsHostKeyVerification && !(
(isLocalConnection || isSerialConnection) && status === "connecting"
) && (
{shouldShowConnectionDialog && (
<TerminalConnectionDialog
host={host}
status={status}
@@ -1742,6 +1779,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
showLogs={showLogs}
_setShowLogs={setShowLogs}
keys={keys}
onDismissDisconnected={handleDismissDisconnectedDialog}
authProps={{
authMethod: auth.authMethod,
setAuthMethod: auth.setAuthMethod,
@@ -1767,7 +1805,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
timeLeft,
isCancelling,
progressLogs,
onCancel: handleCancelConnect,
onCancelConnect: handleCancelConnect,
onCloseSession: handleCloseDisconnectedSession,
onRetry: handleRetry,
}}
/>
+195 -27
View File
@@ -5,7 +5,19 @@ import { useTerminalBackend } from '../application/state/useTerminalBackend';
import { collectSessionIds } from '../domain/workspace';
import { SplitDirection } from '../domain/workspace';
import { KeyBinding, TerminalSettings } from '../domain/models';
import {
clearHostFontFamilyOverride,
clearHostFontSizeOverride,
clearHostThemeOverride,
hasHostFontFamilyOverride,
hasHostFontSizeOverride,
hasHostThemeOverride,
resolveHostTerminalFontFamilyId,
resolveHostTerminalFontSize,
resolveHostTerminalThemeId,
} from '../domain/terminalAppearance';
import { cn } from '../lib/utils';
import { detectLocalOs } from '../lib/localShell';
import { useStoredString } from '../application/state/useStoredString';
import { buildCacheKey } from '../application/state/sftp/sharedRemoteHostCache';
import type { DropEntry } from '../lib/sftpFileUtils';
@@ -22,6 +34,7 @@ import { TERMINAL_THEMES } from '../infrastructure/config/terminalThemes';
import { useCustomThemes } from '../application/state/customThemeStore';
import { Button } from './ui/button';
import { ScrollArea } from './ui/scroll-area';
import { setupMcpApprovalBridge } from '../infrastructure/ai/shared/approvalGate';
type SidePanelTab = 'sftp' | 'scripts' | 'theme' | 'ai';
@@ -65,6 +78,38 @@ const filterTabsMap = <T,>(source: Map<string, T>, validIds: Set<string>): Map<s
return changed ? next : source;
};
type AITerminalSessionInfo = {
sessionId: string;
hostId: string;
hostname: string;
label: string;
os?: string;
username?: string;
protocol?: string;
shellType?: string;
connected: boolean;
};
const buildAITerminalSessionInfo = (
session: TerminalSession | undefined,
host: Host | undefined,
localOs: 'linux' | 'macos' | 'windows',
): AITerminalSessionInfo => {
const protocol = session?.protocol || host?.protocol;
const isLocalSession = protocol === 'local' || session?.hostId?.startsWith('local-');
return {
sessionId: session?.id || '',
hostId: session?.hostId || '',
hostname: host?.hostname || session?.hostname || '',
label: host?.label || session?.hostLabel || '',
os: host?.os || (isLocalSession ? localOs : undefined),
username: host?.username || session?.username,
protocol,
shellType: session?.shellType && session.shellType !== 'unknown' ? session.shellType : undefined,
connected: session?.status === 'connected',
};
};
interface TerminalLayerProps {
hosts: Host[];
keys: SSHKey[];
@@ -108,8 +153,13 @@ interface TerminalLayerProps {
sftpAutoSync: boolean;
sftpShowHiddenFiles: boolean;
sftpUseCompressedUpload: boolean;
sftpAutoOpenSidebar: boolean;
editorWordWrap: boolean;
setEditorWordWrap: (value: boolean) => void;
// Session log settings for real-time streaming
sessionLogsEnabled?: boolean;
sessionLogsDir?: string;
sessionLogsFormat?: string;
}
const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
@@ -153,8 +203,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
sftpAutoSync,
sftpShowHiddenFiles,
sftpUseCompressedUpload,
sftpAutoOpenSidebar,
editorWordWrap,
setEditorWordWrap,
sessionLogsEnabled,
sessionLogsDir,
sessionLogsFormat,
}) => {
// Subscribe to activeTabId from external store
const activeTabId = useActiveTabId();
@@ -167,13 +221,68 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onCloseSession(sessionId);
}, [onCloseSession]);
const sftpAutoOpenSidebarRef = useRef(sftpAutoOpenSidebar);
sftpAutoOpenSidebarRef.current = sftpAutoOpenSidebar;
const handleStatusChange = useCallback((sessionId: string, status: TerminalSession['status']) => {
onUpdateSessionStatus(sessionId, status);
// Auto-open SFTP sidebar when a remote host connects (if setting enabled)
if (status === 'connected' && sftpAutoOpenSidebarRef.current) {
const session = sessionsRef.current.find(s => s.id === sessionId);
if (!session) return;
// Only auto-open for SSH/Mosh (SFTP requires SSH); skip local/unset protocol
const proto = session.protocol;
if (proto !== 'ssh' && proto !== 'mosh') return;
const host = hostsRef.current.find(h => h.id === session.hostId);
// Determine the tab ID (workspace or solo session)
const tabId = session.workspaceId || sessionId;
// Only open if the sidebar is not already open for this tab
if (sidePanelOpenTabsRef.current.has(tabId)) return;
const hostWithOverrides: Host = host
? {
...host,
protocol: session.protocol ?? host.protocol,
port: session.port ?? host.port,
moshEnabled: session.moshEnabled ?? host.moshEnabled,
}
: {
// Quick Connect / temporary session — build minimal host from session data
id: session.hostId || sessionId,
hostname: session.hostname,
username: session.username,
port: session.port ?? 22,
protocol: proto,
label: session.label || session.hostname,
} as Host;
setSidePanelOpenTabs(prev => {
const next = new Map(prev);
next.set(tabId, 'sftp');
return next;
});
setSftpHostForTab(prev => {
const next = new Map(prev);
next.set(tabId, hostWithOverrides);
return next;
});
}
}, [onUpdateSessionStatus]);
const handleSessionExit = useCallback((sessionId: string) => {
onUpdateSessionStatus(sessionId, 'disconnected');
}, [onUpdateSessionStatus]);
const handleSessionExit = useCallback((sessionId: string, evt: { exitCode?: number; signal?: number; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => {
// Auto-close the tab/session when the user actively exited (e.g. typed `exit`)
// reason === "exited" means the remote process/shell exited normally (stream-level close),
// as opposed to network errors, timeouts, or connection-level drops
if (evt.reason === "exited") {
onCloseSession(sessionId);
} else {
onUpdateSessionStatus(sessionId, 'disconnected');
}
}, [onUpdateSessionStatus, onCloseSession]);
const handleOsDetected = useCallback((hostId: string, distro: string) => {
onUpdateHostDistro(hostId, distro);
@@ -237,6 +346,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
activeTabIdRef.current = activeTabId;
const activeWorkspaceRef = useRef(activeWorkspace);
activeWorkspaceRef.current = activeWorkspace;
const sessionsRef = useRef(sessions);
sessionsRef.current = sessions;
const workspacesRef = useRef(workspaces);
workspacesRef.current = workspaces;
const hostsRef = useRef(hosts);
hostsRef.current = hosts;
const onSetWorkspaceFocusedSessionRef = useRef(onSetWorkspaceFocusedSession);
onSetWorkspaceFocusedSessionRef.current = onSetWorkspaceFocusedSession;
@@ -896,56 +1011,74 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
return;
}
if (focusedHost) {
onUpdateHost({ ...focusedHost, theme: themeId });
onUpdateHost({ ...focusedHost, theme: themeId, themeOverride: true });
}
}, [focusedHost, isFocusedHostLocal, onUpdateTerminalThemeId, onUpdateHost]);
const handleThemeResetForFocusedSession = useCallback(() => {
if (!focusedHost || isFocusedHostLocal) return;
onUpdateHost(clearHostThemeOverride(focusedHost));
}, [focusedHost, isFocusedHostLocal, onUpdateHost]);
const handleFontFamilyChangeForFocusedSession = useCallback((fontFamilyId: string) => {
if (isFocusedHostLocal) {
onUpdateTerminalFontFamilyId?.(fontFamilyId);
return;
}
if (focusedHost) {
onUpdateHost({ ...focusedHost, fontFamily: fontFamilyId });
onUpdateHost({ ...focusedHost, fontFamily: fontFamilyId, fontFamilyOverride: true });
}
}, [focusedHost, isFocusedHostLocal, onUpdateTerminalFontFamilyId, onUpdateHost]);
const handleFontFamilyResetForFocusedSession = useCallback(() => {
if (!focusedHost || isFocusedHostLocal) return;
onUpdateHost(clearHostFontFamilyOverride(focusedHost));
}, [focusedHost, isFocusedHostLocal, onUpdateHost]);
const handleFontSizeChangeForFocusedSession = useCallback((newFontSize: number) => {
if (isFocusedHostLocal) {
onUpdateTerminalFontSize?.(newFontSize);
return;
}
if (focusedHost) {
onUpdateHost({ ...focusedHost, fontSize: newFontSize });
onUpdateHost({ ...focusedHost, fontSize: newFontSize, fontSizeOverride: true });
}
}, [focusedHost, isFocusedHostLocal, onUpdateTerminalFontSize, onUpdateHost]);
const handleFontSizeResetForFocusedSession = useCallback(() => {
if (!focusedHost || isFocusedHostLocal) return;
onUpdateHost(clearHostFontSizeOverride(focusedHost));
}, [focusedHost, isFocusedHostLocal, onUpdateHost]);
// Current theme/font/size for the focused session (for ThemeSidePanel)
const focusedThemeId = focusedHost?.theme ?? terminalTheme.id;
const focusedFontFamilyId = focusedHost?.fontFamily ?? terminalFontFamilyId;
const focusedFontSize = focusedHost?.fontSize ?? fontSize;
const focusedThemeId = resolveHostTerminalThemeId(focusedHost, terminalTheme.id);
const focusedFontFamilyId = resolveHostTerminalFontFamilyId(focusedHost, terminalFontFamilyId);
const focusedFontSize = resolveHostTerminalFontSize(focusedHost, fontSize);
const focusedThemeOverridden = hasHostThemeOverride(focusedHost);
const focusedFontFamilyOverridden = hasHostFontFamilyOverride(focusedHost);
const focusedFontSizeOverridden = hasHostFontSizeOverride(focusedHost);
// AI Chat state
const aiState = useAIState();
const { cleanupOrphanedSessions } = aiState;
// On mount: clean up orphaned AI sessions after a short delay
// (allows sessions/workspaces to fully initialize)
const hasCleanedUpRef = useRef(false);
useEffect(() => {
if (hasCleanedUpRef.current) return;
// Guard: wait until both sessions AND workspaces have loaded to avoid
// racing with partial state (e.g. sessions loaded but workspaces not yet).
if (sessions.length === 0 || workspaces.length === 0) return;
hasCleanedUpRef.current = true;
const activeIds = new Set<string>();
for (const s of sessions) activeIds.add(s.id);
for (const w of workspaces) activeIds.add(w.id);
cleanupOrphanedSessions(activeIds);
}, [sessions, workspaces, cleanupOrphanedSessions]);
// Keep MCP/ACP approval IPC listener alive for the entire terminal lifecycle.
// Must live here (TerminalLayer), NOT in AIChatSidePanel (unmounts on tab switch)
// or ChatMessageList (unmounts on panel hide).
useEffect(() => {
return setupMcpApprovalBridge();
}, []);
// Build terminal session context for the AI chat panel
const aiTerminalSessions = useMemo(() => {
const localOs = detectLocalOs(navigator.userAgent || navigator.platform);
const sessionIds = activeWorkspace?.root
? collectSessionIds(activeWorkspace.root)
: activeSession ? [activeSession.id] : [];
@@ -953,19 +1086,42 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const result = sessionIds.map(sid => {
const s = sessions.find(s => s.id === sid);
const host = s?.hostId ? hosts.find(h => h.id === s.hostId) : undefined;
return {
sessionId: sid,
hostId: s?.hostId || '',
hostname: host?.hostname || '',
label: host?.label || s?.hostLabel || '',
os: host?.os,
username: host?.username,
connected: s?.status === 'connected',
};
return buildAITerminalSessionInfo(s, host, localOs);
});
return result;
}, [sessions, hosts, activeWorkspace, activeSession]);
const resolveAIExecutorContext = useCallback((scope: {
type: 'terminal' | 'workspace';
targetId?: string;
label?: string;
}) => {
const latestWorkspaces = workspacesRef.current;
const latestSessions = sessionsRef.current;
const latestHosts = hostsRef.current;
const localOs = detectLocalOs(navigator.userAgent || navigator.platform);
const sessionIds = scope.type === 'workspace'
? (() => {
const workspace = scope.targetId ? latestWorkspaces.find((w) => w.id === scope.targetId) : undefined;
return workspace?.root ? collectSessionIds(workspace.root) : [];
})()
: scope.targetId ? [scope.targetId] : [];
const workspaceName = scope.type === 'workspace'
? latestWorkspaces.find((w) => w.id === scope.targetId)?.title ?? scope.label
: undefined;
return {
sessions: sessionIds.map((sid) => {
const session = latestSessions.find((s) => s.id === sid);
const host = session?.hostId ? latestHosts.find((h) => h.id === session.hostId) : undefined;
return buildAITerminalSessionInfo(session, host, localOs);
}),
workspaceId: scope.type === 'workspace' ? scope.targetId : undefined,
workspaceName,
};
}, []);
// Subscribe to custom theme changes so editing triggers re-render
const customThemes = useCustomThemes();
@@ -1314,11 +1470,19 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
<div className="absolute inset-0 z-10">
<ThemeSidePanel
currentThemeId={focusedThemeId}
globalThemeId={terminalTheme.id}
currentFontFamilyId={focusedFontFamilyId}
globalFontFamilyId={terminalFontFamilyId}
currentFontSize={focusedFontSize}
canResetTheme={focusedThemeOverridden}
canResetFontFamily={focusedFontFamilyOverridden}
canResetFontSize={focusedFontSizeOverridden}
onThemeChange={handleThemeChangeForFocusedSession}
onThemeReset={handleThemeResetForFocusedSession}
onFontFamilyChange={handleFontFamilyChangeForFocusedSession}
onFontFamilyReset={handleFontFamilyResetForFocusedSession}
onFontSizeChange={handleFontSizeChangeForFocusedSession}
onFontSizeReset={handleFontSizeResetForFocusedSession}
/>
</div>
)}
@@ -1333,6 +1497,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
createSession={aiState.createSession}
deleteSession={aiState.deleteSession}
updateSessionTitle={aiState.updateSessionTitle}
updateSessionExternalSessionId={aiState.updateSessionExternalSessionId}
addMessageToSession={aiState.addMessageToSession}
updateLastMessage={aiState.updateLastMessage}
updateMessageById={aiState.updateMessageById}
@@ -1358,8 +1523,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}).filter((id): id is string => !!id)
: activeSession?.hostId ? [activeSession.hostId] : []
}
scopeLabel={activeWorkspace?.name ?? activeSession?.label ?? ''}
scopeLabel={activeWorkspace?.title ?? activeSession?.hostLabel ?? ''}
terminalSessions={aiTerminalSessions}
resolveExecutorContext={resolveAIExecutorContext}
/>
</div>
)}
@@ -1512,6 +1678,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onToggleComposeBar={inActiveWorkspace ? handleToggleWorkspaceComposeBar : undefined}
isWorkspaceComposeBarOpen={inActiveWorkspace ? isComposeBarOpen : undefined}
onBroadcastInput={inActiveWorkspace && activeWorkspace && isBroadcastEnabled?.(activeWorkspace.id) ? handleBroadcastInput : undefined}
sessionLog={sessionLogsEnabled && sessionLogsDir ? { enabled: true, directory: sessionLogsDir, format: sessionLogsFormat || 'txt' } : undefined}
/>
</div>
);
@@ -1613,6 +1780,7 @@ const terminalLayerAreEqual = (prev: TerminalLayerProps, next: TerminalLayerProp
prev.sftpAutoSync === next.sftpAutoSync &&
prev.sftpShowHiddenFiles === next.sftpShowHiddenFiles &&
prev.sftpUseCompressedUpload === next.sftpUseCompressedUpload &&
prev.sftpAutoOpenSidebar === next.sftpAutoOpenSidebar &&
prev.editorWordWrap === next.editorWordWrap &&
prev.setEditorWordWrap === next.setEditorWordWrap &&
prev.onHotkeyAction === next.onHotkeyAction &&
+3 -3
View File
@@ -4,7 +4,7 @@ import { activeTabStore, useActiveTabId } from '../application/state/activeTabSt
import { LogView } from '../application/state/useSessionState';
import { useWindowControls } from '../application/state/useWindowControls';
import { useI18n } from '../application/i18n/I18nProvider';
import { normalizeDistroId } from '../domain/host';
import { getEffectiveHostDistro } from '../domain/host';
import { cn } from '../lib/utils';
import { Host, TerminalSession, Workspace } from '../types';
import { DISTRO_LOGOS, DISTRO_COLORS } from './DistroAvatar';
@@ -89,7 +89,7 @@ const SessionTabIcon: React.FC<{ host: Host | undefined; isActive: boolean; prot
// Try distro logo with brand background color
if (host) {
const distro = normalizeDistroId(host.distro) || (host.distro || '').toLowerCase();
const distro = getEffectiveHostDistro(host);
const logo = DISTRO_LOGOS[distro];
if (logo) {
const bg = DISTRO_COLORS[distro] || DISTRO_COLORS.default;
@@ -97,7 +97,7 @@ const SessionTabIcon: React.FC<{ host: Host | undefined; isActive: boolean; prot
<div className={cn(boxBase, bg)}>
<img
src={logo}
alt={host.distro || host.os}
alt={distro || host.os}
className={cn(iconSize, "object-contain invert brightness-0")}
/>
</div>
+23 -17
View File
@@ -32,7 +32,7 @@ import { useI18n } from "../application/i18n/I18nProvider";
import { useStoredViewMode } from "../application/state/useStoredViewMode";
import { useStoredBoolean } from "../application/state/useStoredBoolean";
import { useTreeExpandedState } from "../application/state/useTreeExpandedState";
import { sanitizeHost } from "../domain/host";
import { getEffectiveHostDistro, sanitizeHost } from "../domain/host";
import { importVaultHostsFromText, exportHostsToCsvWithStats } from "../domain/vaultImport";
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";
@@ -689,6 +689,15 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
],
);
const countAllHostsInNode = useCallback((node: GroupNode): number => {
let count = node.hosts.length;
Object.values(node.children).forEach((child) => {
count += countAllHostsInNode(child);
});
node.totalHostCount = count;
return count;
}, []);
const buildGroupTree = useMemo<Record<string, GroupNode>>(() => {
const root: Record<string, GroupNode> = {};
const insertPath = (path: string, host?: Host) => {
@@ -712,8 +721,11 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
};
customGroups.forEach((path) => insertPath(path));
hosts.forEach((host) => insertPath(host.group || "General", host));
Object.values(root).forEach(countAllHostsInNode);
return root;
}, [hosts, customGroups]);
}, [hosts, customGroups, countAllHostsInNode]);
// Generate all possible group paths from the tree (including all intermediate nodes)
const allGroupPaths = useMemo(() => {
@@ -896,19 +908,11 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
insertPath(host.group, host);
}
});
return root;
}, [treeViewHosts, customGroups]);
// Helper function to recursively count all hosts in a node and its children
const countAllHostsInNode = (node: GroupNode): number => {
let count = node.hosts.length;
if (node.children) {
Object.values(node.children).forEach((child) => {
count += countAllHostsInNode(child);
});
}
return count;
};
Object.values(root).forEach(countAllHostsInNode);
return root;
}, [treeViewHosts, customGroups, countAllHostsInNode]);
// Create tree view specific group tree that excludes ungrouped hosts
const treeViewGroupTree = useMemo<GroupNode[]>(() => {
@@ -1749,7 +1753,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
)}
</div>
<div className="text-[11px] text-muted-foreground">
{t("vault.groups.hostsCount", { count: countAllHostsInNode(node) })}
{t("vault.groups.hostsCount", { count: node.totalHostCount ?? node.hosts.length })}
</div>
</div>
</div>
@@ -1914,9 +1918,10 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
>
{group.hosts.map((host) => {
const safeHost = sanitizeHost(host);
const effectiveDistro = getEffectiveHostDistro(safeHost);
const distroBadge = {
text: (safeHost.os || "L")[0].toUpperCase(),
label: safeHost.distro || safeHost.os || "Linux",
label: effectiveDistro || safeHost.os || "Linux",
};
return (
<ContextMenu key={host.id}>
@@ -2052,9 +2057,10 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
>
{displayedHosts.map((host) => {
const safeHost = sanitizeHost(host);
const effectiveDistro = getEffectiveHostDistro(safeHost);
const distroBadge = {
text: (safeHost.os || "L")[0].toUpperCase(),
label: safeHost.distro || safeHost.os || "Linux",
label: effectiveDistro || safeHost.os || "Linux",
};
return (
<ContextMenu key={host.id}>
+1 -1
View File
@@ -9,7 +9,7 @@ export type ConversationProps = ComponentProps<typeof StickToBottom>;
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn('relative flex-1 overflow-y-hidden', className)}
initial="smooth"
initial="instant"
resize="smooth"
role="log"
{...props}
+1 -1
View File
@@ -26,7 +26,7 @@ export const MessageContent = ({ children, className, ...props }: MessageContent
<div
className={cn(
'flex w-fit min-w-0 max-w-full flex-col gap-1.5 overflow-hidden text-[13px] leading-relaxed',
'group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:border group-[.is-user]:border-border/50 group-[.is-user]:bg-muted/50 group-[.is-user]:px-4 group-[.is-user]:py-2.5',
'group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:border group-[.is-user]:border-border/50 group-[.is-user]:bg-muted/50 group-[.is-user]:px-2.5 group-[.is-user]:py-2',
'group-[.is-assistant]:w-full group-[.is-assistant]:text-foreground/90',
className,
)}
+130 -6
View File
@@ -1,7 +1,9 @@
import { cn } from '../../lib/utils';
import { ChevronDown, ChevronRight, CheckCircle2, Loader2, XCircle } from 'lucide-react';
import type { HTMLAttributes } from 'react';
import { useState } from 'react';
import { Check, ChevronDown, ChevronRight, CheckCircle2, Loader2, ShieldAlert, X, XCircle, Slash } from 'lucide-react';
import React, { useCallback, useEffect, useRef, useState, type HTMLAttributes } from 'react';
import { Button } from '../ui/button';
import { Badge } from '../ui/badge';
import { useI18n } from '../../application/i18n/I18nProvider';
export interface ToolCallProps extends HTMLAttributes<HTMLDivElement> {
name: string;
@@ -9,13 +11,77 @@ export interface ToolCallProps extends HTMLAttributes<HTMLDivElement> {
result?: unknown;
isError?: boolean;
isLoading?: boolean;
isInterrupted?: boolean;
/** Approval state for this tool call (from the approval gate). */
approvalStatus?: 'pending' | 'approved' | 'denied';
/** Called when user approves this tool call. */
onApprove?: () => void;
/** Called when user rejects this tool call. */
onReject?: () => void;
}
export const ToolCall = ({ name, args, result, isError, isLoading, className, ...props }: ToolCallProps) => {
export const ToolCall = ({
name, args, result, isError, isLoading, isInterrupted,
approvalStatus, onApprove, onReject,
className, ...props
}: ToolCallProps) => {
const { t } = useI18n();
const [expanded, setExpanded] = useState(false);
const cardRef = useRef<HTMLDivElement>(null);
const approveBtnRef = useRef<HTMLButtonElement>(null);
const [responded, setResponded] = useState(false);
const statusIcon = isLoading ? (
const isPendingApproval = approvalStatus === 'pending' && !responded;
const handleApprove = useCallback(() => {
if (!isPendingApproval) return;
setResponded(true);
onApprove?.();
}, [isPendingApproval, onApprove]);
const handleReject = useCallback(() => {
if (!isPendingApproval) return;
setResponded(true);
onReject?.();
}, [isPendingApproval, onReject]);
// Keyboard: Enter = approve, Escape = reject (when pending)
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (!isPendingApproval) return;
if (e.key === 'Enter') { e.preventDefault(); handleApprove(); }
else if (e.key === 'Escape') { e.preventDefault(); handleReject(); }
}, [isPendingApproval, handleApprove, handleReject]);
// Auto-focus and auto-scroll when approval is pending
useEffect(() => {
if (isPendingApproval && cardRef.current) {
cardRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' });
// Small delay to let the UI render, then expand and focus
setExpanded(true);
setTimeout(() => approveBtnRef.current?.focus(), 100);
}
}, [isPendingApproval]);
// Reset responded state when approvalStatus changes (e.g. new approval)
useEffect(() => {
if (approvalStatus === 'pending') setResponded(false);
}, [approvalStatus]);
// Border/bg color based on approval status
const borderClass = approvalStatus === 'pending'
? 'border-yellow-500/30 bg-yellow-500/[0.04]'
: approvalStatus === 'approved'
? 'border-green-500/20 bg-green-500/[0.03]'
: approvalStatus === 'denied'
? 'border-red-500/20 bg-red-500/[0.03]'
: 'border-border/25 bg-muted/10';
const statusIcon = approvalStatus === 'pending' ? (
<ShieldAlert size={12} className="text-yellow-500/70 shrink-0" />
) : isLoading ? (
<Loader2 size={12} className="animate-spin text-blue-400/70" />
) : isInterrupted ? (
<Slash size={12} className="text-muted-foreground/55" />
) : isError ? (
<XCircle size={12} className="text-red-400/70" />
) : result !== undefined ? (
@@ -23,7 +89,13 @@ export const ToolCall = ({ name, args, result, isError, isLoading, className, ..
) : null;
return (
<div className={cn('rounded-md border border-border/25 bg-muted/10 overflow-hidden text-[12px]', className)} {...props}>
<div
ref={cardRef}
tabIndex={isPendingApproval ? 0 : undefined}
onKeyDown={isPendingApproval ? handleKeyDown : undefined}
className={cn('rounded-md border overflow-hidden text-[12px] outline-none', borderClass, className)}
{...props}
>
<button
type="button"
onClick={() => setExpanded(e => !e)}
@@ -35,8 +107,20 @@ export const ToolCall = ({ name, args, result, isError, isLoading, className, ..
}
<span className="font-mono text-muted-foreground/70 truncate">{name}</span>
<span className="flex-1" />
{/* Approval badge for resolved approvals */}
{approvalStatus === 'approved' && (
<Badge className="text-[10px] px-1.5 py-0 bg-green-600/20 text-green-400 border-green-600/30">
{t('ai.chat.toolApproved')}
</Badge>
)}
{approvalStatus === 'denied' && (
<Badge className="text-[10px] px-1.5 py-0 bg-red-600/20 text-red-400 border-red-600/30">
{t('ai.chat.toolDenied')}
</Badge>
)}
{statusIcon}
</button>
{expanded && (
<div className="border-t border-border/20">
{args && Object.keys(args).length > 0 && (
@@ -47,6 +131,38 @@ export const ToolCall = ({ name, args, result, isError, isLoading, className, ..
</pre>
</div>
)}
{/* Inline approval buttons */}
{isPendingApproval && (
<div className="px-3 py-2 border-t border-border/20">
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground/30">
{t('ai.chat.toolApprovalHint')}
</span>
<div className="flex items-center gap-1.5">
<Button
variant="outline"
size="sm"
className="h-6 px-2 text-[11px] border-red-500/20 text-red-400/80 hover:bg-red-500/10 hover:text-red-400"
onClick={handleReject}
>
<X size={11} className="mr-0.5" />
{t('ai.chat.reject')}
</Button>
<Button
ref={approveBtnRef}
size="sm"
className="h-6 px-2.5 text-[11px] bg-green-600/80 hover:bg-green-600 text-white"
onClick={handleApprove}
>
<Check size={11} className="mr-0.5" />
{t('ai.chat.approve')}
</Button>
</div>
</div>
</div>
)}
{result !== undefined && (
<div className="px-3 py-2 border-t border-border/20">
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/30 mb-1">Result</div>
@@ -58,6 +174,14 @@ export const ToolCall = ({ name, args, result, isError, isLoading, className, ..
</pre>
</div>
)}
{isInterrupted && result === undefined && (
<div className="px-3 py-2 border-t border-border/20">
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/30 mb-1">Status</div>
<div className="text-[11px] text-muted-foreground/50">
Interrupted
</div>
</div>
)}
</div>
)}
</div>
+30 -27
View File
@@ -11,7 +11,7 @@ import React, { useCallback, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { createPortal } from 'react-dom';
import type { FormEvent } from 'react';
import type { UploadedImage } from '../../application/state/useImageUpload';
import type { UploadedFile } from '../../application/state/useFileUpload';
import {
PromptInput,
PromptInputFooter,
@@ -40,12 +40,12 @@ interface ChatInputProps {
selectedModelId?: string;
/** Callback when user selects a model */
onModelSelect?: (modelId: string) => void;
/** Attached images */
images?: UploadedImage[];
/** Callback to add images (paste/drop) */
onAddImages?: (files: File[]) => void;
/** Callback to remove an image */
onRemoveImage?: (id: string) => void;
/** Attached files (images, PDFs, etc.) */
files?: UploadedFile[];
/** Callback to add files (paste/drop) */
onAddFiles?: (files: File[]) => void;
/** Callback to remove a file */
onRemoveFile?: (id: string) => void;
/** Available hosts for @ mention */
hosts?: Array<{ sessionId: string; hostname: string; label: string; connected: boolean }>;
/** Permission mode (only shown for Catty Agent) */
@@ -68,9 +68,9 @@ const ChatInput: React.FC<ChatInputProps> = ({
modelPresets = [],
selectedModelId,
onModelSelect,
images = [],
onAddImages,
onRemoveImage,
files = [],
onAddFiles,
onRemoveFile,
hosts = [],
permissionMode,
onPermissionModeChange,
@@ -134,23 +134,22 @@ const ChatInput: React.FC<ChatInputProps> = ({
}, [value, onChange, closeAllMenus]);
const handlePaste = useCallback((e: React.ClipboardEvent) => {
const files = Array.from(e.clipboardData.items)
.filter((item) => item.type.startsWith('image/'))
const pastedFiles = Array.from(e.clipboardData.items)
.map((item) => item.getAsFile())
.filter(Boolean) as File[];
if (files.length > 0) {
if (pastedFiles.length > 0) {
e.preventDefault();
onAddImages?.(files);
onAddFiles?.(pastedFiles);
}
}, [onAddImages]);
}, [onAddFiles]);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
const files = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith('image/'));
if (files.length > 0) {
onAddImages?.(files);
const droppedFiles = Array.from(e.dataTransfer.files);
if (droppedFiles.length > 0) {
onAddFiles?.(droppedFiles);
}
}, [onAddImages]);
}, [onAddFiles]);
const defaultPlaceholder = agentName
? t('ai.chat.placeholder').replace('{agent}', agentName)
@@ -183,19 +182,23 @@ const ChatInput: React.FC<ChatInputProps> = ({
return (
<div className="shrink-0 px-4 pb-4">
<PromptInput onSubmit={handleSubmit}>
{/* Image attachment chips */}
{images.length > 0 && (
{/* File attachment chips */}
{files.length > 0 && (
<div className="flex gap-1.5 px-3 pt-2 pb-0.5 flex-wrap">
{images.map((img) => (
{files.map((file) => (
<div
key={img.id}
key={file.id}
className="inline-flex items-center gap-1 h-6 pl-1.5 pr-1 rounded-md bg-muted/30 border border-border/30 text-[11px] text-foreground/70 group"
>
<ImageIcon size={11} className="text-muted-foreground/60 shrink-0" />
<span className="truncate max-w-[80px]">{img.filename}</span>
{file.mediaType.startsWith('image/') ? (
<ImageIcon size={11} className="text-muted-foreground/60 shrink-0" />
) : (
<FileText size={11} className="text-muted-foreground/60 shrink-0" />
)}
<span className="truncate max-w-[80px]">{file.filename}</span>
<button
type="button"
onClick={() => onRemoveImage?.(img.id)}
onClick={() => onRemoveFile?.(file.id)}
className="h-3.5 w-3.5 rounded-sm flex items-center justify-center opacity-50 hover:opacity-100 hover:bg-muted/50 transition-opacity cursor-pointer"
>
<X size={8} />
@@ -213,7 +216,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
className="hidden"
onChange={(e) => {
if (e.target.files?.length) {
onAddImages?.(Array.from(e.target.files));
onAddFiles?.(Array.from(e.target.files));
e.target.value = '';
}
}}
+273 -38
View File
@@ -6,10 +6,11 @@
* No avatars. Thinking blocks are collapsible.
*/
import { AlertCircle } from 'lucide-react';
import React from 'react';
import { AlertCircle, FileText, RotateCcw, X, ZoomIn, ZoomOut } from 'lucide-react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import type { ChatMessage } from '../../infrastructure/ai/types';
import { Dialog, DialogContent, DialogTitle } from '../ui/dialog';
import {
Conversation,
ConversationContent,
@@ -17,19 +18,141 @@ import {
} from '../ai-elements/conversation';
import { Message, MessageContent, MessageResponse } from '../ai-elements/message';
import { ToolCall } from '../ai-elements/tool-call';
import { InlineApprovalCard } from './InlineApprovalCard';
import ThinkingBlock from './ThinkingBlock';
import {
onApprovalRequest,
onApprovalCleared,
replayPendingApprovals,
resolveApproval,
type ApprovalRequest,
} from '../../infrastructure/ai/shared/approvalGate';
interface ChatMessageListProps {
messages: ChatMessage[];
isStreaming?: boolean;
onApprove?: (messageId: string) => void;
onReject?: (messageId: string) => void;
/** Active chat session ID — used to filter standalone MCP approval blocks */
activeSessionId?: string | null;
}
const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming, onApprove, onReject }) => {
const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming, activeSessionId }) => {
// Track pending approvals from the approval gate
const [pendingApprovals, setPendingApprovals] = useState<Map<string, ApprovalRequest>>(new Map());
const [resolvedApprovals, setResolvedApprovals] = useState<Map<string, boolean>>(new Map());
// Subscribe to approval gate events (SDK + MCP tool calls)
useEffect(() => {
const handler = (request: ApprovalRequest) => {
setPendingApprovals(prev => new Map(prev).set(request.toolCallId, request));
};
const unsub = onApprovalRequest(handler);
// Replay any approvals that fired while this component was unmounted
replayPendingApprovals(handler);
return unsub;
}, []);
// Subscribe to approval cleared/removed events (fired on session stop or timeout)
useEffect(() => {
return onApprovalCleared((clearedIds) => {
setPendingApprovals(prev => {
const m = new Map(prev);
for (const id of clearedIds) m.delete(id);
return m;
});
});
}, []);
const handleApprove = useCallback((toolCallId: string) => {
resolveApproval(toolCallId, true);
setPendingApprovals(prev => { const m = new Map(prev); m.delete(toolCallId); return m; });
setResolvedApprovals(prev => new Map(prev).set(toolCallId, true));
}, []);
const handleReject = useCallback((toolCallId: string) => {
resolveApproval(toolCallId, false);
setPendingApprovals(prev => { const m = new Map(prev); m.delete(toolCallId); return m; });
setResolvedApprovals(prev => new Map(prev).set(toolCallId, false));
}, []);
const [preview, setPreview] = useState<{ src: string; name: string } | null>(null);
const [zoom, setZoom] = useState(100);
const [dragged, setDragged] = useState(false);
const imgRef = useRef<HTMLImageElement>(null);
const dragPos = useRef({ x: 0, y: 0 });
const dragStart = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null);
const applyTransform = useCallback((z: number, x: number, y: number, animate: boolean) => {
if (!imgRef.current) return;
imgRef.current.style.transition = animate ? 'transform 0.25s ease' : 'none';
imgRef.current.style.transform = `scale(${z / 100}) translate(${x / (z / 100)}px, ${y / (z / 100)}px)`;
}, []);
const zoomRef = useRef(100);
const setZoomAndRef = useCallback((fn: (z: number) => number) => {
setZoom(z => { const nz = fn(z); zoomRef.current = nz; return nz; });
}, []);
const zoomIn = useCallback(() => setZoomAndRef(z => { const nz = Math.min(z + 25, 200); applyTransform(nz, dragPos.current.x, dragPos.current.y, true); return nz; }), [applyTransform, setZoomAndRef]);
const zoomOut = useCallback(() => setZoomAndRef(z => { const nz = Math.max(z - 25, 25); applyTransform(nz, dragPos.current.x, dragPos.current.y, true); return nz; }), [applyTransform, setZoomAndRef]);
const onWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -10 : 10;
setZoomAndRef(z => {
const nz = Math.max(25, Math.min(200, z + delta));
applyTransform(nz, dragPos.current.x, dragPos.current.y, false);
return nz;
});
}, [applyTransform, setZoomAndRef]);
const openPreview = useCallback((src: string, name: string) => {
setZoom(100); zoomRef.current = 100;
setDragged(false);
dragPos.current = { x: 0, y: 0 };
setPreview({ src, name });
}, []);
const resetPreview = useCallback(() => {
setZoom(100); zoomRef.current = 100;
setDragged(false);
dragPos.current = { x: 0, y: 0 };
applyTransform(100, 0, 0, true);
}, [applyTransform]);
const onPointerDown = useCallback((e: React.PointerEvent) => {
e.preventDefault();
(e.target as HTMLElement).setPointerCapture(e.pointerId);
dragStart.current = { startX: e.clientX, startY: e.clientY, origX: dragPos.current.x, origY: dragPos.current.y };
}, []);
const onPointerMove = useCallback((e: React.PointerEvent) => {
if (!dragStart.current) return;
if ((e.buttons & 1) === 0) { dragStart.current = null; return; }
const x = dragStart.current.origX + (e.clientX - dragStart.current.startX);
const y = dragStart.current.origY + (e.clientY - dragStart.current.startY);
dragPos.current = { x, y };
applyTransform(zoomRef.current, x, y, false);
}, [applyTransform]);
const endDrag = useCallback(() => {
if (dragStart.current && (dragPos.current.x !== 0 || dragPos.current.y !== 0)) {
setDragged(true);
}
dragStart.current = null;
}, []);
const { t } = useI18n();
const visibleMessages = messages.filter(m => m.role !== 'system');
const resolvedToolCallIds = new Set(
visibleMessages
.filter((m) => m.role === 'tool')
.flatMap((m) => m.toolResults?.map((tr) => tr.toolCallId) ?? []),
);
// Build a map from toolCallId → toolName for display
const toolCallNames = new Map<string, string>();
for (const m of visibleMessages) {
if (m.role === 'assistant' && m.toolCalls) {
for (const tc of m.toolCalls) {
toolCallNames.set(tc.id, tc.name);
}
}
}
if (visibleMessages.length === 0 && !isStreaming) {
return (
@@ -44,6 +167,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
const lastAssistantMessage = visibleMessages.findLast(m => m.role === 'assistant');
return (
<>
<Conversation className="flex-1">
<ConversationContent className="gap-1.5 px-4 py-2">
{visibleMessages.map((message) => {
@@ -53,7 +177,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
{message.toolResults?.map((tr) => (
<ToolCall
key={tr.toolCallId}
name={tr.toolCallId}
name={toolCallNames.get(tr.toolCallId) || tr.toolCallId}
result={tr.content}
isError={tr.isError}
/>
@@ -78,16 +202,27 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
/>
)}
{/* User images */}
{isUser && message.images && message.images.length > 0 && (
{/* User attachments (images, files) — fallback to legacy `images` field */}
{isUser && (message.attachments ?? message.images)?.length && (
<div className="flex gap-1.5 flex-wrap mb-1">
{message.images.map((img, i) => (
<img
key={img.filename ? `${img.filename}-${i}` : `img-${message.id}-${i}`}
src={`data:${img.mediaType};base64,${img.base64Data}`}
alt={img.filename || 'image'}
className="max-h-[120px] max-w-[200px] rounded-md object-contain border border-border/20"
/>
{(message.attachments ?? message.images)!.map((att, i) => (
att.mediaType.startsWith('image/') ? (
<img
key={att.filename ? `${att.filename}-${i}` : `att-${message.id}-${i}`}
src={`data:${att.mediaType};base64,${att.base64Data}`}
alt={att.filename || 'image'}
className="max-h-[120px] max-w-[200px] rounded-md object-contain border border-border/20 cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => openPreview(`data:${att.mediaType};base64,${att.base64Data}`, att.filename || 'image')}
/>
) : (
<div
key={att.filename ? `${att.filename}-${i}` : `att-${message.id}-${i}`}
className="inline-flex items-center gap-1.5 h-7 px-2 rounded-md bg-muted/20 border border-border/20 text-[11px] text-foreground/70"
>
<FileText size={12} className="text-muted-foreground/60 shrink-0" />
<span className="truncate max-w-[120px]">{att.filename || 'file'}</span>
</div>
)
))}
</div>
)}
@@ -101,25 +236,30 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
)}
{/* Tool calls */}
{message.toolCalls?.map((tc) => (
<ToolCall
key={tc.id}
name={tc.name}
args={tc.arguments}
isLoading={isThisStreaming && message.executionStatus === 'running'}
/>
))}
{message.toolCalls?.map((tc) => {
const isPending = pendingApprovals.has(tc.id);
const resolved = resolvedApprovals.get(tc.id);
const approvalStatus = isPending
? 'pending' as const
: resolved === true
? 'approved' as const
: resolved === false
? 'denied' as const
: undefined;
{/* Inline approval card */}
{message.pendingApproval && (
<InlineApprovalCard
toolName={message.pendingApproval.toolName}
toolArgs={message.pendingApproval.toolArgs}
status={message.pendingApproval.status}
onApprove={() => onApprove?.(message.id)}
onReject={() => onReject?.(message.id)}
/>
)}
return (
<ToolCall
key={tc.id}
name={tc.name}
args={tc.arguments}
isLoading={isThisStreaming && message.executionStatus === 'running' && !isPending}
isInterrupted={message.executionStatus === 'cancelled' && !resolvedToolCallIds.has(tc.id)}
approvalStatus={approvalStatus}
onApprove={() => handleApprove(tc.id)}
onReject={() => handleReject(tc.id)}
/>
);
})}
{/* Status text with shimmer */}
{message.statusText && (
@@ -133,7 +273,9 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
<div className="flex items-start gap-2 px-3 py-2 rounded-md bg-destructive/10 border border-destructive/20 text-sm">
<AlertCircle className="h-4 w-4 text-destructive shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-destructive font-medium">{message.errorInfo.message}</p>
<p className="text-destructive font-medium whitespace-pre-wrap break-words [overflow-wrap:anywhere]">
{message.errorInfo.message}
</p>
{message.errorInfo.retryable && (
<p className="text-muted-foreground text-xs mt-1">{t('ai.chat.retryHint')}</p>
)}
@@ -145,6 +287,24 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
);
})}
{/* Standalone MCP/ACP approval requests (not tied to SDK tool calls) */}
{Array.from(pendingApprovals.entries())
.filter((entry) => entry[0].startsWith('mcp_approval_') && (!activeSessionId || entry[1].chatSessionId === activeSessionId))
.map((entry) => {
const [id, req] = entry;
return (
<ToolCall
key={id}
name={req.toolName}
args={req.args}
isLoading={false}
isInterrupted={false}
approvalStatus={'pending'}
onApprove={() => handleApprove(id)}
onReject={() => handleReject(id)}
/>
);
})}
{/* Streaming indicator — only when no content and no thinking yet */}
{isStreaming && !lastAssistantMessage?.content && !lastAssistantMessage?.thinking && (
<div className="flex items-center gap-1 py-2">
@@ -156,13 +316,89 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
</ConversationContent>
<ConversationScrollButton />
</Conversation>
{/* Image preview lightbox */}
<Dialog open={!!preview} onOpenChange={(open) => { if (!open) setPreview(null); }}>
<DialogContent
hideCloseButton
className="max-w-[min(90vw,800px)] max-h-[min(90vh,700px)] min-w-[280px] min-h-[200px] w-fit p-0 gap-0 focus:outline-none shadow-2xl"
>
{/* Title bar: filename | zoom controls | close — all in one flex row */}
<div className="flex items-center h-10 px-3 border-b border-border/40 gap-2 shrink-0">
<DialogTitle className="text-sm font-medium truncate flex-1">{preview?.name}</DialogTitle>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={resetPreview}
disabled={zoom === 100 && !dragged}
className="p-1 rounded hover:bg-muted disabled:opacity-30 transition-colors text-muted-foreground"
aria-label={t('common.reset')}
>
<RotateCcw size={14} />
</button>
<div className="w-px h-3.5 bg-border/40 mx-0.5" />
<button
onClick={zoomOut}
disabled={zoom <= 25}
className="p-1 rounded hover:bg-muted disabled:opacity-30 transition-colors text-muted-foreground"
aria-label={t('common.zoomOut')}
>
<ZoomOut size={14} />
</button>
<span className="text-xs text-muted-foreground tabular-nums w-9 text-center select-none">{zoom}%</span>
<button
onClick={zoomIn}
disabled={zoom >= 200}
className="p-1 rounded hover:bg-muted disabled:opacity-30 transition-colors text-muted-foreground"
aria-label={t('common.zoomIn')}
>
<ZoomIn size={14} />
</button>
</div>
<button
onClick={() => setPreview(null)}
className="p-1 rounded hover:bg-muted transition-colors text-muted-foreground shrink-0"
aria-label={t('common.close')}
>
<X size={14} />
</button>
</div>
{/* Image area with drag support */}
{preview && (
<div
className="overflow-hidden flex items-center justify-center"
style={{
height: 'calc(min(90vh, 700px) - 40px)',
cursor: 'grab',
// Clamp aspect ratio: if image is extremely tall/wide, the container
// constrains it; object-contain handles the rest.
aspectRatio: 'auto',
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onWheel={onWheel}
onLostPointerCapture={endDrag}
>
<img
ref={imgRef}
src={preview.src}
alt={preview.name}
draggable={false}
className="select-none max-w-full max-h-full object-contain"
style={{ transition: 'transform 0.25s ease' }}
/>
</div>
)}
</DialogContent>
</Dialog>
</>
);
};
function areMessagesEqual(prev: ChatMessageListProps, next: ChatMessageListProps): boolean {
if (prev.isStreaming !== next.isStreaming) return false;
if (prev.onApprove !== next.onApprove) return false;
if (prev.onReject !== next.onReject) return false;
if (prev.activeSessionId !== next.activeSessionId) return false;
if (prev.messages.length !== next.messages.length) return false;
if (prev.messages === next.messages) return true;
@@ -180,7 +416,6 @@ function areMessagesEqual(prev: ChatMessageListProps, next: ChatMessageListProps
p.role !== n.role ||
p.statusText !== n.statusText ||
p.executionStatus !== n.executionStatus ||
p.pendingApproval !== n.pendingApproval ||
p.errorInfo !== n.errorInfo ||
p.toolCalls !== n.toolCalls ||
p.toolResults !== n.toolResults
-193
View File
@@ -1,193 +0,0 @@
/**
* InlineApprovalCard - Inline tool approval card rendered within chat messages.
*
* Replaces the modal PermissionDialog. Shows tool name, arguments, and
* approve/reject buttons. Keyboard shortcuts: Enter to approve, Escape to reject.
*/
import { Check, ShieldAlert, X } from 'lucide-react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
interface InlineApprovalCardProps {
toolName: string;
toolArgs: Record<string, unknown>;
status: 'pending' | 'approved' | 'denied';
onApprove: () => void;
onReject: () => void;
}
const InlineApprovalCard: React.FC<InlineApprovalCardProps> = ({
toolName,
toolArgs,
status,
onApprove,
onReject,
}) => {
const { t } = useI18n();
const cardRef = useRef<HTMLDivElement>(null);
const approveBtnRef = useRef<HTMLButtonElement>(null);
const isPending = status === 'pending';
const [responded, setResponded] = useState(false);
// Use refs to always access the latest callbacks without re-registering the listener
const onApproveRef = useRef(onApprove);
const onRejectRef = useRef(onReject);
onApproveRef.current = onApprove;
onRejectRef.current = onReject;
const isDisabled = !isPending || responded;
const handleApprove = useCallback(() => {
if (isDisabled) return;
setResponded(true);
onApproveRef.current();
}, [isDisabled]);
const handleReject = useCallback(() => {
if (isDisabled) return;
setResponded(true);
onRejectRef.current();
}, [isDisabled]);
// Keyboard shortcuts: handled via local onKeyDown on the focusable card element
// to avoid conflicts when multiple InlineApprovalCard instances exist simultaneously.
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (isDisabled) return;
if (e.key === 'Enter') {
e.preventDefault();
handleApprove();
} else if (e.key === 'Escape') {
e.preventDefault();
handleReject();
}
}, [isDisabled, handleApprove, handleReject]);
// Auto-focus approve button and auto-scroll into view when mounted as pending
useEffect(() => {
if (isPending && cardRef.current) {
cardRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' });
approveBtnRef.current?.focus();
}
}, [isPending]);
let formattedArgs: string;
try {
formattedArgs = JSON.stringify(toolArgs, null, 2);
} catch {
formattedArgs = String(toolArgs);
}
// Extract target session info if present
const sessionId = toolArgs?.sessionId as string | undefined;
return (
<div
ref={cardRef}
tabIndex={0}
role="alertdialog"
aria-label="Tool execution approval required"
onKeyDown={handleKeyDown}
className={`rounded-md border overflow-hidden text-[12px] mt-1.5 outline-none ${
isPending
? 'border-yellow-500/30 bg-yellow-500/[0.04]'
: status === 'approved'
? 'border-green-500/20 bg-green-500/[0.03]'
: 'border-red-500/20 bg-red-500/[0.03]'
}`}
>
{/* Header */}
<div className="flex items-center gap-2 px-3 py-1.5">
<ShieldAlert
size={13}
className={
isPending
? 'text-yellow-500/70 shrink-0'
: status === 'approved'
? 'text-green-400/70 shrink-0'
: 'text-red-400/70 shrink-0'
}
/>
<span className="text-[11px] font-medium text-foreground/70">
{t('ai.chat.toolApprovalTitle')}
</span>
{!isPending && (
<Badge
className={`ml-auto text-[10px] px-1.5 py-0 ${
status === 'approved'
? 'bg-green-600/20 text-green-400 border-green-600/30'
: 'bg-red-600/20 text-red-400 border-red-600/30'
}`}
>
{status === 'approved' ? t('ai.chat.toolApproved') : t('ai.chat.toolDenied')}
</Badge>
)}
</div>
{/* Tool info */}
<div className="px-3 pb-2 space-y-1.5">
<div className="flex items-center gap-2">
<span className="text-[10px] text-muted-foreground/40 uppercase tracking-wider">{t('ai.chat.toolLabel')}</span>
<code className="text-[11px] font-mono text-muted-foreground/70 bg-muted/30 px-1.5 py-0.5 rounded">
{toolName}
</code>
</div>
{sessionId && (
<div className="flex items-center gap-2">
<span className="text-[10px] text-muted-foreground/40 uppercase tracking-wider">{t('ai.chat.targetLabel')}</span>
<code className="text-[11px] font-mono text-muted-foreground/50 bg-muted/30 px-1.5 py-0.5 rounded">
{sessionId}
</code>
</div>
)}
{/* Arguments */}
<div className="rounded border border-border/20 bg-muted/10 p-2 max-h-32 overflow-auto">
<pre className="text-[11px] font-mono whitespace-pre-wrap break-all text-muted-foreground/50">
{formattedArgs}
</pre>
</div>
{/* Actions or hint */}
{isPending && (
<div className="flex items-center justify-between pt-0.5">
<span className="text-[10px] text-muted-foreground/30">
{t('ai.chat.toolApprovalHint')}
</span>
<div className="flex items-center gap-1.5">
<Button
variant="outline"
size="sm"
disabled={responded}
className={`h-6 px-2 text-[11px] border-red-500/20 text-red-400/80 hover:bg-red-500/10 hover:text-red-400 ${responded ? 'opacity-50 cursor-not-allowed' : ''}`}
onClick={handleReject}
>
<X size={11} className="mr-0.5" />
{t('ai.chat.reject')}
</Button>
<Button
ref={approveBtnRef}
size="sm"
disabled={responded}
className={`h-6 px-2.5 text-[11px] bg-green-600/80 hover:bg-green-600 text-white ${responded ? 'opacity-50 cursor-not-allowed' : ''}`}
onClick={handleApprove}
>
<Check size={11} className="mr-0.5" />
{t('ai.chat.approve')}
</Button>
</div>
</div>
)}
</div>
</div>
);
};
InlineApprovalCard.displayName = 'InlineApprovalCard';
export default InlineApprovalCard;
export { InlineApprovalCard };
export type { InlineApprovalCardProps };
+154 -91
View File
@@ -10,12 +10,13 @@
* - Error reporting
*/
import React, { useCallback, useRef, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { streamText, stepCountIs, type ModelMessage } from 'ai';
import type {
AIPermissionMode,
AISession,
ChatMessage,
ChatMessageAttachment,
ExternalAgentConfig,
ProviderConfig,
WebSearchConfig,
@@ -24,10 +25,10 @@ import { isWebSearchReady } from '../../../infrastructure/ai/types';
import { buildSystemPrompt } from '../../../infrastructure/ai/cattyAgent/systemPrompt';
import { createModelFromConfig } from '../../../infrastructure/ai/sdk/providers';
import { createCattyTools } from '../../../infrastructure/ai/sdk/tools';
import type { NetcattyBridge } from '../../../infrastructure/ai/cattyAgent/executor';
import type { NetcattyBridge, ExecutorContext } from '../../../infrastructure/ai/cattyAgent/executor';
import { runExternalAgentTurn } from '../../../infrastructure/ai/externalAgentAdapter';
import { runAcpAgentTurn } from '../../../infrastructure/ai/acpAgentAdapter';
import { classifyError, sanitizeErrorMessage } from '../../../infrastructure/ai/errorClassifier';
import { classifyError } from '../../../infrastructure/ai/errorClassifier';
// -------------------------------------------------------------------
// Stream chunk type interfaces (Issue #13: replace unsafe casts)
@@ -63,16 +64,30 @@ interface ToolResultChunk {
result?: unknown;
}
/** Shape of a tool-approval-request chunk from the Vercel AI SDK fullStream. */
interface ToolApprovalRequestChunk {
type: 'tool-approval-request';
approvalId: string;
toolCall: {
toolCallId: string;
toolName: string;
args?: Record<string, unknown>;
input?: Record<string, unknown>;
};
/** Detect tool results that represent errors/denials (e.g. `{ error: "..." }` or `{ ok: false }`) */
function isToolResultError(output: unknown): boolean {
if (output == null) return false;
if (typeof output === 'object') {
const obj = output as Record<string, unknown>;
// Check for explicit error objects
if ('error' in obj && typeof obj.error === 'string') return true;
if ('ok' in obj && obj.ok === false) return true;
}
// Check stringified JSON (common for tool result wrapping)
if (typeof output === 'string') {
try {
const parsed = JSON.parse(output);
if (parsed && typeof parsed === 'object') {
const parsedObj = parsed as Record<string, unknown>;
if ('error' in parsedObj && typeof parsedObj.error === 'string') return true;
if ('ok' in parsedObj && parsedObj.ok === false) return true;
}
} catch { /* not JSON, not an error */ }
}
return false;
}
/** Shape of an error chunk from the Vercel AI SDK fullStream. */
@@ -87,9 +102,8 @@ type StreamChunk =
| ReasoningChunk
| ToolCallChunk
| ToolResultChunk
| ToolApprovalRequestChunk
| ErrorChunk
| { type: 'reasoning-end' | 'text-start' | 'text-end' | 'start' | 'finish' | 'start-step' | 'finish-step' };
| { type: 'reasoning-end' | 'text-start' | 'text-end' | 'start' | 'finish' | 'start-step' | 'finish-step' | 'tool-approval-request' };
/** Shape of the netcatty bridge exposed on `window` (panel-specific subset). */
export interface PanelBridge extends NetcattyBridge {
@@ -109,6 +123,8 @@ export interface TerminalSessionInfo {
label: string;
os?: string;
username?: string;
protocol?: string;
shellType?: string;
connected: boolean;
}
@@ -118,29 +134,27 @@ export function getNetcattyBridge(): PanelBridge | undefined {
return (window as any).netcatty as PanelBridge | undefined;
}
/** Approval info returned by processCattyStream when a tool-approval-request is received. */
export interface ApprovalInfo {
approvalId: string;
toolCallId: string;
toolName: string;
toolArgs: Record<string, unknown>;
}
/** Pending approval context stored between approval request and user response. */
export interface PendingApprovalContext {
sessionId: string;
scopeKey: string;
sdkMessages: Array<ModelMessage>;
approvalInfo: ApprovalInfo;
model: ReturnType<typeof createModelFromConfig>;
systemPrompt: string;
tools: ReturnType<typeof createCattyTools>;
}
// ApprovalInfo and PendingApprovalContext removed — approval is now handled
// inside the tool's execute function via the approvalGate module.
function generateId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
const sharedStreamingSessionIds = new Set<string>();
const sharedAbortControllers = new Map<string, AbortController>();
const streamingSubscribers = new Set<() => void>();
function emitStreamingStoreChange(): void {
streamingSubscribers.forEach(listener => {
try {
listener();
} catch (err) {
console.error('[AIChatStreaming] Failed to notify streaming subscriber:', err);
}
});
}
// -------------------------------------------------------------------
// Hook parameters
// -------------------------------------------------------------------
@@ -163,7 +177,7 @@ export interface UseAIChatStreamingReturn {
setStreamingForScope: (key: string, val: boolean) => void;
/** Ref to per-session abort controllers. */
abortControllersRef: React.MutableRefObject<Map<string, AbortController>>;
/** Process a Catty agent stream, returning approval info if one is requested. */
/** Process a Catty agent stream. */
processCattyStream: (
streamSessionId: string,
model: ReturnType<typeof createModelFromConfig>,
@@ -172,7 +186,7 @@ export interface UseAIChatStreamingReturn {
sdkMessages: Array<ModelMessage>,
signal: AbortSignal,
currentAssistantMsgId: string,
) => Promise<ApprovalInfo | null>;
) => Promise<void>;
/** Send a message to the Catty agent (built-in). */
sendToCattyAgent: (
sessionId: string,
@@ -182,6 +196,7 @@ export interface UseAIChatStreamingReturn {
currentSession: AISession | undefined,
assistantMsgId: string,
context: SendToCattyContext,
attachments?: ChatMessageAttachment[],
) => Promise<void>;
/** Send a message to an external agent (ACP or raw process). */
sendToExternalAgent: (
@@ -207,12 +222,15 @@ export interface SendToCattyContext {
commandBlocklist?: string[];
terminalSessions: TerminalSessionInfo[];
webSearchConfig?: WebSearchConfig | null;
setPendingApproval: (ctx: PendingApprovalContext | null) => void;
getExecutorContext?: () => ExecutorContext;
autoTitleSession: (sessionId: string, text: string) => void;
}
/** Context values needed by sendToExternalAgent that change frequently. */
export interface SendToExternalContext {
existingSessionId?: string;
updateExternalSessionId?: (sessionId: string, externalSessionId: string | undefined) => void;
historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>;
terminalSessions: TerminalSessionInfo[];
providers: ProviderConfig[];
selectedAgentModel?: string;
@@ -229,17 +247,34 @@ export function useAIChatStreaming({
updateMessageById,
}: UseAIChatStreamingParams): UseAIChatStreamingReturn {
// Per-session streaming state (keyed by sessionId)
const [streamingSessionIds, setStreamingSessions] = useState<Set<string>>(new Set());
const [streamingSessionIds, setStreamingSessions] = useState<Set<string>>(
() => new Set(sharedStreamingSessionIds),
);
useEffect(() => {
const syncFromStore = () => {
setStreamingSessions(new Set(sharedStreamingSessionIds));
};
streamingSubscribers.add(syncFromStore);
syncFromStore();
return () => {
streamingSubscribers.delete(syncFromStore);
};
}, []);
const setStreamingForScope = useCallback((key: string, val: boolean) => {
setStreamingSessions(prev => {
const next = new Set(prev);
if (val) next.add(key); else next.delete(key);
return next;
});
const hadKey = sharedStreamingSessionIds.has(key);
if (val) {
sharedStreamingSessionIds.add(key);
} else {
sharedStreamingSessionIds.delete(key);
}
if (hadKey !== val) {
emitStreamingStoreChange();
}
}, []);
// Per-scope abort controllers
const abortControllersRef = useRef<Map<string, AbortController>>(new Map());
const abortControllersRef = useRef<Map<string, AbortController>>(sharedAbortControllers);
// -------------------------------------------------------------------
// reportStreamError
@@ -259,8 +294,6 @@ export function useAIChatStreaming({
// Log the full unsanitized error for debugging
console.error('[AIChatSidePanel] Stream error (full):', errorStr);
const errorInfo = classifyError(errorStr);
// Sanitize the displayed message to avoid leaking paths, keys, or other sensitive info
errorInfo.message = sanitizeErrorMessage(errorInfo.message);
updateLastMessage(sessionId, msg => ({
...msg,
statusText: '',
@@ -287,7 +320,7 @@ export function useAIChatStreaming({
sdkMessages: Array<ModelMessage>,
signal: AbortSignal,
currentAssistantMsgId: string,
): Promise<ApprovalInfo | null> => {
): Promise<void> => {
const result = streamText({
model,
messages: sdkMessages,
@@ -301,7 +334,6 @@ export function useAIChatStreaming({
let activeMsgId = currentAssistantMsgId;
let lastAddedRole: 'assistant' | 'tool' = 'assistant';
const reader = result.fullStream.getReader();
let pendingApprovalInfo: ApprovalInfo | null = null;
// -- Text-delta batching: accumulate deltas and flush periodically --
let pendingText = '';
@@ -419,6 +451,7 @@ export function useAIChatStreaming({
? { ...msg, executionStatus: 'completed', statusText: undefined } : msg,
);
const toolOutput = typedChunk.output ?? typedChunk.result;
const toolError = isToolResultError(toolOutput);
addMessageToSession(streamSessionId, {
id: generateId(),
role: 'tool',
@@ -428,7 +461,7 @@ export function useAIChatStreaming({
content: typeof toolOutput === 'string'
? toolOutput
: JSON.stringify(toolOutput),
isError: false,
isError: toolError,
}],
timestamp: Date.now(),
executionStatus: 'completed',
@@ -436,25 +469,9 @@ export function useAIChatStreaming({
lastAddedRole = 'tool';
break;
}
case 'tool-approval-request': {
cancelPendingFlush();
flushText();
const typedChunk = chunk as ToolApprovalRequestChunk;
pendingApprovalInfo = {
approvalId: typedChunk.approvalId,
toolCallId: typedChunk.toolCall.toolCallId,
toolName: typedChunk.toolCall.toolName,
toolArgs: typedChunk.toolCall.args ?? typedChunk.toolCall.input ?? {},
};
updateMessageById(streamSessionId, activeMsgId, msg => ({
...msg,
pendingApproval: {
...pendingApprovalInfo!,
status: 'pending' as const,
},
}));
break;
}
// tool-approval-request is no longer handled here — approval is now
// inside the tool's execute function via the approvalGate module.
// The SDK may still emit this chunk type but we simply ignore it.
case 'error': {
cancelPendingFlush();
flushText();
@@ -486,7 +503,7 @@ export function useAIChatStreaming({
flushText();
reader.releaseLock();
}
return pendingApprovalInfo;
return;
}, [maxIterations, addMessageToSession, updateMessageById]);
// -------------------------------------------------------------------
@@ -556,23 +573,29 @@ export function useAIChatStreaming({
...msg, thinkingDurationMs: msg.thinkingDurationMs || (Date.now() - msg.timestamp),
}));
},
onToolCall: (toolName: string, args: Record<string, unknown>) => {
onToolCall: (toolName: string, args: Record<string, unknown>, toolCallId?: string) => {
maybeCreateAssistantMsg();
updateLastMessage(sessionId, msg => ({
...msg,
toolCalls: [...(msg.toolCalls || []), { id: `tc_${Date.now()}`, name: toolName, arguments: args }],
toolCalls: [...(msg.toolCalls || []), { id: toolCallId || `tc_${Date.now()}`, name: toolName, arguments: args }],
executionStatus: 'running',
statusText: undefined,
}));
},
onToolResult: (toolCallId: string, result: string) => {
updateLastMessage(sessionId, msg =>
msg.role === 'assistant' && msg.executionStatus === 'running'
? { ...msg, executionStatus: 'completed', statusText: undefined } : msg,
);
onToolResult: (toolCallId: string, result: string, toolName?: string) => {
updateLastMessage(sessionId, msg => {
if (msg.role !== 'assistant' || msg.executionStatus !== 'running') return msg;
// Only patch tool call name if the existing name is missing/generic
// (don't overwrite a good name from onToolCall with a wrapper name from tool-result)
const updatedToolCalls = toolName && !toolName.includes('acp_provider_agent_dynamic_tool') && msg.toolCalls
? msg.toolCalls.map(tc => tc.id === toolCallId && !tc.name ? { ...tc, name: toolName } : tc)
: msg.toolCalls;
return { ...msg, toolCalls: updatedToolCalls, executionStatus: 'completed', statusText: undefined };
});
const toolError = isToolResultError(result);
addMessageToSession(sessionId, {
id: generateId(), role: 'tool', content: '',
toolResults: [{ toolCallId, content: result, isError: false }],
toolResults: [{ toolCallId, content: result, isError: toolError }],
timestamp: Date.now(), executionStatus: 'completed',
});
needsNewAssistantMsg = true;
@@ -581,6 +604,9 @@ export function useAIChatStreaming({
maybeCreateAssistantMsg();
updateLastMessage(sessionId, msg => ({ ...msg, statusText: message }));
},
onSessionId: (externalSessionId: string) => {
context.updateExternalSessionId?.(sessionId, externalSessionId);
},
onError: (error: string) => {
reportStreamError(sessionId, abortController.signal, error);
setStreamingForScope(sessionId, false);
@@ -590,6 +616,8 @@ export function useAIChatStreaming({
abortController.signal,
agentProviderId,
context.selectedAgentModel,
context.existingSessionId,
context.historyMessages,
attachedImages.length > 0 ? attachedImages : undefined,
);
} else {
@@ -627,19 +655,32 @@ export function useAIChatStreaming({
currentSession: AISession | undefined,
assistantMsgId: string,
context: SendToCattyContext,
attachments?: ChatMessageAttachment[],
) => {
const bridge = getNetcattyBridge();
const tools = createCattyTools(bridge, {
const getExecutorContext = context.getExecutorContext ?? (() => ({
sessions: context.terminalSessions,
workspaceId: context.scopeTargetId,
workspaceName: context.scopeLabel,
}, context.commandBlocklist, context.globalPermissionMode, context.webSearchConfig ?? undefined);
workspaceId: context.scopeType === 'workspace' ? context.scopeTargetId : undefined,
workspaceName: context.scopeType === 'workspace' ? context.scopeLabel : undefined,
}));
const tools = createCattyTools(
bridge,
getExecutorContext,
context.commandBlocklist,
context.globalPermissionMode,
context.webSearchConfig ?? undefined,
sessionId,
);
const systemPrompt = buildSystemPrompt({
scopeType: context.scopeType, scopeLabel: context.scopeLabel,
hosts: context.terminalSessions.map(s => ({
sessionId: s.sessionId, hostname: s.hostname, label: s.label,
os: s.os, username: s.username, connected: s.connected,
os: s.os,
username: s.username,
protocol: s.protocol,
shellType: s.shellType,
connected: s.connected,
})),
permissionMode: context.globalPermissionMode,
webSearchEnabled: isWebSearchReady(context.webSearchConfig),
@@ -693,7 +734,22 @@ export function useAIChatStreaming({
const sdkMessages: Array<ModelMessage> = [];
for (const m of allMessages) {
if (m.role === 'user') {
sdkMessages.push({ role: 'user', content: m.content });
// Build multimodal content when attachments are present (fallback to legacy `images` field)
const messageAttachments = m.attachments ?? m.images;
if (messageAttachments?.length) {
const parts: Array<{ type: 'text'; text: string } | { type: 'image'; image: string; mediaType?: string } | { type: 'file'; data: string; mediaType: string; filename?: string }> = [];
parts.push({ type: 'text', text: m.content });
for (const att of messageAttachments) {
if (att.mediaType.startsWith('image/')) {
parts.push({ type: 'image', image: att.base64Data, mediaType: att.mediaType });
} else {
parts.push({ type: 'file', data: att.base64Data, mediaType: att.mediaType, filename: att.filename });
}
}
sdkMessages.push({ role: 'user', content: parts });
} else {
sdkMessages.push({ role: 'user', content: m.content });
}
} else if (m.role === 'assistant') {
if (m.toolCalls?.length) {
// Only include tool calls that have matching results
@@ -732,16 +788,23 @@ export function useAIChatStreaming({
});
}
}
sdkMessages.push({ role: 'user', content: trimmed });
const approvalInfo = await processCattyStream(sessionId, model, systemPrompt, tools, sdkMessages, abortController.signal, assistantMsgId);
if (approvalInfo) {
context.setPendingApproval({
sessionId, scopeKey: sendScopeKey, sdkMessages, approvalInfo, model, systemPrompt, tools,
});
return; // Keep streaming flag — waiting for user approval
// Build the current user message — include attachments as multimodal content
if (attachments?.length) {
const parts: Array<{ type: 'text'; text: string } | { type: 'image'; image: string; mediaType?: string } | { type: 'file'; data: string; mediaType: string; filename?: string }> = [];
parts.push({ type: 'text', text: trimmed });
for (const att of attachments) {
if (att.mediaType.startsWith('image/')) {
parts.push({ type: 'image', image: att.base64Data, mediaType: att.mediaType });
} else {
parts.push({ type: 'file', data: att.base64Data, mediaType: att.mediaType, filename: att.filename });
}
}
sdkMessages.push({ role: 'user', content: parts });
} else {
sdkMessages.push({ role: 'user', content: trimmed });
}
await processCattyStream(sessionId, model, systemPrompt, tools, sdkMessages, abortController.signal, assistantMsgId);
} catch (err) {
console.error('[Catty] streamText error:', err);
reportStreamError(sessionId, abortController.signal, err);
-284
View File
@@ -1,284 +0,0 @@
/**
* useToolApproval — Encapsulates the tool approval workflow for the AI chat panel.
*
* Handles:
* - Pending approval context management
* - Approval timeout (auto-clear after 5 minutes)
* - handleApprovalResponse (approve/reject from InlineApprovalCard)
* - Resuming the Catty stream after approval
*/
import React, { useCallback, useRef } from 'react';
import type { ModelMessage } from 'ai';
import type {
AIPermissionMode,
ChatMessage,
WebSearchConfig,
} from '../../../infrastructure/ai/types';
import { isWebSearchReady } from '../../../infrastructure/ai/types';
import { buildSystemPrompt } from '../../../infrastructure/ai/cattyAgent/systemPrompt';
import { createCattyTools } from '../../../infrastructure/ai/sdk/tools';
import { classifyError } from '../../../infrastructure/ai/errorClassifier';
import type {
ApprovalInfo,
PendingApprovalContext,
TerminalSessionInfo,
} from './useAIChatStreaming';
import { getNetcattyBridge } from './useAIChatStreaming';
import type { createModelFromConfig } from '../../../infrastructure/ai/sdk/providers';
function generateId(): string {
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
// -------------------------------------------------------------------
// Hook parameters
// -------------------------------------------------------------------
export interface UseToolApprovalParams {
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
updateLastMessage: (sessionId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
updateMessageById: (sessionId: string, messageId: string, updater: (msg: ChatMessage) => ChatMessage) => void;
setStreamingForScope: (key: string, val: boolean) => void;
abortControllersRef: React.MutableRefObject<Map<string, AbortController>>;
processCattyStream: (
streamSessionId: string,
model: ReturnType<typeof createModelFromConfig>,
systemPrompt: string,
tools: ReturnType<typeof createCattyTools>,
sdkMessages: Array<ModelMessage>,
signal: AbortSignal,
currentAssistantMsgId: string,
) => Promise<ApprovalInfo | null>;
t: (key: string) => string;
}
// -------------------------------------------------------------------
// Hook return type
// -------------------------------------------------------------------
export interface UseToolApprovalReturn {
/** Ref to the current pending approval context (null when none). */
pendingApprovalContextRef: React.MutableRefObject<PendingApprovalContext | null>;
/** Set or clear the pending approval context (manages timeout). */
setPendingApproval: (ctx: PendingApprovalContext | null) => void;
/** Handle a user's approve/reject response from InlineApprovalCard. */
handleApprovalResponse: (
messageId: string,
approved: boolean,
approvalContext: ToolApprovalContext,
) => Promise<void>;
}
/** Context values needed by handleApprovalResponse that change frequently. */
export interface ToolApprovalContext {
terminalSessions: TerminalSessionInfo[];
scopeType: 'terminal' | 'workspace';
scopeTargetId?: string;
scopeLabel?: string;
globalPermissionMode: AIPermissionMode;
commandBlocklist?: string[];
webSearchConfig?: WebSearchConfig | null;
}
// -------------------------------------------------------------------
// Hook implementation
// -------------------------------------------------------------------
export function useToolApproval({
addMessageToSession,
updateLastMessage,
updateMessageById,
setStreamingForScope,
abortControllersRef,
processCattyStream,
t,
}: UseToolApprovalParams): UseToolApprovalReturn {
// Pending approval context — stores SDK state needed to resume after user approves/rejects
const pendingApprovalContextRef = useRef<PendingApprovalContext | null>(null);
// Timeout ID for auto-clearing stale pending approval (Issue #14)
const pendingApprovalTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
/** Set pending approval context with a 5-minute auto-clear timeout. */
const setPendingApproval = useCallback((ctx: PendingApprovalContext | null) => {
// Clear any existing timeout
if (pendingApprovalTimeoutRef.current) {
clearTimeout(pendingApprovalTimeoutRef.current);
pendingApprovalTimeoutRef.current = null;
}
pendingApprovalContextRef.current = ctx;
if (ctx) {
pendingApprovalTimeoutRef.current = setTimeout(() => {
// Auto-clear after 5 minutes if user never responds
if (pendingApprovalContextRef.current?.sessionId === ctx.sessionId) {
pendingApprovalContextRef.current = null;
setStreamingForScope(ctx.sessionId, false);
abortControllersRef.current.get(ctx.sessionId)?.abort();
abortControllersRef.current.delete(ctx.sessionId);
// Notify the user that the approval timed out
updateLastMessage(ctx.sessionId, msg => ({
...msg,
statusText: '',
executionStatus: msg.executionStatus === 'running' ? 'failed' : msg.executionStatus,
}));
addMessageToSession(ctx.sessionId, {
id: generateId(),
role: 'assistant',
content: t('ai.chat.approvalTimeout'),
timestamp: Date.now(),
});
}
pendingApprovalTimeoutRef.current = null;
}, 5 * 60 * 1000); // 5 minutes
}
}, [setStreamingForScope, abortControllersRef, updateLastMessage, addMessageToSession, t]);
// Handle inline approval response (approve/reject from InlineApprovalCard)
const handleApprovalResponse = useCallback(async (
messageId: string,
approved: boolean,
approvalContext: ToolApprovalContext,
) => {
const ctx = pendingApprovalContextRef.current;
if (!ctx) return;
// Destructure all needed values BEFORE clearing the ref to avoid race conditions
const { sessionId: sid, scopeKey: sk, sdkMessages, approvalInfo, model: ctxModel } = ctx;
// Clear pending approval (and its timeout) via setPendingApproval
setPendingApproval(null);
// Update the message's pendingApproval status using message ID
updateMessageById(sid, messageId, msg => ({
...msg,
pendingApproval: msg.pendingApproval
? { ...msg.pendingApproval, status: approved ? 'approved' as const : 'denied' as const }
: undefined,
}));
if (!approved) {
// User rejected — add denial text and stop
updateMessageById(sid, messageId, msg => ({
...msg,
content: msg.content + (msg.content ? '\n\n' : '') + t('ai.chat.toolDenied'),
statusText: '',
executionStatus: 'completed',
}));
setStreamingForScope(sid, false);
abortControllersRef.current.delete(sid);
return;
}
// User approved — construct SDK messages with approval response and resume
const resumeMessages: Array<Record<string, unknown>> = [
...sdkMessages,
// The assistant message that contained the tool call + approval request
{
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: approvalInfo.toolCallId,
toolName: approvalInfo.toolName,
input: approvalInfo.toolArgs,
},
{
type: 'tool-approval-request',
approvalId: approvalInfo.approvalId,
toolCallId: approvalInfo.toolCallId,
},
],
},
// The user's approval response
{
role: 'tool',
content: [
{
type: 'tool-approval-response',
approvalId: approvalInfo.approvalId,
approved: true,
},
],
},
];
// Create a new assistant message placeholder for the continuation
const newAssistantMsgId = generateId();
addMessageToSession(sid, {
id: newAssistantMsgId,
role: 'assistant',
content: '',
timestamp: Date.now(),
});
const abortController = new AbortController();
abortControllersRef.current.set(sid, abortController);
try {
// Rebuild tools and system prompt with the latest permission mode to prevent
// stale closure issues (e.g. user changed permission mode during approval wait)
const bridge = getNetcattyBridge();
const freshTools = createCattyTools(bridge, {
sessions: approvalContext.terminalSessions,
workspaceId: approvalContext.scopeTargetId,
workspaceName: approvalContext.scopeLabel,
}, approvalContext.commandBlocklist, approvalContext.globalPermissionMode, approvalContext.webSearchConfig ?? undefined);
const freshSystemPrompt = buildSystemPrompt({
scopeType: approvalContext.scopeType, scopeLabel: approvalContext.scopeLabel,
hosts: approvalContext.terminalSessions.map(s => ({
sessionId: s.sessionId, hostname: s.hostname, label: s.label,
os: s.os, username: s.username, connected: s.connected,
})),
permissionMode: approvalContext.globalPermissionMode,
webSearchEnabled: isWebSearchReady(approvalContext.webSearchConfig),
});
const newApprovalInfo = await processCattyStream(sid, ctxModel, freshSystemPrompt, freshTools, resumeMessages as unknown as ModelMessage[], abortController.signal, newAssistantMsgId);
if (newApprovalInfo) {
// Another approval needed — save context for the next round (with timeout)
setPendingApproval({
sessionId: sid,
scopeKey: sk,
sdkMessages: resumeMessages,
approvalInfo: newApprovalInfo,
model: ctxModel,
systemPrompt: freshSystemPrompt,
tools: freshTools,
});
return;
}
} catch (err) {
console.error('[Catty resume] streamText error:', err);
if (!abortController.signal.aborted) {
const errorStr = err instanceof Error ? err.message : String(err);
updateMessageById(sid, newAssistantMsgId, msg => ({
...msg,
statusText: '',
executionStatus: msg.executionStatus === 'running' ? 'failed' : msg.executionStatus,
}));
addMessageToSession(sid, {
id: generateId(),
role: 'assistant',
content: '',
errorInfo: classifyError(errorStr),
timestamp: Date.now(),
});
}
} finally {
if (!pendingApprovalContextRef.current || pendingApprovalContextRef.current.sessionId !== sid) {
// Clear any lingering statusText when the resumed stream finishes
updateLastMessage(sid, msg => msg.statusText ? { ...msg, statusText: '' } : msg);
setStreamingForScope(sid, false);
abortControllersRef.current.delete(sid);
}
}
}, [
processCattyStream, addMessageToSession, updateMessageById, updateLastMessage,
setStreamingForScope, abortControllersRef, t, setPendingApproval,
]);
return {
pendingApprovalContextRef,
setPendingApproval,
handleApprovalResponse,
};
}
@@ -29,7 +29,7 @@ const getOpenerLabel = (
export default function SettingsFileAssociationsTab() {
const { t } = useI18n();
const { getAllAssociations, removeAssociation, setOpenerForExtension } = useSftpFileAssociations();
const { sftpDoubleClickBehavior, setSftpDoubleClickBehavior, sftpAutoSync, setSftpAutoSync, sftpShowHiddenFiles, setSftpShowHiddenFiles, sftpUseCompressedUpload, setSftpUseCompressedUpload } = useSettingsState();
const { sftpDoubleClickBehavior, setSftpDoubleClickBehavior, sftpAutoSync, setSftpAutoSync, sftpShowHiddenFiles, setSftpShowHiddenFiles, sftpUseCompressedUpload, setSftpUseCompressedUpload, sftpAutoOpenSidebar, setSftpAutoOpenSidebar } = useSettingsState();
const associations = getAllAssociations();
const [editingExtension, setEditingExtension] = useState<string | null>(null);
@@ -253,6 +253,46 @@ export default function SettingsFileAssociationsTab() {
</button>
</div>
{/* Auto-open sidebar section */}
<div className="space-y-4">
<SectionHeader title={t('settings.sftp.autoOpenSidebar')} />
<p className="text-sm text-muted-foreground">
{t('settings.sftp.autoOpenSidebar.desc')}
</p>
<button
onClick={() => setSftpAutoOpenSidebar(!sftpAutoOpenSidebar)}
className={cn(
"w-full text-left p-4 rounded-lg border-2 transition-colors",
sftpAutoOpenSidebar
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-secondary/50"
)}
>
<div className="flex items-start gap-3">
<div className={cn(
"h-5 w-5 rounded border-2 flex items-center justify-center mt-0.5 shrink-0",
sftpAutoOpenSidebar
? "border-primary bg-primary"
: "border-muted-foreground/30"
)}>
{sftpAutoOpenSidebar && (
<svg className="h-3 w-3 text-primary-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</div>
<div className="space-y-1">
<Label className="font-medium cursor-pointer">
{t('settings.sftp.autoOpenSidebar.enable')}
</Label>
<p className="text-sm text-muted-foreground">
{t('settings.sftp.autoOpenSidebar.enableDesc')}
</p>
</div>
</div>
</button>
</div>
{/* File associations section */}
<div className="space-y-4">
<SectionHeader title={t('settings.sftpFileAssociations.title')} />
+1 -1
View File
@@ -77,7 +77,7 @@ export const AGENT_DEFAULTS: Record<string, Omit<ExternalAgentConfig, "id" | "co
name: "Claude Code",
args: ["-p", "--output-format", "text", "{prompt}"],
icon: "claude",
acpCommand: "claude-code-acp",
acpCommand: "claude-agent-acp",
acpArgs: [],
},
};
-186
View File
@@ -1,186 +0,0 @@
import React from "react";
import { Loader2 } from "lucide-react";
import { Button } from "../ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
import { Input } from "../ui/input";
import type { RemoteFile } from "../../types";
interface PermissionsState {
owner: { read: boolean; write: boolean; execute: boolean };
group: { read: boolean; write: boolean; execute: boolean };
others: { read: boolean; write: boolean; execute: boolean };
}
interface SftpModalDialogsProps {
t: (key: string, params?: Record<string, unknown>) => string;
showRenameDialog: boolean;
setShowRenameDialog: (open: boolean) => void;
renameTarget: RemoteFile | null;
renameName: string;
setRenameName: (value: string) => void;
handleRename: () => void;
isRenaming: boolean;
showPermissionsDialog: boolean;
setShowPermissionsDialog: (open: boolean) => void;
permissionsTarget: RemoteFile | null;
permissions: PermissionsState;
togglePermission: (role: "owner" | "group" | "others", perm: "read" | "write" | "execute") => void;
getOctalPermissions: () => string;
getSymbolicPermissions: () => string;
handleSavePermissions: () => void;
isChangingPermissions: boolean;
showCreateDialog: boolean;
setShowCreateDialog: (open: boolean) => void;
createType: "file" | "folder";
createName: string;
setCreateName: (value: string) => void;
isCreating: boolean;
handleCreateSubmit: () => void;
}
export const SftpModalDialogs: React.FC<SftpModalDialogsProps> = ({
t,
showRenameDialog,
setShowRenameDialog,
renameTarget,
renameName,
setRenameName,
handleRename,
isRenaming,
showPermissionsDialog,
setShowPermissionsDialog,
permissionsTarget,
permissions,
togglePermission,
getOctalPermissions,
getSymbolicPermissions,
handleSavePermissions,
isChangingPermissions,
showCreateDialog,
setShowCreateDialog,
createType,
createName,
setCreateName,
isCreating,
handleCreateSubmit,
}) => (
<>
<Dialog open={showRenameDialog} onOpenChange={setShowRenameDialog}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>{t("sftp.rename.title")}</DialogTitle>
<DialogDescription className="truncate">
{renameTarget?.name}
</DialogDescription>
</DialogHeader>
<div className="py-4">
<Input
value={renameName}
onChange={(e) => setRenameName(e.target.value)}
placeholder={t("sftp.rename.placeholder")}
onKeyDown={(e) => {
if (e.key === "Enter") handleRename();
}}
autoFocus
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowRenameDialog(false)}>
{t("common.cancel")}
</Button>
<Button onClick={handleRename} disabled={isRenaming || !renameName.trim()}>
{isRenaming ? <Loader2 size={14} className="mr-2 animate-spin" /> : null}
{t("common.apply")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showPermissionsDialog} onOpenChange={setShowPermissionsDialog}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>{t("sftp.permissions.title")}</DialogTitle>
<DialogDescription className="truncate">
{permissionsTarget?.name}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-3">
{(["owner", "group", "others"] as const).map((role) => (
<div key={role} className="flex items-center gap-4">
<div className="w-16 text-sm font-medium">
{t(`sftp.permissions.${role}`)}
</div>
<div className="flex gap-3">
{(["read", "write", "execute"] as const).map((perm) => (
<label key={perm} className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={permissions[role][perm]}
onChange={() => togglePermission(role, perm)}
className="rounded border-border"
/>
<span className="text-xs">
{perm === "read" ? "R" : perm === "write" ? "W" : "X"}
</span>
</label>
))}
</div>
</div>
))}
</div>
<div className="flex items-center justify-between pt-2 border-t border-border/60">
<div className="text-xs text-muted-foreground">
{t("sftp.permissions.octal")}: <span className="font-mono text-foreground">{getOctalPermissions()}</span>
</div>
<div className="text-xs text-muted-foreground">
{t("sftp.permissions.symbolic")}: <span className="font-mono text-foreground">{getSymbolicPermissions()}</span>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowPermissionsDialog(false)}>
{t("common.cancel")}
</Button>
<Button onClick={handleSavePermissions} disabled={isChangingPermissions}>
{isChangingPermissions ? <Loader2 size={14} className="mr-2 animate-spin" /> : null}
{t("common.apply")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>
{t(createType === "folder" ? "sftp.newFolder" : "sftp.newFile")}
</DialogTitle>
<DialogDescription>
{t(createType === "folder" ? "sftp.prompt.newFolderName" : "sftp.fileName.placeholder")}
</DialogDescription>
</DialogHeader>
<div className="py-4">
<Input
value={createName}
onChange={(e) => setCreateName(e.target.value)}
placeholder={t(createType === "folder" ? "sftp.prompt.newFolderName" : "sftp.fileName.placeholder")}
onKeyDown={(e) => {
if (e.key === "Enter") handleCreateSubmit();
}}
autoFocus
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCreateDialog(false)}>
{t("common.cancel")}
</Button>
<Button onClick={handleCreateSubmit} disabled={isCreating || !createName.trim()}>
{isCreating ? <Loader2 size={14} className="mr-2 animate-spin" /> : null}
{t("common.apply")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
-438
View File
@@ -1,438 +0,0 @@
import React from "react";
import { Download, Edit2, Folder, FolderOpen, FolderUp, Link, Loader2, MoreHorizontal, Plus, RefreshCw, Shield, Trash2, Upload } from "lucide-react";
import { cn } from "../../lib/utils";
import type { RemoteFile } from "../../types";
import { isKnownBinaryFile } from "../../lib/sftpFileUtils";
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from "../ui/context-menu";
import { Button } from "../ui/button";
import { getFileIcon } from "./fileIcons";
interface VisibleRow {
file: RemoteFile;
index: number;
top: number;
}
interface SftpModalFileListProps {
t: (key: string, params?: Record<string, unknown>) => string;
currentPath: string;
isLocalSession: boolean;
hasFiles: boolean;
hasDisplayFiles: boolean;
selectedFiles: Set<string>;
dragActive: boolean;
loading: boolean;
loadingTextContent: boolean;
reconnecting: boolean;
columnWidths: { name: number; size: number; modified: number; actions: number };
sortField: "name" | "size" | "modified";
sortOrder: "asc" | "desc";
shouldVirtualize: boolean;
totalHeight: number;
visibleRows: VisibleRow[];
fileListRef: React.RefObject<HTMLDivElement>;
inputRef: React.RefObject<HTMLInputElement>;
folderInputRef: React.RefObject<HTMLInputElement>;
handleSort: (field: "name" | "size" | "modified") => void;
handleResizeStart: (field: string, e: React.MouseEvent) => void;
handleFileListScroll: (e: React.UIEvent<HTMLDivElement>) => void;
handleDrag: (e: React.DragEvent) => void;
handleDrop: (e: React.DragEvent) => void;
handleFileClick: (file: RemoteFile, index: number, e: React.MouseEvent) => void;
handleFileDoubleClick: (file: RemoteFile) => void;
handleDownload: (file: RemoteFile) => void;
handleDelete: (file: RemoteFile) => void;
handleOpenFile: (file: RemoteFile) => void;
openFileOpenerDialog: (file: RemoteFile) => void;
handleEditFile: (file: RemoteFile) => void;
openRenameDialog: (file: RemoteFile) => void;
openPermissionsDialog: (file: RemoteFile) => void;
handleNavigate: (path: string) => void;
handleCreateFolder: () => void;
handleCreateFile: () => void;
handleDownloadSelected: () => void;
handleDeleteSelected: () => void;
loadFiles: (path: string, options?: { force?: boolean }) => void;
formatBytes: (bytes: number | string) => string;
formatDate: (dateStr: string | number | undefined) => string;
}
export const SftpModalFileList: React.FC<SftpModalFileListProps> = ({
t,
currentPath,
isLocalSession,
hasFiles,
hasDisplayFiles,
selectedFiles,
dragActive,
loading,
loadingTextContent,
reconnecting,
columnWidths,
sortField,
sortOrder,
shouldVirtualize,
totalHeight,
visibleRows,
fileListRef,
inputRef,
folderInputRef,
handleSort,
handleResizeStart,
handleFileListScroll,
handleDrag,
handleDrop,
handleFileClick,
handleFileDoubleClick,
handleDownload,
handleDelete,
handleOpenFile,
openFileOpenerDialog,
handleEditFile,
openRenameDialog,
openPermissionsDialog,
handleNavigate,
handleCreateFolder,
handleCreateFile,
handleDownloadSelected,
handleDeleteSelected,
loadFiles,
formatBytes,
formatDate,
}) => (
<>
<div
className="shrink-0 bg-muted/80 backdrop-blur-sm border-b border-border/60 px-4 py-2 flex items-center text-xs font-medium text-muted-foreground select-none"
style={{
display: "grid",
gridTemplateColumns: `${columnWidths.name}% ${columnWidths.size}% ${columnWidths.modified}% ${columnWidths.actions}%`,
}}
>
<div
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2"
onClick={() => handleSort("name")}
>
<span>{t("sftp.columns.name")}</span>
{sortField === "name" && (
<span className="text-primary">{sortOrder === "asc" ? "^" : "v"}</span>
)}
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
onMouseDown={(e) => handleResizeStart("name", e)}
/>
</div>
<div
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2"
onClick={() => handleSort("size")}
>
<span>{t("sftp.columns.size")}</span>
{sortField === "size" && (
<span className="text-primary">{sortOrder === "asc" ? "^" : "v"}</span>
)}
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
onMouseDown={(e) => handleResizeStart("size", e)}
/>
</div>
<div
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2"
onClick={() => handleSort("modified")}
>
<span>{t("sftp.columns.modified")}</span>
{sortField === "modified" && (
<span className="text-primary">{sortOrder === "asc" ? "^" : "v"}</span>
)}
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
onMouseDown={(e) => handleResizeStart("modified", e)}
/>
</div>
<div className="text-right">{t("sftp.columns.actions")}</div>
</div>
<div
ref={fileListRef}
className={cn(
"flex-1 min-h-0 overflow-y-auto relative",
dragActive && "bg-primary/5 ring-2 ring-inset ring-primary",
)}
onScroll={handleFileListScroll}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
>
{dragActive && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
<div className="bg-background/95 p-6 rounded-xl shadow-lg border-2 border-dashed border-primary text-primary font-medium flex flex-col items-center gap-2">
<Upload size={32} />
<span>{t("sftp.dropFilesHere")}</span>
</div>
</div>
)}
{loading && !hasFiles && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)}
{loadingTextContent && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-20">
<div className="flex flex-col items-center gap-2">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">
{t("sftp.status.loading")}
</span>
</div>
</div>
)}
{reconnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-20">
<div className="flex flex-col items-center gap-3 p-6 rounded-xl bg-secondary/90 border border-border/60 shadow-lg">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<div className="text-center">
<div className="text-sm font-medium">{t("sftp.reconnecting.title")}</div>
<div className="text-xs text-muted-foreground mt-1">
{t("sftp.reconnecting.desc")}
</div>
</div>
</div>
</div>
)}
{!hasDisplayFiles && !loading && (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<Folder size={48} className="mb-3 opacity-50" />
<div className="text-sm font-medium">{t("sftp.emptyDirectory")}</div>
<div className="text-xs mt-1">{t("sftp.dragDropToUpload")}</div>
</div>
)}
<ContextMenu>
<ContextMenuTrigger asChild>
<div
className={shouldVirtualize ? "relative" : "divide-y divide-border/30"}
style={shouldVirtualize ? { height: totalHeight } : undefined}
>
{visibleRows.map(({ file, index: idx, top }) => {
const isNavigableDirectory =
file.type === "directory" ||
(file.type === "symlink" && file.linkTarget === "directory");
const isDownloadableFile =
file.type === "file" ||
(file.type === "symlink" && file.linkTarget === "file");
const isParentEntry = file.name === "..";
return (
<ContextMenu key={file.name}>
<ContextMenuTrigger>
<div
data-sftp-modal-row="true"
className={cn(
"px-4 py-2.5 items-center hover:bg-muted/50 cursor-pointer transition-colors text-sm",
selectedFiles.has(file.name) && !isParentEntry && "bg-primary/10",
shouldVirtualize ? "absolute left-0 right-0 border-b border-border/30" : "",
)}
style={
shouldVirtualize
? {
top,
display: "grid",
gridTemplateColumns: `${columnWidths.name}% ${columnWidths.size}% ${columnWidths.modified}% ${columnWidths.actions}%`,
}
: {
display: "grid",
gridTemplateColumns: `${columnWidths.name}% ${columnWidths.size}% ${columnWidths.modified}% ${columnWidths.actions}%`,
}
}
onClick={(e) => handleFileClick(file, idx, e)}
onDoubleClick={() => handleFileDoubleClick(file)}
>
<div className="flex items-center gap-3 min-w-0">
<div className="relative shrink-0 h-7 w-7 flex items-center justify-center">
{getFileIcon(
file.name,
isNavigableDirectory,
file.type === "symlink" && !isNavigableDirectory,
)}
{file.type === "symlink" && (
<Link
size={10}
className="absolute -bottom-0.5 -right-0.5 text-muted-foreground"
aria-hidden="true"
/>
)}
</div>
<span
className={cn(
"truncate font-medium",
file.type === "symlink" && "italic pr-1",
)}
>
{file.name}
{file.type === "symlink" && (
<span className="sr-only"> (symbolic link)</span>
)}
</span>
</div>
<div className="text-xs text-muted-foreground">
{isNavigableDirectory ? "--" : formatBytes(file.size)}
</div>
<div className="text-xs text-muted-foreground truncate">
{formatDate(file.lastModified)}
</div>
<div className="flex items-center justify-end gap-1">
{isDownloadableFile && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={(e) => {
e.stopPropagation();
handleDownload(file);
}}
title={t("sftp.context.download")}
>
<Download size={14} />
</Button>
)}
{!isParentEntry && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleDelete(file);
}}
title={t("sftp.context.delete")}
>
<Trash2 size={14} />
</Button>
)}
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
{isParentEntry ? (
<ContextMenuItem
onClick={() => {
const segments = currentPath.split("/").filter(Boolean);
segments.pop();
const parentPath =
segments.length === 0 ? "/" : `/${segments.join("/")}`;
handleNavigate(parentPath);
}}
>
{t("sftp.context.open")}
</ContextMenuItem>
) : (
<>
{isNavigableDirectory && (
<>
<ContextMenuItem
onClick={() =>
handleNavigate(
currentPath === "/"
? `/${file.name}`
: `${currentPath}/${file.name}`,
)
}
>
<FolderOpen size={14} className="mr-2" />
{t("sftp.context.open")}
</ContextMenuItem>
{!isLocalSession && (
<ContextMenuItem onClick={() => handleDownload(file)}>
<Download size={14} className="mr-2" />
{t("sftp.context.download")}
</ContextMenuItem>
)}
</>
)}
{isDownloadableFile && (
<>
<ContextMenuItem onClick={() => handleOpenFile(file)}>
<FolderOpen size={14} className="mr-2" />
{t("sftp.context.open")}
</ContextMenuItem>
<ContextMenuItem onClick={() => openFileOpenerDialog(file)}>
<MoreHorizontal size={14} className="mr-2" />
{t("sftp.context.openWith")}
</ContextMenuItem>
{!isKnownBinaryFile(file.name) && (
<ContextMenuItem onClick={() => handleEditFile(file)}>
<Edit2 size={14} className="mr-2" />
{t("sftp.context.edit")}
</ContextMenuItem>
)}
<ContextMenuSeparator />
<ContextMenuItem onClick={() => handleDownload(file)}>
<Download size={14} className="mr-2" />
{t("sftp.context.download")}
</ContextMenuItem>
</>
)}
<ContextMenuItem onClick={() => openRenameDialog(file)}>
<Edit2 size={14} className="mr-2" />
{t("sftp.context.rename")}
</ContextMenuItem>
{!isLocalSession && (
<ContextMenuItem onClick={() => openPermissionsDialog(file)}>
<Shield size={14} className="mr-2" />
{t("sftp.context.permissions")}
</ContextMenuItem>
)}
<ContextMenuItem
className="text-destructive"
onClick={() => handleDelete(file)}
>
<Trash2 size={14} className="mr-2" />
{t("sftp.context.delete")}
</ContextMenuItem>
</>
)}
</ContextMenuContent>
</ContextMenu>
);
})}
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={handleCreateFolder}>
<Plus className="h-4 w-4 mr-2" /> {t("sftp.newFolder")}
</ContextMenuItem>
<ContextMenuItem onClick={handleCreateFile}>
<Plus className="h-4 w-4 mr-2" /> {t("sftp.newFile")}
</ContextMenuItem>
<ContextMenuItem onClick={() => inputRef.current?.click()}>
<Upload className="h-4 w-4 mr-2" /> {t("sftp.uploadFiles")}
</ContextMenuItem>
<ContextMenuItem onClick={() => folderInputRef.current?.click()}>
<FolderUp className="h-4 w-4 mr-2" /> {t("sftp.uploadFolder")}
</ContextMenuItem>
<ContextMenuItem onClick={() => loadFiles(currentPath, { force: true })}>
<RefreshCw className="h-4 w-4 mr-2" /> {t("sftp.context.refresh")}
</ContextMenuItem>
{selectedFiles.size > 0 && (
<>
<ContextMenuItem onClick={handleDownloadSelected}>
<Download className="h-4 w-4 mr-2" />
{t("sftp.context.downloadSelected", { count: selectedFiles.size })}
</ContextMenuItem>
<ContextMenuItem
className="text-destructive"
onClick={handleDeleteSelected}
>
<Trash2 className="h-4 w-4 mr-2" />
{t("sftp.context.deleteSelected", { count: selectedFiles.size })}
</ContextMenuItem>
</>
)}
</ContextMenuContent>
</ContextMenu>
</div>
</>
);
-61
View File
@@ -1,61 +0,0 @@
import React from "react";
import { Download, Trash2 } from "lucide-react";
import { Button } from "../ui/button";
import type { RemoteFile } from "../../types";
interface SftpModalFooterProps {
t: (key: string, params?: Record<string, unknown>) => string;
files: RemoteFile[];
selectedFiles: Set<string>;
loading: boolean;
uploading: boolean;
onDownloadSelected: () => void;
onDeleteSelected: () => void;
}
export const SftpModalFooter: React.FC<SftpModalFooterProps> = ({
t,
files,
selectedFiles,
loading,
uploading,
onDownloadSelected,
onDeleteSelected,
}) => (
<div className="px-4 py-2 border-t border-border/60 flex items-center justify-between text-xs text-muted-foreground bg-muted/30 flex-shrink-0">
<span>
{t("sftp.itemsCount", { count: files.length })}
{selectedFiles.size > 0 && (
<>
<span className="mx-2">|</span>
<span className="text-primary">
{t("sftp.selectedCount", { count: selectedFiles.size })}
</span>
<Button
variant="ghost"
size="sm"
className="h-5 px-2 ml-2 text-xs text-primary hover:text-primary"
onClick={onDownloadSelected}
>
<Download size={10} className="mr-1" /> {t("sftp.context.download")}
</Button>
<Button
variant="ghost"
size="sm"
className="h-5 px-2 text-xs text-destructive hover:text-destructive"
onClick={onDeleteSelected}
>
<Trash2 size={10} className="mr-1" /> {t("sftp.context.delete")}
</Button>
</>
)}
</span>
<span>
{loading
? t("sftp.status.loading")
: uploading
? t("sftp.status.uploading")
: t("sftp.status.ready")}
</span>
</div>
);
-480
View File
@@ -1,480 +0,0 @@
import React, { useEffect, useState } from "react";
import { ArrowUp, Bookmark, Check, ChevronRight, Eye, EyeOff, FilePlus, FolderPlus, FolderUp, Home, Languages, MoreHorizontal, RefreshCw, Trash2, Upload, X } from "lucide-react";
import { cn } from "../../lib/utils";
import type { Host, SftpFilenameEncoding } from "../../types";
import { useSftpBookmarks } from "../sftp/hooks/useSftpBookmarks";
import { DistroAvatar } from "../DistroAvatar";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from "../ui/popover";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
interface BreadcrumbPart {
part: string;
originalIndex: number;
}
interface SftpModalHeaderProps {
t: (key: string, params?: Record<string, unknown>) => string;
host: Host;
credentials: { username?: string; hostname: string; port?: number };
showEncoding: boolean;
filenameEncoding: SftpFilenameEncoding;
onFilenameEncodingChange: (encoding: SftpFilenameEncoding) => void;
currentPath: string;
isEditingPath: boolean;
editingPathValue: string;
setEditingPathValue: (value: string) => void;
handlePathSubmit: () => void;
handlePathKeyDown: (e: React.KeyboardEvent) => void;
handlePathDoubleClick: () => void;
isAtRoot: boolean;
rootLabel: string;
isRefreshing: boolean;
onUp: () => void;
onHome: () => void;
onRefresh: () => void;
visibleBreadcrumbs: BreadcrumbPart[];
hiddenBreadcrumbs: BreadcrumbPart[];
needsBreadcrumbTruncation: boolean;
breadcrumbs: string[];
onBreadcrumbSelect: (index: number) => void;
onRootSelect: () => void;
inputRef: React.RefObject<HTMLInputElement>;
folderInputRef: React.RefObject<HTMLInputElement>;
pathInputRef: React.RefObject<HTMLInputElement>;
uploading: boolean;
onTriggerUpload: () => void;
onTriggerFolderUpload: () => void;
onCreateFolder: () => void;
onCreateFile: () => void;
onFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
onFolderSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
showHiddenFiles: boolean;
onToggleShowHiddenFiles: () => void;
onUpdateHost?: (host: Host) => void;
onNavigateToBookmark?: (path: string) => void;
onClose?: () => void;
}
export const SftpModalHeader: React.FC<SftpModalHeaderProps> = ({
t,
host,
credentials,
showEncoding,
filenameEncoding,
onFilenameEncodingChange,
currentPath,
isEditingPath,
editingPathValue,
setEditingPathValue,
handlePathSubmit,
handlePathKeyDown,
handlePathDoubleClick,
isAtRoot,
rootLabel,
isRefreshing,
onUp,
onHome,
onRefresh,
visibleBreadcrumbs,
hiddenBreadcrumbs,
needsBreadcrumbTruncation,
breadcrumbs,
onBreadcrumbSelect,
onRootSelect,
inputRef,
folderInputRef,
pathInputRef,
uploading,
onTriggerUpload,
onTriggerFolderUpload,
onCreateFolder,
onCreateFile,
onFileSelect,
onFolderSelect,
showHiddenFiles,
onToggleShowHiddenFiles,
onUpdateHost,
onNavigateToBookmark,
onClose,
}) => {
// Delay tooltip activation to prevent flickering when modal opens
const [tooltipsReady, setTooltipsReady] = useState(false);
const [openTooltip, setOpenTooltip] = useState<string | null>(null);
// Bookmarks
const {
bookmarks,
isCurrentPathBookmarked,
toggleBookmark,
deleteBookmark,
} = useSftpBookmarks({
host,
currentPath,
onUpdateHost,
});
useEffect(() => {
const timer = setTimeout(() => setTooltipsReady(true), 500);
return () => clearTimeout(timer);
}, []);
const handleTooltipOpenChange = (id: string) => (open: boolean) => {
if (!tooltipsReady) return;
setOpenTooltip(open ? id : null);
};
return (
<>
<div className="px-4 py-3 border-b border-border/60 flex-shrink-0">
<div className="flex items-center gap-3">
<DistroAvatar
host={host}
fallback={host.label.slice(0, 2).toUpperCase()}
className="h-8 w-8"
size="sm"
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold">
{host.label}
</div>
<div className="text-xs text-muted-foreground font-mono">
{credentials.username || "root"}@{credentials.hostname}:
{credentials.port || 22}
</div>
</div>
{onClose && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={onClose}
>
<X size={14} />
</Button>
)}
</div>
</div>
<TooltipProvider delayDuration={500} skipDelayDuration={800} disableHoverableContent>
<div className="px-4 py-2 border-b border-border/60 flex items-center gap-2 flex-shrink-0 bg-muted/30">
<Tooltip open={openTooltip === 'up'} onOpenChange={handleTooltipOpenChange('up')}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onUp}
disabled={isAtRoot}
>
<ArrowUp size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.nav.up")}</TooltipContent>
</Tooltip>
<Tooltip open={openTooltip === 'home'} onOpenChange={handleTooltipOpenChange('home')}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onHome}
>
<Home size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.nav.home")}</TooltipContent>
</Tooltip>
<Tooltip open={openTooltip === 'refresh'} onOpenChange={handleTooltipOpenChange('refresh')}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onRefresh}
>
<RefreshCw
size={14}
className={cn(isRefreshing && "animate-spin")}
/>
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.nav.refresh")}</TooltipContent>
</Tooltip>
{/* Bookmark button */}
{onUpdateHost && (
<Popover>
<Tooltip open={openTooltip === 'bookmark'} onOpenChange={handleTooltipOpenChange('bookmark')}>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
>
<Bookmark
size={14}
className={cn(
isCurrentPathBookmarked && "fill-yellow-500 text-yellow-500"
)}
/>
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>
{isCurrentPathBookmarked ? t("sftp.bookmark.remove") : t("sftp.bookmark.add")}
</TooltipContent>
</Tooltip>
<PopoverContent className="w-56 p-1" align="start">
{/* Toggle button */}
<button
className="w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors"
onClick={toggleBookmark}
>
<Bookmark
size={12}
className={cn(
"shrink-0",
isCurrentPathBookmarked && "fill-yellow-500 text-yellow-500"
)}
/>
{isCurrentPathBookmarked ? t("sftp.bookmark.remove") : t("sftp.bookmark.add")}
</button>
{/* Divider + list */}
{bookmarks.length > 0 && (
<>
<div className="my-1 border-t border-border/60" />
{bookmarks.map((bm) => (
<div
key={bm.id}
className="group flex items-center gap-1 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors cursor-pointer"
onClick={() => onNavigateToBookmark?.(bm.path)}
title={bm.path}
>
<Bookmark size={10} className="shrink-0 text-muted-foreground" />
<span className="flex-1 truncate">{bm.label}</span>
<span className="flex-1 truncate text-muted-foreground text-[10px]">{bm.path}</span>
<Button
variant="ghost"
size="icon"
className="h-4 w-4 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
onClick={(e) => {
e.stopPropagation();
deleteBookmark(bm.id);
}}
>
<Trash2 size={10} />
</Button>
</div>
))}
</>
)}
{bookmarks.length === 0 && (
<div className="p-2 text-xs text-muted-foreground text-center">
{t("sftp.bookmark.empty")}
</div>
)}
</PopoverContent>
</Popover>
)}
{showEncoding && (
<Popover>
<Tooltip open={openTooltip === 'encoding'} onOpenChange={handleTooltipOpenChange('encoding')}>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
>
<Languages size={14} />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>{t("sftp.encoding.label")}</TooltipContent>
</Tooltip>
<PopoverContent className="w-36 p-1" align="start">
{(["auto", "utf-8", "gb18030"] as const).map((encoding) => (
<PopoverClose asChild key={encoding}>
<button
className={cn(
"w-full flex items-center gap-2 px-2 py-1.5 text-sm rounded-sm hover:bg-secondary transition-colors",
filenameEncoding === encoding && "bg-secondary"
)}
onClick={() => onFilenameEncodingChange(encoding)}
>
<Check
size={14}
className={cn(
"shrink-0",
filenameEncoding === encoding ? "opacity-100" : "opacity-0"
)}
/>
{t(`sftp.encoding.${encoding === "utf-8" ? "utf8" : encoding}`)}
</button>
</PopoverClose>
))}
</PopoverContent>
</Popover>
)}
<Tooltip
open={openTooltip === 'showHiddenFiles'}
onOpenChange={handleTooltipOpenChange('showHiddenFiles')}
>
<TooltipTrigger asChild>
<Button
variant={showHiddenFiles ? "secondary" : "ghost"}
size="icon"
className={cn("h-7 w-7", showHiddenFiles && "text-primary")}
onClick={onToggleShowHiddenFiles}
>
{showHiddenFiles ? <EyeOff size={14} /> : <Eye size={14} />}
</Button>
</TooltipTrigger>
<TooltipContent>{t("settings.sftp.showHiddenFiles")}</TooltipContent>
</Tooltip>
<div className="flex items-center gap-1 text-sm flex-1 min-w-0 overflow-hidden">
{isEditingPath ? (
<Input
ref={pathInputRef}
value={editingPathValue}
onChange={(e) => setEditingPathValue(e.target.value)}
onBlur={handlePathSubmit}
onKeyDown={handlePathKeyDown}
className="h-7 text-sm bg-background"
autoFocus
/>
) : (
<div
className="flex items-center gap-1 flex-1 min-w-0 cursor-text hover:bg-secondary/50 rounded px-1 py-0.5 transition-colors"
onDoubleClick={handlePathDoubleClick}
title={currentPath}
>
<button
className="text-muted-foreground hover:text-foreground px-1 shrink-0"
onClick={onRootSelect}
>
{rootLabel}
</button>
{visibleBreadcrumbs.map(({ part, originalIndex }, displayIdx) => {
const isLast = originalIndex === breadcrumbs.length - 1;
const showEllipsisBefore =
needsBreadcrumbTruncation && displayIdx === 1;
return (
<React.Fragment key={originalIndex}>
{showEllipsisBefore && (
<>
<ChevronRight
size={12}
className="text-muted-foreground flex-shrink-0"
/>
<span
className="text-muted-foreground px-1 shrink-0 flex items-center cursor-default"
title={`${t("sftp.showHiddenPaths")}: ${hiddenBreadcrumbs
.map((h) => h.part)
.join(" > ")}`}
>
<MoreHorizontal size={14} />
</span>
</>
)}
<ChevronRight
size={12}
className="text-muted-foreground flex-shrink-0"
/>
<button
className={cn(
"text-muted-foreground hover:text-foreground truncate px-1 max-w-[100px]",
isLast && "text-foreground font-medium",
)}
onClick={() => onBreadcrumbSelect(originalIndex)}
title={part}
>
{part}
</button>
</React.Fragment>
);
})}
</div>
)}
</div>
<div className="flex items-center gap-1 ml-auto">
<Tooltip open={openTooltip === 'upload'} onOpenChange={handleTooltipOpenChange('upload')}>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-7 w-7"
onClick={onTriggerUpload}
disabled={uploading}
>
<Upload size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.upload")}</TooltipContent>
</Tooltip>
<Tooltip open={openTooltip === 'uploadFolder'} onOpenChange={handleTooltipOpenChange('uploadFolder')}>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-7 w-7"
onClick={onTriggerFolderUpload}
disabled={uploading}
>
<FolderUp size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.uploadFolder")}</TooltipContent>
</Tooltip>
<Tooltip open={openTooltip === 'newFolder'} onOpenChange={handleTooltipOpenChange('newFolder')}>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-7 w-7"
onClick={onCreateFolder}
>
<FolderPlus size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.newFolder")}</TooltipContent>
</Tooltip>
<Tooltip open={openTooltip === 'newFile'} onOpenChange={handleTooltipOpenChange('newFile')}>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-7 w-7"
onClick={onCreateFile}
>
<FilePlus size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.newFile")}</TooltipContent>
</Tooltip>
<input
type="file"
className="hidden"
ref={inputRef}
onChange={onFileSelect}
multiple
/>
<input
type="file"
className="hidden"
ref={folderInputRef}
onChange={onFolderSelect}
webkitdirectory=""
multiple
/>
</div>
</div>
</TooltipProvider>
</>
);
};
@@ -1,228 +0,0 @@
import React from "react";
import { Download, Loader2, Upload, X, XCircle } from "lucide-react";
import { cn } from "../../lib/utils";
import { Button } from "../ui/button";
interface TransferTask {
id: string;
fileName: string;
totalBytes: number;
transferredBytes: number;
progress: number;
speed: number;
status: "pending" | "uploading" | "downloading" | "completed" | "failed" | "cancelled";
error?: string;
direction: "upload" | "download";
targetPath?: string;
}
interface SftpModalUploadTasksProps {
tasks: TransferTask[];
t: (key: string, params?: Record<string, unknown>) => string;
onCancel?: () => void;
onCancelTask?: (taskId: string) => void;
onDismiss?: (taskId: string) => void;
}
export const SftpModalUploadTasks: React.FC<SftpModalUploadTasksProps> = ({ tasks, t, onCancel, onCancelTask, onDismiss }) => {
if (tasks.length === 0) return null;
// Helper function to get localized display name for compressed uploads
const getDisplayName = (task: TransferTask) => {
// Check for explicit phase marker format: "folderName|phase"
// This is the format sent by uploadService.ts for compressed uploads
if (task.fileName.includes('|')) {
const pipeIndex = task.fileName.lastIndexOf('|');
const baseName = task.fileName.substring(0, pipeIndex);
const phase = task.fileName.substring(pipeIndex + 1);
if (phase === 'compressing' || phase === 'extracting' || phase === 'uploading' || phase === 'compressed') {
const phaseLabel = t(`sftp.upload.phase.${phase}`);
return `${baseName} (${phaseLabel})`;
}
}
// Check for exact matches of phase status strings (legacy support)
if (task.fileName === t('sftp.upload.compressing') || task.fileName === 'Compressing...' || task.fileName === 'Compressing') {
return t('sftp.upload.compressing');
}
if (task.fileName === t('sftp.upload.extracting') || task.fileName === 'Extracting...' || task.fileName === 'Extracting') {
return t('sftp.upload.extracting');
}
if (task.fileName === t('sftp.upload.scanning') || task.fileName === 'Scanning files...' || task.fileName === 'Scanning files') {
return t('sftp.upload.scanning');
}
// Check if this is a compressed upload task (legacy format)
if (task.fileName.includes('(compressed)')) {
const baseName = task.fileName.replace(' (compressed)', '');
return `${baseName} (${t('sftp.upload.compressed')})`;
}
return task.fileName;
};
return (
<div className="border-t border-border/60 bg-secondary/50 flex-shrink-0">
<div className="max-h-40 overflow-y-auto overflow-x-hidden">
{[...tasks].reverse().map((task) => {
const formatSpeed = (bytesPerSec: number) => {
if (bytesPerSec <= 0) return "";
if (bytesPerSec >= 1024 * 1024)
return `${(bytesPerSec / (1024 * 1024)).toFixed(1)} MB/s`;
if (bytesPerSec >= 1024)
return `${(bytesPerSec / 1024).toFixed(1)} KB/s`;
return `${Math.round(bytesPerSec)} B/s`;
};
const formatBytes = (bytes: number) => {
if (bytes === 0) return "0 B";
if (bytes >= 1024 * 1024)
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${bytes} B`;
};
const remainingBytes = task.totalBytes - task.transferredBytes;
const effectiveSpeed = task.speed > 0 ? task.speed : 0;
const remainingTime =
effectiveSpeed > 0 ? Math.ceil(remainingBytes / effectiveSpeed) : 0;
const remainingStr =
remainingTime > 60
? `~${Math.ceil(remainingTime / 60)}m left`
: remainingTime > 0
? `~${remainingTime}s left`
: "";
return (
<div
key={task.id}
className="px-4 py-2.5 flex items-center gap-3 border-b border-border/30 last:border-b-0"
>
<div className="shrink-0">
{(task.status === "uploading" || task.status === "downloading") && (
<Loader2 size={14} className="animate-spin text-primary" />
)}
{task.status === "pending" && (
task.direction === "download"
? <Download size={14} className="text-muted-foreground animate-pulse" />
: <Upload size={14} className="text-muted-foreground animate-pulse" />
)}
{task.status === "completed" && (
task.direction === "download"
? <Download size={14} className="text-green-500" />
: <Upload size={14} className="text-green-500" />
)}
{task.status === "failed" && (
<XCircle size={14} className="text-destructive" />
)}
{task.status === "cancelled" && (
<XCircle size={14} className="text-muted-foreground" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-medium truncate">
{getDisplayName(task)}
</span>
{(task.status === "uploading" || task.status === "downloading") && effectiveSpeed > 0 && (
<span className="text-[10px] text-primary font-mono shrink-0">
{formatSpeed(effectiveSpeed)}
</span>
)}
{(task.status === "uploading" || task.status === "downloading") && remainingStr && (
<span className="text-[10px] text-muted-foreground shrink-0">
{remainingStr}
</span>
)}
</div>
{(task.status === "uploading" || task.status === "downloading" || task.status === "pending") && (
<div className="mt-1.5 flex items-center gap-2">
<div className="flex-1 h-1.5 bg-secondary rounded-full overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all duration-150",
task.status === "pending"
? "bg-muted-foreground/50 animate-pulse w-full"
: "bg-primary",
)}
style={{
width:
task.status === "uploading" || task.status === "downloading"
? `${task.progress}%`
: undefined,
}}
/>
</div>
<span className="text-[10px] text-muted-foreground font-mono shrink-0 w-8 text-right">
{task.status === "uploading" || task.status === "downloading" ? `${Math.round(task.progress)}%` : "..."}
</span>
</div>
)}
{(task.status === "uploading" || task.status === "downloading") && task.totalBytes > 0 && (
<div className="text-[10px] text-muted-foreground mt-0.5 font-mono">
{formatBytes(task.transferredBytes)} / {formatBytes(task.totalBytes)}
</div>
)}
{task.status === "completed" && (
<div className="text-[10px] text-green-600 mt-0.5">
{t(task.direction === "download" ? "sftp.download.completed" : "sftp.upload.completed")} - {formatBytes(task.totalBytes)}
{task.targetPath && (
<span className="text-muted-foreground ml-1">→ {task.targetPath}</span>
)}
</div>
)}
{task.status === "cancelled" && (
<div className="text-[10px] text-muted-foreground mt-0.5">
{t(task.direction === "download" ? "sftp.download.cancelled" : "sftp.upload.cancelled")}
</div>
)}
{task.status === "failed" && task.error && (
<div className="text-[10px] text-destructive truncate mt-0.5">
{task.error}
</div>
)}
</div>
<div className="shrink-0 flex items-center gap-1">
{task.status === "pending" && (
<span className="text-[10px] text-muted-foreground">
{t("sftp.task.waiting")}
</span>
)}
{(task.status === "uploading" || task.status === "downloading" || task.status === "pending") && (onCancelTask || onCancel) && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive hover:text-destructive"
onClick={() => {
// For download tasks or when onCancelTask is available, use task-specific cancel
if (onCancelTask) {
onCancelTask(task.id);
} else if (onCancel) {
onCancel();
}
}}
title={t("sftp.action.cancel")}
>
<X size={12} />
</Button>
)}
{(task.status === "completed" || task.status === "failed" || task.status === "cancelled") && onDismiss && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-foreground"
onClick={() => onDismiss(task.id)}
title={t("sftp.action.dismiss")}
>
<X size={12} />
</Button>
)}
</div>
</div>
);
})}
</div>
</div>
);
};
-149
View File
@@ -1,149 +0,0 @@
import {
Database,
ExternalLink,
File,
FileArchive,
FileAudio,
FileCode,
FileImage,
FileSpreadsheet,
FileText,
FileType,
FileVideo,
Folder,
Globe,
Lock,
Settings,
Terminal,
} from "lucide-react";
import React from "react";
export const getFileIcon = (fileName: string, isDirectory: boolean, isSymlink?: boolean) => {
if (isDirectory)
return (
<Folder
size={18}
fill="currentColor"
fillOpacity={0.2}
className="text-blue-400"
/>
);
if (isSymlink) {
return <ExternalLink size={18} className="text-cyan-500" />;
}
const ext = fileName.split(".").pop()?.toLowerCase() || "";
if (["doc", "docx", "rtf", "odt"].includes(ext))
return <FileText size={18} className="text-blue-500" />;
if (["xls", "xlsx", "csv", "ods"].includes(ext))
return <FileSpreadsheet size={18} className="text-green-500" />;
if (["ppt", "pptx", "odp"].includes(ext))
return <FileType size={18} className="text-orange-500" />;
if (["pdf"].includes(ext))
return <FileText size={18} className="text-red-500" />;
if (["js", "jsx", "ts", "tsx", "mjs", "cjs"].includes(ext))
return <FileCode size={18} className="text-yellow-500" />;
if (["py", "pyc", "pyw"].includes(ext))
return <FileCode size={18} className="text-blue-400" />;
if (["sh", "bash", "zsh", "fish", "bat", "cmd", "ps1"].includes(ext))
return <Terminal size={18} className="text-green-400" />;
if (["c", "cpp", "h", "hpp", "cc", "cxx"].includes(ext))
return <FileCode size={18} className="text-blue-600" />;
if (["java", "class", "jar"].includes(ext))
return <FileCode size={18} className="text-orange-600" />;
if (["go"].includes(ext))
return <FileCode size={18} className="text-cyan-500" />;
if (["rs"].includes(ext))
return <FileCode size={18} className="text-orange-400" />;
if (["rb"].includes(ext))
return <FileCode size={18} className="text-red-400" />;
if (["php"].includes(ext))
return <FileCode size={18} className="text-purple-500" />;
if (["html", "htm", "xhtml"].includes(ext))
return <Globe size={18} className="text-orange-500" />;
if (["css", "scss", "sass", "less"].includes(ext))
return <FileCode size={18} className="text-blue-500" />;
if (["vue", "svelte"].includes(ext))
return <FileCode size={18} className="text-green-500" />;
if (["json", "json5"].includes(ext))
return <FileCode size={18} className="text-yellow-600" />;
if (["xml", "xsl", "xslt"].includes(ext))
return <FileCode size={18} className="text-orange-400" />;
if (["yml", "yaml"].includes(ext))
return <Settings size={18} className="text-pink-400" />;
if (["toml", "ini", "conf", "cfg", "config"].includes(ext))
return <Settings size={18} className="text-gray-400" />;
if (["env"].includes(ext))
return <Lock size={18} className="text-yellow-500" />;
if (["sql", "sqlite", "db"].includes(ext))
return <Database size={18} className="text-blue-400" />;
if (
[
"jpg",
"jpeg",
"png",
"gif",
"bmp",
"webp",
"svg",
"ico",
"tiff",
"tif",
"heic",
"heif",
"avif",
].includes(ext)
)
return <FileImage size={18} className="text-purple-400" />;
if (
[
"mp4",
"mkv",
"avi",
"mov",
"wmv",
"flv",
"webm",
"m4v",
"3gp",
"mpeg",
"mpg",
].includes(ext)
)
return <FileVideo size={18} className="text-pink-500" />;
if (
["mp3", "wav", "flac", "aac", "ogg", "m4a", "wma", "opus", "aiff"].includes(
ext,
)
)
return <FileAudio size={18} className="text-green-400" />;
if (
[
"zip",
"rar",
"7z",
"tar",
"gz",
"bz2",
"xz",
"tgz",
"tbz2",
"lz",
"lzma",
"cab",
"iso",
"dmg",
].includes(ext)
)
return <FileArchive size={18} className="text-yellow-600" />;
return <File size={18} className="text-muted-foreground" />;
};
@@ -1,140 +0,0 @@
import { useCallback, useState } from "react";
import type { RemoteFile } from "../../../types";
import { toast } from "../../ui/toast";
interface UseSftpModalCreateDeleteParams {
currentPath: string;
isLocalSession: boolean;
joinPath: (base: string, name: string) => string;
ensureSftp: () => Promise<string>;
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
deleteLocalFile: (path: string) => Promise<void>;
deleteSftp: (sftpId: string, path: string) => Promise<void>;
mkdirLocal: (path: string) => Promise<void>;
mkdirSftp: (sftpId: string, path: string) => Promise<void>;
writeLocalFile: (path: string, data: ArrayBuffer) => Promise<void>;
writeSftpBinary: (sftpId: string, path: string, data: ArrayBuffer) => Promise<void>;
writeSftp: (sftpId: string, path: string, data: string) => Promise<void>;
t: (key: string, params?: Record<string, unknown>) => string;
}
interface UseSftpModalCreateDeleteResult {
handleDelete: (file: RemoteFile) => Promise<void>;
handleCreateFolder: () => void;
handleCreateFile: () => void;
// Create dialog state
showCreateDialog: boolean;
setShowCreateDialog: (open: boolean) => void;
createType: "file" | "folder";
createName: string;
setCreateName: (value: string) => void;
isCreating: boolean;
handleCreateSubmit: () => Promise<void>;
}
export const useSftpModalCreateDelete = ({
currentPath,
isLocalSession,
joinPath,
ensureSftp,
loadFiles,
deleteLocalFile,
deleteSftp,
mkdirLocal,
mkdirSftp,
writeLocalFile,
writeSftpBinary,
writeSftp,
t,
}: UseSftpModalCreateDeleteParams): UseSftpModalCreateDeleteResult => {
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [createType, setCreateType] = useState<"file" | "folder">("folder");
const [createName, setCreateName] = useState("");
const [isCreating, setIsCreating] = useState(false);
const handleDelete = useCallback(
async (file: RemoteFile) => {
if (file.name === "..") return;
if (!confirm(t("sftp.deleteConfirm.single", { name: file.name }))) return;
try {
const fullPath = joinPath(currentPath, file.name);
if (isLocalSession) {
await deleteLocalFile(fullPath);
} else {
await deleteSftp(await ensureSftp(), fullPath);
}
await loadFiles(currentPath, { force: true });
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.deleteFailed"),
"SFTP",
);
}
},
[currentPath, deleteLocalFile, deleteSftp, ensureSftp, isLocalSession, joinPath, loadFiles, t],
);
const handleCreateFolder = useCallback(() => {
setCreateType("folder");
setCreateName("");
setShowCreateDialog(true);
}, []);
const handleCreateFile = useCallback(() => {
setCreateType("file");
setCreateName("");
setShowCreateDialog(true);
}, []);
const handleCreateSubmit = useCallback(async () => {
const name = createName.trim();
if (!name || isCreating) return;
setIsCreating(true);
try {
const fullPath = joinPath(currentPath, name);
if (createType === "folder") {
if (isLocalSession) {
await mkdirLocal(fullPath);
} else {
await mkdirSftp(await ensureSftp(), fullPath);
}
} else {
if (isLocalSession) {
await writeLocalFile(fullPath, new ArrayBuffer(0));
} else {
try {
await writeSftpBinary(await ensureSftp(), fullPath, new ArrayBuffer(0));
} catch {
await writeSftp(await ensureSftp(), fullPath, "");
}
}
}
setShowCreateDialog(false);
setCreateName("");
await loadFiles(currentPath, { force: true });
} catch (e) {
toast.error(
e instanceof Error
? e.message
: t(createType === "folder" ? "sftp.error.createFolderFailed" : "sftp.error.createFileFailed"),
"SFTP",
);
} finally {
setIsCreating(false);
}
}, [createName, createType, currentPath, ensureSftp, isCreating, isLocalSession, joinPath, loadFiles, mkdirLocal, mkdirSftp, t, writeLocalFile, writeSftp, writeSftpBinary]);
return {
handleDelete,
handleCreateFolder,
handleCreateFile,
showCreateDialog,
setShowCreateDialog,
createType,
createName,
setCreateName,
isCreating,
handleCreateSubmit,
};
};
@@ -1,277 +0,0 @@
import type { RemoteFile } from "../../../types";
import { useSftpModalCreateDelete } from "./useSftpModalCreateDelete";
import { useSftpModalRename } from "./useSftpModalRename";
import { useSftpModalPermissions } from "./useSftpModalPermissions";
import { useSftpModalTextEditor } from "./useSftpModalTextEditor";
import { useSftpModalFileOpener } from "./useSftpModalFileOpener";
import type { FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
interface UseSftpModalFileActionsParams {
currentPath: string;
isLocalSession: boolean;
joinPath: (base: string, name: string) => string;
ensureSftp: () => Promise<string>;
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
readLocalFile: (path: string) => Promise<ArrayBuffer>;
readSftp: (sftpId: string, path: string) => Promise<string>;
writeLocalFile: (path: string, data: ArrayBuffer) => Promise<void>;
writeSftp: (sftpId: string, path: string, data: string) => Promise<void>;
writeSftpBinary: (sftpId: string, path: string, data: ArrayBuffer) => Promise<void>;
deleteLocalFile: (path: string) => Promise<void>;
deleteSftp: (sftpId: string, path: string) => Promise<void>;
mkdirLocal: (path: string) => Promise<void>;
mkdirSftp: (sftpId: string, path: string) => Promise<void>;
renameSftp: (sftpId: string, oldPath: string, newPath: string) => Promise<void>;
chmodSftp: (sftpId: string, path: string, permissions: string) => Promise<void>;
statSftp: (sftpId: string, path: string) => Promise<{ permissions?: string }>;
t: (key: string, params?: Record<string, unknown>) => string;
sftpAutoSync: boolean;
getOpenerForFile: (name: string) => { openerType: FileOpenerType; systemApp?: SystemAppInfo } | null;
setOpenerForExtension: (ext: string, openerType: FileOpenerType, systemApp?: SystemAppInfo) => void;
downloadSftpToTempAndOpen: (sftpId: string, path: string, fileName: string, appPath: string, opts: { enableWatch: boolean }) => Promise<void>;
selectApplication: () => Promise<{ path: string; name: string } | null>;
}
interface UseSftpModalFileActionsResult {
handleDelete: (file: RemoteFile) => Promise<void>;
handleCreateFolder: () => void;
handleCreateFile: () => void;
showCreateDialog: boolean;
setShowCreateDialog: (open: boolean) => void;
createType: "file" | "folder";
createName: string;
setCreateName: (value: string) => void;
isCreating: boolean;
handleCreateSubmit: () => Promise<void>;
showRenameDialog: boolean;
setShowRenameDialog: (open: boolean) => void;
renameTarget: RemoteFile | null;
renameName: string;
setRenameName: (value: string) => void;
isRenaming: boolean;
openRenameDialog: (file: RemoteFile) => void;
handleRename: () => Promise<void>;
showPermissionsDialog: boolean;
setShowPermissionsDialog: (open: boolean) => void;
permissionsTarget: RemoteFile | null;
permissions: {
owner: { read: boolean; write: boolean; execute: boolean };
group: { read: boolean; write: boolean; execute: boolean };
others: { read: boolean; write: boolean; execute: boolean };
};
isChangingPermissions: boolean;
openPermissionsDialog: (file: RemoteFile) => Promise<void>;
togglePermission: (role: "owner" | "group" | "others", perm: "read" | "write" | "execute") => void;
getOctalPermissions: () => string;
getSymbolicPermissions: () => string;
handleSavePermissions: () => Promise<void>;
showFileOpenerDialog: boolean;
setShowFileOpenerDialog: (open: boolean) => void;
fileOpenerTarget: RemoteFile | null;
setFileOpenerTarget: (target: RemoteFile | null) => void;
openFileOpenerDialog: (file: RemoteFile) => void;
handleFileOpenerSelect: (
openerType: FileOpenerType,
setAsDefault: boolean,
systemApp?: SystemAppInfo,
) => Promise<void>;
handleSelectSystemApp: () => Promise<SystemAppInfo | null>;
showTextEditor: boolean;
setShowTextEditor: (open: boolean) => void;
textEditorTarget: RemoteFile | null;
setTextEditorTarget: (target: RemoteFile | null) => void;
textEditorContent: string;
setTextEditorContent: (value: string) => void;
loadingTextContent: boolean;
handleEditFile: (file: RemoteFile) => Promise<void>;
handleSaveTextFile: (content: string) => Promise<void>;
handleOpenFile: (file: RemoteFile) => Promise<void>;
}
export const useSftpModalFileActions = ({
currentPath,
isLocalSession,
joinPath,
ensureSftp,
loadFiles,
readLocalFile,
readSftp,
writeLocalFile,
writeSftp,
writeSftpBinary,
deleteLocalFile,
deleteSftp,
mkdirLocal,
mkdirSftp,
renameSftp,
chmodSftp,
statSftp,
t,
sftpAutoSync,
getOpenerForFile,
setOpenerForExtension,
downloadSftpToTempAndOpen,
selectApplication,
}: UseSftpModalFileActionsParams): UseSftpModalFileActionsResult => {
const {
handleDelete,
handleCreateFolder,
handleCreateFile,
showCreateDialog,
setShowCreateDialog,
createType,
createName,
setCreateName,
isCreating,
handleCreateSubmit,
} =
useSftpModalCreateDelete({
currentPath,
isLocalSession,
joinPath,
ensureSftp,
loadFiles,
deleteLocalFile,
deleteSftp,
mkdirLocal,
mkdirSftp,
writeLocalFile,
writeSftpBinary,
writeSftp,
t,
});
const {
showRenameDialog,
setShowRenameDialog,
renameTarget,
renameName,
setRenameName,
isRenaming,
openRenameDialog,
handleRename,
} = useSftpModalRename({
currentPath,
isLocalSession,
joinPath,
ensureSftp,
loadFiles,
renameSftp,
t,
});
const {
showPermissionsDialog,
setShowPermissionsDialog,
permissionsTarget,
permissions,
isChangingPermissions,
openPermissionsDialog,
togglePermission,
getOctalPermissions,
getSymbolicPermissions,
handleSavePermissions,
} = useSftpModalPermissions({
currentPath,
isLocalSession,
joinPath,
ensureSftp,
loadFiles,
chmodSftp,
statSftp,
t,
});
const {
showTextEditor,
setShowTextEditor,
textEditorTarget,
setTextEditorTarget,
textEditorContent,
setTextEditorContent,
loadingTextContent,
handleEditFile,
handleSaveTextFile,
} = useSftpModalTextEditor({
currentPath,
isLocalSession,
joinPath,
ensureSftp,
readLocalFile,
readSftp,
writeLocalFile,
writeSftp,
t,
});
const {
showFileOpenerDialog,
setShowFileOpenerDialog,
fileOpenerTarget,
setFileOpenerTarget,
openFileOpenerDialog,
handleOpenFile,
handleFileOpenerSelect,
handleSelectSystemApp,
} = useSftpModalFileOpener({
currentPath,
isLocalSession,
joinPath,
ensureSftp,
sftpAutoSync,
getOpenerForFile,
setOpenerForExtension,
downloadSftpToTempAndOpen,
selectApplication,
t,
handleEditFile,
});
return {
handleDelete,
handleCreateFolder,
handleCreateFile,
showCreateDialog,
setShowCreateDialog,
createType,
createName,
setCreateName,
isCreating,
handleCreateSubmit,
showRenameDialog,
setShowRenameDialog,
renameTarget,
renameName,
setRenameName,
isRenaming,
openRenameDialog,
handleRename,
showPermissionsDialog,
setShowPermissionsDialog,
permissionsTarget,
permissions,
isChangingPermissions,
openPermissionsDialog,
togglePermission,
getOctalPermissions,
getSymbolicPermissions,
handleSavePermissions,
showFileOpenerDialog,
setShowFileOpenerDialog,
fileOpenerTarget,
setFileOpenerTarget,
openFileOpenerDialog,
handleFileOpenerSelect,
handleSelectSystemApp,
showTextEditor,
setShowTextEditor,
textEditorTarget,
setTextEditorTarget,
textEditorContent,
setTextEditorContent,
loadingTextContent,
handleEditFile,
handleSaveTextFile,
handleOpenFile,
};
};
@@ -1,154 +0,0 @@
import { useCallback, useState } from "react";
import type { RemoteFile } from "../../../types";
import { toast } from "../../ui/toast";
import { getFileExtension, FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
interface UseSftpModalFileOpenerParams {
currentPath: string;
isLocalSession: boolean;
joinPath: (base: string, name: string) => string;
ensureSftp: () => Promise<string>;
sftpAutoSync: boolean;
getOpenerForFile: (name: string) => { openerType: FileOpenerType; systemApp?: SystemAppInfo } | null;
setOpenerForExtension: (ext: string, openerType: FileOpenerType, systemApp?: SystemAppInfo) => void;
downloadSftpToTempAndOpen: (sftpId: string, path: string, fileName: string, appPath: string, opts: { enableWatch: boolean }) => Promise<void>;
selectApplication: () => Promise<{ path: string; name: string } | null>;
t: (key: string, params?: Record<string, unknown>) => string;
handleEditFile: (file: RemoteFile) => Promise<void>;
}
interface UseSftpModalFileOpenerResult {
showFileOpenerDialog: boolean;
setShowFileOpenerDialog: (open: boolean) => void;
fileOpenerTarget: RemoteFile | null;
setFileOpenerTarget: (target: RemoteFile | null) => void;
openFileOpenerDialog: (file: RemoteFile) => void;
handleOpenFile: (file: RemoteFile) => Promise<void>;
handleFileOpenerSelect: (
openerType: FileOpenerType,
setAsDefault: boolean,
systemApp?: SystemAppInfo,
) => Promise<void>;
handleSelectSystemApp: () => Promise<SystemAppInfo | null>;
}
export const useSftpModalFileOpener = ({
currentPath,
isLocalSession,
joinPath,
ensureSftp,
sftpAutoSync,
getOpenerForFile,
setOpenerForExtension,
downloadSftpToTempAndOpen,
selectApplication,
t,
handleEditFile,
}: UseSftpModalFileOpenerParams): UseSftpModalFileOpenerResult => {
const [showFileOpenerDialog, setShowFileOpenerDialog] = useState(false);
const [fileOpenerTarget, setFileOpenerTarget] = useState<RemoteFile | null>(null);
const openFileOpenerDialog = useCallback((file: RemoteFile) => {
setFileOpenerTarget(file);
setShowFileOpenerDialog(true);
}, []);
const handleOpenFile = useCallback(async (file: RemoteFile) => {
const savedOpener = getOpenerForFile(file.name);
if (savedOpener) {
if (savedOpener.openerType === "builtin-editor") {
await handleEditFile(file);
} else if (savedOpener.openerType === "system-app" && savedOpener.systemApp) {
try {
const fullPath = joinPath(currentPath, file.name);
if (isLocalSession) {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
if (bridge?.openWithApplication) {
await bridge.openWithApplication(fullPath, savedOpener.systemApp.path);
}
} else {
const sftpId = await ensureSftp();
await downloadSftpToTempAndOpen(
sftpId,
fullPath,
file.name,
savedOpener.systemApp.path,
{ enableWatch: sftpAutoSync },
);
}
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.openFailed"),
"SFTP",
);
}
}
} else {
openFileOpenerDialog(file);
}
}, [currentPath, downloadSftpToTempAndOpen, ensureSftp, getOpenerForFile, handleEditFile, isLocalSession, joinPath, openFileOpenerDialog, sftpAutoSync, t]);
const handleFileOpenerSelect = useCallback(
async (openerType: FileOpenerType, setAsDefault: boolean, systemApp?: SystemAppInfo) => {
if (!fileOpenerTarget) return;
if (setAsDefault) {
const ext = getFileExtension(fileOpenerTarget.name);
setOpenerForExtension(ext, openerType, systemApp);
}
setShowFileOpenerDialog(false);
if (openerType === "builtin-editor") {
await handleEditFile(fileOpenerTarget);
} else if (openerType === "system-app" && systemApp) {
try {
const fullPath = joinPath(currentPath, fileOpenerTarget.name);
if (isLocalSession) {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
if (bridge?.openWithApplication) {
await bridge.openWithApplication(fullPath, systemApp.path);
}
} else {
const sftpId = await ensureSftp();
await downloadSftpToTempAndOpen(
sftpId,
fullPath,
fileOpenerTarget.name,
systemApp.path,
{ enableWatch: sftpAutoSync },
);
}
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.openFailed"),
"SFTP",
);
}
}
setFileOpenerTarget(null);
},
[currentPath, downloadSftpToTempAndOpen, ensureSftp, fileOpenerTarget, handleEditFile, isLocalSession, joinPath, sftpAutoSync, setOpenerForExtension, t],
);
const handleSelectSystemApp = useCallback(async (): Promise<SystemAppInfo | null> => {
const result = await selectApplication();
if (result) {
return { path: result.path, name: result.name };
}
return null;
}, [selectApplication]);
return {
showFileOpenerDialog,
setShowFileOpenerDialog,
fileOpenerTarget,
setFileOpenerTarget,
openFileOpenerDialog,
handleOpenFile,
handleFileOpenerSelect,
handleSelectSystemApp,
};
};
@@ -1,156 +0,0 @@
/**
* useSftpModalKeyboardShortcuts
*
* Hook that handles keyboard shortcuts for SFTPModal operations.
* Supports select all, rename, delete, refresh, and new folder.
* Note: Copy/Cut/Paste are not supported in the modal as it's a single-pane view.
*/
import { useCallback, useEffect } from "react";
import { KeyBinding, matchesKeyBinding } from "../../../domain/models";
import type { RemoteFile } from "../../../types";
// SFTP Modal action names that we handle (subset of main SFTP actions)
const SFTP_MODAL_ACTIONS = new Set([
"sftpSelectAll",
"sftpRename",
"sftpDelete",
"sftpRefresh",
"sftpNewFolder",
]);
interface UseSftpModalKeyboardShortcutsParams {
keyBindings: KeyBinding[];
hotkeyScheme: "disabled" | "mac" | "pc";
open: boolean;
files: RemoteFile[];
visibleFiles: RemoteFile[];
selectedFiles: Set<string>;
setSelectedFiles: (files: Set<string>) => void;
onRefresh: () => void;
onRename?: (file: RemoteFile) => void;
onDelete?: (fileNames: string[]) => void;
onNewFolder?: () => void;
}
/**
* Check if a keyboard event matches any SFTP action
*/
const matchSftpAction = (
e: KeyboardEvent,
keyBindings: KeyBinding[],
isMac: boolean
): { action: string; binding: KeyBinding } | null => {
for (const binding of keyBindings) {
if (binding.category !== "sftp") continue;
const keyStr = isMac ? binding.mac : binding.pc;
if (matchesKeyBinding(e, keyStr, isMac)) {
return { action: binding.action, binding };
}
}
return null;
};
export const useSftpModalKeyboardShortcuts = ({
keyBindings,
hotkeyScheme,
open,
files,
visibleFiles,
selectedFiles,
setSelectedFiles,
onRefresh,
onRename,
onDelete,
onNewFolder,
}: UseSftpModalKeyboardShortcutsParams) => {
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
// Skip if shortcuts are disabled or modal is not open
if (hotkeyScheme === "disabled" || !open) return;
// Skip if focus is on an input element
const target = e.target as HTMLElement;
const isEditableTarget =
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable ||
!!target.closest?.(".monaco-editor, .monaco-diff-editor, .monaco-inputbox");
if (isEditableTarget) {
return;
}
const isMac = hotkeyScheme === "mac";
const matched = matchSftpAction(e, keyBindings, isMac);
if (!matched) return;
const { action } = matched;
if (!SFTP_MODAL_ACTIONS.has(action)) return;
// Prevent default behavior
e.preventDefault();
e.stopPropagation();
switch (action) {
case "sftpSelectAll": {
// Select all files
const allFileNames = new Set(
visibleFiles.filter((f) => f.name !== "..").map((f) => f.name)
);
setSelectedFiles(allFileNames);
break;
}
case "sftpRename": {
// Trigger rename for the first selected file
const selectedArray = Array.from(selectedFiles);
if (selectedArray.length !== 1) return;
const file = files.find((f) => f.name === selectedArray[0]);
if (file && onRename) {
onRename(file);
}
break;
}
case "sftpDelete": {
// Delete selected files
const selectedArray = Array.from(selectedFiles);
if (selectedArray.length === 0) return;
onDelete?.(selectedArray);
break;
}
case "sftpRefresh": {
// Refresh file list
onRefresh();
break;
}
case "sftpNewFolder": {
// Create new folder
onNewFolder?.();
break;
}
}
},
[
hotkeyScheme,
open,
files,
visibleFiles,
selectedFiles,
setSelectedFiles,
onRefresh,
onRename,
onDelete,
onNewFolder,
keyBindings,
]
);
useEffect(() => {
// Use capture phase to intercept before other handlers
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
}, [handleKeyDown]);
};
@@ -1,135 +0,0 @@
import React, { useCallback, useMemo, useRef, useState } from "react";
import { breadcrumbPathAt, getBreadcrumbs, getRootPath, getWindowsDrive, isWindowsPath } from "../pathUtils";
interface UseSftpModalPathParams {
currentPath: string;
isLocalSession: boolean;
localHomePath: string | null;
onNavigate: (path: string) => void;
maxVisibleBreadcrumbParts?: number;
}
interface UseSftpModalPathResult {
isEditingPath: boolean;
editingPathValue: string;
setEditingPathValue: (value: string) => void;
pathInputRef: React.RefObject<HTMLInputElement>;
handlePathDoubleClick: () => void;
handlePathSubmit: () => void;
handlePathKeyDown: (e: React.KeyboardEvent) => void;
breadcrumbs: string[];
visibleBreadcrumbs: { part: string; originalIndex: number }[];
hiddenBreadcrumbs: { part: string; originalIndex: number }[];
needsBreadcrumbTruncation: boolean;
breadcrumbPathAtForIndex: (index: number) => string;
rootLabel: string;
rootPath: string;
}
export const useSftpModalPath = ({
currentPath,
isLocalSession,
localHomePath,
onNavigate,
maxVisibleBreadcrumbParts = 4,
}: UseSftpModalPathParams): UseSftpModalPathResult => {
const [isEditingPath, setIsEditingPath] = useState(false);
const [editingPathValue, setEditingPathValue] = useState("");
const pathInputRef = useRef<HTMLInputElement>(null);
const handlePathDoubleClick = () => {
setEditingPathValue(currentPath);
setIsEditingPath(true);
setTimeout(() => pathInputRef.current?.select(), 0);
};
const handlePathSubmit = () => {
const fallbackPath = localHomePath || getRootPath(currentPath, isLocalSession);
const newPath = editingPathValue.trim() || fallbackPath;
setIsEditingPath(false);
if (newPath !== currentPath) {
if (isLocalSession) {
onNavigate(newPath);
} else {
onNavigate(newPath.startsWith("/") ? newPath : `/${newPath}`);
}
}
};
const handlePathKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
handlePathSubmit();
} else if (e.key === "Escape") {
setIsEditingPath(false);
}
};
const breadcrumbs = useMemo(
() => getBreadcrumbs(currentPath, isLocalSession),
[currentPath, isLocalSession],
);
const { visibleBreadcrumbs, hiddenBreadcrumbs, needsBreadcrumbTruncation } =
useMemo(() => {
if (breadcrumbs.length <= maxVisibleBreadcrumbParts) {
return {
visibleBreadcrumbs: breadcrumbs.map((part, idx) => ({ part, originalIndex: idx })),
hiddenBreadcrumbs: [] as { part: string; originalIndex: number }[],
needsBreadcrumbTruncation: false,
};
}
const firstPart = [{ part: breadcrumbs[0], originalIndex: 0 }];
const lastPartsCount = maxVisibleBreadcrumbParts - 1;
const lastParts = breadcrumbs.slice(-lastPartsCount).map((part, idx) => ({
part,
originalIndex: breadcrumbs.length - lastPartsCount + idx,
}));
const hidden = breadcrumbs.slice(1, -lastPartsCount).map((part, idx) => ({
part,
originalIndex: idx + 1,
}));
return {
visibleBreadcrumbs: [...firstPart, ...lastParts],
hiddenBreadcrumbs: hidden,
needsBreadcrumbTruncation: true,
};
}, [breadcrumbs, maxVisibleBreadcrumbParts]);
const breadcrumbPathAtForIndex = useCallback(
(index: number) =>
breadcrumbPathAt(breadcrumbs, index, currentPath, isLocalSession),
[breadcrumbs, currentPath, isLocalSession],
);
const rootLabel = useMemo(
() =>
isLocalSession && isWindowsPath(currentPath)
? getWindowsDrive(currentPath) ?? "C:"
: "/",
[currentPath, isLocalSession],
);
const rootPath = useMemo(
() => getRootPath(currentPath, isLocalSession),
[currentPath, isLocalSession],
);
return {
isEditingPath,
editingPathValue,
setEditingPathValue,
pathInputRef,
handlePathDoubleClick,
handlePathSubmit,
handlePathKeyDown,
breadcrumbs,
visibleBreadcrumbs,
hiddenBreadcrumbs,
needsBreadcrumbTruncation,
breadcrumbPathAtForIndex,
rootLabel,
rootPath,
};
};
@@ -1,189 +0,0 @@
import { useCallback, useState } from "react";
import type { RemoteFile } from "../../../types";
import { toast } from "../../ui/toast";
interface UseSftpModalPermissionsParams {
currentPath: string;
isLocalSession: boolean;
joinPath: (base: string, name: string) => string;
ensureSftp: () => Promise<string>;
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
chmodSftp: (sftpId: string, path: string, permissions: string) => Promise<void>;
statSftp: (sftpId: string, path: string) => Promise<{ permissions?: string }>;
t: (key: string, params?: Record<string, unknown>) => string;
}
interface PermissionsState {
owner: { read: boolean; write: boolean; execute: boolean };
group: { read: boolean; write: boolean; execute: boolean };
others: { read: boolean; write: boolean; execute: boolean };
}
interface UseSftpModalPermissionsResult {
showPermissionsDialog: boolean;
setShowPermissionsDialog: (open: boolean) => void;
permissionsTarget: RemoteFile | null;
permissions: PermissionsState;
isChangingPermissions: boolean;
openPermissionsDialog: (file: RemoteFile) => Promise<void>;
togglePermission: (role: "owner" | "group" | "others", perm: "read" | "write" | "execute") => void;
getOctalPermissions: () => string;
getSymbolicPermissions: () => string;
handleSavePermissions: () => Promise<void>;
}
export const useSftpModalPermissions = ({
currentPath,
isLocalSession,
joinPath,
ensureSftp,
loadFiles,
chmodSftp,
statSftp,
t,
}: UseSftpModalPermissionsParams): UseSftpModalPermissionsResult => {
const [showPermissionsDialog, setShowPermissionsDialog] = useState(false);
const [permissionsTarget, setPermissionsTarget] = useState<RemoteFile | null>(null);
const [permissions, setPermissions] = useState<PermissionsState>({
owner: { read: false, write: false, execute: false },
group: { read: false, write: false, execute: false },
others: { read: false, write: false, execute: false },
});
const [isChangingPermissions, setIsChangingPermissions] = useState(false);
const parsePermissions = useCallback((perms: string | undefined) => {
const defaultPerms = {
owner: { read: false, write: false, execute: false },
group: { read: false, write: false, execute: false },
others: { read: false, write: false, execute: false },
};
if (!perms) return defaultPerms;
if (/^[0-7]{3,4}$/.test(perms)) {
const octal = perms.length === 4 ? perms.slice(1) : perms;
const ownerBits = parseInt(octal[0], 10);
const groupBits = parseInt(octal[1], 10);
const othersBits = parseInt(octal[2], 10);
return {
owner: {
read: (ownerBits & 4) !== 0,
write: (ownerBits & 2) !== 0,
execute: (ownerBits & 1) !== 0,
},
group: {
read: (groupBits & 4) !== 0,
write: (groupBits & 2) !== 0,
execute: (groupBits & 1) !== 0,
},
others: {
read: (othersBits & 4) !== 0,
write: (othersBits & 2) !== 0,
execute: (othersBits & 1) !== 0,
},
};
}
const pStr = perms.length === 10 ? perms.slice(1) : perms;
if (pStr.length >= 9) {
return {
owner: {
read: pStr[0] === "r",
write: pStr[1] === "w",
execute: pStr[2] === "x" || pStr[2] === "s",
},
group: {
read: pStr[3] === "r",
write: pStr[4] === "w",
execute: pStr[5] === "x" || pStr[5] === "s",
},
others: {
read: pStr[6] === "r",
write: pStr[7] === "w",
execute: pStr[8] === "x" || pStr[8] === "t",
},
};
}
return defaultPerms;
}, []);
const openPermissionsDialog = useCallback(async (file: RemoteFile) => {
if (isLocalSession) {
toast.error("Permissions not available for local files", "SFTP");
return;
}
setPermissionsTarget(file);
let permsStr = file.permissions;
try {
const fullPath = joinPath(currentPath, file.name);
const stat = await statSftp(await ensureSftp(), fullPath);
if (stat.permissions) {
permsStr = stat.permissions;
}
} catch (e) {
console.warn("Failed to fetch file permissions:", e);
}
setPermissions(parsePermissions(permsStr));
setShowPermissionsDialog(true);
}, [currentPath, ensureSftp, isLocalSession, joinPath, parsePermissions, statSftp]);
const togglePermission = useCallback(
(role: "owner" | "group" | "others", perm: "read" | "write" | "execute") => {
setPermissions((prev) => ({
...prev,
[role]: { ...prev[role], [perm]: !prev[role][perm] },
}));
},
[],
);
const getOctalPermissions = useCallback(() => {
const getNum = (p: { read: boolean; write: boolean; execute: boolean }) =>
(p.read ? 4 : 0) + (p.write ? 2 : 0) + (p.execute ? 1 : 0);
return `${getNum(permissions.owner)}${getNum(permissions.group)}${getNum(permissions.others)}`;
}, [permissions]);
const getSymbolicPermissions = useCallback(() => {
const getSym = (p: { read: boolean; write: boolean; execute: boolean }) =>
`${p.read ? "r" : "-"}${p.write ? "w" : "-"}${p.execute ? "x" : "-"}`;
return (
getSym(permissions.owner) +
getSym(permissions.group) +
getSym(permissions.others)
);
}, [permissions]);
const handleSavePermissions = useCallback(async () => {
if (!permissionsTarget || isChangingPermissions) return;
setIsChangingPermissions(true);
try {
const fullPath = joinPath(currentPath, permissionsTarget.name);
await chmodSftp(await ensureSftp(), fullPath, getOctalPermissions());
setShowPermissionsDialog(false);
setPermissionsTarget(null);
await loadFiles(currentPath, { force: true });
toast.success(t("sftp.permissions.success"), "SFTP");
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.permissions.failed"),
"SFTP",
);
} finally {
setIsChangingPermissions(false);
}
}, [chmodSftp, currentPath, ensureSftp, getOctalPermissions, isChangingPermissions, joinPath, loadFiles, permissionsTarget, t]);
return {
showPermissionsDialog,
setShowPermissionsDialog,
permissionsTarget,
permissions,
isChangingPermissions,
openPermissionsDialog,
togglePermission,
getOctalPermissions,
getSymbolicPermissions,
handleSavePermissions,
};
};
@@ -1,85 +0,0 @@
import { useCallback, useState } from "react";
import type { RemoteFile } from "../../../types";
import { toast } from "../../ui/toast";
interface UseSftpModalRenameParams {
currentPath: string;
isLocalSession: boolean;
joinPath: (base: string, name: string) => string;
ensureSftp: () => Promise<string>;
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
renameSftp: (sftpId: string, oldPath: string, newPath: string) => Promise<void>;
t: (key: string, params?: Record<string, unknown>) => string;
}
interface UseSftpModalRenameResult {
showRenameDialog: boolean;
setShowRenameDialog: (open: boolean) => void;
renameTarget: RemoteFile | null;
renameName: string;
setRenameName: (value: string) => void;
isRenaming: boolean;
openRenameDialog: (file: RemoteFile) => void;
handleRename: () => Promise<void>;
}
export const useSftpModalRename = ({
currentPath,
isLocalSession,
joinPath,
ensureSftp,
loadFiles,
renameSftp,
t,
}: UseSftpModalRenameParams): UseSftpModalRenameResult => {
const [showRenameDialog, setShowRenameDialog] = useState(false);
const [renameTarget, setRenameTarget] = useState<RemoteFile | null>(null);
const [renameName, setRenameName] = useState("");
const [isRenaming, setIsRenaming] = useState(false);
const openRenameDialog = useCallback((file: RemoteFile) => {
setRenameTarget(file);
setRenameName(file.name);
setShowRenameDialog(true);
}, []);
const handleRename = useCallback(async () => {
if (!renameTarget || !renameName.trim() || isRenaming) return;
if (renameName.trim() === renameTarget.name) {
setShowRenameDialog(false);
return;
}
setIsRenaming(true);
try {
const oldPath = joinPath(currentPath, renameTarget.name);
const newPath = joinPath(currentPath, renameName.trim());
if (isLocalSession) {
toast.error("Local rename not implemented", "SFTP");
} else {
await renameSftp(await ensureSftp(), oldPath, newPath);
}
setShowRenameDialog(false);
setRenameTarget(null);
setRenameName("");
await loadFiles(currentPath, { force: true });
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.renameFailed"),
"SFTP",
);
} finally {
setIsRenaming(false);
}
}, [currentPath, ensureSftp, isLocalSession, joinPath, loadFiles, renameName, renameSftp, renameTarget, t, isRenaming]);
return {
showRenameDialog,
setShowRenameDialog,
renameTarget,
renameName,
setRenameName,
isRenaming,
openRenameDialog,
handleRename,
};
};
@@ -1,99 +0,0 @@
import React, { useCallback, useRef } from "react";
import type { RemoteFile } from "../../../types";
interface UseSftpModalSelectionParams {
files: RemoteFile[];
setSelectedFiles: (value: Set<string> | ((prev: Set<string>) => Set<string>)) => void;
currentPath: string;
joinPath: (base: string, name: string) => string;
onNavigate: (path: string) => void;
onOpenFile: (file: RemoteFile) => void;
onNavigateUp: () => void;
}
interface UseSftpModalSelectionResult {
handleFileClick: (file: RemoteFile, index: number, e: React.MouseEvent) => void;
handleFileDoubleClick: (file: RemoteFile) => void;
}
export const useSftpModalSelection = ({
files,
setSelectedFiles,
currentPath,
joinPath,
onNavigate,
onOpenFile,
onNavigateUp,
}: UseSftpModalSelectionParams): UseSftpModalSelectionResult => {
const lastSelectedIndexRef = useRef<number | null>(null);
const handleFileClick = useCallback(
(file: RemoteFile, index: number, e: React.MouseEvent) => {
if (file.name === "..") return;
if (file.type === "directory") {
if (e.shiftKey && lastSelectedIndexRef.current !== null) {
const start = Math.min(lastSelectedIndexRef.current, index);
const end = Math.max(lastSelectedIndexRef.current, index);
const newSelection = new Set<string>();
for (let i = start; i <= end; i++) {
if (files[i] && files[i].type !== "directory") {
newSelection.add(files[i].name);
}
}
setSelectedFiles(newSelection);
} else if (e.ctrlKey || e.metaKey) {
setSelectedFiles((prev) => {
const next = new Set(prev);
return next;
});
}
return;
}
if (e.shiftKey && lastSelectedIndexRef.current !== null) {
const start = Math.min(lastSelectedIndexRef.current, index);
const end = Math.max(lastSelectedIndexRef.current, index);
const newSelection = new Set<string>();
for (let i = start; i <= end; i++) {
if (files[i] && files[i].type !== "directory") {
newSelection.add(files[i].name);
}
}
setSelectedFiles(newSelection);
} else if (e.ctrlKey || e.metaKey) {
setSelectedFiles((prev) => {
const next = new Set(prev);
if (next.has(file.name)) {
next.delete(file.name);
} else {
next.add(file.name);
}
return next;
});
lastSelectedIndexRef.current = index;
} else {
setSelectedFiles(new Set([file.name]));
lastSelectedIndexRef.current = index;
}
},
[files, setSelectedFiles],
);
const handleFileDoubleClick = useCallback(
(file: RemoteFile) => {
if (file.name === "..") {
onNavigateUp();
return;
}
if (file.type === "directory" || (file.type === "symlink" && file.linkTarget === "directory")) {
onNavigate(joinPath(currentPath, file.name));
} else {
onOpenFile(file);
}
},
[currentPath, joinPath, onNavigate, onNavigateUp, onOpenFile],
);
return { handleFileClick, handleFileDoubleClick };
};
@@ -1,462 +0,0 @@
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import type { Host, RemoteFile } from "../../../types";
import { logger } from "../../../lib/logger";
import { isSessionError } from "../../../application/state/sftp/errors";
import { toast } from "../../ui/toast";
interface UseSftpModalSessionParams {
open: boolean;
host: Host;
credentials: {
username?: string;
hostname: string;
port?: number;
password?: string;
privateKey?: string;
certificate?: string;
passphrase?: string;
publicKey?: string;
keyId?: string;
keySource?: "generated" | "imported";
proxy?: NetcattyProxyConfig;
jumpHosts?: NetcattyJumpHost[];
sftpSudo?: boolean;
legacyAlgorithms?: boolean;
};
initialPath?: string;
isLocalSession: boolean;
t: (key: string, params?: Record<string, unknown>) => string;
openSftp: (params: {
sessionId: string;
hostname: string;
username: string;
port: number;
password?: string;
privateKey?: string;
certificate?: string;
passphrase?: string;
publicKey?: string;
keyId?: string;
keySource?: "generated" | "imported";
proxy?: NetcattyProxyConfig;
jumpHosts?: NetcattyJumpHost[];
sudo?: boolean;
legacyAlgorithms?: boolean;
}) => Promise<string>;
closeSftp: (sftpId: string) => Promise<void>;
listSftp: (sftpId: string, path: string) => Promise<RemoteFile[]>;
listLocalDir: (path: string) => Promise<RemoteFile[]>;
getHomeDir: () => Promise<string | null>;
onClearSelection: () => void;
}
interface UseSftpModalSessionResult {
currentPath: string;
setCurrentPath: (path: string) => void;
currentPathRef: React.MutableRefObject<string>;
files: RemoteFile[];
setFiles: (files: RemoteFile[]) => void;
loading: boolean;
setLoading: (loading: boolean) => void;
reconnecting: boolean;
sessionVersion: number;
ensureSftp: () => Promise<string>;
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
closeSftpSession: () => Promise<void>;
localHomeRef: React.MutableRefObject<string | null>;
}
export const useSftpModalSession = ({
open,
host,
credentials,
initialPath,
isLocalSession,
t,
openSftp,
closeSftp,
listSftp,
listLocalDir,
getHomeDir,
onClearSelection,
}: UseSftpModalSessionParams): UseSftpModalSessionResult => {
const [currentPath, setCurrentPathState] = useState("/");
const [files, setFiles] = useState<RemoteFile[]>([]);
const [loading, setLoading] = useState(false);
const [reconnecting, setReconnecting] = useState(false);
const [sessionVersion, setSessionVersion] = useState(0);
const currentPathRef = useRef(currentPath);
const sftpIdRef = useRef<string | null>(null);
const closingPromiseRef = useRef<Promise<void> | null>(null);
const initializedRef = useRef(false);
const initializingRef = useRef(false);
const lastInitialPathRef = useRef<string | undefined>(undefined);
const localHomeRef = useRef<string | null>(null);
const reconnectingRef = useRef(false);
const reconnectAttemptsRef = useRef(0);
const MAX_RECONNECT_ATTEMPTS = 3;
const DIR_CACHE_TTL_MS = 10_000;
const dirCacheRef = useRef<
Map<string, { files: RemoteFile[]; timestamp: number }>
>(new Map());
const loadSeqRef = useRef(0);
const setCurrentPath = useCallback((path: string) => {
currentPathRef.current = path;
setCurrentPathState(path);
}, []);
const bumpSessionVersion = useCallback(() => {
setSessionVersion((prev) => prev + 1);
}, []);
const ensureSftp = useCallback(async () => {
if (isLocalSession) throw new Error("Local session does not use SFTP");
if (closingPromiseRef.current) {
await closingPromiseRef.current;
}
if (sftpIdRef.current) return sftpIdRef.current;
const sftpId = await openSftp({
sessionId: `sftp-modal-${host.id}`,
hostname: credentials.hostname,
username: credentials.username || "root",
port: credentials.port || 22,
password: credentials.password,
privateKey: credentials.privateKey,
certificate: credentials.certificate,
passphrase: credentials.passphrase,
publicKey: credentials.publicKey,
keyId: credentials.keyId,
keySource: credentials.keySource,
proxy: credentials.proxy,
jumpHosts: credentials.jumpHosts,
sudo: credentials.sftpSudo,
legacyAlgorithms: credentials.legacyAlgorithms,
});
if (sftpIdRef.current !== sftpId) {
sftpIdRef.current = sftpId;
bumpSessionVersion();
}
return sftpId;
}, [
isLocalSession,
host.id,
credentials.hostname,
credentials.username,
credentials.port,
credentials.password,
credentials.privateKey,
credentials.certificate,
credentials.passphrase,
credentials.publicKey,
credentials.keyId,
credentials.keySource,
credentials.proxy,
credentials.jumpHosts,
credentials.sftpSudo,
credentials.legacyAlgorithms,
bumpSessionVersion,
openSftp,
]);
const closeSftpSession = useCallback(async () => {
if (isLocalSession) {
if (sftpIdRef.current !== null) {
sftpIdRef.current = null;
bumpSessionVersion();
}
return;
}
// Clear ref before awaiting backend close to avoid handing out a stale ID
// if the modal is reopened while close is still in flight.
const sftpIdToClose = sftpIdRef.current;
if (sftpIdToClose !== null) {
sftpIdRef.current = null;
bumpSessionVersion();
}
if (!sftpIdToClose) {
return;
}
const currentClosePromise = (async () => {
try {
await closeSftp(sftpIdToClose);
} catch {
// Silently ignore close errors - connection may already be closed
} finally {
if (closingPromiseRef.current === currentClosePromise) {
closingPromiseRef.current = null;
}
}
})();
closingPromiseRef.current = currentClosePromise;
await currentClosePromise;
}, [bumpSessionVersion, closeSftp, isLocalSession]);
// Use shared session-error classifier from errors.ts
const handleSessionError = useCallback(async () => {
if (reconnectingRef.current) return;
reconnectingRef.current = true;
setReconnecting(true);
reconnectAttemptsRef.current = 0;
while (reconnectAttemptsRef.current < MAX_RECONNECT_ATTEMPTS) {
try {
reconnectAttemptsRef.current += 1;
await closeSftpSession();
const newSftpId = await ensureSftp();
reconnectingRef.current = false;
setReconnecting(false);
// Auto-reload current directory after successful reconnect
try {
const reloadPath = currentPathRef.current;
const reloadRequestId = loadSeqRef.current;
const list = await listSftp(newSftpId, reloadPath);
if (
reloadRequestId !== loadSeqRef.current ||
currentPathRef.current !== reloadPath
) {
return;
}
onClearSelection();
setFiles(list);
dirCacheRef.current.set(`${host.id}::${reloadPath}`, {
files: list,
timestamp: Date.now(),
});
} catch {
// Reload failed — UI still shows old data, user can manually refresh
}
return;
} catch (err) {
logger.warn(
`[SFTP] Reconnect attempt ${reconnectAttemptsRef.current} failed`,
err,
);
if (reconnectAttemptsRef.current >= MAX_RECONNECT_ATTEMPTS) {
reconnectingRef.current = false;
setReconnecting(false);
toast.error(t("sftp.error.reconnectFailed"), "SFTP");
return;
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
}, [closeSftpSession, ensureSftp, listSftp, host.id, onClearSelection, t]);
const loadFiles = useCallback(
async (path: string, options?: { force?: boolean }) => {
const requestId = ++loadSeqRef.current;
setLoading(true);
onClearSelection();
try {
if (isLocalSession) {
const list = await listLocalDir(path);
if (requestId === loadSeqRef.current) {
setFiles(list);
}
return;
}
const cacheKey = `${host.id}::${path}`;
const cached = dirCacheRef.current.get(cacheKey);
const isFresh =
cached && Date.now() - cached.timestamp < DIR_CACHE_TTL_MS;
if (cached && isFresh && !options?.force) {
setFiles(cached.files);
return;
}
const sftpId = await ensureSftp();
const list = await listSftp(sftpId, path);
if (requestId !== loadSeqRef.current) return;
setFiles(list);
dirCacheRef.current.set(cacheKey, {
files: list,
timestamp: Date.now(),
});
} catch (e) {
if (!isLocalSession && isSessionError(e) && files.length > 0) {
logger.info("[SFTP] Session lost, attempting to reconnect...");
handleSessionError();
return;
}
logger.error("Failed to load files", e);
toast.error(
e instanceof Error ? e.message : t("sftp.error.loadFailed"),
"SFTP",
);
setFiles([]);
} finally {
if (loadSeqRef.current === requestId) {
setLoading(false);
}
}
},
[ensureSftp, host.id, isLocalSession, listLocalDir, listSftp, t, handleSessionError, files.length, onClearSelection],
);
useLayoutEffect(() => {
if (!open) return;
const cacheKey = `${host.id}::${currentPath}`;
const cached = dirCacheRef.current.get(cacheKey);
const isFresh = cached && Date.now() - cached.timestamp < DIR_CACHE_TTL_MS;
if (!isFresh) {
setFiles([]);
onClearSelection();
}
}, [currentPath, host.id, onClearSelection, open]);
useEffect(() => {
if (open) {
if (!initializedRef.current || lastInitialPathRef.current !== initialPath) {
initializedRef.current = true;
initializingRef.current = true;
lastInitialPathRef.current = initialPath;
onClearSelection();
setLoading(true);
if (isLocalSession) {
(async () => {
try {
const homePath = await getHomeDir();
localHomeRef.current = homePath ?? null;
const startPath = initialPath || homePath || "/";
try {
const list = await listLocalDir(startPath);
setCurrentPath(startPath);
setFiles(list);
dirCacheRef.current.set(`${host.id}::${startPath}`, {
files: list,
timestamp: Date.now(),
});
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.loadFailed"),
"SFTP",
);
} finally {
setLoading(false);
}
} finally {
initializingRef.current = false;
}
})();
return;
}
(async () => {
try {
const homePath = await getHomeDir();
localHomeRef.current = homePath ?? null;
if (initialPath) {
try {
const sftpId = await ensureSftp();
const list = await listSftp(sftpId, initialPath);
setCurrentPath(initialPath);
setFiles(list);
dirCacheRef.current.set(`${host.id}::${initialPath}`, {
files: list,
timestamp: Date.now(),
});
setLoading(false);
return;
} catch {
logger.warn(
`[SFTP] Initial path ${initialPath} not accessible, falling back to home`,
);
}
}
try {
const sftpId = await ensureSftp();
const list = await listSftp(sftpId, homePath || "/");
setCurrentPath(homePath || "/");
setFiles(list);
dirCacheRef.current.set(`${host.id}::${homePath || "/"}`, {
files: list,
timestamp: Date.now(),
});
setLoading(false);
} catch {
logger.warn(`[SFTP] Home ${homePath} not accessible, using /`);
try {
const sftpId = await ensureSftp();
const list = await listSftp(sftpId, "/");
setCurrentPath("/");
setFiles(list);
dirCacheRef.current.set(`${host.id}::/`, {
files: list,
timestamp: Date.now(),
});
} catch (e) {
logger.error("[SFTP] Failed to load root directory", e);
toast.error(t("sftp.error.loadFailed"), "SFTP");
} finally {
setLoading(false);
}
}
} finally {
initializingRef.current = false;
}
})();
return;
}
// Skip redundant loadFiles while async initialization is still in flight.
// Without this guard, dependency changes (e.g. loadFiles recreation from
// files.length change) can re-trigger this effect and call loadFiles with
// the stale currentPath before the initialization IIFE has resolved and
// updated currentPathRef — causing uploads to target the wrong directory.
if (!initializingRef.current) {
void loadFiles(currentPath);
}
} else {
loadSeqRef.current += 1;
initializedRef.current = false;
initializingRef.current = false;
}
}, [
closeSftpSession,
currentPath,
ensureSftp,
getHomeDir,
host.id,
initialPath,
isLocalSession,
listLocalDir,
listSftp,
loadFiles,
onClearSelection,
open,
setCurrentPath,
t,
]);
useEffect(() => {
return () => {
void closeSftpSession();
};
}, [closeSftpSession]);
return {
currentPath,
setCurrentPath,
currentPathRef,
files,
setFiles,
loading,
setLoading,
reconnecting,
sessionVersion,
ensureSftp,
loadFiles,
closeSftpSession,
localHomeRef,
};
};
@@ -1,76 +0,0 @@
import React, { useCallback, useRef, useState } from "react";
export type SortField = "name" | "size" | "modified";
export type SortOrder = "asc" | "desc";
interface UseSftpModalSortingResult {
sortField: SortField;
sortOrder: SortOrder;
columnWidths: { name: number; size: number; modified: number; actions: number };
handleSort: (field: SortField) => void;
handleResizeStart: (field: string, e: React.MouseEvent) => void;
}
export const useSftpModalSorting = (): UseSftpModalSortingResult => {
const [sortField, setSortField] = useState<SortField>("name");
const [sortOrder, setSortOrder] = useState<SortOrder>("asc");
const [columnWidths, setColumnWidths] = useState({
name: 45,
size: 15,
modified: 25,
actions: 15,
});
const resizingRef = useRef<{
field: string;
startX: number;
startWidth: number;
} | null>(null);
const handleSort = (field: SortField) => {
if (sortField === field) {
setSortOrder((prev) => (prev === "asc" ? "desc" : "asc"));
} else {
setSortField(field);
setSortOrder("asc");
}
};
const handleResizeMove = useCallback((e: MouseEvent) => {
if (!resizingRef.current) return;
const diff = e.clientX - resizingRef.current.startX;
const newWidth = Math.max(
10,
Math.min(60, resizingRef.current.startWidth + diff / 5),
);
setColumnWidths((prev) => ({
...prev,
[resizingRef.current!.field]: newWidth,
}));
}, []);
const handleResizeEnd = useCallback(() => {
resizingRef.current = null;
document.removeEventListener("mousemove", handleResizeMove);
document.removeEventListener("mouseup", handleResizeEnd);
}, [handleResizeMove]);
const handleResizeStart = (field: string, e: React.MouseEvent) => {
e.preventDefault();
resizingRef.current = {
field,
startX: e.clientX,
startWidth: columnWidths[field as keyof typeof columnWidths],
};
document.addEventListener("mousemove", handleResizeMove);
document.addEventListener("mouseup", handleResizeEnd);
};
return {
sortField,
sortOrder,
columnWidths,
handleSort,
handleResizeStart,
};
};
@@ -1,87 +0,0 @@
import { useCallback, useState } from "react";
import type { RemoteFile } from "../../../types";
import { toast } from "../../ui/toast";
interface UseSftpModalTextEditorParams {
currentPath: string;
isLocalSession: boolean;
joinPath: (base: string, name: string) => string;
ensureSftp: () => Promise<string>;
readLocalFile: (path: string) => Promise<ArrayBuffer>;
readSftp: (sftpId: string, path: string) => Promise<string>;
writeLocalFile: (path: string, data: ArrayBuffer) => Promise<void>;
writeSftp: (sftpId: string, path: string, data: string) => Promise<void>;
t: (key: string, params?: Record<string, unknown>) => string;
}
interface UseSftpModalTextEditorResult {
showTextEditor: boolean;
setShowTextEditor: (open: boolean) => void;
textEditorTarget: RemoteFile | null;
setTextEditorTarget: (target: RemoteFile | null) => void;
textEditorContent: string;
setTextEditorContent: (value: string) => void;
loadingTextContent: boolean;
handleEditFile: (file: RemoteFile) => Promise<void>;
handleSaveTextFile: (content: string) => Promise<void>;
}
export const useSftpModalTextEditor = ({
currentPath,
isLocalSession,
joinPath,
ensureSftp,
readLocalFile,
readSftp,
writeLocalFile,
writeSftp,
t,
}: UseSftpModalTextEditorParams): UseSftpModalTextEditorResult => {
const [showTextEditor, setShowTextEditor] = useState(false);
const [textEditorTarget, setTextEditorTarget] = useState<RemoteFile | null>(null);
const [textEditorContent, setTextEditorContent] = useState("");
const [loadingTextContent, setLoadingTextContent] = useState(false);
const handleEditFile = useCallback(async (file: RemoteFile) => {
try {
setLoadingTextContent(true);
setTextEditorTarget(file);
const fullPath = joinPath(currentPath, file.name);
const content = isLocalSession
? await readLocalFile(fullPath).then((buf) => new TextDecoder().decode(buf))
: await readSftp(await ensureSftp(), fullPath);
setTextEditorContent(content);
setShowTextEditor(true);
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.loadFailed"),
"SFTP",
);
} finally {
setLoadingTextContent(false);
}
}, [currentPath, ensureSftp, isLocalSession, joinPath, readLocalFile, readSftp, t]);
const handleSaveTextFile = useCallback(async (content: string) => {
if (!textEditorTarget) return;
const fullPath = joinPath(currentPath, textEditorTarget.name);
if (isLocalSession) {
const encoder = new TextEncoder();
await writeLocalFile(fullPath, encoder.encode(content).buffer);
} else {
await writeSftp(await ensureSftp(), fullPath, content);
}
}, [currentPath, ensureSftp, isLocalSession, joinPath, textEditorTarget, writeLocalFile, writeSftp]);
return {
showTextEditor,
setShowTextEditor,
textEditorTarget,
setTextEditorTarget,
textEditorContent,
setTextEditorContent,
loadingTextContent,
handleEditFile,
handleSaveTextFile,
};
};
File diff suppressed because it is too large. Load diff
@@ -1,123 +0,0 @@
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { RemoteFile } from "../../../types";
interface UseSftpModalVirtualListParams {
open: boolean;
sortedFiles: RemoteFile[];
}
interface UseSftpModalVirtualListResult {
fileListRef: React.RefObject<HTMLDivElement>;
rowHeight: number;
handleFileListScroll: (e: React.UIEvent<HTMLDivElement>) => void;
shouldVirtualize: boolean;
totalHeight: number;
visibleRows: { file: RemoteFile; index: number; top: number }[];
}
export const useSftpModalVirtualList = ({
open,
sortedFiles,
}: UseSftpModalVirtualListParams): UseSftpModalVirtualListResult => {
const fileListRef = useRef<HTMLDivElement>(null);
const scrollFrameRef = useRef<number | null>(null);
const [scrollTop, setScrollTop] = useState(0);
const [viewportHeight, setViewportHeight] = useState(0);
const [rowHeight, setRowHeight] = useState(40);
useLayoutEffect(() => {
const container = fileListRef.current;
if (!container || !open) return;
const update = () => setViewportHeight(container.clientHeight);
update();
const raf = window.requestAnimationFrame(update);
const resizeObserver = new ResizeObserver(update);
resizeObserver.observe(container);
return () => {
resizeObserver.disconnect();
window.cancelAnimationFrame(raf);
};
}, [open, sortedFiles.length]);
useLayoutEffect(() => {
const container = fileListRef.current;
if (!container || !open || sortedFiles.length === 0) return;
const raf = window.requestAnimationFrame(() => {
const rowElement = container.querySelector(
'[data-sftp-modal-row="true"]',
) as HTMLElement | null;
if (!rowElement) return;
const nextHeight = Math.round(rowElement.getBoundingClientRect().height);
if (nextHeight && Math.abs(nextHeight - rowHeight) > 1) {
setRowHeight(nextHeight);
}
});
return () => window.cancelAnimationFrame(raf);
}, [open, rowHeight, sortedFiles.length]);
useEffect(() => {
return () => {
if (scrollFrameRef.current !== null) {
window.cancelAnimationFrame(scrollFrameRef.current);
}
};
}, []);
const handleFileListScroll = useCallback(
(e: React.UIEvent<HTMLDivElement>) => {
const nextTop = e.currentTarget.scrollTop;
if (scrollFrameRef.current !== null) return;
scrollFrameRef.current = window.requestAnimationFrame(() => {
scrollFrameRef.current = null;
setScrollTop(nextTop);
});
},
[],
);
const { shouldVirtualize, totalHeight, visibleRows } = useMemo(() => {
const overscan = 6;
const canVirtualize = open && viewportHeight > 0 && rowHeight > 0;
const shouldVirtualizeLocal = canVirtualize && sortedFiles.length > 50;
const totalHeightLocal = shouldVirtualizeLocal
? sortedFiles.length * rowHeight
: 0;
const startIndex = shouldVirtualizeLocal
? Math.max(0, Math.floor(scrollTop / rowHeight) - overscan)
: 0;
const endIndex = shouldVirtualizeLocal
? Math.min(
sortedFiles.length - 1,
Math.ceil((scrollTop + viewportHeight) / rowHeight) + overscan,
)
: sortedFiles.length - 1;
const visibleRowsLocal = shouldVirtualizeLocal
? sortedFiles
.slice(startIndex, endIndex + 1)
.map((file, idx) => ({
file,
index: startIndex + idx,
top: (startIndex + idx) * rowHeight,
}))
: sortedFiles.map((file, index) => ({
file,
index,
top: 0,
}));
return {
shouldVirtualize: shouldVirtualizeLocal,
totalHeight: totalHeightLocal,
visibleRows: visibleRowsLocal,
};
}, [open, rowHeight, scrollTop, sortedFiles, viewportHeight]);
return {
fileListRef,
rowHeight,
handleFileListScroll,
shouldVirtualize,
totalHeight,
visibleRows,
};
};
-83
View File
@@ -1,83 +0,0 @@
export const isWindowsPath = (path: string): boolean => /^[A-Za-z]:/.test(path);
export const normalizeWindowsRoot = (path: string): string => {
const normalized = path.replace(/\//g, "\\");
if (/^[A-Za-z]:\\$/.test(normalized)) return normalized;
if (/^[A-Za-z]:$/.test(normalized)) return `${normalized}\\`;
return normalized;
};
export const joinPath = (base: string, name: string, isLocalSession: boolean): string => {
if (isLocalSession && isWindowsPath(base)) {
const normalizedBase = normalizeWindowsRoot(base).replace(/[\\/]+$/, "");
return `${normalizedBase}\\${name}`;
}
if (base === "/") return `/${name}`;
return `${base}/${name}`;
};
export const isRootPath = (path: string, isLocalSession: boolean): boolean => {
if (isLocalSession && isWindowsPath(path)) {
return /^[A-Za-z]:\\?$/.test(path.replace(/\//g, "\\"));
}
return path === "/";
};
export const getParentPath = (path: string, isLocalSession: boolean): string => {
if (isLocalSession && isWindowsPath(path)) {
const normalized = normalizeWindowsRoot(path).replace(/[\\]+$/, "");
const drive = normalized.slice(0, 2);
if (/^[A-Za-z]:$/.test(normalized) || /^[A-Za-z]:\\$/.test(normalized)) {
return `${drive}\\`;
}
const rest = normalized.slice(2).replace(/^[\\]+/, "");
const parts = rest ? rest.split(/[\\]+/).filter(Boolean) : [];
if (parts.length <= 1) return `${drive}\\`;
parts.pop();
return `${drive}\\${parts.join("\\")}`;
}
if (path === "/") return "/";
const parts = path.split("/").filter(Boolean);
parts.pop();
return parts.length ? `/${parts.join("/")}` : "/";
};
export const getRootPath = (path: string, isLocalSession: boolean): string => {
if (isLocalSession && isWindowsPath(path)) {
const drive = path.replace(/\//g, "\\").slice(0, 2);
return `${drive}\\`;
}
return "/";
};
export const getWindowsDrive = (path: string): string | null => {
if (!isWindowsPath(path)) return null;
const normalized = path.replace(/\//g, "\\");
return /^[A-Za-z]:/.test(normalized) ? normalized.slice(0, 2) : null;
};
export const getBreadcrumbs = (path: string, isLocalSession: boolean): string[] => {
if (isLocalSession && isWindowsPath(path)) {
const normalized = normalizeWindowsRoot(path).replace(/[\\]+$/, "");
const rest = normalized.slice(2).replace(/^[\\]+/, "");
const parts = rest ? rest.split(/[\\]+/).filter(Boolean) : [];
return parts;
}
return path === "/" ? [] : path.split("/").filter(Boolean);
};
export const breadcrumbPathAt = (
breadcrumbs: string[],
idx: number,
currentPath: string,
isLocalSession: boolean,
): string => {
if (isLocalSession) {
const drive = getWindowsDrive(currentPath);
if (drive) {
const rest = breadcrumbs.slice(0, idx + 1).join("\\");
return rest ? `${drive}\\${rest}` : `${drive}\\`;
}
}
return "/" + breadcrumbs.slice(0, idx + 1).join("/");
};
-16
View File
@@ -1,16 +0,0 @@
export const formatBytes = (bytes: number | string): string => {
const numBytes = typeof bytes === "string" ? parseInt(bytes, 10) : bytes;
if (isNaN(numBytes) || numBytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(numBytes) / Math.log(1024));
const size = numBytes / Math.pow(1024, i);
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
};
export const formatDate = (dateStr: string | number | undefined): string => {
if (!dateStr) return "--";
const date = typeof dateStr === "number" ? new Date(dateStr) : new Date(dateStr);
if (isNaN(date.getTime())) return String(dateStr);
const pad = (value: number) => value.toString().padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
};
+2 -1
View File
@@ -151,7 +151,8 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
{t("sftp.context.edit")}
</ContextMenuItem>
)}
{!isNavigableDirectory(entry) && onDownloadFile && (
{onDownloadFile &&
(!isNavigableDirectory(entry) || !pane.connection?.isLocal) && (
<ContextMenuItem onClick={() => onDownloadFile(entry)}>
<Download size={14} className="mr-2" />{" "}
{t("sftp.context.download")}
+23 -4
View File
@@ -13,6 +13,7 @@ import {
} from 'lucide-react';
import React, { memo } from 'react';
import { getParentPath } from '../../application/state/sftp/utils';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
import { TransferTask } from '../../types';
import { Button } from '../ui/button';
@@ -35,6 +36,8 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
canRevealTarget = false,
onRevealTarget,
}) => {
const { t } = useI18n();
const hasKnownTotal = task.totalBytes > 0;
const progress = task.totalBytes > 0 ? Math.min((task.transferredBytes / task.totalBytes) * 100, 100) : 0;
// Calculate remaining time from backend-reported sliding-window speed
@@ -42,7 +45,7 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
const effectiveSpeed = task.status === 'transferring'
? (Number.isFinite(task.speed) && task.speed > 0 ? task.speed : 0)
: 0;
const remainingTime = effectiveSpeed > 0
const remainingTime = hasKnownTotal && effectiveSpeed > 0
? Math.ceil(remainingBytes / effectiveSpeed)
: 0;
const remainingFormatted = remainingTime > 60
@@ -54,6 +57,8 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
// Format bytes transferred / total
const bytesDisplay = task.status === 'transferring' && task.totalBytes > 0
? `${formatTransferBytes(task.transferredBytes)} / ${formatTransferBytes(task.totalBytes)}`
: task.status === 'transferring'
? formatTransferBytes(task.transferredBytes)
: task.status === 'completed' && task.totalBytes > 0
? formatTransferBytes(task.totalBytes)
: '';
@@ -99,12 +104,14 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
<div
className={cn(
"h-full rounded-full relative overflow-hidden",
task.status === 'pending'
task.status === 'pending' || (task.status === 'transferring' && !hasKnownTotal)
? "bg-muted-foreground/50 animate-pulse"
: "bg-gradient-to-r from-primary via-primary/90 to-primary"
)}
style={{
width: task.status === 'pending' ? '100%' : `${progress}%`,
width: task.status === 'pending' || (task.status === 'transferring' && !hasKnownTotal)
? '100%'
: `${progress}%`,
transition: 'width 150ms ease-out'
}}
>
@@ -121,7 +128,11 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
</div>
</div>
<span className="text-[10px] text-muted-foreground shrink-0 min-w-[34px] text-right font-mono">
{task.status === 'pending' ? 'waiting...' : `${Math.round(progress)}%`}
{task.status === 'pending'
? 'waiting...'
: hasKnownTotal
? `${Math.round(progress)}%`
: '...'}
</span>
</div>
)}
@@ -130,6 +141,11 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({
{bytesDisplay}
</div>
)}
{task.status === 'transferring' && !hasKnownTotal && (
<div className="text-[9px] text-muted-foreground mt-0.5">
{t('sftp.transfers.calculatingTotal')}
</div>
)}
{task.status === 'completed' && bytesDisplay && (
<div className="text-[9px] text-green-600 mt-0.5">
Completed - {bytesDisplay}
@@ -196,10 +212,13 @@ const arePropsEqual = (
// Always re-render on fileName change
if (prev.fileName !== next.fileName) return false;
if (prev.targetPath !== next.targetPath) return false;
if (prev.totalBytes !== next.totalBytes) return false;
if ((prevProps.canRevealTarget ?? false) !== (nextProps.canRevealTarget ?? false)) return false;
// For transferring status, allow frequent re-renders for smooth progress bar
if (next.status === 'transferring') {
if (next.totalBytes <= 0 && prev.transferredBytes !== next.transferredBytes) return false;
// Re-render on any meaningful progress change (0.1% for smooth bar animation)
const prevProgress = prev.totalBytes > 0 ? (prev.transferredBytes / prev.totalBytes) * 100 : 0;
const nextProgress = next.totalBytes > 0 ? (next.transferredBytes / next.totalBytes) * 100 : 0;
+439 -4
View File
@@ -1,6 +1,7 @@
import React, { useCallback, useState } from "react";
import type { MutableRefObject } from "react";
import type { SftpFileEntry } from "../../../types";
import type { RemoteFile, SftpFileEntry, SftpFilenameEncoding } from "../../../types";
import { joinPath as joinFsPath } from "../../../application/state/sftp/utils";
import type { SftpStateApi } from "../../../application/state/useSftpState";
import { logger } from "../../../lib/logger";
import { toast } from "../../ui/toast";
@@ -20,7 +21,11 @@ interface UseSftpViewFileOpsParams {
systemApp?: SystemAppInfo,
) => void;
t: (key: string, vars?: Record<string, string | number>) => string;
listSftp?: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<RemoteFile[]>;
mkdirLocal?: (path: string) => Promise<unknown>;
deleteLocalFile?: (path: string) => Promise<unknown>;
showSaveDialog?: (defaultPath: string, filters?: Array<{ name: string; extensions: string[] }>) => Promise<string | null>;
selectDirectory?: (title?: string, defaultPath?: string) => Promise<string | null>;
startStreamTransfer?: (
options: {
transferId: string;
@@ -31,6 +36,8 @@ interface UseSftpViewFileOpsParams {
sourceSftpId?: string;
targetSftpId?: string;
totalBytes?: number;
sourceEncoding?: SftpFilenameEncoding;
targetEncoding?: SftpFilenameEncoding;
},
onProgress?: (transferred: number, total: number, speed: number) => void,
onComplete?: () => void,
@@ -105,7 +112,11 @@ export const useSftpViewFileOps = ({
getOpenerForFileRef,
setOpenerForExtension,
t,
listSftp,
mkdirLocal,
deleteLocalFile,
showSaveDialog,
selectDirectory,
startStreamTransfer,
getSftpIdForConnection,
}: UseSftpViewFileOpsParams): UseSftpViewFileOpsResult => {
@@ -363,10 +374,16 @@ export const useSftpViewFileOps = ({
if (!pane.connection) return;
const fullPath = sftpRef.current.joinPath(pane.connection.currentPath, file.name);
const isDirectory = isNavigableDirectory(file);
try {
// For local files, use blob download
// For local files, use blob download.
if (pane.connection.isLocal) {
if (isDirectory) {
toast.error(t("sftp.error.downloadFailed"), "SFTP");
return;
}
const content = await sftpRef.current.readBinaryFile(side, fullPath);
const blob = new Blob([content], { type: "application/octet-stream" });
@@ -383,7 +400,7 @@ export const useSftpViewFileOps = ({
return;
}
// For remote SFTP files, use streaming download with save dialog
// For remote SFTP files/directories, use streaming download with save dialog.
if (!showSaveDialog || !startStreamTransfer || !getSftpIdForConnection) {
toast.error(t("sftp.error.downloadFailed"), "SFTP");
return;
@@ -394,6 +411,413 @@ export const useSftpViewFileOps = ({
throw new Error("SFTP session not found");
}
if (isDirectory) {
if (!listSftp || !mkdirLocal || !selectDirectory) {
toast.error(t("sftp.error.downloadFailed"), "SFTP");
return;
}
const selectedDirectory = await selectDirectory(t("sftp.context.download"));
if (!selectedDirectory) return;
const targetPath = joinFsPath(selectedDirectory, file.name);
const transferId = `download-dir-${Date.now()}-${Math.random().toString(36).slice(2)}`;
let completedBytes = 0;
const MAX_SYMLINK_DEPTH = 32;
const DIRECTORY_DOWNLOAD_MAX_CONCURRENCY = 10;
const activeChildTransferIds = new Set<string>();
const activeFileProgress = new Map<string, { transferred: number; speed: number }>();
const activeFileSizes = new Map<string, number>();
const visitedPaths = new Set<string>();
const directoryTaskQueue: Array<{
type: "directory";
remotePath: string;
localPath: string;
symlinkDepth: number;
}> = [];
const fileTaskQueue: Array<{
type: "file";
remotePath: string;
localPath: string;
size: number;
}> = [];
let pendingDirectoryTasks = 0;
let discoveredTotalBytes = 0;
let estimatedTotalBytes = 0;
let activeQueueTasks = 0;
const isTaskCancelled = () =>
sftpRef.current.transfers.some(
(task) => task.id === transferId && task.status === "cancelled",
);
const updateAggregateProgress = () => {
let activeTransferredBytes = 0;
let activeSpeed = 0;
for (const progress of activeFileProgress.values()) {
activeTransferredBytes += progress.transferred;
activeSpeed += progress.speed;
}
sftpRef.current.updateExternalUpload(transferId, {
fileName: pendingDirectoryTasks > 0 ? `${file.name} (${t("sftp.upload.scanning")})` : file.name,
transferredBytes: completedBytes + activeTransferredBytes,
totalBytes: estimatedTotalBytes > 0 ? estimatedTotalBytes : 0,
speed: activeSpeed,
});
};
const cancelActiveChildTransfers = async () => {
await Promise.all(
Array.from(activeChildTransferIds).map((childTransferId) =>
sftpRef.current.cancelTransfer(childTransferId).catch(() => undefined),
),
);
};
const maybeFinalizeDiscovery = () => {
if (pendingDirectoryTasks === 0) {
estimatedTotalBytes = discoveredTotalBytes;
updateAggregateProgress();
}
};
const getDynamicConcurrencyLimit = () => {
let largeFiles = 0;
let mediumFiles = 0;
for (const size of activeFileSizes.values()) {
if (size >= 32 * 1024 * 1024) largeFiles += 1;
else if (size >= 1 * 1024 * 1024) mediumFiles += 1;
}
if (largeFiles > 0) return 2;
if (mediumFiles >= 2) return 4;
if (mediumFiles === 1) return 5;
return DIRECTORY_DOWNLOAD_MAX_CONCURRENCY;
};
const enqueueDirectoryTask = (task: {
type: "directory";
remotePath: string;
localPath: string;
symlinkDepth: number;
}) => {
directoryTaskQueue.push(task);
};
const enqueueFileTask = (task: {
type: "file";
remotePath: string;
localPath: string;
size: number;
}) => {
const insertIndex = fileTaskQueue.findIndex((queuedTask) => queuedTask.size > task.size);
if (insertIndex === -1) {
fileTaskQueue.push(task);
} else {
fileTaskQueue.splice(insertIndex, 0, task);
}
};
const dequeueTask = () => {
if (pendingDirectoryTasks > 0 && directoryTaskQueue.length > 0) {
return directoryTaskQueue.shift() ?? null;
}
if (fileTaskQueue.length > 0) return fileTaskQueue.shift() ?? null;
if (directoryTaskQueue.length > 0) return directoryTaskQueue.shift() ?? null;
return null;
};
const processFileTask = async (task: {
type: "file";
remotePath: string;
localPath: string;
size: number;
}) => {
const childTransferId = `download-${Date.now()}-${Math.random().toString(36).slice(2)}`;
activeChildTransferIds.add(childTransferId);
activeFileSizes.set(childTransferId, task.size);
activeFileProgress.set(childTransferId, { transferred: 0, speed: 0 });
updateAggregateProgress();
try {
await new Promise<void>((resolve, reject) => {
startStreamTransfer(
{
transferId: childTransferId,
sourcePath: task.remotePath,
targetPath: task.localPath,
sourceType: "sftp",
targetType: "local",
sourceSftpId: sftpId,
totalBytes: task.size,
sourceEncoding: pane.filenameEncoding,
},
(transferred, _total, speed) => {
if (isTaskCancelled()) {
sftpRef.current.cancelTransfer(childTransferId).catch(() => undefined);
return;
}
activeFileProgress.set(childTransferId, {
transferred,
speed: Number.isFinite(speed) && speed > 0 ? speed : 0,
});
updateAggregateProgress();
},
() => {
completedBytes += task.size;
activeChildTransferIds.delete(childTransferId);
activeFileSizes.delete(childTransferId);
activeFileProgress.delete(childTransferId);
updateAggregateProgress();
resolve();
},
(error) => {
activeChildTransferIds.delete(childTransferId);
activeFileSizes.delete(childTransferId);
activeFileProgress.delete(childTransferId);
updateAggregateProgress();
reject(new Error(error));
},
)
.then((result) => {
if (result === undefined) {
activeChildTransferIds.delete(childTransferId);
activeFileSizes.delete(childTransferId);
activeFileProgress.delete(childTransferId);
updateAggregateProgress();
reject(new Error("Stream transfer unavailable"));
} else if (result.error) {
activeChildTransferIds.delete(childTransferId);
activeFileSizes.delete(childTransferId);
activeFileProgress.delete(childTransferId);
updateAggregateProgress();
reject(new Error(result.error));
}
})
.catch(reject);
});
} finally {
activeChildTransferIds.delete(childTransferId);
activeFileSizes.delete(childTransferId);
activeFileProgress.delete(childTransferId);
}
};
const processDirectoryTask = async (task: {
type: "directory";
remotePath: string;
localPath: string;
symlinkDepth: number;
}) => {
if (visitedPaths.has(task.remotePath)) {
pendingDirectoryTasks -= 1;
maybeFinalizeDiscovery();
return;
}
visitedPaths.add(task.remotePath);
if (isTaskCancelled()) {
throw new Error("Transfer cancelled");
}
const entries = await listSftp(sftpId, task.remotePath, pane.filenameEncoding);
for (const entry of entries) {
if (entry.name === ".." || entry.name === ".") continue;
if (isTaskCancelled()) {
await cancelActiveChildTransfers();
throw new Error("Transfer cancelled");
}
const remoteEntryPath = sftpRef.current.joinPath(task.remotePath, entry.name);
const localEntryPath = joinFsPath(task.localPath, entry.name);
const isRealDir = entry.type === "directory";
const isSymlinkDir =
entry.type === "symlink" && entry.linkTarget === "directory";
if (isRealDir || isSymlinkDir) {
if (isSymlinkDir && task.symlinkDepth >= MAX_SYMLINK_DEPTH) {
throw new Error(
"Maximum symlink directory depth exceeded (possible symlink cycle)",
);
}
try {
await mkdirLocal(localEntryPath);
} catch (mkdirErr: unknown) {
const isEEXIST =
mkdirErr instanceof Error && mkdirErr.message.includes("EEXIST");
if (!isEEXIST) throw mkdirErr;
}
pendingDirectoryTasks += 1;
enqueueDirectoryTask({
type: "directory",
remotePath: remoteEntryPath,
localPath: localEntryPath,
symlinkDepth: isSymlinkDir ? task.symlinkDepth + 1 : task.symlinkDepth,
});
continue;
}
const entrySize =
typeof entry.size === "string"
? parseInt(String(entry.size), 10) || 0
: entry.size || 0;
discoveredTotalBytes += entrySize;
enqueueFileTask({
type: "file",
remotePath: remoteEntryPath,
localPath: localEntryPath,
size: entrySize,
});
}
pendingDirectoryTasks -= 1;
maybeFinalizeDiscovery();
};
const runQueue = async () =>
new Promise<void>((resolve, reject) => {
let settled = false;
const pump = () => {
if (settled) return;
if (isTaskCancelled()) {
settled = true;
void cancelActiveChildTransfers().finally(() =>
reject(new Error("Transfer cancelled")),
);
return;
}
while (
activeQueueTasks < getDynamicConcurrencyLimit()
) {
const nextTask = dequeueTask();
if (!nextTask) break;
activeQueueTasks += 1;
Promise.resolve(
nextTask.type === "directory"
? processDirectoryTask(nextTask)
: processFileTask(nextTask),
)
.then(() => {
activeQueueTasks -= 1;
if (
!settled &&
fileTaskQueue.length === 0 &&
directoryTaskQueue.length === 0 &&
activeQueueTasks === 0 &&
pendingDirectoryTasks === 0
) {
settled = true;
resolve();
return;
}
pump();
})
.catch((error) => {
if (settled) return;
settled = true;
void cancelActiveChildTransfers().finally(() => reject(error));
});
}
if (
!settled &&
fileTaskQueue.length === 0 &&
directoryTaskQueue.length === 0 &&
activeQueueTasks === 0 &&
pendingDirectoryTasks === 0
) {
settled = true;
resolve();
}
};
pump();
});
sftpRef.current.addExternalUpload({
id: transferId,
fileName: `${file.name} (${t("sftp.upload.scanning")})`,
sourcePath: fullPath,
targetPath,
sourceConnectionId: pane.connection.id,
targetConnectionId: "local",
direction: "download",
status: "transferring",
totalBytes: 0,
transferredBytes: 0,
speed: 0,
startTime: Date.now(),
isDirectory: true,
retryable: false,
});
try {
try {
await mkdirLocal(targetPath);
} catch (mkdirErr: unknown) {
const isEEXIST =
mkdirErr instanceof Error && mkdirErr.message.includes("EEXIST");
if (isEEXIST && deleteLocalFile) {
await deleteLocalFile(targetPath);
await mkdirLocal(targetPath);
} else {
throw mkdirErr;
}
}
pendingDirectoryTasks = 1;
enqueueDirectoryTask({
type: "directory",
remotePath: fullPath,
localPath: targetPath,
symlinkDepth: 0,
});
await runQueue();
sftpRef.current.updateExternalUpload(transferId, {
status: "completed",
fileName: file.name,
transferredBytes: completedBytes,
totalBytes: estimatedTotalBytes > 0 ? estimatedTotalBytes : completedBytes,
speed: 0,
endTime: Date.now(),
});
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : t("sftp.error.downloadFailed");
const isCancelled =
errorMessage.includes("cancelled") || errorMessage.includes("canceled");
sftpRef.current.updateExternalUpload(transferId, {
status: isCancelled ? "cancelled" : "failed",
error: isCancelled ? undefined : errorMessage,
speed: 0,
endTime: Date.now(),
});
if (!isCancelled) {
toast.error(errorMessage, "SFTP");
}
}
return;
}
// Show save dialog to get target path
const targetPath = await showSaveDialog(file.name);
if (!targetPath) {
@@ -433,6 +857,7 @@ export const useSftpViewFileOps = ({
targetType: 'local',
sourceSftpId: sftpId,
totalBytes: fileSize,
sourceEncoding: pane.filenameEncoding,
},
(transferred, total, speed) => {
// Update transfer progress in the queue
@@ -497,7 +922,17 @@ export const useSftpViewFileOps = ({
);
}
},
[sftpRef, t, showSaveDialog, startStreamTransfer, getSftpIdForConnection],
[
sftpRef,
t,
listSftp,
mkdirLocal,
deleteLocalFile,
showSaveDialog,
selectDirectory,
startStreamTransfer,
getSftpIdForConnection,
],
);
const onDownloadFileLeft = useCallback(
@@ -1,6 +1,7 @@
import { useMemo } from "react";
import type { MutableRefObject } from "react";
import type { SftpStateApi } from "../../../application/state/useSftpState";
import type { RemoteFile, SftpFilenameEncoding } from "../../../types";
import type { SftpPaneCallbacks } from "../SftpContext";
import { useSftpViewPaneActions } from "./useSftpViewPaneActions";
import { useSftpViewFileOps } from "./useSftpViewFileOps";
@@ -19,7 +20,11 @@ interface UseSftpViewPaneCallbacksParams {
systemApp?: SystemAppInfo,
) => void;
t: (key: string, vars?: Record<string, string | number>) => string;
listSftp?: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<RemoteFile[]>;
mkdirLocal?: (path: string) => Promise<unknown>;
deleteLocalFile?: (path: string) => Promise<unknown>;
showSaveDialog?: (defaultPath: string, filters?: Array<{ name: string; extensions: string[] }>) => Promise<string | null>;
selectDirectory?: (title?: string, defaultPath?: string) => Promise<string | null>;
startStreamTransfer?: (
options: {
transferId: string;
@@ -30,6 +35,8 @@ interface UseSftpViewPaneCallbacksParams {
sourceSftpId?: string;
targetSftpId?: string;
totalBytes?: number;
sourceEncoding?: SftpFilenameEncoding;
targetEncoding?: SftpFilenameEncoding;
},
onProgress?: (transferred: number, total: number, speed: number) => void,
onComplete?: () => void,
@@ -45,7 +52,11 @@ export const useSftpViewPaneCallbacks = ({
getOpenerForFileRef,
setOpenerForExtension,
t,
listSftp,
mkdirLocal,
deleteLocalFile,
showSaveDialog,
selectDirectory,
startStreamTransfer,
getSftpIdForConnection,
}: UseSftpViewPaneCallbacksParams) => {
@@ -57,7 +68,11 @@ export const useSftpViewPaneCallbacks = ({
getOpenerForFileRef,
setOpenerForExtension,
t,
listSftp,
mkdirLocal,
deleteLocalFile,
showSaveDialog,
selectDirectory,
startStreamTransfer,
getSftpIdForConnection,
});
@@ -2,7 +2,7 @@
* Terminal Connection Dialog
* Full connection overlay with host info, progress indicator, and auth/progress content
*/
import { Loader2, TerminalSquare, User } from 'lucide-react';
import { Loader2, Plug, TerminalSquare, X } from 'lucide-react';
import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
@@ -30,6 +30,7 @@ export interface TerminalConnectionDialogProps {
// Auth dialog props
authProps: Omit<TerminalAuthDialogProps, 'keys'>;
keys: SSHKey[];
onDismissDisconnected?: () => void;
// Progress props
progressProps: Omit<TerminalConnectionProgressProps, 'status' | 'error' | 'showLogs'>;
}
@@ -68,11 +69,13 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
_setShowLogs: setShowLogs, // Rename back to setShowLogs for internal use
authProps,
keys,
onDismissDisconnected,
progressProps,
}) => {
const { t } = useI18n();
const hasError = Boolean(error);
const isConnecting = status === 'connecting';
const canDismissDisconnected = status === 'disconnected' && !needsAuth && !!onDismissDisconnected;
const protocolInfo = getProtocolInfo(host);
return (
@@ -80,12 +83,11 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
"absolute inset-0 z-20 flex items-center justify-center",
needsAuth ? "bg-black" : "bg-black/30"
)}>
<div className="w-[560px] max-w-[90vw] bg-background/95 border border-border/60 rounded-2xl shadow-xl p-6 space-y-4">
<div className="w-[560px] max-w-[90vw] bg-background/95 border border-border/60 rounded-xl shadow-xl p-6 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} className="h-10 w-10" />
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} className="h-10 w-10 rounded-lg" />
<div>
{/* Show chain progress if available */}
{chainProgress ? (
<>
<div className="text-sm font-semibold">
@@ -104,7 +106,7 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
</>
) : (
<>
<div className="text-sm font-semibold">{host.label}</div>
<div className="text-lg font-semibold">{host.label}</div>
<div className="text-[11px] text-muted-foreground font-mono">
{t(protocolInfo.i18nKey)} {protocolInfo.showPort ? `${host.hostname}:${protocolInfo.port}` : host.hostname}
</div>
@@ -112,32 +114,56 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
)}
</div>
</div>
{!needsAuth && (
<Button
size="sm"
variant="outline"
className="h-8 text-xs"
onClick={() => setShowLogs(!showLogs)}
>
{showLogs ? t('terminal.connection.hideLogs') : t('terminal.connection.showLogs')}
</Button>
)}
<div className="flex items-center gap-2">
{!needsAuth && (
<Button
size="sm"
variant="outline"
className="h-8 text-xs"
onClick={() => setShowLogs(!showLogs)}
>
{showLogs ? t('terminal.connection.hideLogs') : t('terminal.connection.showLogs')}
</Button>
)}
{status === 'connecting' && !needsAuth && (
<Button
size="sm"
variant="outline"
className="h-8 text-xs"
onClick={progressProps.onCancelConnect}
disabled={progressProps.isCancelling}
>
{progressProps.isCancelling ? t('terminal.progress.cancelling') : t('common.close')}
</Button>
)}
{canDismissDisconnected && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
aria-label={t('terminal.connection.dismissDisconnectedDialog')}
title={t('terminal.connection.dismissDisconnectedDialog')}
onClick={onDismissDisconnected}
>
<X size={14} />
</Button>
)}
</div>
</div>
{/* Progress indicator - icons with progress bar below */}
<div className="space-y-2">
<div className="flex items-center gap-3">
<div className={cn(
"h-8 w-8 rounded-full flex items-center justify-center flex-shrink-0",
"h-8 w-8 rounded-lg flex items-center justify-center flex-shrink-0",
needsAuth
? "bg-primary text-primary-foreground"
: hasError
? "bg-destructive/20 text-destructive"
: isConnecting
: isConnecting
? "bg-primary/15 text-primary"
: "bg-muted text-muted-foreground"
)}>
<User size={14} />
<Plug size={14} />
</div>
<div className="flex-1 h-1.5 rounded-full bg-border/60 overflow-hidden relative">
<div
@@ -151,7 +177,7 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
/>
</div>
<div className={cn(
"h-8 w-8 rounded-full flex items-center justify-center flex-shrink-0",
"h-8 w-8 rounded-lg flex items-center justify-center flex-shrink-0",
hasError ? "bg-destructive/20 text-destructive" : "bg-muted text-muted-foreground"
)}>
{isConnecting ? (
@@ -2,7 +2,7 @@
* Terminal Connection Progress
* Displays connection progress with logs and timeout
*/
import { AlertCircle, Clock, Play, ShieldCheck } from 'lucide-react';
import { Loader2, Play } from 'lucide-react';
import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Button } from '../ui/button';
@@ -15,7 +15,8 @@ export interface TerminalConnectionProgressProps {
isCancelling: boolean;
showLogs: boolean;
progressLogs: string[];
onCancel: () => void;
onCancelConnect: () => void;
onCloseSession: () => void;
onRetry: () => void;
}
@@ -23,71 +24,70 @@ export const TerminalConnectionProgress: React.FC<TerminalConnectionProgressProp
status,
error,
timeLeft,
isCancelling,
isCancelling: _isCancelling,
showLogs,
progressLogs,
onCancel,
onCancelConnect: _onCancelConnect,
onCloseSession,
onRetry,
}) => {
const { t } = useI18n();
return (
<>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<Clock className="h-3 w-3" />
<span>
{status === 'connecting'
? t('terminal.progress.timeoutIn', { seconds: timeLeft })
: error || t('terminal.progress.disconnected')}
</span>
</div>
<div className="flex items-center gap-2">
<div className="flex items-start justify-between gap-3 text-xs text-muted-foreground">
<div className="flex min-w-0 items-start gap-2">
{status === 'connecting' ? (
<Button
variant="ghost"
size="sm"
className="h-8"
onClick={onCancel}
disabled={isCancelling}
>
{isCancelling ? t('terminal.progress.cancelling') : t('common.close')}
</Button>
<>
<Loader2 className="h-3 w-3 mt-0.5 flex-shrink-0 animate-spin" />
<span className="min-w-0 whitespace-pre-wrap break-words leading-5">
{t('terminal.progress.timeoutIn', { seconds: timeLeft })}
</span>
</>
) : (
<div className="flex gap-2">
<Button variant="ghost" size="sm" className="h-8" onClick={onCancel}>
{t('common.close')}
</Button>
<Button size="sm" className="h-8" onClick={onRetry}>
<Play className="h-3 w-3 mr-2" /> {t('terminal.progress.startOver')}
</Button>
</div>
<>
<div className="mt-[0.4rem] h-1.5 w-1.5 flex-shrink-0 rounded-full bg-destructive" />
<span className="min-w-0 whitespace-pre-wrap break-words leading-5 text-destructive">
{error || t('terminal.progress.disconnected')}
</span>
</>
)}
</div>
</div>
{showLogs && (
<div className="rounded-xl border border-border/60 bg-background/70 shadow-inner">
<div className="rounded-md border border-border/35 bg-background/40">
<ScrollArea className="max-h-52 p-3">
<div className="space-y-2 text-sm text-foreground/90">
<div className="space-y-1 text-sm text-foreground/90">
{progressLogs.map((line, idx) => (
<div key={idx} className="flex items-start gap-2">
<div className="mt-0.5">
<ShieldCheck className="h-3.5 w-3.5 text-primary" />
</div>
<div>{line}</div>
<div className="mt-[0.4rem] h-1.5 w-1.5 flex-shrink-0 rounded-full bg-emerald-500" />
<div className="min-w-0 break-words leading-5">{line}</div>
</div>
))}
{error && (
<div className="flex items-start gap-2 text-destructive">
<AlertCircle className="h-3.5 w-3.5 mt-0.5" />
<div>{error}</div>
<div className="mt-[0.4rem] h-1.5 w-1.5 flex-shrink-0 rounded-full bg-destructive" />
<div className="min-w-0 break-words leading-5">{error}</div>
</div>
)}
</div>
</ScrollArea>
</div>
)}
<div className="flex justify-end gap-2">
{status !== 'connecting' && (
<>
<Button variant="ghost" size="sm" className="h-8" onClick={onCloseSession}>
{t('terminal.toolbar.closeSession')}
</Button>
<Button size="sm" className="h-8" onClick={onRetry}>
<Play className="h-3 w-3 mr-2" /> {t('terminal.progress.startOver')}
</Button>
</>
)}
</div>
</>
);
};
+60 -2
View File
@@ -118,21 +118,37 @@ FontItem.displayName = 'FontItem';
interface ThemeSidePanelProps {
currentThemeId: string;
globalThemeId: string;
currentFontFamilyId: string;
globalFontFamilyId: string;
currentFontSize: number;
canResetTheme?: boolean;
canResetFontFamily?: boolean;
canResetFontSize?: boolean;
onThemeChange: (themeId: string) => void;
onThemeReset?: () => void;
onFontFamilyChange: (fontFamilyId: string) => void;
onFontFamilyReset?: () => void;
onFontSizeChange: (fontSize: number) => void;
onFontSizeReset?: () => void;
isVisible?: boolean;
}
const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
currentThemeId,
globalThemeId,
currentFontFamilyId,
globalFontFamilyId,
currentFontSize,
canResetTheme = false,
canResetFontFamily = false,
canResetFontSize = false,
onThemeChange,
onThemeReset,
onFontFamilyChange,
onFontFamilyReset,
onFontSizeChange,
onFontSizeReset,
isVisible = true,
}) => {
const { t } = useI18n();
@@ -149,6 +165,14 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
() => [...TERMINAL_THEMES, ...customThemes],
[customThemes]
);
const globalTheme = useMemo(
() => allThemes.find((theme) => theme.id === globalThemeId) || TERMINAL_THEMES[0],
[allThemes, globalThemeId],
);
const globalFont = useMemo(
() => availableFonts.find((font) => font.id === globalFontFamilyId) || availableFonts[0],
[availableFonts, globalFontFamilyId],
);
const handleThemeSelect = useCallback((themeId: string) => {
setEditingTheme(null);
@@ -294,6 +318,18 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
))}
</>
)}
{canResetTheme && (
<>
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mt-2 mb-1 px-1 font-semibold">
{t('terminal.themeModal.globalTheme')}
</div>
<ThemeItem
theme={globalTheme}
isSelected={!canResetTheme}
onSelect={() => onThemeReset?.()}
/>
</>
)}
</div>
)}
{activeTab === 'font' && (
@@ -306,6 +342,18 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
onSelect={handleFontSelect}
/>
))}
{canResetFontFamily && (
<>
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mt-2 mb-1 px-1 font-semibold">
{t('terminal.themeModal.globalFont')}
</div>
<FontItem
font={globalFont}
isSelected={!canResetFontFamily}
onSelect={() => onFontFamilyReset?.()}
/>
</>
)}
</div>
)}
{activeTab === 'custom' && !editingTheme && (
@@ -365,8 +413,18 @@ const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
{/* Font Size Control (only in font tab) */}
{activeTab === 'font' && (
<div className="p-2.5 border-t border-border/50 shrink-0">
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mb-1.5 font-semibold">
{t('terminal.themeModal.fontSize')}
<div className="flex items-center justify-between gap-2 mb-1.5">
<div className="text-[9px] uppercase tracking-wider text-muted-foreground font-semibold">
{t('terminal.themeModal.fontSize')}
</div>
{canResetFontSize && (
<button
onClick={onFontSizeReset}
className="text-[10px] font-medium text-primary hover:opacity-80 transition-opacity"
>
{t('common.useGlobal')}
</button>
)}
</div>
<div className="flex items-center justify-between gap-2 bg-muted/30 rounded-lg p-1.5">
<button
@@ -41,7 +41,7 @@ type TerminalBackendApi = {
onSessionData: (sessionId: string, cb: (data: string) => void) => () => void;
onSessionExit: (
sessionId: string,
cb: (evt: { exitCode?: number; signal?: number }) => void,
cb: (evt: { exitCode?: number; signal?: number; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => void,
) => () => void;
onChainProgress: (
cb: (hop: number, total: number, label: string, status: string) => void,
@@ -64,6 +64,12 @@ type ChainProgressState = {
currentHostLabel: string;
} | null;
export type SessionLogConfig = {
enabled: boolean;
directory: string;
format: string;
};
export type TerminalSessionStartersContext = {
host: Host;
keys: SSHKey[];
@@ -76,6 +82,7 @@ export type TerminalSessionStartersContext = {
terminalSettingsRef?: RefObject<TerminalSettings | undefined>;
terminalBackend: TerminalBackendApi;
serialConfig?: SerialConfig;
sessionLog?: SessionLogConfig;
isVisibleRef?: RefObject<boolean>;
pendingOutputScrollRef?: RefObject<boolean>;
@@ -100,7 +107,7 @@ export type TerminalSessionStartersContext = {
t?: (key: string) => string;
onSessionAttached?: (sessionId: string) => void;
onSessionExit?: (sessionId: string) => void;
onSessionExit?: (sessionId: string, evt: { exitCode?: number; signal?: number; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => void;
onTerminalDataCapture?: (sessionId: string, data: string) => void;
onOsDetected?: (hostId: string, distro: string) => void;
onCommandExecuted?: (
@@ -213,7 +220,7 @@ const attachSessionToTerminal = (
}
}
ctx.onSessionExit?.(ctx.sessionId);
ctx.onSessionExit?.(ctx.sessionId, evt);
});
};
@@ -435,6 +442,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
}): Promise<string> => {
return ctx.terminalBackend.startSSHSession({
sessionId: ctx.sessionId,
hostLabel: ctx.host.label,
hostname: ctx.host.hostname,
username: effectiveUsername,
port: ctx.host.port || 22,
@@ -456,6 +464,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
proxy: proxyConfig,
jumpHosts: jumpHosts.length > 0 ? jumpHosts : undefined,
keepaliveInterval: ctx.terminalSettings?.keepaliveInterval,
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
});
};
@@ -609,6 +618,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
rows: term.rows,
charset: ctx.host.charset,
env: telnetEnv,
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
});
attachSessionToTerminal(ctx, term, id, {
@@ -650,6 +660,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
rows: term.rows,
charset: ctx.host.charset,
env: moshEnv,
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
});
attachSessionToTerminal(ctx, term, id, {
@@ -708,6 +719,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
env: {
TERM: ctx.terminalSettings?.terminalEmulationType ?? "xterm-256color",
},
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
});
ctx.sessionRef.current = id;
@@ -754,7 +766,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
}
}
ctx.onSessionExit?.(ctx.sessionId);
ctx.onSessionExit?.(ctx.sessionId, evt);
});
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
@@ -787,6 +799,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
stopBits: ctx.serialConfig.stopBits,
parity: ctx.serialConfig.parity,
flowControl: ctx.serialConfig.flowControl,
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
});
// Serial connection is established immediately when session starts
@@ -22,6 +22,10 @@ import {
shouldScrollOnTerminalInput,
shouldScrollOnTerminalPaste,
} from "../../../domain/terminalScroll";
import {
resolveHostTerminalFontFamilyId,
resolveHostTerminalFontSize,
} from "../../../domain/terminalAppearance";
import { logger } from "../../../lib/logger";
import { isMacPlatform, normalizeLineEndings, wrapBracketedPaste } from "../../../lib/utils";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
@@ -141,12 +145,12 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
rendererType,
});
const hostFontId = ctx.host.fontFamily || ctx.fontFamilyId || "menlo";
const hostFontId = resolveHostTerminalFontFamilyId(ctx.host, ctx.fontFamilyId) || "menlo";
// Use fontStore for font lookup - guarantees non-empty result
const fontObj = fontStore.getFontById(hostFontId);
const fontFamily = fontObj.family;
const effectiveFontSize = ctx.host.fontSize || ctx.fontSize;
const effectiveFontSize = resolveHostTerminalFontSize(ctx.host, ctx.fontSize);
const cursorStyle = settings?.cursorShape ?? "block";
const cursorBlink = settings?.cursorBlink ?? true;
+3 -3
View File
@@ -30,13 +30,13 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { hideCloseButton?: boolean }
>(({ className, children, hideCloseButton, ...props }, ref) => {
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { hideCloseButton?: boolean; overlayClassName?: string }
>(({ className, children, hideCloseButton, overlayClassName, ...props }, ref) => {
const { t } = useI18n()
return (
<DialogPortal>
<DialogOverlay />
<DialogOverlay className={overlayClassName} />
<DialogPrimitive.Content
ref={ref}
className={cn(
+43 -1
View File
@@ -1,5 +1,22 @@
import { Host } from './models';
export const LINUX_DISTRO_OPTIONS = [
'linux',
'ubuntu',
'debian',
'centos',
'rocky',
'fedora',
'arch',
'alpine',
'amazon',
'opensuse',
'redhat',
'almalinux',
'oracle',
'kali',
] as const;
export const normalizeDistroId = (value?: string) => {
const v = (value || '').toLowerCase().trim();
if (!v) return '';
@@ -16,11 +33,36 @@ export const normalizeDistroId = (value?: string) => {
if (v.includes('almalinux')) return 'almalinux';
if (v.includes('oracle')) return 'oracle';
if (v.includes('kali')) return 'kali';
if (v === 'linux' || v.includes('linux')) return 'linux';
return '';
};
export const getEffectiveHostDistro = (
host?: Pick<Host, 'distro' | 'manualDistro' | 'distroMode'> | null,
) => {
if (!host) return '';
const detected = normalizeDistroId(host.distro);
const manual = normalizeDistroId(host.manualDistro);
if (host.distroMode === 'manual') return manual || detected;
if (host.distroMode === 'auto') return detected;
return detected;
};
export const sanitizeHost = (host: Host): Host => {
const cleanHostname = (host.hostname || '').split(/\s+/)[0];
const cleanDistro = normalizeDistroId(host.distro);
return { ...host, hostname: cleanHostname, distro: cleanDistro };
const cleanManualDistro = normalizeDistroId(host.manualDistro);
const cleanDistroMode =
host.distroMode === 'manual'
? 'manual'
: host.distroMode === 'auto'
? 'auto'
: undefined;
return {
...host,
hostname: cleanHostname,
distro: cleanDistro,
distroMode: cleanDistroMode,
manualDistro: cleanManualDistro || undefined,
};
};
+8
View File
@@ -86,9 +86,14 @@ export interface Host {
moshEnabled?: boolean;
moshServerPath?: string; // Custom mosh-server path (e.g., /usr/local/bin/mosh-server)
theme?: string;
themeOverride?: boolean; // Explicitly override the global terminal theme for this host
fontFamily?: string; // Terminal font family for this host
fontFamilyOverride?: boolean; // Explicitly override the global terminal font family for this host
fontSize?: number; // Terminal font size for this host (pt)
fontSizeOverride?: boolean; // Explicitly override the global terminal font size for this host
distro?: string; // detected distro id (e.g., ubuntu, debian)
distroMode?: 'auto' | 'manual'; // whether distro icon comes from detection or manual override
manualDistro?: string; // manually selected distro id when distroMode='manual'
// Multi-protocol support
protocols?: ProtocolConfig[]; // Multiple protocol configurations
telnetPort?: number; // Telnet-specific port (for quick access)
@@ -169,6 +174,8 @@ export interface GroupNode {
path: string;
children: Record<string, GroupNode>;
hosts: Host[];
/** Pre-computed total host count including all descendants. Set during tree construction. */
totalHostCount?: number;
}
export interface SyncConfig {
@@ -592,6 +599,7 @@ export interface TerminalSession {
protocol?: 'ssh' | 'telnet' | 'local' | 'serial';
port?: number;
moshEnabled?: boolean;
shellType?: 'posix' | 'fish' | 'powershell' | 'cmd' | 'unknown';
// Serial-specific connection settings
serialConfig?: SerialConfig;
}
+11 -6
View File
@@ -31,9 +31,10 @@ export type SyncState =
/**
* Conflict Resolution Strategy
*/
export type ConflictResolution =
| 'USE_REMOTE' // Download cloud data, overwrite local
| 'USE_LOCAL'; // Upload local data, overwrite cloud
export type ConflictResolution =
| 'USE_REMOTE' // Download cloud data, overwrite local
| 'USE_LOCAL' // Upload local data, overwrite cloud
| 'AUTO_MERGED'; // Three-way merge was applied automatically
// ============================================================================
// Cloud Provider Types
@@ -196,8 +197,9 @@ export interface SyncPayload {
sftpAutoSync?: boolean;
sftpShowHiddenFiles?: boolean;
sftpUseCompressedUpload?: boolean;
sftpAutoOpenSidebar?: boolean;
};
// Sync metadata
syncedAt: number; // When this payload was created
}
@@ -275,10 +277,12 @@ export interface UnlockedMasterKey {
export interface SyncResult {
success: boolean;
provider: CloudProvider;
action: 'upload' | 'download' | 'none';
action: 'upload' | 'download' | 'merge' | 'none';
version?: number;
error?: string;
conflictDetected?: boolean;
/** Present when action === 'merge'; caller should apply this to update local state */
mergedPayload?: import('./sync').SyncPayload;
}
/**
@@ -312,7 +316,7 @@ export interface SyncHistoryEntry {
id: string;
timestamp: number;
provider: CloudProvider;
action: 'upload' | 'download' | 'conflict_resolved';
action: 'upload' | 'download' | 'merge' | 'conflict_resolved';
success: boolean;
localVersion: number;
remoteVersion?: number;
@@ -405,6 +409,7 @@ export const SYNC_STORAGE_KEYS = {
PROVIDER_S3: 'netcatty_provider_s3_v1',
PROVIDER_SMB: 'netcatty_provider_smb_v1',
LOCAL_SYNC_META: 'netcatty_local_sync_meta_v1',
SYNC_BASE_PAYLOAD: 'netcatty_sync_base_payload_v1',
} as const;
// ============================================================================
+432
View File
@@ -0,0 +1,432 @@
/**
* Three-Way Merge for Cloud Sync Payloads
*
* Implements a Git-style three-way merge using a stored "base" snapshot
* (the last successfully synced payload) to detect per-entity changes
* on both the local and remote sides.
*
* Algorithm:
* For each entity (identified by `id`):
* - Only in local → local addition → keep
* - Only in remote → remote addition → keep
* - In base, removed locally → local deletion → remove (unless remote modified)
* - In base, removed remotely → remote deletion → remove (unless local modified)
* - Modified only locally → keep local version
* - Modified only remotely → keep remote version
* - Modified on both sides → prefer local (conflict logged)
*
* When no base is available (first sync), falls back to a set-union
* merge by entity ID, preferring local for duplicates.
*/
import type { SyncPayload } from './sync';
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
export interface MergeSummary {
added: { local: number; remote: number };
deleted: { local: number; remote: number };
modified: { local: number; remote: number; conflicts: number };
}
export interface MergeResult {
payload: SyncPayload;
/** True when both sides modified the same entity (resolved by preferring local) */
hadConflicts: boolean;
summary: MergeSummary;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Deterministic JSON string for content comparison.
* Sorts object keys to avoid false diffs from key ordering.
*/
function fingerprint(value: unknown): string {
return JSON.stringify(value, (_key, v) => {
if (v && typeof v === 'object' && !Array.isArray(v)) {
return Object.keys(v).sort().reduce<Record<string, unknown>>((acc, k) => {
acc[k] = (v as Record<string, unknown>)[k];
return acc;
}, {});
}
return v;
});
}
// ---------------------------------------------------------------------------
// Entity-array merge (hosts, keys, identities, snippets, etc.)
// ---------------------------------------------------------------------------
interface EntityMergeResult<T> {
merged: T[];
conflicts: number;
added: { local: number; remote: number };
deleted: { local: number; remote: number };
modified: { local: number; remote: number };
}
function mergeEntityArrays<T extends { id: string }>(
base: T[],
local: T[],
remote: T[],
): EntityMergeResult<T> {
const baseMap = new Map(base.map((e) => [e.id, e]));
const localMap = new Map(local.map((e) => [e.id, e]));
const remoteMap = new Map(remote.map((e) => [e.id, e]));
const allIds = new Set([
...baseMap.keys(),
...localMap.keys(),
...remoteMap.keys(),
]);
const merged: T[] = [];
let conflicts = 0;
const added = { local: 0, remote: 0 };
const deleted = { local: 0, remote: 0 };
const modified = { local: 0, remote: 0 };
for (const id of allIds) {
const baseItem = baseMap.get(id);
const localItem = localMap.get(id);
const remoteItem = remoteMap.get(id);
const inBase = baseItem !== undefined;
const inLocal = localItem !== undefined;
const inRemote = remoteItem !== undefined;
if (!inBase && inLocal && !inRemote) {
// Local addition
merged.push(localItem);
added.local++;
} else if (!inBase && !inLocal && inRemote) {
// Remote addition
merged.push(remoteItem);
added.remote++;
} else if (!inBase && inLocal && inRemote) {
// Both added same ID — prefer local
merged.push(localItem);
if (fingerprint(localItem) !== fingerprint(remoteItem)) {
conflicts++;
}
} else if (inBase && inLocal && inRemote) {
// Exists in all three — compare changes
const localChanged = fingerprint(localItem) !== fingerprint(baseItem);
const remoteChanged = fingerprint(remoteItem) !== fingerprint(baseItem);
if (!localChanged && !remoteChanged) {
merged.push(baseItem);
} else if (localChanged && !remoteChanged) {
merged.push(localItem);
modified.local++;
} else if (!localChanged && remoteChanged) {
merged.push(remoteItem);
modified.remote++;
} else {
// Both changed — prefer local
merged.push(localItem);
if (fingerprint(localItem) !== fingerprint(remoteItem)) {
conflicts++;
}
modified.local++;
modified.remote++;
}
} else if (inBase && !inLocal && inRemote) {
// Local deleted
const remoteChanged = fingerprint(remoteItem) !== fingerprint(baseItem);
if (remoteChanged) {
// Remote modified + local deleted → keep modification (safer)
merged.push(remoteItem);
conflicts++;
} else {
deleted.local++;
}
} else if (inBase && inLocal && !inRemote) {
// Remote deleted
const localChanged = fingerprint(localItem) !== fingerprint(baseItem);
if (localChanged) {
// Local modified + remote deleted → keep modification (safer)
merged.push(localItem);
conflicts++;
} else {
deleted.remote++;
}
}
// inBase && !inLocal && !inRemote → both deleted → gone
}
return { merged, conflicts, added, deleted, modified };
}
// ---------------------------------------------------------------------------
// String-array merge (customGroups, snippetPackages)
// ---------------------------------------------------------------------------
function mergeStringArrays(
base: string[],
local: string[],
remote: string[],
): string[] {
const baseSet = new Set(base);
const localSet = new Set(local);
const remoteSet = new Set(remote);
const result = new Set<string>();
// Start with base items, then apply additions/deletions
const allValues = new Set([...baseSet, ...localSet, ...remoteSet]);
for (const value of allValues) {
const inBase = baseSet.has(value);
const inLocal = localSet.has(value);
const inRemote = remoteSet.has(value);
if (!inBase) {
// Addition — keep if either side added it
if (inLocal || inRemote) result.add(value);
} else {
// Was in base — keep unless both sides deleted
const localDeleted = !inLocal;
const remoteDeleted = !inRemote;
if (localDeleted && remoteDeleted) {
// Both deleted — gone
} else if (localDeleted || remoteDeleted) {
// Only one side deleted — honour the deletion
// (If the other side didn't touch it, it's still in their set from base)
} else {
result.add(value);
}
}
}
return [...result];
}
// ---------------------------------------------------------------------------
// Settings merge (flat key-value)
// ---------------------------------------------------------------------------
type SettingsObj = NonNullable<SyncPayload['settings']>;
/** Check if an array contains objects with `id` fields (for entity merge). */
function isIdArray(arr: unknown[]): boolean {
return arr.length > 0 && typeof arr[0] === 'object' && arr[0] !== null && 'id' in arr[0];
}
/** Recursively merge two plain objects against a base using three-way logic. */
function mergeSettingsDeep(
base: Record<string, unknown>,
local: Record<string, unknown>,
remote: Record<string, unknown>,
): Record<string, unknown> {
const allKeys = new Set([
...Object.keys(base),
...Object.keys(local),
...Object.keys(remote),
]);
const merged: Record<string, unknown> = {};
for (const key of allKeys) {
const bVal = base[key];
const lVal = local[key];
const rVal = remote[key];
const lChanged = fingerprint(lVal) !== fingerprint(bVal);
const rChanged = fingerprint(rVal) !== fingerprint(bVal);
if (!lChanged && !rChanged) {
if (bVal !== undefined) merged[key] = bVal;
} else if (lChanged && !rChanged) {
if (lVal !== undefined) merged[key] = lVal;
} else if (!lChanged && rChanged) {
if (rVal !== undefined) merged[key] = rVal;
} else {
// Both changed — recurse if both are plain objects, else prefer local
if (
lVal && rVal &&
typeof lVal === 'object' && !Array.isArray(lVal) &&
typeof rVal === 'object' && !Array.isArray(rVal)
) {
merged[key] = mergeSettingsDeep(
(bVal && typeof bVal === 'object' && !Array.isArray(bVal) ? bVal : {}) as Record<string, unknown>,
lVal as Record<string, unknown>,
rVal as Record<string, unknown>,
);
} else if (lVal !== undefined) {
merged[key] = lVal;
}
}
}
return merged;
}
function mergeSettings(
base: SettingsObj | undefined,
local: SettingsObj | undefined,
remote: SettingsObj | undefined,
): SettingsObj | undefined {
if (!local && !remote) return undefined;
if (!local) return remote;
if (!remote) return local;
const b = base ?? {};
const allKeys = new Set([
...Object.keys(b),
...Object.keys(local),
...Object.keys(remote),
]);
const merged: Record<string, unknown> = {};
for (const key of allKeys) {
const bVal = (b as Record<string, unknown>)[key];
const lVal = (local as Record<string, unknown>)[key];
const rVal = (remote as Record<string, unknown>)[key];
const lChanged = fingerprint(lVal) !== fingerprint(bVal);
const rChanged = fingerprint(rVal) !== fingerprint(bVal);
if (!lChanged && !rChanged) {
if (bVal !== undefined) merged[key] = bVal;
} else if (lChanged && !rChanged) {
if (lVal !== undefined) merged[key] = lVal;
} else if (!lChanged && rChanged) {
if (rVal !== undefined) merged[key] = rVal;
} else {
// Both changed — deep merge if both are plain objects, else prefer local
if (
lVal && rVal &&
typeof lVal === 'object' && !Array.isArray(lVal) &&
typeof rVal === 'object' && !Array.isArray(rVal)
) {
merged[key] = mergeSettingsDeep(
(bVal && typeof bVal === 'object' && !Array.isArray(bVal) ? bVal : {}) as Record<string, unknown>,
lVal as Record<string, unknown>,
rVal as Record<string, unknown>,
);
} else if (
Array.isArray(lVal) && Array.isArray(rVal) &&
(isIdArray(lVal) || isIdArray(rVal) || isIdArray(Array.isArray(bVal) ? bVal as unknown[] : []))
) {
// Array of objects with `id` (e.g. customTerminalThemes) — entity merge
const bArr = Array.isArray(bVal) ? bVal as Array<{ id: string }> : [];
const result = mergeEntityArrays(bArr, lVal as Array<{ id: string }>, rVal as Array<{ id: string }>);
merged[key] = result.merged;
} else if (lVal !== undefined) {
merged[key] = lVal;
}
}
}
return Object.keys(merged).length > 0 ? (merged as SettingsObj) : undefined;
}
// ---------------------------------------------------------------------------
// Main merge function
// ---------------------------------------------------------------------------
/**
* Three-way merge of sync payloads.
*
* @param base - The last successfully synced payload (null if unavailable)
* @param local - The current device's data
* @param remote - The other device's data (downloaded from cloud)
*/
export function mergeSyncPayloads(
base: SyncPayload | null,
local: SyncPayload,
remote: SyncPayload,
): MergeResult {
const emptyBase: SyncPayload = {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
snippetPackages: [],
knownHosts: [],
portForwardingRules: [],
settings: undefined,
syncedAt: 0,
};
const b = base ?? emptyBase;
const summary: MergeSummary = {
added: { local: 0, remote: 0 },
deleted: { local: 0, remote: 0 },
modified: { local: 0, remote: 0, conflicts: 0 },
};
// Merge each entity type
const hosts = mergeEntityArrays(b.hosts ?? [], local.hosts ?? [], remote.hosts ?? []);
const keys = mergeEntityArrays(b.keys ?? [], local.keys ?? [], remote.keys ?? []);
const identities = mergeEntityArrays(b.identities ?? [], local.identities ?? [], remote.identities ?? []);
const snippets = mergeEntityArrays(b.snippets ?? [], local.snippets ?? [], remote.snippets ?? []);
const knownHostsRaw = mergeEntityArrays(b.knownHosts ?? [], local.knownHosts ?? [], remote.knownHosts ?? []);
// Deduplicate known hosts by (hostname, port, keyType) since IDs are random per device
const knownHostSeen = new Set<string>();
const knownHosts = {
...knownHostsRaw,
merged: knownHostsRaw.merged.filter((kh) => {
const entry = kh as unknown as { hostname: string; port: number; keyType: string };
const fp = `${entry.hostname}:${entry.port}:${entry.keyType}`;
if (knownHostSeen.has(fp)) return false;
knownHostSeen.add(fp);
return true;
}),
};
const portForwardingRules = mergeEntityArrays(
b.portForwardingRules ?? [],
local.portForwardingRules ?? [],
remote.portForwardingRules ?? [],
);
// Aggregate stats
const entityResults = [hosts, keys, identities, snippets, knownHosts, portForwardingRules];
for (const r of entityResults) {
summary.added.local += r.added.local;
summary.added.remote += r.added.remote;
summary.deleted.local += r.deleted.local;
summary.deleted.remote += r.deleted.remote;
summary.modified.local += r.modified.local;
summary.modified.remote += r.modified.remote;
summary.modified.conflicts += r.conflicts;
}
// Merge string arrays
const customGroups = mergeStringArrays(
b.customGroups ?? [],
local.customGroups ?? [],
remote.customGroups ?? [],
);
const snippetPackages = mergeStringArrays(
b.snippetPackages ?? [],
local.snippetPackages ?? [],
remote.snippetPackages ?? [],
);
// Merge settings
const settings = mergeSettings(b.settings, local.settings, remote.settings);
const payload: SyncPayload = {
hosts: hosts.merged,
keys: keys.merged,
identities: identities.merged,
snippets: snippets.merged,
customGroups,
snippetPackages,
knownHosts: knownHosts.merged,
portForwardingRules: portForwardingRules.merged,
settings,
syncedAt: Date.now(),
};
return {
payload,
hadConflicts: summary.modified.conflicts > 0,
summary,
};
}
+4
View File
@@ -36,6 +36,7 @@ import {
STORAGE_KEY_SFTP_AUTO_SYNC,
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_CUSTOM_THEMES,
} from '../infrastructure/config/storageKeys';
@@ -153,6 +154,8 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
if (hidden === 'true' || hidden === 'false') settings.sftpShowHiddenFiles = hidden === 'true';
const compress = localStorageAdapter.readString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD);
if (compress === 'true' || compress === 'false') settings.sftpUseCompressedUpload = compress === 'true';
const autoOpenSidebar = localStorageAdapter.readString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
if (autoOpenSidebar === 'true' || autoOpenSidebar === 'false') settings.sftpAutoOpenSidebar = autoOpenSidebar === 'true';
return Object.keys(settings).length > 0 ? settings : undefined;
}
@@ -211,6 +214,7 @@ export function applySyncableSettings(settings: NonNullable<SyncPayload['setting
if (settings.sftpAutoSync != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_SYNC, String(settings.sftpAutoSync));
if (settings.sftpShowHiddenFiles != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, String(settings.sftpShowHiddenFiles));
if (settings.sftpUseCompressedUpload != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, String(settings.sftpUseCompressedUpload));
if (settings.sftpAutoOpenSidebar != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, String(settings.sftpAutoOpenSidebar));
}
// ---------------------------------------------------------------------------
+66
View File
@@ -0,0 +1,66 @@
import { Host } from './models';
type TerminalAppearanceDefaults = {
themeId: string;
fontFamilyId: string;
fontSize: number;
};
const hasLegacyStringValue = (value: string | undefined): boolean =>
typeof value === 'string' && value.trim().length > 0;
const hasLegacyNumberValue = (value: number | undefined): boolean =>
typeof value === 'number' && !Number.isNaN(value);
const hasEffectiveOverride = (
explicitOverride: boolean | undefined,
legacyValuePresent: boolean,
): boolean => explicitOverride === true || (explicitOverride === undefined && legacyValuePresent);
export const hasHostThemeOverride = (host?: Pick<Host, 'themeOverride' | 'theme'> | null): boolean =>
hasEffectiveOverride(host?.themeOverride, hasLegacyStringValue(host?.theme));
export const hasHostFontFamilyOverride = (host?: Pick<Host, 'fontFamilyOverride' | 'fontFamily'> | null): boolean =>
hasEffectiveOverride(host?.fontFamilyOverride, hasLegacyStringValue(host?.fontFamily));
export const hasHostFontSizeOverride = (host?: Pick<Host, 'fontSizeOverride' | 'fontSize'> | null): boolean =>
hasEffectiveOverride(host?.fontSizeOverride, hasLegacyNumberValue(host?.fontSize));
export const clearHostThemeOverride = (host: Host): Host => ({
...host,
theme: undefined,
themeOverride: false,
});
export const clearHostFontFamilyOverride = (host: Host): Host => ({
...host,
fontFamily: undefined,
fontFamilyOverride: false,
});
export const clearHostFontSizeOverride = (host: Host): Host => ({
...host,
fontSize: undefined,
fontSizeOverride: false,
});
export const resolveHostTerminalThemeId = (host: Host | null | undefined, defaultThemeId: string): string =>
hasHostThemeOverride(host) && host?.theme ? host.theme : defaultThemeId;
export const resolveHostTerminalFontFamilyId = (host: Host | null | undefined, defaultFontFamilyId: string): string =>
hasHostFontFamilyOverride(host) && host?.fontFamily ? host.fontFamily : defaultFontFamilyId;
export const resolveHostTerminalFontSize = (host: Host | null | undefined, defaultFontSize: number): number =>
hasHostFontSizeOverride(host) && host?.fontSize != null ? host.fontSize : defaultFontSize;
export const resolveHostTerminalAppearance = (
host: Host | null | undefined,
defaults: TerminalAppearanceDefaults,
) => ({
themeId: resolveHostTerminalThemeId(host, defaults.themeId),
fontFamilyId: resolveHostTerminalFontFamilyId(host, defaults.fontFamilyId),
fontSize: resolveHostTerminalFontSize(host, defaults.fontSize),
hasThemeOverride: hasHostThemeOverride(host),
hasFontFamilyOverride: hasHostFontFamilyOverride(host),
hasFontSizeOverride: hasHostFontSizeOverride(host),
});
+4
View File
@@ -13,6 +13,7 @@ module.exports = {
files: [
'dist/**/*',
'electron/**/*',
'lib/**/*.cjs',
'!electron/.dev-config.json',
'public/**/*',
'node_modules/**/*'
@@ -21,6 +22,9 @@ module.exports = {
'node_modules/node-pty/**/*',
'node_modules/ssh2/**/*',
'node_modules/cpu-features/**/*',
'node_modules/@zed-industries/claude-agent-acp/**/*',
'node_modules/@agentclientprotocol/sdk/**/*',
'node_modules/@anthropic-ai/claude-agent-sdk/**/*',
'node_modules/@zed-industries/codex-acp/**/*',
'node_modules/@zed-industries/codex-acp-*/**/*',
'node_modules/@modelcontextprotocol/sdk/**/*',
+125 -23
View File
@@ -11,6 +11,89 @@
const crypto = require("crypto");
const { stripAnsi } = require("./shellUtils.cjs");
const { classifyLocalShellType } = require("../../../lib/localShell.cjs");
function detectShellKind(shellPath, platform = process.platform) {
return classifyLocalShellType(shellPath, platform);
}
function subscribeToPtyData(ptyStream, onData) {
if (typeof ptyStream?.onData === "function") {
const disposable = ptyStream.onData((data) => onData(data));
return () => {
try {
disposable?.dispose?.();
} catch {
// Ignore cleanup failures
}
};
}
if (typeof ptyStream?.on === "function" && typeof ptyStream?.removeListener === "function") {
ptyStream.on("data", onData);
return () => {
try {
ptyStream.removeListener("data", onData);
} catch {
// Ignore cleanup failures
}
};
}
throw new Error("PTY stream does not support data subscriptions");
}
function buildWrappedCommand(command, shellKind, marker) {
switch (shellKind) {
case "powershell": {
// Combine into 2 PTY lines (like posix) to minimise prompt echo duplication:
// Line 1: start marker + pager env + user command
// Line 2: capture exit code + end marker
const psPager = "$env:PAGER='cat'; $env:SYSTEMD_PAGER=''; $env:GIT_PAGER='cat'; $env:LESS=''; ";
return (
`Write-Output '${marker}_S'; ${psPager}${command}\r\n` +
`Write-Output "${marker}_E:$LASTEXITCODE"\r\n`
);
}
case "cmd":
return [
'set "PAGER=cat"',
'set "SYSTEMD_PAGER="',
'set "GIT_PAGER=cat"',
'set "LESS="',
`echo ${marker}_S`,
command,
`echo ${marker}_E:%errorlevel%`,
"",
].join("\r\n");
case "fish":
return [
"set -gx PAGER cat",
"set -gx SYSTEMD_PAGER ''",
"set -gx GIT_PAGER cat",
"set -gx LESS ''",
`printf '%s\\n' '${marker}_S'`,
command,
"set __NCMCP_rc $status",
`printf '%s\\n' '${marker}_E:'$__NCMCP_rc`,
"",
].join("\n");
case "posix":
default: {
// Combine into 2 PTY lines to minimise prompt echo duplication:
// Line 1: start marker + pager env + user command
// Line 2: capture exit code + end marker + restore exit code
const noPager = "PAGER=cat SYSTEMD_PAGER= GIT_PAGER=cat LESS= ";
return (
`printf '%s\\n' '${marker}_S';${noPager}${command}\n` +
`__NCMCP_rc=$?;printf '%s\\n' '${marker}_E:'"$__NCMCP_rc";(exit $__NCMCP_rc)\n`
);
}
}
}
/**
* Execute command through a terminal PTY stream.
@@ -29,15 +112,18 @@ function execViaPty(ptyStream, command, options) {
stripMarkers = false,
trackForCancellation = null,
timeoutMs = 60000,
shellKind,
} = options || {};
const marker = `__NCMCP_${Date.now().toString(36)}_${crypto.randomBytes(16).toString('hex')}__`;
const resolvedShellKind = shellKind || "posix";
return new Promise((resolve) => {
let output = "";
let foundStart = false;
let timeoutId = null;
let finished = false;
let unsubscribe = null;
const onData = (data) => {
const text = data.toString();
@@ -97,14 +183,14 @@ function execViaPty(ptyStream, command, options) {
if (finished) return;
finished = true;
clearTimeout(timeoutId);
ptyStream.removeListener("data", onData);
unsubscribe?.();
if (trackForCancellation) {
trackForCancellation.delete(marker);
}
let cleaned = stripAnsi(stdout || "").trim();
if (stripMarkers) {
cleaned = cleaned.replace(/__NCMCP_[^\r\n]*[\r\n]*/g, "").trim();
cleaned = cleaned.replace(/^[^\r\n]*__NCMCP_[^\r\n]*[\r\n]*/gm, "").trim();
}
resolve({
ok: exitCode === 0 || exitCode === null,
@@ -117,7 +203,7 @@ function execViaPty(ptyStream, command, options) {
timeoutId = setTimeout(() => {
if (finished) return;
finished = true;
ptyStream.removeListener("data", onData);
unsubscribe?.();
if (trackForCancellation) {
trackForCancellation.delete(marker);
}
@@ -128,22 +214,21 @@ function execViaPty(ptyStream, command, options) {
resolve({ ok: false, stdout: cleaned, stderr: "", exitCode: -1, error: `Command timed out (${timeoutSec}s)` });
}, timeoutMs);
ptyStream.on("data", onData);
unsubscribe = subscribeToPtyData(ptyStream, onData);
// Register for cancellation if tracking map provided
if (trackForCancellation) {
trackForCancellation.set(marker, {
ptyStream,
cleanup: () => { clearTimeout(timeoutId); ptyStream.removeListener("data", onData); },
cleanup: () => {
clearTimeout(timeoutId);
unsubscribe?.();
},
});
}
// Markers are filtered from terminal display by preload.cjs (MCP_MARKER_RE).
const noPager = "PAGER=cat SYSTEMD_PAGER= GIT_PAGER=cat LESS= ";
ptyStream.write(
`printf '${marker}_S\\n';${noPager}${command}\n` +
`__nc=$?;printf '${marker}_E:'$__nc'\\n';(exit $__nc)\n`
);
ptyStream.write(buildWrappedCommand(command, resolvedShellKind, marker));
});
}
@@ -156,7 +241,10 @@ function execViaPty(ptyStream, command, options) {
* @param {number} [options.timeoutMs=60000] - Command timeout in milliseconds
*/
function execViaChannel(sshClient, command, options) {
const { timeoutMs = 60000 } = options || {};
const {
timeoutMs = 60000,
trackForCancellation = null,
} = options || {};
return new Promise((resolve) => {
sshClient.exec(command, (err, execStream) => {
@@ -168,27 +256,40 @@ function execViaChannel(sshClient, command, options) {
resolve({ ok: false, error: 'Failed to create exec stream', exitCode: 1 });
return;
}
const marker = `__NCMCP_CH_${Date.now().toString(36)}_${crypto.randomBytes(16).toString('hex')}__`;
let stdout = "";
let stderr = "";
let finished = false;
const timeoutId = setTimeout(() => {
if (finished) return;
finished = true;
try { execStream.close(); } catch { /* ignore */ }
const timeoutSec = Math.round(timeoutMs / 1000);
resolve({ ok: false, stdout, stderr, exitCode: -1, error: `Command timed out (${timeoutSec}s)` });
}, timeoutMs);
execStream.on("data", (data) => { stdout += data.toString(); });
execStream.stderr.on("data", (data) => { stderr += data.toString(); });
execStream.on("close", (code) => {
const finish = (result) => {
if (finished) return;
finished = true;
clearTimeout(timeoutId);
if (trackForCancellation) {
trackForCancellation.delete(marker);
}
resolve(result);
};
const timeoutId = setTimeout(() => {
try { execStream.close(); } catch { /* ignore */ }
const timeoutSec = Math.round(timeoutMs / 1000);
finish({ ok: false, stdout, stderr, exitCode: -1, error: `Command timed out (${timeoutSec}s)` });
}, timeoutMs);
if (trackForCancellation) {
trackForCancellation.set(marker, {
cleanup: () => {
clearTimeout(timeoutId);
try { execStream.close(); } catch { /* ignore */ }
},
});
}
execStream.on("data", (data) => { stdout += data.toString(); });
execStream.stderr.on("data", (data) => { stderr += data.toString(); });
execStream.on("close", (code) => {
// code is null when SSH disconnects or process is signal-terminated
if (code == null) {
resolve({ ok: false, stdout, stderr, exitCode: -1, error: "Command terminated unexpectedly (connection lost or signal)" });
finish({ ok: false, stdout, stderr, exitCode: -1, error: "Command terminated unexpectedly (connection lost or signal)" });
} else {
resolve({ ok: code === 0, stdout, stderr, exitCode: code });
finish({ ok: code === 0, stdout, stderr, exitCode: code });
}
});
});
@@ -198,5 +299,6 @@ function execViaChannel(sshClient, command, options) {
module.exports = {
execViaPty,
execViaChannel,
detectShellKind,
stripAnsi,
};
+98 -6
View File
@@ -15,6 +15,7 @@ const path = require("node:path");
const ANSI_ESCAPE_REGEX = /\u001B\[[0-?]*[ -/]*[@-~]/g;
const ANSI_OSC_REGEX = /\u001B\][^\u0007]*(?:\u0007|\u001B\\)/g;
const URL_CANDIDATE_REGEX = /https?:\/\/[^\s]+/g;
const WINDOWS_RUNNABLE_EXTENSIONS = [".exe", ".cmd", ".bat", ".com"];
// ── ANSI stripping ──
@@ -56,6 +57,37 @@ function extractFirstNonLocalhostUrl(output) {
// ── CLI / path helpers ──
function normalizeCliPathForPlatform(filePath) {
const normalized = String(filePath || "").trim();
if (!normalized) return null;
if (process.platform !== "win32") {
return existsSync(normalized) ? normalized : null;
}
const ext = path.extname(normalized).toLowerCase();
if (ext) {
return existsSync(normalized) ? normalized : null;
}
// Windows npm globals often contain both a POSIX shim (`codex`) and the
// actual runnable wrapper (`codex.cmd`). Prefer the wrapper when present.
for (const suffix of WINDOWS_RUNNABLE_EXTENSIONS) {
const candidate = `${normalized}${suffix}`;
if (existsSync(candidate)) {
return candidate;
}
}
return existsSync(normalized) ? normalized : null;
}
function shouldUseShellForCommand(command) {
if (process.platform !== "win32") return false;
const normalized = String(command || "").trim().toLowerCase();
return normalized.endsWith(".cmd") || normalized.endsWith(".bat");
}
function resolveCliFromPath(command, shellEnv) {
// Validate command: only allow valid binary names (alphanumeric, hyphens, underscores, dots)
if (!command || !/^[a-zA-Z0-9._-]+$/.test(command)) {
@@ -70,8 +102,11 @@ function resolveCliFromPath(command, shellEnv) {
timeout: 3000,
stdio: ["pipe", "pipe", "pipe"],
env: shellEnv,
}).trim().split("\n")[0].trim();
if (resolved && existsSync(resolved)) return resolved;
}).trim();
for (const candidate of resolved.split(/\r?\n/)) {
const normalized = normalizeCliPathForPlatform(candidate);
if (normalized) return normalized;
}
} catch {
// Not found on PATH
}
@@ -141,6 +176,48 @@ async function getShellEnv() {
return _cachedShellEnv;
}
// ── Claude Code ACP binary resolution ──
/**
* Resolve the Claude ACP binary, returning { command, prependArgs }.
*
* On macOS/Linux a shebang-based .js script can be spawned directly, but on
* Windows `child_process.spawn` does not interpret shebangs — so when the
* resolved path is a JS file we invoke it via the system Node runtime.
*/
function resolveClaudeAcpBinaryPath(shellEnv, electronModule) {
const binaryName = "claude-agent-acp";
// Dev mode: prefer system PATH (npm creates platform-appropriate wrappers)
const isPackaged = electronModule?.app?.isPackaged;
if (!isPackaged && shellEnv) {
const systemPath = resolveCliFromPath(binaryName, shellEnv);
if (systemPath) return { command: systemPath, prependArgs: [] };
}
// Packaged build (or dev fallback): use npm-bundled binary
try {
const resolved = require.resolve("@zed-industries/claude-agent-acp/dist/index.js");
const scriptPath = toUnpackedAsarPath(resolved);
// On Windows, .js files cannot be spawned directly (no shebang support) —
// invoke via Node. In packaged Electron builds process.execPath is the
// app binary (e.g. Netcatty.exe), not a Node runtime, so we must resolve
// the real `node` from PATH. If Node is not installed, fall back to the
// bare command name and let the system find the npm-generated .cmd wrapper.
if (process.platform === "win32") {
const nodePath = resolveCliFromPath("node", shellEnv);
if (nodePath) {
return { command: nodePath, prependArgs: [scriptPath] };
}
return { command: binaryName, prependArgs: [] };
}
return { command: scriptPath, prependArgs: [] };
} catch {
return { command: binaryName, prependArgs: [] };
}
}
// ── Stream chunk serialization ──
function serializeStreamChunk(chunk) {
@@ -154,13 +231,25 @@ function serializeStreamChunk(chunk) {
return { type: "reasoning-start", id: chunk.id ?? undefined };
case "reasoning-end":
return { type: "reasoning-end", id: chunk.id ?? undefined };
case "tool-call":
case "tool-call": {
// ACP wraps all tools as "acp.acp_provider_agent_dynamic_tool" —
// the real tool name and args are inside chunk.args
const isAcpWrapper = chunk.toolName === "acp.acp_provider_agent_dynamic_tool";
const acpInput = isAcpWrapper ? chunk.input : null;
let realToolName = isAcpWrapper ? (acpInput?.toolName || chunk.toolName) : chunk.toolName;
const realArgs = isAcpWrapper ? (acpInput?.args || chunk.args) : chunk.args;
const realToolCallId = isAcpWrapper ? (acpInput?.toolCallId || chunk.toolCallId) : chunk.toolCallId;
// Simplify MCP tool names: "mcp__netcatty-remote-hosts__get_environment" → "get_environment"
if (realToolName && realToolName.includes("__")) {
realToolName = realToolName.split("__").pop();
}
return {
type: "tool-call",
toolCallId: chunk.toolCallId,
toolName: chunk.toolName,
args: chunk.args,
toolCallId: realToolCallId,
toolName: realToolName,
args: realArgs,
};
}
case "tool-result":
return {
type: "tool-result",
@@ -184,7 +273,10 @@ module.exports = {
stripAnsi,
isLocalhostHostname,
extractFirstNonLocalhostUrl,
normalizeCliPathForPlatform,
shouldUseShellForCommand,
resolveCliFromPath,
resolveClaudeAcpBinaryPath,
toUnpackedAsarPath,
getShellEnv,
serializeStreamChunk,
+395 -75
View File
@@ -17,7 +17,10 @@ const mcpServerBridge = require("./mcpServerBridge.cjs");
// ── Extracted modules ──
const {
stripAnsi,
normalizeCliPathForPlatform,
shouldUseShellForCommand,
resolveCliFromPath,
resolveClaudeAcpBinaryPath,
getShellEnv,
serializeStreamChunk,
} = require("./ai/shellUtils.cjs");
@@ -56,6 +59,9 @@ const MAX_CONCURRENT_AGENTS = 5;
// ACP providers (module-level so cleanup() can access them)
const acpProviders = new Map();
const acpActiveStreams = new Map();
const acpRequestSessions = new Map();
const acpForceProviderReset = new Set();
const acpChatRuns = new Map();
// ── Provider registry (synced from renderer, keys stay encrypted) ──
const ENC_PREFIX = "enc:v1:";
@@ -137,6 +143,8 @@ function injectApiKeyIntoRequest(url, headers, providerId) {
function cleanupAcpProvider(chatSessionId) {
const entry = acpProviders.get(chatSessionId);
if (!entry) return;
const rootPid = entry.provider?.model?.agentProcess?.pid;
const childPids = getChildProcessTreePids(rootPid);
try {
if (typeof entry.provider.forceCleanup === "function") {
entry.provider.forceCleanup();
@@ -146,9 +154,75 @@ function cleanupAcpProvider(chatSessionId) {
} catch (err) {
console.warn("[ACP] Provider cleanup failed for session", chatSessionId, err?.message || err);
}
killTrackedProcessTree(rootPid, childPids);
acpProviders.delete(chatSessionId);
}
function isActiveAcpRun(chatSessionId, requestId) {
const activeRun = acpChatRuns.get(chatSessionId);
return Boolean(activeRun && activeRun.requestId === requestId);
}
function isUnsupportedLoadSessionError(err) {
const message = String(err?.message || err || "").toLowerCase();
return message.includes("method not found") && message.includes("session/load");
}
function getChildProcessTreePids(rootPid) {
if (!Number.isInteger(rootPid) || rootPid <= 0) return [];
if (process.platform === "win32") return [];
const discovered = new Set();
const queue = [rootPid];
while (queue.length > 0) {
const pid = queue.shift();
if (!Number.isInteger(pid) || pid <= 0) continue;
try {
const output = execFileSync("pgrep", ["-P", String(pid)], { encoding: "utf8" }).trim();
if (!output) continue;
for (const line of output.split(/\s+/)) {
const childPid = Number(line);
if (!Number.isInteger(childPid) || childPid <= 0 || discovered.has(childPid)) continue;
discovered.add(childPid);
queue.push(childPid);
}
} catch {
// No child processes or pgrep unavailable.
}
}
return Array.from(discovered);
}
function killTrackedProcessTree(rootPid, childPids) {
if (process.platform === "win32") {
if (Number.isInteger(rootPid) && rootPid > 0) {
try {
execFileSync("taskkill", ["/PID", String(rootPid), "/T", "/F"], { stdio: "ignore" });
} catch {
// Ignore kill failures; the process may have already exited.
}
}
return;
}
const pids = [...(Array.isArray(childPids) ? childPids : [])];
if (Number.isInteger(rootPid) && rootPid > 0) {
pids.push(rootPid);
}
// Kill children before the wrapper so orphaned grandchildren do not survive.
for (const pid of pids.reverse()) {
if (!Number.isInteger(pid) || pid <= 0) continue;
try {
process.kill(pid, "SIGKILL");
} catch {
// Ignore kill failures; the process may have already exited.
}
}
}
/**
* Safely send an IPC message to a renderer, guarding against destroyed senders.
*/
@@ -164,6 +238,17 @@ function init(deps) {
electronModule = deps.electronModule;
mcpServerBridge.init({ sessions, sftpClients });
// Wire up main window getter for MCP approval IPC
mcpServerBridge.setMainWindowGetter(() => {
try {
const windowManager = require("./windowManager.cjs");
const mainWin = windowManager.getMainWindow?.();
return (mainWin && !mainWin.isDestroyed()) ? mainWin : null;
} catch {
return null;
}
});
// Store main window webContents ID for IPC sender validation (Issue #17)
try {
const windowManager = require("./windowManager.cjs");
@@ -415,6 +500,10 @@ function registerHandlers(ipcMain) {
// Track temporarily added entries so cleanup can distinguish them from synced ones
const tempAllowedHosts = new Set();
const tempAllowedPorts = new Set();
// Track temporarily added HTTP hosts (for rebuild restoration)
const tempHttpHosts = new Set();
// Track active expiry timers per host to avoid duplicate/premature expiry
const hostExpiryTimers = new Map();
/** Check if a host is owned by a currently synced provider config */
function isHostInProviderConfigs(host) {
@@ -424,6 +513,17 @@ function registerHandlers(ipcMain) {
}
return false;
}
/** Check if a host is owned by a provider config that uses http:// */
function isHttpHostInProviderConfigs(host) {
for (const config of providerConfigs) {
if (!config.baseURL) continue;
try {
const p = new URL(config.baseURL);
if (p.hostname === host && p.protocol === "http:") return true;
} catch {}
}
return false;
}
/** Check if a localhost port is owned by a currently synced provider config */
function isPortInProviderConfigs(port) {
for (const config of providerConfigs) {
@@ -457,17 +557,36 @@ function registerHandlers(ipcMain) {
}, TEMP_ALLOWLIST_TTL);
}
} else {
if (!providerFetchHosts.has(host)) {
const isNewHost = !providerFetchHosts.has(host);
if (isNewHost) {
providerFetchHosts.add(host);
tempAllowedHosts.add(host);
setTimeout(() => {
// Only remove if not owned by a synced provider config
if (!isHostInProviderConfigs(host)) {
providerFetchHosts.delete(host);
}
tempAllowedHosts.delete(host);
}, TEMP_ALLOWLIST_TTL);
}
// Always track in tempAllowedHosts so rebuild can restore to providerFetchHosts
// even if the original persistent source (e.g. HTTPS provider) is removed mid-TTL
tempAllowedHosts.add(host);
if (parsed.protocol === "http:") {
providerHttpHosts.add(host);
if (!isHttpHostInProviderConfigs(host)) tempHttpHosts.add(host);
}
// Always (re-)schedule expiry timer to clean up temp entries
const existing = hostExpiryTimers.get(host);
if (existing) clearTimeout(existing);
const timer = setTimeout(() => {
hostExpiryTimers.delete(host);
// Check if host is still needed by a provider config or web search
const isWebSearchHost = webSearchApiHost && (() => {
try { return new URL(webSearchApiHost).hostname === host; } catch { return false; }
})();
if (!isHostInProviderConfigs(host) && !isWebSearchHost) {
providerFetchHosts.delete(host);
providerHttpHosts.delete(host);
} else if (!isHttpHostInProviderConfigs(host)) {
providerHttpHosts.delete(host);
}
tempAllowedHosts.delete(host);
tempHttpHosts.delete(host);
}, TEMP_ALLOWLIST_TTL);
hostExpiryTimers.set(host, timer);
}
return { ok: true };
} catch {
@@ -489,6 +608,8 @@ function registerHandlers(ipcMain) {
]);
// Dynamically populated from configured provider baseURLs
const providerFetchHosts = new Set();
// Subset of providerFetchHosts where the provider baseURL explicitly uses http://
const providerHttpHosts = new Set();
/**
* Rebuild the dynamic host allowlist from the current providerConfigs.
@@ -496,11 +617,13 @@ function registerHandlers(ipcMain) {
*/
function rebuildProviderFetchHosts() {
providerFetchHosts.clear();
providerHttpHosts.clear();
// Reset localhost ports to built-in defaults, then add provider-configured ones
ALLOWED_LOCALHOST_PORTS.clear();
for (const port of BUILTIN_LOCALHOST_PORTS) ALLOWED_LOCALHOST_PORTS.add(port);
// Re-add any still-active temporary entries so a sync doesn't wipe them
for (const host of tempAllowedHosts) providerFetchHosts.add(host);
for (const host of tempHttpHosts) providerHttpHosts.add(host);
for (const port of tempAllowedPorts) ALLOWED_LOCALHOST_PORTS.add(port);
for (const config of providerConfigs) {
if (!config.baseURL) continue;
@@ -513,6 +636,7 @@ function registerHandlers(ipcMain) {
ALLOWED_LOCALHOST_PORTS.add(port);
} else {
providerFetchHosts.add(host);
if (parsed.protocol === "http:") providerHttpHosts.add(host);
}
} catch {
// Invalid URL in config — skip
@@ -598,16 +722,16 @@ function registerHandlers(ipcMain) {
const port = parsed.port ? Number(parsed.port) : (parsed.protocol === "https:" ? 443 : 80);
return ALLOWED_LOCALHOST_PORTS.has(port);
}
// Require HTTPS for remote hosts; allow HTTP only for the configured web search apiHost
// (e.g. self-hosted SearXNG at http://searxng.lan:8080 or http://192.168.x.x)
if (parsed.protocol !== "https:") {
if (!webSearchApiHost) return false;
try {
const wsHost = new URL(webSearchApiHost).hostname;
if (parsed.hostname !== wsHost) return false;
} catch {
return false;
// Only allow http: and https: schemes for remote hosts
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return false;
// For HTTP, only allow providers explicitly configured with http:// or the web search apiHost
if (parsed.protocol === "http:") {
const isProviderHost = providerHttpHosts.has(parsed.hostname);
let isWebSearchHost = false;
if (webSearchApiHost) {
try { isWebSearchHost = new URL(webSearchApiHost).hostname === parsed.hostname; } catch { }
}
if (!isProviderHost && !isWebSearchHost) return false;
}
// Check built-in + provider-configured host allowlist
if (BUILTIN_FETCH_HOSTS.has(parsed.hostname)) return true;
@@ -630,16 +754,12 @@ function registerHandlers(ipcMain) {
const resolvedUrl = patched.url;
const resolvedHeaders = patched.headers;
// Validate URL: only allow HTTP(S) schemes; require HTTPS for non-localhost
// Validate URL: only allow HTTP(S) schemes
try {
const parsed = new URL(resolvedUrl);
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
return { ok: false, error: "Only HTTP(S) URLs are allowed" };
}
const isLocalhost = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
if (parsed.protocol === "http:" && !isLocalhost) {
return { ok: false, error: "HTTP is only allowed for localhost" };
}
} catch {
return { ok: false, error: "Invalid URL" };
}
@@ -782,11 +902,22 @@ function registerHandlers(ipcMain) {
}
try {
if ((session.protocol === "local" || session.type === "local") && session.shellKind === "unknown") {
return {
ok: false,
error: "AI execution is not supported for this local shell executable. Configure the local terminal to use bash/zsh/sh, fish, PowerShell/pwsh, or cmd.exe.",
};
}
// Prefer PTY stream (visible in terminal)
const ptyStream = session.stream || session.pty;
const ptyStream = session.stream || session.pty || session.proc;
if (ptyStream && typeof ptyStream.write === "function") {
const timeoutMs = mcpServerBridge.getCommandTimeoutMs ? mcpServerBridge.getCommandTimeoutMs() : 60000;
return execViaPty(ptyStream, command, { stripMarkers: true, timeoutMs });
return execViaPty(ptyStream, command, {
stripMarkers: true,
timeoutMs,
shellKind: session.shellKind,
});
}
// Fallback: SSH exec channel (invisible to terminal)
@@ -832,6 +963,10 @@ function registerHandlers(ipcMain) {
session.pty.write(data);
return { ok: true };
}
if (session.proc) {
session.proc.write(data);
return { ok: true };
}
return { ok: false, error: "No writable stream for session" };
} catch (err) {
return { ok: false, error: err?.message || String(err) };
@@ -844,6 +979,7 @@ function registerHandlers(ipcMain) {
stdio: ["ignore", "pipe", "pipe"],
cwd: options?.cwd || undefined,
env: options?.env || process.env,
shell: shouldUseShellForCommand(command),
windowsHide: true,
});
@@ -1070,7 +1206,7 @@ function registerHandlers(ipcMain) {
}
}
// Discover external agents from PATH, plus the bundled Codex CLI if present.
// Discover external agents from PATH, plus bundled ACP binaries if present.
ipcMain.handle("netcatty:ai:agents:discover", async (event) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
const agents = [];
@@ -1080,9 +1216,10 @@ function registerHandlers(ipcMain) {
name: "Claude Code",
icon: "claude",
description: "Anthropic's agentic coding assistant",
acpCommand: "claude-code-acp",
acpCommand: "claude-agent-acp",
acpArgs: [],
args: ["-p", "--output-format", "text", "{prompt}"],
resolveAcp: resolveClaudeAcpBinaryPath,
},
{
command: "codex",
@@ -1092,6 +1229,7 @@ function registerHandlers(ipcMain) {
acpCommand: "codex-acp",
acpArgs: [],
args: ["exec", "--full-auto", "--json", "{prompt}"],
resolveAcp: resolveCodexAcpBinaryPath,
},
];
@@ -1099,21 +1237,35 @@ function registerHandlers(ipcMain) {
const seenPaths = new Set();
for (const agent of knownAgents) {
let resolvedPath = null;
let resolvedPath = resolveCliFromPath(agent.command, shellEnv);
try {
const whichCmd = process.platform === "win32" ? "where" : "which";
const result = execFileSync(whichCmd, [agent.command], {
encoding: "utf8",
timeout: 5000,
stdio: ["pipe", "pipe", "pipe"],
env: shellEnv,
}).trim();
if (result) {
resolvedPath = result.split("\n")[0].trim();
// If the base command is not on PATH, check whether the bundled ACP
// binary is available — the agent can still work via ACP without the
// standalone CLI installed.
// resolveClaudeAcpBinaryPath returns { command, prependArgs },
// resolveCodexAcpBinaryPath returns a plain string.
let versionCommand = null;
let versionPrependArgs = [];
if (!resolvedPath && agent.resolveAcp) {
const result = agent.resolveAcp(shellEnv, electronModule);
if (typeof result === "string") {
if (result && result !== agent.acpCommand && existsSync(result)) {
resolvedPath = result;
}
} else if (result?.command) {
// On Windows the command may be `node` with the script in prependArgs.
// Use the script path for display/dedup so the UI shows the actual
// agent rather than the Node binary.
const scriptPath = result.prependArgs?.[0];
const displayPath = scriptPath || result.command;
if (displayPath !== agent.acpCommand && existsSync(displayPath)) {
resolvedPath = displayPath;
if (scriptPath) {
versionCommand = result.command;
versionPrependArgs = result.prependArgs;
}
}
}
} catch {
resolvedPath = null;
}
if (!resolvedPath || seenPaths.has(resolvedPath)) {
@@ -1122,14 +1274,19 @@ function registerHandlers(ipcMain) {
let version = "";
try {
const result = await runCommand(resolvedPath, ["--version"], { env: shellEnv });
// When the agent is invoked via Node (Windows), probe version with
// the full command (e.g. `node /path/to/dist/index.js --version`).
const probeCmd = versionCommand || resolvedPath;
const probeArgs = [...versionPrependArgs, "--version"];
const result = await runCommand(probeCmd, probeArgs, { env: shellEnv });
version = (result.stdout || result.stderr || "").trim().split("\n")[0];
} catch {
version = "";
}
const { resolveAcp: _unused, ...agentInfo } = agent;
agents.push({
...agent,
...agentInfo,
path: resolvedPath,
version,
available: true,
@@ -1147,10 +1304,8 @@ function registerHandlers(ipcMain) {
let resolvedPath = null;
if (customPath) {
// User provided a custom path – validate it exists
if (existsSync(customPath)) {
resolvedPath = customPath;
}
// Normalize Windows shim paths like `codex` -> `codex.cmd` when present.
resolvedPath = normalizeCliPathForPlatform(customPath);
} else {
resolvedPath = resolveCliFromPath(command, shellEnv);
}
@@ -1235,6 +1390,7 @@ function registerHandlers(ipcMain) {
const child = spawn(codexCliPath, ["login"], {
stdio: ["ignore", "pipe", "pipe"],
env: shellEnv,
shell: shouldUseShellForCommand(codexCliPath),
windowsHide: true,
});
@@ -1341,7 +1497,7 @@ function registerHandlers(ipcMain) {
// Known agent command names (must match knownAgents in discover handler)
const ALLOWED_AGENT_COMMANDS = new Set([
"claude", "claude-code-acp",
"claude", "claude-agent-acp",
"codex", "codex-acp",
]);
@@ -1543,20 +1699,30 @@ function registerHandlers(ipcMain) {
return { ok: true };
});
// ── MCP Approval response (renderer → main) ──
ipcMain.handle("netcatty:ai:mcp:approval-response", async (event, { approvalId, approved }) => {
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
mcpServerBridge.resolveApprovalFromRenderer(approvalId, approved);
return { ok: true };
});
// ── ACP (Agent Client Protocol) streaming ──
ipcMain.handle("netcatty:ai:acp:stream", async (event, { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, images }) => {
ipcMain.handle("netcatty:ai:acp:stream", async (event, { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images }) => {
// Validate IPC sender (Issue #17)
if (!validateSender(event)) {
return { ok: false, error: "Unauthorized IPC sender" };
}
let abortController = null;
try {
mcpServerBridge.setChatSessionCancelled?.(chatSessionId, false);
const { createACPProvider } = require("@mcpc-tech/acp-ai-provider");
const { streamText, stepCountIs } = require("ai");
const shellEnv = await getShellEnv();
const sessionCwd = cwd || process.cwd();
const isCodexAgent = acpCommand === "codex-acp";
const isClaudeAgent = acpCommand === "claude-agent-acp";
// Resolve API key from providerId (decrypted in main process only)
const resolvedProvider = providerId ? resolveProviderApiKey(providerId) : null;
@@ -1587,7 +1753,7 @@ function registerHandlers(ipcMain) {
? await resolveCodexMcpSnapshot(sessionCwd)
: { mcpServers: [], fingerprint: getCodexMcpFingerprint([]) };
// Inject Netcatty MCP server for remote host access (scoped to this chat session)
// Inject Netcatty MCP server for scoped terminal-session access
try {
const mcpPort = await mcpServerBridge.getOrCreateHost();
const scopedIds = mcpServerBridge.getScopedSessionIds(chatSessionId);
@@ -1601,9 +1767,22 @@ function registerHandlers(ipcMain) {
mcpSnapshot.fingerprint = getCodexMcpFingerprint(mcpSnapshot.mcpServers);
const currentPermissionMode = mcpServerBridge.getPermissionMode();
const existingRun = acpChatRuns.get(chatSessionId);
if (existingRun && existingRun.requestId !== requestId) {
existingRun.cancelRequested = true;
const existingController = acpActiveStreams.get(existingRun.requestId);
if (existingController) {
existingController.abort();
acpActiveStreams.delete(existingRun.requestId);
}
acpRequestSessions.delete(existingRun.requestId);
cleanupAcpProvider(chatSessionId);
}
let providerEntry = acpProviders.get(chatSessionId);
const shouldForceProviderReset = acpForceProviderReset.has(chatSessionId);
const shouldReuseProvider = Boolean(
!shouldForceProviderReset &&
providerEntry &&
providerEntry.acpCommand === acpCommand &&
providerEntry.cwd === sessionCwd &&
@@ -1613,6 +1792,7 @@ function registerHandlers(ipcMain) {
);
if (!shouldReuseProvider) {
const resumeSessionId = providerEntry?.provider?.getSessionId?.() || existingSessionId || undefined;
cleanupAcpProvider(chatSessionId);
const agentEnv = { ...shellEnv };
@@ -1620,18 +1800,25 @@ function registerHandlers(ipcMain) {
agentEnv.CODEX_API_KEY = apiKey;
}
const claudeAcp = isClaudeAgent ? resolveClaudeAcpBinaryPath(shellEnv, electronModule) : null;
const resolvedCommand = isCodexAgent
? resolveCodexAcpBinaryPath(shellEnv, electronModule)
: acpCommand;
: claudeAcp
? claudeAcp.command
: acpCommand;
const resolvedArgs = claudeAcp
? [...claudeAcp.prependArgs, ...(acpArgs || [])]
: acpArgs || [];
const provider = createACPProvider({
command: resolvedCommand,
args: acpArgs || [],
args: resolvedArgs,
env: agentEnv,
session: {
cwd: sessionCwd,
mcpServers: mcpSnapshot.mcpServers,
},
...(resumeSessionId ? { existingSessionId: resumeSessionId } : {}),
...(isCodexAgent
? { authMethodId: apiKey ? "codex-api-key" : "chatgpt" }
: {}),
@@ -1645,46 +1832,144 @@ function registerHandlers(ipcMain) {
authFingerprint,
mcpFingerprint: mcpSnapshot.fingerprint,
permissionMode: currentPermissionMode,
historyReplayFallback: false,
};
acpProviders.set(chatSessionId, providerEntry);
}
acpForceProviderReset.delete(chatSessionId);
const abortController = new AbortController();
let modelInstance = providerEntry.provider.languageModel(model || undefined);
try {
await providerEntry.provider.initSession(providerEntry.provider.tools);
} catch (err) {
const attemptedResumeSessionId = providerEntry.provider?.getSessionId?.() || existingSessionId;
if (!attemptedResumeSessionId || !isUnsupportedLoadSessionError(err)) {
throw err;
}
cleanupAcpProvider(chatSessionId);
const fallbackClaudeAcp = isClaudeAgent ? resolveClaudeAcpBinaryPath(shellEnv, electronModule) : null;
const fallbackProvider = createACPProvider({
command: isCodexAgent
? resolveCodexAcpBinaryPath(shellEnv, electronModule)
: fallbackClaudeAcp
? fallbackClaudeAcp.command
: acpCommand,
args: fallbackClaudeAcp
? [...fallbackClaudeAcp.prependArgs, ...(acpArgs || [])]
: acpArgs || [],
env: apiKey ? { ...shellEnv, CODEX_API_KEY: apiKey } : { ...shellEnv },
session: {
cwd: sessionCwd,
mcpServers: mcpSnapshot.mcpServers,
},
...(isCodexAgent
? { authMethodId: apiKey ? "codex-api-key" : "chatgpt" }
: {}),
persistSession: true,
});
providerEntry = {
provider: fallbackProvider,
acpCommand,
cwd: sessionCwd,
authFingerprint,
mcpFingerprint: mcpSnapshot.fingerprint,
permissionMode: currentPermissionMode,
historyReplayFallback: Array.isArray(historyMessages) && historyMessages.length > 0,
};
acpProviders.set(chatSessionId, providerEntry);
modelInstance = providerEntry.provider.languageModel(model || undefined);
await providerEntry.provider.initSession(providerEntry.provider.tools);
}
const activeProviderSessionId = providerEntry.provider.getSessionId?.() || null;
if (activeProviderSessionId) {
safeSend(event.sender, "netcatty:ai:acp:event", {
requestId,
event: { type: "session-id", sessionId: activeProviderSessionId },
});
}
abortController = new AbortController();
acpActiveStreams.set(requestId, abortController);
acpRequestSessions.set(requestId, chatSessionId);
acpChatRuns.set(chatSessionId, { requestId, cancelRequested: false });
// Prepend context hint so the agent uses MCP tools for remote hosts
// Prepend context hint so the agent uses Netcatty MCP tools for the scoped sessions
const contextualPrompt =
`[Context: You are inside Netcatty, a multi-host SSH terminal manager. ` +
`The user is managing REMOTE servers, not the local machine. ` +
`Use the "netcatty-remote-hosts" MCP tools to operate on the remote hosts. ` +
`Call get_environment first to discover available hosts and their session IDs. ` +
`[Context: You are inside Netcatty, a multi-session terminal manager. ` +
`Use the "netcatty-remote-hosts" MCP tools to operate only on the terminal sessions exposed by Netcatty. ` +
`Those sessions may be remote hosts, a local terminal, or Mosh-backed shells. ` +
`Call get_environment first to discover available sessions and their IDs. ` +
`For normal shell commands, use terminal_execute so you receive command output. ` +
`Use terminal_send_input only to respond to an interactive prompt that is already running; it does not read back the updated terminal output. ` +
`Do NOT use local shell execution.]\n\n${prompt}`;
`SFTP file tools only work for remote SSH sessions, not local terminals.]\n\n${prompt}`;
// Build message content: text + optional images
function buildMessageContent(text, imgs) {
const content = [{ type: "text", text }];
if (Array.isArray(imgs)) {
for (const img of imgs) {
if (!img.base64Data || !img.mediaType) continue;
// Build message content: text + optional attachments
// ACP provider only supports image/* and audio/* inline via `type: "file"`.
// For other file types (PDF, text, etc.), tell the agent the original file
// path so it can read it directly — ACP agents have local file access.
function buildMessageContent(text, attachments) {
if (!Array.isArray(attachments) || attachments.length === 0) {
return text;
}
const content = [];
const fileHints = [];
for (const att of attachments) {
if (!att.base64Data || !att.mediaType) continue;
if (att.mediaType.startsWith("image/")) {
// Images: pass inline as ACP-compatible file parts
content.push({
type: "file",
mediaType: img.mediaType,
data: img.base64Data,
...(img.filename ? { filename: img.filename } : {}),
mediaType: att.mediaType,
data: att.base64Data,
...(att.filename ? { filename: att.filename } : {}),
});
} else if (att.filePath) {
// Non-image files with a known local path: tell the agent to read it
fileHints.push(`[Attached file "${att.filename || "file"}" is on the LOCAL machine (not a remote server), path: ${att.filePath} — read it locally]`);
} else {
// Pasted/virtual files without a path: save to managed temp dir so the agent can read them
try {
const fs = require("node:fs");
const tempDirBridge = require("./tempDirBridge.cjs");
const safeName = att.filename || `file-${Date.now()}`;
const tempPath = tempDirBridge.getTempFilePath(safeName);
fs.writeFileSync(tempPath, Buffer.from(att.base64Data, "base64"));
fileHints.push(`[Attached file "${att.filename || safeName}" is on the LOCAL machine (not a remote server), path: ${tempPath} — read it locally]`);
} catch (err) {
console.error("[ACP] Failed to save pasted attachment to temp:", err?.message || err);
}
}
}
const fullText = fileHints.length > 0
? fileHints.join("\n") + "\n\n" + text
: text;
content.unshift({ type: "text", text: fullText });
return content;
}
const latestPromptMessage = {
role: "user",
content: buildMessageContent(contextualPrompt, images),
};
const result = streamText({
model: providerEntry.provider.languageModel(model || undefined),
messages: [{
role: "user",
content: buildMessageContent(contextualPrompt, images),
}],
model: modelInstance,
messages: providerEntry.historyReplayFallback
? [
...(Array.isArray(historyMessages)
? historyMessages.map((msg) => ({ role: msg.role, content: msg.content }))
: []),
latestPromptMessage,
]
: [latestPromptMessage],
tools: providerEntry.provider.tools,
stopWhen: stepCountIs(mcpServerBridge.getMaxIterations ? mcpServerBridge.getMaxIterations() : 20),
abortSignal: abortController.signal,
@@ -1698,6 +1983,7 @@ function registerHandlers(ipcMain) {
if (stallTimer) clearTimeout(stallTimer);
stallTimer = setTimeout(() => {
if (!abortController.signal.aborted) {
if (!isActiveAcpRun(chatSessionId, requestId)) return;
safeSend(event.sender, "netcatty:ai:acp:event", {
requestId,
event: { type: "status", message: "Waiting for response from agent..." },
@@ -1710,6 +1996,7 @@ function registerHandlers(ipcMain) {
while (true) {
const { done, value: chunk } = await reader.read();
if (done || abortController.signal.aborted) break;
if (!isActiveAcpRun(chatSessionId, requestId)) break;
resetStallTimer();
try {
const serialized = serializeStreamChunk(chunk);
@@ -1733,6 +2020,9 @@ function registerHandlers(ipcMain) {
// If stream completed with zero content, likely an auth or connection issue
if (!hasContent && !abortController.signal.aborted) {
if (!isActiveAcpRun(chatSessionId, requestId)) {
return { ok: true };
}
safeSend(event.sender, "netcatty:ai:acp:error", {
requestId,
error: isCodexAgent
@@ -1740,6 +2030,9 @@ function registerHandlers(ipcMain) {
: "Agent returned an empty response.",
});
} else {
if (!isActiveAcpRun(chatSessionId, requestId)) {
return { ok: true };
}
safeSend(event.sender, "netcatty:ai:acp:done", { requestId });
}
} catch (err) {
@@ -1761,27 +2054,54 @@ function registerHandlers(ipcMain) {
});
} finally {
acpActiveStreams.delete(requestId);
acpRequestSessions.delete(requestId);
const activeRun = acpChatRuns.get(chatSessionId);
if (activeRun?.requestId === requestId) {
if (abortController?.signal?.aborted || activeRun.cancelRequested) {
cleanupAcpProvider(chatSessionId);
}
acpChatRuns.delete(chatSessionId);
}
}
return { ok: true };
});
ipcMain.handle("netcatty:ai:acp:cancel", async (event, { requestId }) => {
ipcMain.handle("netcatty:ai:acp:cancel", async (event, { requestId, chatSessionId }) => {
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
// Cancel any active PTY executions (send Ctrl+C)
mcpServerBridge.cancelAllPtyExecs();
const effectiveChatSessionId = chatSessionId || acpRequestSessions.get(requestId);
mcpServerBridge.setChatSessionCancelled?.(effectiveChatSessionId, true);
mcpServerBridge.clearPendingApprovals(effectiveChatSessionId);
const activeRun = effectiveChatSessionId ? acpChatRuns.get(effectiveChatSessionId) : null;
if (activeRun && activeRun.requestId === requestId) {
activeRun.cancelRequested = true;
}
const controller = acpActiveStreams.get(requestId);
let cancelled = false;
if (controller) {
controller.abort();
acpActiveStreams.delete(requestId);
return { ok: true };
cancelled = true;
}
return { ok: false, error: "Stream not found" };
if (effectiveChatSessionId) {
acpForceProviderReset.add(effectiveChatSessionId);
cleanupAcpProvider(effectiveChatSessionId);
}
// Preserve the ACP provider session on stop so the next user message can
// continue within the same persisted conversation context. Full provider
// cleanup is handled by netcatty:ai:acp:cleanup when the chat is deleted.
if (effectiveChatSessionId) cancelled = true;
acpRequestSessions.delete(requestId);
return cancelled ? { ok: true } : { ok: false, error: "Stream not found" };
});
// Cleanup a specific ACP session (when chat session is deleted)
ipcMain.handle("netcatty:ai:acp:cleanup", async (event, { chatSessionId }) => {
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
mcpServerBridge.setChatSessionCancelled?.(chatSessionId, true);
acpForceProviderReset.delete(chatSessionId);
cleanupAcpProvider(chatSessionId);
mcpServerBridge.cleanupScopedMetadata(chatSessionId);
return { ok: true };
+11 -1
View File
@@ -161,7 +161,17 @@ async function renameLocalFile(event, payload) {
* Create a local directory
*/
async function mkdirLocal(event, payload) {
await fs.promises.mkdir(payload.path, { recursive: true });
try {
await fs.promises.mkdir(payload.path, { recursive: true });
} catch (err) {
// On Windows, mkdir on drive roots (e.g. "E:\") throws EPERM.
// If the directory already exists, that's fine — ignore the error.
try {
const stat = await fs.promises.stat(payload.path);
if (stat.isDirectory()) return true;
} catch { /* stat failed, re-throw original */ }
throw err;
}
return true;
}
+193 -31
View File
@@ -15,7 +15,7 @@ const { existsSync } = require("node:fs");
const { toUnpackedAsarPath } = require("./ai/shellUtils.cjs");
const { execViaPty, execViaChannel } = require("./ai/ptyExec.cjs");
let sessions = null; // Map<sessionId, { sshClient, stream, pty, conn, ... }>
let sessions = null; // Map<sessionId, { sshClient, stream, pty, proc, conn, ... }>
let sftpClients = null; // Map<sftpId, SFTPWrapper>
let tcpServer = null;
let tcpPort = null;
@@ -55,6 +55,92 @@ let permissionMode = "confirm";
// Track active PTY executions for cancellation
const activePtyExecs = new Map(); // marker → { ptyStream, cleanup }
const cancelledChatSessions = new Set();
// ── Approval gate (for confirm mode with ACP/MCP agents) ──
let getMainWindowFn = null; // () => BrowserWindow | null
const pendingApprovals = new Map(); // approvalId → { resolve, chatSessionId }
let approvalIdCounter = 0;
function setMainWindowGetter(fn) {
getMainWindowFn = fn;
}
/**
* Request approval from the renderer process.
* Sends an IPC event and returns a Promise<boolean> that resolves
* when the user approves/rejects in the UI, or auto-denies after timeout.
*/
const APPROVAL_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
function requestApprovalFromRenderer(toolName, args, chatSessionId) {
return new Promise((resolve) => {
const mainWin = typeof getMainWindowFn === 'function' ? getMainWindowFn() : null;
if (!mainWin || mainWin.isDestroyed()) {
// No renderer available — deny to preserve confirm mode safety guarantee
resolve(false);
return;
}
const approvalId = `mcp_approval_${++approvalIdCounter}_${Date.now()}`;
// Auto-deny after timeout so ACP/MCP tool calls don't hang indefinitely
const timerId = setTimeout(() => {
if (pendingApprovals.has(approvalId)) {
pendingApprovals.delete(approvalId);
resolve(false);
// Notify renderer to remove the stale approval card
try {
const win = typeof getMainWindowFn === 'function' ? getMainWindowFn() : null;
if (win && !win.isDestroyed()) {
win.webContents.send('netcatty:ai:mcp:approval-cleared', { approvalIds: [approvalId] });
}
} catch { /* ignore */ }
}
}, APPROVAL_TIMEOUT_MS);
pendingApprovals.set(approvalId, {
resolve: (approved) => {
clearTimeout(timerId);
resolve(approved);
},
chatSessionId: chatSessionId || null,
});
mainWin.webContents.send('netcatty:ai:mcp:approval-request', {
approvalId,
toolName,
args,
chatSessionId: chatSessionId || undefined,
});
});
}
function resolveApprovalFromRenderer(approvalId, approved) {
const entry = pendingApprovals.get(approvalId);
if (entry) {
pendingApprovals.delete(approvalId);
entry.resolve(approved);
}
}
/**
* Clear pending MCP approvals, optionally scoped to a specific chatSessionId.
* Resolves matched entries with false (denied) to unblock hanging promises.
*/
function clearPendingApprovals(chatSessionId) {
if (!chatSessionId) {
for (const [, entry] of pendingApprovals) {
entry.resolve(false);
}
pendingApprovals.clear();
return;
}
for (const [id, entry] of pendingApprovals) {
if (entry.chatSessionId === chatSessionId) {
pendingApprovals.delete(id);
entry.resolve(false);
}
}
}
function cancelAllPtyExecs() {
for (const [marker, entry] of activePtyExecs) {
@@ -116,11 +202,24 @@ function getPermissionMode() {
return permissionMode;
}
function setChatSessionCancelled(chatSessionId, cancelled) {
if (!chatSessionId) return;
if (cancelled) {
cancelledChatSessions.add(chatSessionId);
} else {
cancelledChatSessions.delete(chatSessionId);
}
}
function isChatSessionCancelled(chatSessionId) {
return Boolean(chatSessionId && cancelledChatSessions.has(chatSessionId));
}
/**
* Register metadata for terminal sessions (called from renderer via IPC).
* Metadata is stored per-scope (chatSessionId) so different AI chat sessions
* only see their own hosts.
* @param {Array<{sessionId, hostname, label, os, username, connected}>} sessionList
* @param {Array<{sessionId, hostname, label, os, username, connected, protocol?, shellType?}>} sessionList
* @param {string} [chatSessionId] - AI chat session ID for per-scope isolation
*/
function updateSessionMetadata(sessionList, chatSessionId) {
@@ -132,6 +231,8 @@ function updateSessionMetadata(sessionList, chatSessionId) {
label: s.label || "",
os: s.os || "",
username: s.username || "",
protocol: s.protocol || "",
shellType: s.shellType || "",
connected: s.connected !== false,
});
}
@@ -175,6 +276,20 @@ function getSessionMeta(sessionId, chatSessionId) {
return null;
}
function sessionSupportsSftp(session) {
const sshClient = session?.conn || session?.sshClient;
return !!(sshClient && typeof sshClient.exec === "function");
}
function scopeHasSftpSessions(sessionIds) {
if (!Array.isArray(sessionIds) || sessionIds.length === 0) return false;
for (const sessionId of sessionIds) {
const session = sessions?.get(sessionId);
if (sessionSupportsSftp(session)) return true;
}
return false;
}
/**
* Run an array of async task factories with a concurrency limit.
*/
@@ -336,6 +451,19 @@ async function dispatch(method, params) {
return { ok: false, error: `Operation denied: permission mode is "observer" (read-only). Change to "confirm" or "autonomous" in Settings → AI → Safety to allow this action.` };
}
if (WRITE_METHODS.has(method) && isChatSessionCancelled(params?.chatSessionId)) {
return { ok: false, error: "Operation cancelled: the ACP session was stopped." };
}
// Confirm mode: request user approval for write operations
if (permissionMode === "confirm" && WRITE_METHODS.has(method)) {
const { chatSessionId, ...toolArgs } = params || {};
const approved = await requestApprovalFromRenderer(method, toolArgs, chatSessionId);
if (!approved) {
return { ok: false, error: "Operation denied by user." };
}
}
// Scope validation for session-targeted operations
if (method !== "netcatty/getContext" && params?.sessionId) {
const scopeErr = validateSessionScope(params.sessionId, params?.chatSessionId);
@@ -382,20 +510,19 @@ async function dispatch(method, params) {
function handleGetContext(params) {
if (!sessions) return { hosts: [], instructions: "No sessions available." };
// Scope resolution: use explicit scopedSessionIds from MCP server env var (per-process, set at spawn).
// If scopedSessionIds is provided but empty, that means "no access" (not "all access").
// Only fall back to unscoped (show all) when scopedSessionIds is not provided at all.
const hasScopeParam = params?.scopedSessionIds != null;
const scopedIds = hasScopeParam
? new Set(params.scopedSessionIds)
: null;
// chatSessionId may be passed via env for per-scope metadata lookup
const chatSessionId = params?.chatSessionId || null;
const explicitScopedIds = Array.isArray(params?.scopedSessionIds)
? params.scopedSessionIds
: null;
const resolvedScopedIds = explicitScopedIds ?? (chatSessionId ? getScopedSessionIds(chatSessionId) : null);
const hasScopedContext = explicitScopedIds !== null || chatSessionId !== null;
const scopedIds = resolvedScopedIds ? new Set(resolvedScopedIds) : null;
const hosts = [];
// When scope param is provided (even if empty Set), enforce it strictly
if (hasScopeParam && scopedIds.size === 0) {
// When a scoped context exists but currently resolves to zero sessions, treat
// it as "no access" rather than falling back to all sessions.
if (hasScopedContext && (!resolvedScopedIds || resolvedScopedIds.length === 0)) {
return {
environment: "netcatty-terminal",
description: "No hosts are available in the current scope.",
@@ -405,9 +532,11 @@ function handleGetContext(params) {
}
for (const [sessionId, session] of sessions.entries()) {
if (scopedIds && !scopedIds.has(sessionId)) continue;
// Only include SSH sessions (skip local terminal sessions)
const ptyStream = session.stream || session.pty || session.proc;
const sshClient = session.conn || session.sshClient;
if (!sshClient || typeof sshClient.exec !== "function") continue;
const hasCommandablePty = ptyStream && typeof ptyStream.write === "function";
const hasSshExec = sshClient && typeof sshClient.exec === "function";
if (!hasCommandablePty && !hasSshExec) continue;
// Look up metadata scoped to this chat session
const meta = getSessionMeta(sessionId, chatSessionId) || {};
@@ -417,15 +546,19 @@ function handleGetContext(params) {
label: meta.label || session.label || "",
os: meta.os || "",
username: meta.username || session.username || "",
connected: meta.connected !== undefined ? meta.connected : !!(session.sshClient || session.conn),
protocol: meta.protocol || session.protocol || session.type || "",
shellType: meta.shellType || session.shellKind || "",
supportsSftp: sessionSupportsSftp(session),
connected: meta.connected !== undefined ? meta.connected : !!(session.sshClient || session.conn || ptyStream),
});
}
return {
environment: "netcatty-terminal",
description: "You are operating inside Netcatty, a multi-host SSH terminal manager. " +
"The user is managing remote servers. Use the provided tools to execute commands, " +
"read/write files, and manage hosts on the remote machines. " +
description: "You are operating inside Netcatty, a multi-session terminal manager. " +
"The available sessions may be remote hosts, local terminals, or Mosh-backed shells. " +
"Use the provided tools to execute commands through the sessions exposed by Netcatty. " +
"SFTP tools only work for remote SSH sessions. " +
"Always prefer these tools over suggesting the user to do things manually.",
hosts,
hostCount: hosts.length,
@@ -449,23 +582,36 @@ function handleExec(params) {
const session = sessions?.get(sessionId);
if (!session) return { ok: false, error: "Session not found" };
if ((session.protocol === "local" || session.type === "local") && session.shellKind === "unknown") {
return {
ok: false,
error: "AI execution is not supported for this local shell executable. Configure the local terminal to use bash/zsh/sh, fish, PowerShell/pwsh, or cmd.exe.",
};
}
const sshClient = session.conn || session.sshClient;
const ptyStream = session.stream || session.pty || session.proc;
// Prefer the interactive PTY so the user sees command/output in-session.
if (ptyStream && typeof ptyStream.write === "function") {
return execViaPty(ptyStream, command, {
trackForCancellation: activePtyExecs,
timeoutMs: commandTimeoutMs,
shellKind: session.shellKind,
});
}
// If no PTY stream, fall back to exec channel for SSH sessions only.
if (!sshClient || typeof sshClient.exec !== "function") {
return { ok: false, error: "Not an SSH session" };
return { ok: false, error: "Session does not support command execution" };
}
const ptyStream = session.stream;
// If no PTY stream, fall back to exec channel (invisible to terminal)
if (!ptyStream || typeof ptyStream.write !== "function") {
return execViaChannel(sshClient, command, { timeoutMs: commandTimeoutMs });
return execViaChannel(sshClient, command, {
timeoutMs: commandTimeoutMs,
trackForCancellation: activePtyExecs,
});
}
// Execute via PTY stream so user sees the command in the terminal
return execViaPty(ptyStream, command, {
trackForCancellation: activePtyExecs,
timeoutMs: commandTimeoutMs,
});
}
// ── Handler: terminalWrite ──
@@ -491,6 +637,10 @@ function handleTerminalWrite(params) {
session.pty.write(input);
return { ok: true };
}
if (session.proc) {
session.proc.write(input);
return { ok: true };
}
return { ok: false, error: "No writable stream" };
}
@@ -720,7 +870,7 @@ async function handleMultiExec(params) {
} else {
// Parallel execution with concurrency limit
const tasks = sessionIds.map((sid) => () => {
return handleExec({ sessionId: sid, command }).then(result => ({
return Promise.resolve(handleExec({ sessionId: sid, command })).then(result => ({
sid,
ok: result.ok,
output: result.ok ? (result.stdout || "(no output)") : `Error: ${result.error || result.stderr || "Failed"}`,
@@ -755,7 +905,9 @@ function buildMcpServerConfig(port, scopedSessionIds, chatSessionId) {
env.push({ name: "NETCATTY_MCP_TOKEN", value: authToken });
}
if (effectiveIds && effectiveIds.length > 0) {
// When chatSessionId is present, the MCP subprocess resolves scope dynamically
// through main-process metadata, so avoid freezing session IDs at spawn time.
if (!chatSessionId && effectiveIds && effectiveIds.length > 0) {
env.push({ name: "NETCATTY_MCP_SESSION_IDS", value: effectiveIds.join(",") });
}
@@ -764,6 +916,11 @@ function buildMcpServerConfig(port, scopedSessionIds, chatSessionId) {
env.push({ name: "NETCATTY_MCP_CHAT_SESSION_ID", value: chatSessionId });
}
env.push({
name: "NETCATTY_MCP_ENABLE_SFTP",
value: scopeHasSftpSessions(effectiveIds) ? "1" : "0",
});
// Pass permission mode so MCP server can enforce it locally (defense-in-depth)
env.push({ name: "NETCATTY_MCP_PERMISSION_MODE", value: permissionMode });
@@ -781,6 +938,7 @@ function buildMcpServerConfig(port, scopedSessionIds, chatSessionId) {
function cleanupScopedMetadata(chatSessionId) {
if (chatSessionId) {
scopedMetadata.delete(chatSessionId);
cancelledChatSessions.delete(chatSessionId);
}
}
@@ -802,6 +960,7 @@ module.exports = {
getMaxIterations,
setPermissionMode,
getPermissionMode,
setChatSessionCancelled,
checkCommandSafety,
updateSessionMetadata,
getScopedSessionIds,
@@ -810,4 +969,7 @@ module.exports = {
cancelAllPtyExecs,
cleanupScopedMetadata,
cleanup,
setMainWindowGetter,
resolveApprovalFromRenderer,
clearPendingApprovals,
};
@@ -0,0 +1,207 @@
/**
* Session Log Stream Manager - Manages real-time log write streams per session
* Writes terminal data to files in real-time instead of only on session close.
* Fixes issue #394 where session logs only capture ~55 lines.
*/
const fs = require("node:fs");
const path = require("node:path");
const { toLocalISOString, stripAnsi, terminalDataToHtml } = require("./sessionLogsBridge.cjs");
// Active log streams keyed by sessionId
const activeStreams = new Map();
// Buffer flush interval (ms)
const FLUSH_INTERVAL = 500;
// Max buffer size before immediate flush (bytes)
const MAX_BUFFER_SIZE = 64 * 1024;
/**
* Start a log stream for a session.
* Creates the log file and opens a write stream.
* @param {string} sessionId
* @param {{ hostLabel: string, hostname: string, directory: string, format: string, startTime?: number }} opts
*/
function startStream(sessionId, opts) {
if (activeStreams.has(sessionId)) {
console.warn(`[SessionLogStream] Stream already active for ${sessionId}, stopping old one`);
stopStream(sessionId);
}
const { hostLabel, hostname, directory, format, startTime } = opts;
if (!directory) {
console.warn("[SessionLogStream] No directory specified, skipping");
return;
}
try {
// Build file path: directory / hostSubdir / timestamp.ext
const safeHostLabel = (hostLabel || hostname || "unknown").replace(/[^a-zA-Z0-9-_]/g, "_");
const hostDir = path.join(directory, safeHostLabel);
fs.mkdirSync(hostDir, { recursive: true });
const date = new Date(startTime || Date.now());
const dateStr = toLocalISOString(date);
// For html format, write raw data to a temp file during streaming,
// then convert on stopStream.
const isHtml = format === "html";
const ext = isHtml ? "log.tmp" : format === "raw" ? "log" : "txt";
const fileName = `${dateStr}.${ext}`;
const filePath = path.join(hostDir, fileName);
const writeStream = fs.createWriteStream(filePath, { flags: "w", encoding: "utf8" });
writeStream.on("error", (err) => {
console.error(`[SessionLogStream] Write error for ${sessionId}:`, err.message);
// Disable this stream on error to avoid cascading failures
const entry = activeStreams.get(sessionId);
if (entry) {
entry.disabled = true;
}
});
const entry = {
writeStream,
filePath,
hostDir,
format,
isHtml,
hostLabel: hostLabel || hostname || "unknown",
startTime: startTime || Date.now(),
buffer: "",
flushTimer: null,
disabled: false,
};
// Start periodic flush
entry.flushTimer = setInterval(() => {
flushBuffer(entry);
}, FLUSH_INTERVAL);
activeStreams.set(sessionId, entry);
console.log(`[SessionLogStream] Started stream for ${sessionId} -> ${filePath}`);
} catch (err) {
console.error(`[SessionLogStream] Failed to start stream for ${sessionId}:`, err.message);
}
}
/**
* Flush buffered data to the write stream.
* @param {object} entry - The stream entry
*/
function flushBuffer(entry) {
if (!entry || entry.disabled || entry.buffer.length === 0) return;
try {
const data = entry.buffer;
entry.buffer = "";
if (entry.isHtml) {
// For HTML format, write raw data during streaming; convert on close
entry.writeStream.write(data);
} else if (entry.format === "raw") {
entry.writeStream.write(data);
} else {
// txt format: strip ANSI codes
entry.writeStream.write(stripAnsi(data));
}
} catch (err) {
console.error("[SessionLogStream] Flush error:", err.message);
entry.disabled = true;
}
}
/**
* Append data to the session's log buffer.
* Data is flushed periodically or when the buffer exceeds MAX_BUFFER_SIZE.
* @param {string} sessionId
* @param {string} dataChunk - Decoded terminal data string
*/
function appendData(sessionId, dataChunk) {
const entry = activeStreams.get(sessionId);
if (!entry || entry.disabled) return;
entry.buffer += dataChunk;
// Immediate flush if buffer is large
if (entry.buffer.length >= MAX_BUFFER_SIZE) {
flushBuffer(entry);
}
}
/**
* Stop the log stream for a session.
* Flushes remaining data, closes the write stream, and finalizes the file.
* @param {string} sessionId
* @returns {Promise<string|null>} The final file path, or null if no stream was active
*/
async function stopStream(sessionId) {
const entry = activeStreams.get(sessionId);
if (!entry) return null;
activeStreams.delete(sessionId);
// Stop periodic flush
if (entry.flushTimer) {
clearInterval(entry.flushTimer);
entry.flushTimer = null;
}
// Flush remaining buffer
flushBuffer(entry);
// Close the write stream and wait for it to finish
await new Promise((resolve) => {
entry.writeStream.end(resolve);
});
let finalPath = entry.filePath;
// For HTML format: read the temp raw file and convert to HTML
if (entry.isHtml && !entry.disabled) {
try {
const rawData = await fs.promises.readFile(entry.filePath, "utf8");
const htmlContent = terminalDataToHtml(rawData, entry.hostLabel, entry.startTime);
const htmlPath = entry.filePath.replace(/\.log\.tmp$/, ".html");
await fs.promises.writeFile(htmlPath, htmlContent, "utf8");
// Remove temp file
try {
await fs.promises.unlink(entry.filePath);
} catch {
// Ignore cleanup errors
}
finalPath = htmlPath;
} catch (err) {
console.error(`[SessionLogStream] HTML conversion failed for ${sessionId}:`, err.message);
// Keep the raw temp file as fallback
}
}
console.log(`[SessionLogStream] Stopped stream for ${sessionId} -> ${finalPath}`);
return finalPath;
}
/**
* Check if a session has an active log stream.
* @param {string} sessionId
* @returns {boolean}
*/
function hasStream(sessionId) {
return activeStreams.has(sessionId);
}
/**
* Cleanup all active streams (called on app quit).
*/
async function cleanupAll() {
console.log(`[SessionLogStream] Cleaning up ${activeStreams.size} active streams`);
const ids = [...activeStreams.keys()];
await Promise.allSettled(ids.map(id => stopStream(id)));
}
module.exports = {
startStream,
appendData,
stopStream,
hasStream,
cleanupAll,
};
+27 -3
View File
@@ -7,13 +7,36 @@ const fs = require("node:fs");
const path = require("node:path");
const { dialog } = require("electron");
/**
* Get current Date to a local ISO-like string (YYYY-MM-DDTHH-MM-SS)
*/
function toLocalISOString(date = new Date()) {
const pad = (n) => String(n).padStart(2, '0');
const year = date.getFullYear();
const month = pad(date.getMonth() + 1);
const day = pad(date.getDate());
const hours = pad(date.getHours());
const minutes = pad(date.getMinutes());
const seconds = pad(date.getSeconds());
return `${year}-${month}-${day}T${hours}-${minutes}-${seconds}`;
}
/**
* Strip ANSI escape codes from text
* Used for plain text export format
*/
function stripAnsi(str) {
// eslint-disable-next-line no-control-regex
return str.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
return str
// OSC: ESC ] ... BEL or ESC ] ... ESC \
.replace(/\x1B\][\s\S]*?(?:\x07|\x1B\\)/g, '')
// ANSI CSI / ESC sequences
// eslint-disable-next-line no-control-regex
.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "")
// Remove remaining control chars except \n \r \t
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '');
}
/**
@@ -148,7 +171,7 @@ async function exportSessionLog(event, payload) {
// Generate default filename
const date = new Date(startTime);
const dateStr = date.toISOString().replace(/[:.]/g, "-").slice(0, 19);
const dateStr = toLocalISOString(date);
const safeHostLabel = (hostLabel || hostname || "session").replace(/[^a-zA-Z0-9-_]/g, "_");
const ext = format === "html" ? "html" : format === "raw" ? "log" : "txt";
const defaultPath = `${safeHostLabel}_${dateStr}.${ext}`;
@@ -223,7 +246,7 @@ async function autoSaveSessionLog(event, payload) {
// Generate filename with timestamp
const date = new Date(startTime);
const dateStr = date.toISOString().replace(/[:.]/g, "-").slice(0, 19);
const dateStr = toLocalISOString(date);
const ext = format === "html" ? "html" : format === "raw" ? "log" : "txt";
const fileName = `${dateStr}.${ext}`;
const filePath = path.join(hostDir, fileName);
@@ -285,5 +308,6 @@ module.exports = {
autoSaveSessionLog,
openSessionLogsDir,
stripAnsi,
toLocalISOString,
terminalDataToHtml,
};
+39 -6
View File
@@ -22,6 +22,7 @@ const {
findAllDefaultPrivateKeys: findAllDefaultPrivateKeysFromHelper,
getSshAgentSocket,
} = require("./sshAuthHelper.cjs");
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
// Default SSH key names in priority order
const DEFAULT_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
@@ -977,6 +978,17 @@ async function startSSHSession(event, options) {
};
sessions.set(sessionId, session);
// Start real-time session log stream if configured
if (options.sessionLog?.enabled && options.sessionLog?.directory) {
sessionLogStreamManager.startStream(sessionId, {
hostLabel: options.hostLabel || options.hostname || '',
hostname: options.hostname || '',
directory: options.sessionLog.directory,
format: options.sessionLog.format || 'txt',
startTime: Date.now(),
});
}
// Data buffering for reduced IPC overhead
let dataBuffer = '';
let flushTimeout = null;
@@ -1009,12 +1021,29 @@ async function startSSHSession(event, options) {
stream.on("data", (data) => {
const decoder = getSessionDecoder(sessionId, "stdout");
bufferData(decoder.write(data));
const decoded = decoder.write(data);
bufferData(decoded);
sessionLogStreamManager.appendData(sessionId, decoded);
});
stream.stderr?.on("data", (data) => {
const decoder = getSessionDecoder(sessionId, "stderr");
bufferData(decoder.write(data));
const decoded = decoder.write(data);
bufferData(decoded);
sessionLogStreamManager.appendData(sessionId, decoded);
});
// Capture the real exit code from the remote process.
// "exit" fires when the remote shell/process exits normally;
// "close" fires whenever the channel closes (could be network drop).
// Only treat it as user-initiated exit if "exit" fired with a numeric
// code and no signal. Signal terminations (e.g. server kill, idle
// timeout) have code=null and signal set — those are not user exits.
let streamExitCode = 0;
let streamExited = false;
stream.on("exit", (code, signal) => {
streamExitCode = typeof code === "number" ? code : 0;
streamExited = typeof code === "number" && !signal;
});
stream.on("close", () => {
@@ -1023,8 +1052,9 @@ async function startSSHSession(event, options) {
clearTimeout(flushTimeout);
}
flushBuffer();
sessionLogStreamManager.stopStream(sessionId);
const contents = event.sender;
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 0 });
safeSend(contents, "netcatty:exit", { sessionId, exitCode: streamExitCode, reason: streamExited ? "exited" : "closed" });
sessions.delete(sessionId);
sessionEncodings.delete(sessionId);
sessionDecoders.delete(sessionId);
@@ -1072,7 +1102,8 @@ async function startSSHSession(event, options) {
console.error(`${logPrefix} ${options.hostname} error:`, err.message);
}
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message });
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "error" });
sessionLogStreamManager.stopStream(sessionId);
sessions.delete(sessionId);
sessionEncodings.delete(sessionId);
sessionDecoders.delete(sessionId);
@@ -1086,7 +1117,8 @@ async function startSSHSession(event, options) {
console.error(`${logPrefix} ${options.hostname} connection timeout`);
const err = new Error(`Connection timeout to ${options.hostname}`);
const contents = event.sender;
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message });
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "timeout" });
sessionLogStreamManager.stopStream(sessionId);
sessions.delete(sessionId);
sessionEncodings.delete(sessionId);
sessionDecoders.delete(sessionId);
@@ -1098,7 +1130,8 @@ async function startSSHSession(event, options) {
conn.once("close", () => {
const contents = event.sender;
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 0 });
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 0, reason: "closed" });
sessionLogStreamManager.stopStream(sessionId);
sessions.delete(sessionId);
sessionEncodings.delete(sessionId);
sessionDecoders.delete(sessionId);
+114 -19
View File
@@ -11,6 +11,9 @@ const { StringDecoder } = require("node:string_decoder");
const pty = require("node-pty");
const { SerialPort } = require("serialport");
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
const { detectShellKind } = require("./ai/ptyExec.cjs");
// Shared references
let sessions = null;
let electronModule = null;
@@ -19,6 +22,22 @@ const DEFAULT_UTF8_LOCALE = "en_US.UTF-8";
const LOGIN_SHELLS = new Set(["bash", "zsh", "fish", "ksh"]);
const POWERSHELL_SHELLS = new Set(["powershell", "powershell.exe", "pwsh", "pwsh.exe"]);
function expandHomePath(targetPath) {
if (!targetPath) return targetPath;
if (targetPath === "~") return os.homedir();
if (targetPath.startsWith("~/")) return path.join(os.homedir(), targetPath.slice(2));
return targetPath;
}
function normalizeExecutablePath(targetPath) {
const expanded = expandHomePath(targetPath);
if (!expanded) return expanded;
if (expanded.includes(path.sep) || expanded.startsWith(".")) {
return path.resolve(expanded);
}
return expanded;
}
const getLoginShellArgs = (shellPath) => {
if (!shellPath || process.platform === "win32") return [];
const shellName = path.basename(shellPath);
@@ -172,8 +191,9 @@ function startLocalSession(event, payload) {
payload?.sessionId ||
`${Date.now()}-${Math.random().toString(16).slice(2)}`;
const defaultShell = getDefaultLocalShell();
const shell = payload?.shell || defaultShell;
const shell = normalizeExecutablePath(payload?.shell) || defaultShell;
const shellArgs = getLocalShellArgs(shell);
const shellKind = detectShellKind(shell);
const env = applyLocaleDefaults({
...process.env,
...(payload?.env || {}),
@@ -189,7 +209,7 @@ function startLocalSession(event, payload) {
if (payload?.cwd) {
try {
// Resolve to absolute path and check if it exists and is a directory
const resolvedPath = path.resolve(payload.cwd);
const resolvedPath = path.resolve(expandHomePath(payload.cwd));
if (fs.existsSync(resolvedPath) && fs.statSync(resolvedPath).isDirectory()) {
cwd = resolvedPath;
} else {
@@ -210,21 +230,52 @@ function startLocalSession(event, payload) {
const session = {
proc,
pty: proc,
type: "local",
protocol: "local",
webContentsId: event.sender.id,
hostname: "localhost",
username: (() => {
try {
return os.userInfo().username || "local";
} catch {
return "local";
}
})(),
label: "Local Terminal",
shellExecutable: shell,
shellKind,
};
sessions.set(sessionId, session);
// Start real-time session log stream if configured
if (payload?.sessionLog?.enabled && payload?.sessionLog?.directory) {
sessionLogStreamManager.startStream(sessionId, {
hostLabel: "Local",
hostname: "localhost",
directory: payload.sessionLog.directory,
format: payload.sessionLog.format || "txt",
startTime: Date.now(),
});
}
proc.onData((data) => {
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:data", { sessionId, data });
sessionLogStreamManager.appendData(sessionId, data);
});
proc.onExit((evt) => {
sessionLogStreamManager.stopStream(sessionId);
sessions.delete(sessionId);
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:exit", { sessionId, ...evt });
// Signal present = killed externally (show disconnected UI).
// No signal = process exited normally, even with non-zero code
// (e.g. user typed `exit` after a failed command), so auto-close.
const reason = evt.signal ? "error" : "exited";
contents?.send("netcatty:exit", { sessionId, ...evt, reason });
});
return { sessionId };
}
@@ -386,6 +437,17 @@ async function startTelnetSession(event, options) {
};
sessions.set(sessionId, session);
// Start real-time session log stream if configured
if (options.sessionLog?.enabled && options.sessionLog?.directory) {
sessionLogStreamManager.startStream(sessionId, {
hostLabel: options.label || hostname,
hostname,
directory: options.sessionLog.directory,
format: options.sessionLog.format || "txt",
startTime: Date.now(),
});
}
resolve({ sessionId });
});
@@ -412,6 +474,7 @@ async function startTelnetSession(event, options) {
if (decoded) {
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:data", { sessionId, data: decoded });
sessionLogStreamManager.appendData(sessionId, decoded);
}
}
});
@@ -419,14 +482,15 @@ async function startTelnetSession(event, options) {
socket.on('error', (err) => {
console.error(`[Telnet] Socket error: ${err.message}`);
clearTimeout(connectTimeout);
if (!connected) {
reject(new Error(`Failed to connect: ${err.message}`));
} else {
sessionLogStreamManager.stopStream(sessionId);
const session = sessions.get(sessionId);
if (session) {
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:exit", { sessionId, exitCode: 1, error: err.message });
contents?.send("netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "error" });
}
sessions.delete(sessionId);
}
@@ -435,11 +499,12 @@ async function startTelnetSession(event, options) {
socket.on('close', (hadError) => {
console.log(`[Telnet] Connection closed${hadError ? ' with error' : ''}`);
clearTimeout(connectTimeout);
sessionLogStreamManager.stopStream(sessionId);
const session = sessions.get(sessionId);
if (session) {
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:exit", { sessionId, exitCode: hadError ? 1 : 0 });
contents?.send("netcatty:exit", { sessionId, exitCode: hadError ? 1 : 0, reason: hadError ? "error" : "closed" });
}
sessions.delete(sessionId);
});
@@ -510,20 +575,41 @@ async function startMoshSession(event, options) {
const session = {
proc,
pty: proc,
type: 'mosh',
protocol: 'mosh',
webContentsId: event.sender.id,
hostname: options.hostname || '',
username: options.username || '',
label: options.label || options.hostname || 'Mosh Session',
shellKind: 'posix',
shellExecutable: 'remote-shell',
};
sessions.set(sessionId, session);
// Start real-time session log stream if configured
if (options.sessionLog?.enabled && options.sessionLog?.directory) {
sessionLogStreamManager.startStream(sessionId, {
hostLabel: options.label || options.hostname,
hostname: options.hostname,
directory: options.sessionLog.directory,
format: options.sessionLog.format || "txt",
startTime: Date.now(),
});
}
proc.onData((data) => {
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:data", { sessionId, data });
sessionLogStreamManager.appendData(sessionId, data);
});
proc.onExit((evt) => {
sessionLogStreamManager.stopStream(sessionId);
sessions.delete(sessionId);
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:exit", { sessionId, ...evt });
// Mosh non-zero exit typically means connection/auth failure — show error UI
contents?.send("netcatty:exit", { sessionId, ...evt, reason: evt.exitCode === 0 ? "exited" : "error" });
});
return { sessionId };
@@ -602,6 +688,17 @@ async function startSerialSession(event, options) {
};
sessions.set(sessionId, session);
// Start real-time session log stream if configured
if (options.sessionLog?.enabled && options.sessionLog?.directory) {
sessionLogStreamManager.startStream(sessionId, {
hostLabel: options.label || portPath,
hostname: portPath,
directory: options.sessionLog.directory,
format: options.sessionLog.format || "txt",
startTime: Date.now(),
});
}
const serialDecoder = new StringDecoder('latin1');
serialPort.on('data', (data) => {
@@ -609,20 +706,23 @@ async function startSerialSession(event, options) {
if (decoded) {
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:data", { sessionId, data: decoded });
sessionLogStreamManager.appendData(sessionId, decoded);
}
});
serialPort.on('error', (err) => {
console.error(`[Serial] Port error: ${err.message}`);
sessionLogStreamManager.stopStream(sessionId);
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:exit", { sessionId, exitCode: 1, error: err.message });
contents?.send("netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "error" });
sessions.delete(sessionId);
});
serialPort.on('close', () => {
console.log(`[Serial] Port closed`);
sessionLogStreamManager.stopStream(sessionId);
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:exit", { sessionId, exitCode: 0 });
contents?.send("netcatty:exit", { sessionId, exitCode: 0, reason: "closed" });
sessions.delete(sessionId);
});
@@ -757,12 +857,7 @@ function validatePath(event, payload) {
try {
// Resolve path (handle ~, etc.)
let resolvedPath = targetPath;
if (resolvedPath === "~") {
resolvedPath = os.homedir();
} else if (resolvedPath.startsWith("~/")) {
resolvedPath = path.join(os.homedir(), resolvedPath.slice(2));
}
let resolvedPath = expandHomePath(targetPath);
resolvedPath = path.resolve(resolvedPath);
if (fs.existsSync(resolvedPath)) {
+209 -15
View File
@@ -8,6 +8,24 @@ const path = require("node:path");
const os = require("node:os");
const { encodePathForSession, ensureRemoteDirForSession, requireSftpChannel } = require("./sftpBridge.cjs");
/**
* Safely ensure a local directory exists.
* On Windows, `mkdir("E:\\", { recursive: true })` throws EPERM for drive roots.
* We catch that and verify the directory already exists before re-throwing.
*/
async function ensureLocalDir(dir) {
try {
await fs.promises.mkdir(dir, { recursive: true });
} catch (err) {
// If the directory already exists, ignore the error (covers EPERM on drive roots)
try {
const stat = await fs.promises.stat(dir);
if (stat.isDirectory()) return;
} catch { /* stat failed, re-throw original */ }
throw err;
}
}
// ── Transfer performance tuning ──────────────────────────────────────────────
// ssh2's fastPut/fastGet send multiple SFTP read/write requests in parallel,
// dramatically improving throughput over sequential stream piping.
@@ -16,6 +34,7 @@ const TRANSFER_CONCURRENCY = 64; // 64 parallel SFTP requests
// Progress IPC throttle: sending too many IPC messages bogs down the event loop
const PROGRESS_THROTTLE_MS = 100; // Send IPC at most every 100ms
const PROGRESS_THROTTLE_BYTES = 256 * 1024; // Or every 256KB of progress
const ISOLATED_DOWNLOAD_IDLE_TTL_MS = 5000;
// Speed calculation uses strict sliding-window average:
// speed = bytes_delta_in_window / time_delta_in_window
@@ -27,6 +46,7 @@ let sftpClients = null;
// Active transfers storage
const activeTransfers = new Map();
const isolatedDownloadChannelPools = new WeakMap();
/**
* Initialize the transfer bridge with dependencies
@@ -47,6 +67,185 @@ async function openIsolatedSftpChannel(client) {
});
}
function getIsolatedDownloadChannelPool(client) {
let pool = isolatedDownloadChannelPools.get(client);
if (!pool) {
pool = {
idle: [],
idleTimers: new Map(),
busy: new Set(),
waiters: [],
opening: 0,
maxChannels: null,
warnedCapacity: false,
};
isolatedDownloadChannelPools.set(client, pool);
}
return pool;
}
function isIsolatedChannelOpenFailure(err) {
const message = err?.message || String(err || "");
return (
message.includes("Channel open failure") ||
message.includes("open failed")
);
}
function waitForIsolatedDownloadChannel(pool, transfer) {
return new Promise((resolve) => {
let settled = false;
const waiter = () => {
if (settled) return;
settled = true;
const index = pool.waiters.indexOf(waiter);
if (index !== -1) {
pool.waiters.splice(index, 1);
}
if (transfer?.wakeWaiter === waiter) {
transfer.wakeWaiter = null;
}
resolve();
};
if (transfer) {
transfer.wakeWaiter = waiter;
}
pool.waiters.push(waiter);
});
}
function notifyIsolatedDownloadWaiter(pool) {
const waiter = pool.waiters.shift();
if (waiter) waiter();
}
function removeIdleIsolatedDownloadChannel(pool, sftp) {
const index = pool.idle.indexOf(sftp);
if (index !== -1) {
pool.idle.splice(index, 1);
}
}
function clearIdleIsolatedDownloadTimer(pool, sftp) {
const timer = pool.idleTimers.get(sftp);
if (timer) {
clearTimeout(timer);
pool.idleTimers.delete(sftp);
}
}
function scheduleIdleIsolatedDownloadChannel(client, sftp) {
const pool = isolatedDownloadChannelPools.get(client);
if (!pool) return;
clearIdleIsolatedDownloadTimer(pool, sftp);
const timer = setTimeout(() => {
clearIdleIsolatedDownloadTimer(pool, sftp);
removeIdleIsolatedDownloadChannel(pool, sftp);
try { sftp?.end?.(); } catch { }
}, ISOLATED_DOWNLOAD_IDLE_TTL_MS);
pool.idleTimers.set(sftp, timer);
}
function releaseIsolatedDownloadChannel(client, sftp, options = {}) {
const { dispose = false } = options;
const pool = isolatedDownloadChannelPools.get(client);
if (!pool) {
if (dispose) {
try { sftp?.end?.(); } catch { }
}
return;
}
pool.busy.delete(sftp);
clearIdleIsolatedDownloadTimer(pool, sftp);
if (dispose) {
try { sftp?.end?.(); } catch { }
notifyIsolatedDownloadWaiter(pool);
return;
}
pool.idle.push(sftp);
scheduleIdleIsolatedDownloadChannel(client, sftp);
notifyIsolatedDownloadWaiter(pool);
}
async function acquireIsolatedDownloadChannel(client, transfer) {
const pool = getIsolatedDownloadChannelPool(client);
while (true) {
if (transfer?.cancelled) return null;
const cached = pool.idle.pop();
if (cached) {
clearIdleIsolatedDownloadTimer(pool, cached);
pool.busy.add(cached);
return cached;
}
const knownCapacity = pool.maxChannels;
const currentChannelCount = pool.idle.length + pool.busy.size + pool.opening;
if (knownCapacity !== null && currentChannelCount >= knownCapacity) {
if (pool.opening > 0) {
await waitForIsolatedDownloadChannel(pool, transfer);
if (transfer?.cancelled) return null;
continue;
}
if (pool.busy.size === 0) {
return null;
}
await waitForIsolatedDownloadChannel(pool, transfer);
if (transfer?.cancelled) return null;
continue;
}
pool.opening += 1;
try {
const opened = await openIsolatedSftpChannel(client);
pool.opening -= 1;
notifyIsolatedDownloadWaiter(pool);
if (!opened) return null;
pool.busy.add(opened);
const knownCapacity = pool.idle.length + pool.busy.size;
if (pool.maxChannels !== null) {
pool.maxChannels = Math.max(pool.maxChannels, knownCapacity);
}
return opened;
} catch (err) {
pool.opening -= 1;
notifyIsolatedDownloadWaiter(pool);
if (isIsolatedChannelOpenFailure(err)) {
if (pool.opening > 0) {
await waitForIsolatedDownloadChannel(pool, transfer);
if (transfer?.cancelled) return null;
continue;
}
const detectedCapacity = pool.idle.length + pool.busy.size;
pool.maxChannels = detectedCapacity;
if (!pool.warnedCapacity) {
pool.warnedCapacity = true;
console.warn(
`[transferBridge] Isolated fastGet channel capacity reached; reusing up to ${detectedCapacity} extra channel(s) for this SFTP session.`,
);
}
if (detectedCapacity > 0) {
await waitForIsolatedDownloadChannel(pool, transfer);
if (transfer?.cancelled) return null;
continue;
}
return null;
}
console.warn(
"[transferBridge] Failed to open isolated SFTP channel for fastGet, falling back to streams:",
err.message || String(err),
);
return null;
}
}
}
/**
* Upload a local file to SFTP using ssh2's fastPut (parallel SFTP requests).
* Falls back to sequential stream piping if fastPut is unavailable.
@@ -166,12 +365,7 @@ async function downloadFile(remotePath, localPath, client, fileSize, transfer, s
// Prefer fastGet on an isolated SFTP channel so cancellation can abort just this transfer.
if (!client.__netcattySudoMode) {
let fastSftp = null;
try {
fastSftp = await openIsolatedSftpChannel(client);
} catch (err) {
console.warn("[transferBridge] Failed to open isolated SFTP channel for fastGet, falling back to streams:", err.message || String(err));
}
const fastSftp = await acquireIsolatedDownloadChannel(client, transfer);
if (fastSftp && typeof fastSftp.fastGet === "function") {
return new Promise((resolve, reject) => {
@@ -187,7 +381,9 @@ async function downloadFile(remotePath, localPath, client, fileSize, transfer, s
try { fastSftp.removeListener("error", onFastSftpError); } catch { }
onFastSftpError = null;
}
try { fastSftp.end(); } catch { }
releaseIsolatedDownloadChannel(client, fastSftp, {
dispose: !!err || transfer.cancelled,
});
if (transfer.cancelled) reject(new Error("Transfer cancelled"));
else if (err) reject(err);
@@ -196,7 +392,6 @@ async function downloadFile(remotePath, localPath, client, fileSize, transfer, s
const abortFastTransfer = () => {
if (settled) return;
transfer.cancelled = true;
try { fastSftp.end(); } catch { }
finish(new Error("Transfer cancelled"));
};
transfer.abort = abortFastTransfer;
@@ -218,10 +413,6 @@ async function downloadFile(remotePath, localPath, client, fileSize, transfer, s
}, finish);
});
}
if (fastSftp && typeof fastSftp.end === "function") {
try { fastSftp.end(); } catch { }
}
}
// Fallback: sequential stream piping
@@ -284,7 +475,7 @@ async function startTransfer(event, payload, onProgress) {
} = payload;
const sender = event.sender;
const transfer = { cancelled: false, readStream: null, writeStream: null, abort: null };
const transfer = { cancelled: false, readStream: null, writeStream: null, abort: null, wakeWaiter: null };
activeTransfers.set(transferId, transfer);
const transferCreatedAt = Date.now();
@@ -430,14 +621,14 @@ async function startTransfer(event, payload, onProgress) {
if (!client) throw new Error("Source SFTP session not found");
const dir = path.dirname(targetPath);
await fs.promises.mkdir(dir, { recursive: true });
await ensureLocalDir(dir);
const encodedSourcePath = encodePathForSession(sourceSftpId, sourcePath, sourceEncoding);
await downloadFile(encodedSourcePath, targetPath, client, fileSize, transfer, sendProgress);
} else if (sourceType === 'local' && targetType === 'local') {
const dir = path.dirname(targetPath);
await fs.promises.mkdir(dir, { recursive: true });
await ensureLocalDir(dir);
await new Promise((resolve, reject) => {
const readStream = fs.createReadStream(sourcePath, { highWaterMark: TRANSFER_CHUNK_SIZE });
@@ -536,6 +727,9 @@ async function cancelTransfer(event, payload) {
const transfer = activeTransfers.get(transferId);
if (transfer) {
transfer.cancelled = true;
if (typeof transfer.wakeWaiter === "function") {
try { transfer.wakeWaiter(); } catch { }
}
if (typeof transfer.abort === "function") {
try { transfer.abort(); } catch { }
+23
View File
@@ -79,6 +79,7 @@ const cloudSyncBridge = require("./bridges/cloudSyncBridge.cjs");
const fileWatcherBridge = require("./bridges/fileWatcherBridge.cjs");
const tempDirBridge = require("./bridges/tempDirBridge.cjs");
const sessionLogsBridge = require("./bridges/sessionLogsBridge.cjs");
const sessionLogStreamManager = require("./bridges/sessionLogStreamManager.cjs");
const compressUploadBridge = require("./bridges/compressUploadBridge.cjs");
const globalShortcutBridge = require("./bridges/globalShortcutBridge.cjs");
const credentialBridge = require("./bridges/credentialBridge.cjs");
@@ -605,6 +606,23 @@ const registerBridges = (win) => {
return result.filePath;
});
// Select a directory and return the selected path
ipcMain.handle("netcatty:selectDirectory", async (_event, { title, defaultPath }) => {
const { dialog } = electronModule;
const result = await dialog.showOpenDialog({
title: title || "Select Directory",
defaultPath,
properties: ["openDirectory", "createDirectory"],
});
if (result.canceled || !result.filePaths.length) {
return null;
}
return result.filePaths[0];
});
// Download SFTP file to temp and return local path
ipcMain.handle("netcatty:sftp:downloadToTemp", async (_event, { sftpId, remotePath, fileName, encoding }) => {
console.log(`[Main] Downloading SFTP file to temp:`);
@@ -849,6 +867,11 @@ if (!gotLock) {
// Cleanup all PTY sessions and port forwarding tunnels before quitting
app.on("will-quit", () => {
try {
sessionLogStreamManager.cleanupAll();
} catch (err) {
console.warn("Error during session log stream cleanup:", err);
}
try {
terminalBridge.cleanupAllSessions();
} catch (err) {
+161 -145
View File
@@ -3,7 +3,7 @@
*
* Spawned by codex-acp (or other ACP agents) as a child process.
* Communicates with the Netcatty main process via TCP (JSON-RPC over newline-delimited JSON).
* Exposes SSH terminal and SFTP tools so ACP agents can operate on remote hosts.
* Exposes Netcatty terminal and SFTP tools so ACP agents can operate on scoped sessions.
*/
"use strict";
@@ -39,6 +39,7 @@ const CHAT_SESSION_ID = process.env.NETCATTY_MCP_CHAT_SESSION_ID || null;
// Permission mode: 'observer' | 'confirm' | 'autonomous' (defense-in-depth, TCP bridge also checks)
const PERMISSION_MODE = process.env.NETCATTY_MCP_PERMISSION_MODE || "confirm";
const ENABLE_SFTP_TOOLS = process.env.NETCATTY_MCP_ENABLE_SFTP !== "0";
// Default command blocklist (defense-in-depth, TCP bridge also checks)
// NOTE: Keep in sync with DEFAULT_COMMAND_BLOCKLIST in infrastructure/ai/types.ts
@@ -168,8 +169,13 @@ const server = new McpServer({
version: "1.0.0",
});
// Scope params shared by all tool calls (includes chatSessionId for metadata isolation)
const scopeParams = { scopedSessionIds: SCOPED_SESSION_IDS, chatSessionId: CHAT_SESSION_ID };
// Scope params shared by all tool calls.
// When chatSessionId is present, let the main process resolve the current
// workspace membership dynamically so mid-session workspace changes are visible
// without restarting the MCP subprocess.
const scopeParams = CHAT_SESSION_ID
? { chatSessionId: CHAT_SESSION_ID }
: { scopedSessionIds: SCOPED_SESSION_IDS, chatSessionId: CHAT_SESSION_ID };
// Resource: environment context
server.resource(
@@ -191,30 +197,38 @@ server.resource(
// Tool: get_environment
server.tool(
"get_environment",
"Get information about the current Netcatty workspace: all connected remote hosts, their session IDs, OS, and connection status. Call this first to discover available hosts before executing commands.",
"Get information about the current Netcatty scope: all terminal sessions exposed by Netcatty, their session IDs, OS, shell hints, and connection status. Sessions may be remote hosts, a local terminal, or Mosh-backed shells. Call this first before executing commands.",
{},
async () => {
process.stderr.write(`[netcatty-mcp] get_environment called, SCOPED_SESSION_IDS: ${JSON.stringify(SCOPED_SESSION_IDS)}\n`);
process.stderr.write(`[netcatty-mcp] get_environment called, SCOPED_SESSION_IDS: ${JSON.stringify(SCOPED_SESSION_IDS)}, CHAT_SESSION_ID: ${CHAT_SESSION_ID}\n`);
const ctx = await rpcCall("netcatty/getContext", scopeParams);
process.stderr.write(`[netcatty-mcp] get_environment result: hostCount=${ctx.hostCount}, hosts=${JSON.stringify(ctx.hosts?.map(h => h.sessionId))}\n`);
return { content: [{ type: "text", text: JSON.stringify(ctx, null, 2) }] };
return {
content: [{
type: "text",
text: JSON.stringify({
...ctx,
sftpAvailable: ENABLE_SFTP_TOOLS,
}, null, 2),
}],
};
},
);
// Tool: terminal_execute
server.tool(
"terminal_execute",
"Execute a shell command on a remote host via SSH. The command runs in the host's shell and output (stdout/stderr) is returned when complete.",
"Execute a shell command on a Netcatty terminal session. The command runs in that session's shell and output (stdout/stderr) is returned when complete.",
{
sessionId: z.string().describe("The terminal session ID (from get_environment) to execute on."),
command: z.string().describe("The shell command to execute on the remote host."),
command: z.string().describe("The shell command to execute in the target session."),
},
async ({ sessionId, command }) => {
const guardErr = guardWriteOperation(command);
if (guardErr) {
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
}
const result = await rpcCall("netcatty/exec", { sessionId, command });
const result = await rpcCall("netcatty/exec", { ...scopeParams, sessionId, command });
if (!result.ok) {
return { content: [{ type: "text", text: `Error: ${result.error || "Command failed"}` }], isError: true };
}
@@ -229,7 +243,7 @@ server.tool(
// Tool: terminal_send_input
server.tool(
"terminal_send_input",
"Send raw input to a terminal session on a remote host. Use only for interactive programs that are already running: y/n prompts, passwords, ctrl+c (\\x03), ctrl+d (\\x04), or pressing enter (\\n). This tool does not return the updated terminal output. For normal commands, use terminal_execute.",
"Send raw input to a Netcatty terminal session. Use only for interactive programs that are already running: y/n prompts, passwords, ctrl+c (\\x03), ctrl+d (\\x04), or pressing enter (\\n). This tool does not return the updated terminal output. For normal commands, use terminal_execute.",
{
sessionId: z.string().describe("The terminal session ID to send input to."),
input: z.string().describe("The raw input string. Use escape sequences for special keys (e.g. \\x03 for ctrl+c, \\n for enter)."),
@@ -239,7 +253,7 @@ server.tool(
if (guardErr) {
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
}
const result = await rpcCall("netcatty/terminalWrite", { sessionId, input });
const result = await rpcCall("netcatty/terminalWrite", { ...scopeParams, sessionId, input });
if (!result.ok) {
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
}
@@ -247,149 +261,151 @@ server.tool(
},
);
// Tool: sftp_list_directory
server.tool(
"sftp_list_directory",
"List the contents of a directory on the remote host. Returns file names, sizes, types, and modification timestamps.",
{
sessionId: z.string().describe("The terminal session ID for the remote host."),
path: z.string().describe("The absolute path of the remote directory to list."),
},
async ({ sessionId, path }) => {
const result = await rpcCall("netcatty/sftpList", { sessionId, path });
if (result.error) {
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
}
return { content: [{ type: "text", text: JSON.stringify(result.files || result.output, null, 2) }] };
},
);
if (ENABLE_SFTP_TOOLS) {
// Tool: sftp_list_directory
server.tool(
"sftp_list_directory",
"List the contents of a directory on the remote host. Returns file names, sizes, types, and modification timestamps.",
{
sessionId: z.string().describe("The terminal session ID for the remote host."),
path: z.string().describe("The absolute path of the remote directory to list."),
},
async ({ sessionId, path }) => {
const result = await rpcCall("netcatty/sftpList", { ...scopeParams, sessionId, path });
if (result.error) {
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
}
return { content: [{ type: "text", text: JSON.stringify(result.files || result.output, null, 2) }] };
},
);
// Tool: sftp_read_file
server.tool(
"sftp_read_file",
"Read the content of a file on the remote host. Returns file content as text, truncated if the file is large.",
{
sessionId: z.string().describe("The terminal session ID for the remote host."),
path: z.string().describe("The absolute path of the remote file to read."),
maxBytes: z.number().optional().default(10000).describe("Maximum bytes to read. Defaults to 10000."),
},
async ({ sessionId, path, maxBytes }) => {
const safeMaxBytes = Math.max(1, Math.min(10 * 1024 * 1024, Number(maxBytes) || 10000));
const result = await rpcCall("netcatty/sftpRead", { sessionId, path, maxBytes: safeMaxBytes });
if (result.error) {
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
}
return { content: [{ type: "text", text: result.content || "(empty file)" }] };
},
);
// Tool: sftp_read_file
server.tool(
"sftp_read_file",
"Read the content of a file on the remote host. Returns file content as text, truncated if the file is large.",
{
sessionId: z.string().describe("The terminal session ID for the remote host."),
path: z.string().describe("The absolute path of the remote file to read."),
maxBytes: z.number().optional().default(10000).describe("Maximum bytes to read. Defaults to 10000."),
},
async ({ sessionId, path, maxBytes }) => {
const safeMaxBytes = Math.max(1, Math.min(10 * 1024 * 1024, Number(maxBytes) || 10000));
const result = await rpcCall("netcatty/sftpRead", { ...scopeParams, sessionId, path, maxBytes: safeMaxBytes });
if (result.error) {
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
}
return { content: [{ type: "text", text: result.content || "(empty file)" }] };
},
);
// Tool: sftp_write_file
server.tool(
"sftp_write_file",
"Write content to a file on the remote host. Creates the file if it does not exist, or overwrites it.",
{
sessionId: z.string().describe("The terminal session ID for the remote host."),
path: z.string().describe("The absolute path of the remote file to write."),
content: z.string().describe("The text content to write to the file."),
},
async ({ sessionId, path, content }) => {
const guardErr = guardWriteOperation(path);
if (guardErr) {
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
}
const result = await rpcCall("netcatty/sftpWrite", { sessionId, path, content });
if (result.error) {
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
}
return { content: [{ type: "text", text: `Written: ${path}` }] };
},
);
// Tool: sftp_write_file
server.tool(
"sftp_write_file",
"Write content to a file on the remote host. Creates the file if it does not exist, or overwrites it.",
{
sessionId: z.string().describe("The terminal session ID for the remote host."),
path: z.string().describe("The absolute path of the remote file to write."),
content: z.string().describe("The text content to write to the file."),
},
async ({ sessionId, path, content }) => {
const guardErr = guardWriteOperation(path);
if (guardErr) {
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
}
const result = await rpcCall("netcatty/sftpWrite", { ...scopeParams, sessionId, path, content });
if (result.error) {
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
}
return { content: [{ type: "text", text: `Written: ${path}` }] };
},
);
// Tool: sftp_mkdir
server.tool(
"sftp_mkdir",
"Create a directory on the remote host. Creates parent directories if they don't exist.",
{
sessionId: z.string().describe("The terminal session ID for the remote host."),
path: z.string().describe("The absolute path of the directory to create."),
},
async ({ sessionId, path }) => {
const guardErr = guardWriteOperation();
if (guardErr) {
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
}
const result = await rpcCall("netcatty/sftpMkdir", { sessionId, path });
if (result.error) {
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
}
return { content: [{ type: "text", text: `Created directory: ${path}` }] };
},
);
// Tool: sftp_mkdir
server.tool(
"sftp_mkdir",
"Create a directory on the remote host. Creates parent directories if they don't exist.",
{
sessionId: z.string().describe("The terminal session ID for the remote host."),
path: z.string().describe("The absolute path of the directory to create."),
},
async ({ sessionId, path }) => {
const guardErr = guardWriteOperation();
if (guardErr) {
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
}
const result = await rpcCall("netcatty/sftpMkdir", { ...scopeParams, sessionId, path });
if (result.error) {
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
}
return { content: [{ type: "text", text: `Created directory: ${path}` }] };
},
);
// Tool: sftp_remove
server.tool(
"sftp_remove",
"Delete a file or directory on the remote host. Directories are removed recursively.",
{
sessionId: z.string().describe("The terminal session ID for the remote host."),
path: z.string().describe("The absolute path of the file or directory to delete."),
},
async ({ sessionId, path }) => {
const guardErr = guardWriteOperation();
if (guardErr) {
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
}
const result = await rpcCall("netcatty/sftpRemove", { sessionId, path });
if (result.error) {
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
}
return { content: [{ type: "text", text: `Removed: ${path}` }] };
},
);
// Tool: sftp_remove
server.tool(
"sftp_remove",
"Delete a file or directory on the remote host. Directories are removed recursively.",
{
sessionId: z.string().describe("The terminal session ID for the remote host."),
path: z.string().describe("The absolute path of the file or directory to delete."),
},
async ({ sessionId, path }) => {
const guardErr = guardWriteOperation();
if (guardErr) {
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
}
const result = await rpcCall("netcatty/sftpRemove", { ...scopeParams, sessionId, path });
if (result.error) {
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
}
return { content: [{ type: "text", text: `Removed: ${path}` }] };
},
);
// Tool: sftp_rename
server.tool(
"sftp_rename",
"Rename or move a file/directory on the remote host.",
{
sessionId: z.string().describe("The terminal session ID for the remote host."),
oldPath: z.string().describe("The current absolute path."),
newPath: z.string().describe("The new absolute path."),
},
async ({ sessionId, oldPath, newPath }) => {
const guardErr = guardWriteOperation();
if (guardErr) {
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
}
const result = await rpcCall("netcatty/sftpRename", { sessionId, oldPath, newPath });
if (result.error) {
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
}
return { content: [{ type: "text", text: `Renamed: ${oldPath} → ${newPath}` }] };
},
);
// Tool: sftp_rename
server.tool(
"sftp_rename",
"Rename or move a file/directory on the remote host.",
{
sessionId: z.string().describe("The terminal session ID for the remote host."),
oldPath: z.string().describe("The current absolute path."),
newPath: z.string().describe("The new absolute path."),
},
async ({ sessionId, oldPath, newPath }) => {
const guardErr = guardWriteOperation();
if (guardErr) {
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
}
const result = await rpcCall("netcatty/sftpRename", { ...scopeParams, sessionId, oldPath, newPath });
if (result.error) {
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
}
return { content: [{ type: "text", text: `Renamed: ${oldPath} → ${newPath}` }] };
},
);
// Tool: sftp_stat
server.tool(
"sftp_stat",
"Get file/directory metadata on the remote host: type, size, permissions, and modification time.",
{
sessionId: z.string().describe("The terminal session ID for the remote host."),
path: z.string().describe("The absolute path to stat."),
},
async ({ sessionId, path }) => {
const result = await rpcCall("netcatty/sftpStat", { sessionId, path });
if (result.error) {
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
}
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
},
);
// Tool: sftp_stat
server.tool(
"sftp_stat",
"Get file/directory metadata on the remote host: type, size, permissions, and modification time.",
{
sessionId: z.string().describe("The terminal session ID for the remote host."),
path: z.string().describe("The absolute path to stat."),
},
async ({ sessionId, path }) => {
const result = await rpcCall("netcatty/sftpStat", { ...scopeParams, sessionId, path });
if (result.error) {
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
}
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
},
);
}
// Tool: multi_host_execute
server.tool(
"multi_host_execute",
"Execute a command on multiple remote hosts simultaneously or sequentially. Useful for fleet-wide operations like checking status, deploying updates, or maintenance.",
"Execute a command on multiple Netcatty terminal sessions simultaneously or sequentially. Useful for fleet-wide operations, or to compare local and remote environments.",
{
sessionIds: z.array(z.string()).describe("Array of session IDs to execute on."),
command: z.string().describe("The shell command to execute on each host."),
@@ -401,7 +417,7 @@ server.tool(
if (guardErr) {
return { content: [{ type: "text", text: `Error: ${guardErr}` }], isError: true };
}
const result = await rpcCall("netcatty/multiExec", { sessionIds, command, mode, stopOnError });
const result = await rpcCall("netcatty/multiExec", { ...scopeParams, sessionIds, command, mode, stopOnError });
if (result.error) {
return { content: [{ type: "text", text: `Error: ${result.error}` }], isError: true };
}
+87 -24
View File
@@ -27,30 +27,75 @@ function cleanupTransferListeners(transferId) {
transferCancelledListeners.delete(transferId);
}
// Filter MCP marker artifacts from terminal output:
// 1. Marker output lines (standalone): __NCMCP_xxx_S or __NCMCP_xxx_E:0
// 2. End marker command echo: __nc=$?;printf '__NCMCP_...'
// 3. Start marker printf prefix in echoed command: printf '__NCMCP_...\n';
// We keep the actual command part visible.
function filterMcpMarkers(data) {
return data
// Remove standalone marker output lines (printf output)
.replace(/^__NCMCP_[^\r\n]*[\r\n]*/gm, "")
// Remove end marker command echo lines
.replace(/[^\r\n]*__nc=\$\?;printf '[^\r\n]*__NCMCP_[^\r\n]*[\r\n]*/g, "")
// Remove start marker printf prefix from combined command lines
.replace(/printf '__NCMCP_[^']*\\n';/g, "");
// ── MCP marker filter with per-session line buffering ──
// PTY data arrives in arbitrary chunks; the marker string (__NCMCP_) can be
// split across chunk boundaries so a simple data.includes() guard misses it.
// We buffer the trailing fragment of each chunk and prepend it to the next
// chunk, then filter complete lines that contain the marker.
const _mcpLineBufs = new Map(); // sessionId -> trailing fragment string
// Returns true if `s` ends with a non-empty prefix of "__NCMCP_"
// (i.e. the next chunk might complete it into a marker-containing line).
function _endsWithMarkerPrefix(s) {
const p = "__NCMCP_";
for (let i = 1; i < p.length; i++) {
if (s.endsWith(p.slice(0, i))) return true;
}
return false;
}
function filterMcpChunk(sessionId, chunk) {
// Prepend any buffered fragment from the previous chunk
const held = _mcpLineBufs.get(sessionId) || "";
const data = held + chunk;
_mcpLineBufs.delete(sessionId);
// Fast path: nothing suspicious in the combined data
if (!data.includes("__NCMCP_") && !_endsWithMarkerPrefix(data)) {
return data;
}
// Slow path: scan line by line
let result = "";
let pos = 0;
while (pos < data.length) {
const nlIdx = data.indexOf("\n", pos);
if (nlIdx === -1) {
// Incomplete trailing line — no newline yet
const tail = data.slice(pos);
if (tail.includes("__NCMCP_") || _endsWithMarkerPrefix(tail)) {
// Hold it; next chunk might complete or confirm the marker
_mcpLineBufs.set(sessionId, tail);
} else {
result += tail; // safe to display immediately
}
break;
}
const line = data.slice(pos, nlIdx + 1); // includes the \n
if (!line.includes("__NCMCP_")) {
result += line;
}
// else: drop it — it's a wrapper marker line (or echo of one)
pos = nlIdx + 1;
}
// Also strip Posix pager prefix and Fish env lines that have no __NCMCP_
if (result) {
result = result
.replace(/PAGER=cat SYSTEMD_PAGER= GIT_PAGER=cat LESS= /g, "")
.replace(/^set -gx (?:PAGER|SYSTEMD_PAGER|GIT_PAGER|LESS) [^\r\n]*[\r\n]*/gm, "")
.replace(/^set "(?:PAGER|SYSTEMD_PAGER|GIT_PAGER|LESS)=[^"]*"[\r\n]*/gm, "");
}
return result;
}
ipcRenderer.on("netcatty:data", (_event, payload) => {
const set = dataListeners.get(payload.sessionId);
if (!set) return;
// Filter MCP marker artifacts before they reach xterm.js
let data = payload.data;
if (data.includes("__NCMCP_")) {
data = filterMcpMarkers(data);
if (!data) return;
}
const data = filterMcpChunk(payload.sessionId, payload.data);
if (!data) return;
set.forEach((cb) => {
try {
cb(data);
@@ -73,6 +118,7 @@ ipcRenderer.on("netcatty:exit", (_event, payload) => {
}
dataListeners.delete(payload.sessionId);
exitListeners.delete(payload.sessionId);
_mcpLineBufs.delete(payload.sessionId); // clean up any held fragment
});
// Chain progress events (for jump host connections)
@@ -827,6 +873,8 @@ const api = {
// Save dialog for file downloads
showSaveDialog: (defaultPath, filters) =>
ipcRenderer.invoke("netcatty:showSaveDialog", { defaultPath, filters }),
selectDirectory: (title, defaultPath) =>
ipcRenderer.invoke("netcatty:selectDirectory", { title, defaultPath }),
// File watcher for auto-sync feature
startFileWatch: (localPath, remotePath, sftpId, encoding) =>
@@ -1064,12 +1112,27 @@ const api = {
aiMcpSetPermissionMode: async (mode) => {
return ipcRenderer.invoke("netcatty:ai:mcp:set-permission-mode", { mode });
},
// ACP streaming
aiAcpStream: async (requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, images) => {
return ipcRenderer.invoke("netcatty:ai:acp:stream", { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, images });
// MCP approval gate: renderer receives approval requests from main process
onMcpApprovalRequest: (cb) => {
const handler = (_event, payload) => cb(payload);
ipcRenderer.on("netcatty:ai:mcp:approval-request", handler);
return () => ipcRenderer.removeListener("netcatty:ai:mcp:approval-request", handler);
},
aiAcpCancel: async (requestId) => {
return ipcRenderer.invoke("netcatty:ai:acp:cancel", { requestId });
respondMcpApproval: async (approvalId, approved) => {
return ipcRenderer.invoke("netcatty:ai:mcp:approval-response", { approvalId, approved });
},
// MCP approval cleared: main process timed out or cancelled an approval
onMcpApprovalCleared: (cb) => {
const handler = (_event, payload) => cb(payload);
ipcRenderer.on("netcatty:ai:mcp:approval-cleared", handler);
return () => ipcRenderer.removeListener("netcatty:ai:mcp:approval-cleared", handler);
},
// ACP streaming
aiAcpStream: async (requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images) => {
return ipcRenderer.invoke("netcatty:ai:acp:stream", { requestId, chatSessionId, acpCommand, acpArgs, prompt, cwd, providerId, model, existingSessionId, historyMessages, images });
},
aiAcpCancel: async (requestId, chatSessionId) => {
return ipcRenderer.invoke("netcatty:ai:acp:cancel", { requestId, chatSessionId });
},
aiAcpCleanup: async (chatSessionId) => {
return ipcRenderer.invoke("netcatty:ai:acp:cleanup", { chatSessionId });
Vendored
+26 -5
View File
@@ -1,6 +1,11 @@
import type { RemoteFile, SftpFilenameEncoding } from "./types";
import type { S3Config, SMBConfig, SyncedFile, WebDAVConfig } from "./domain/sync";
declare module "*.cjs" {
const value: Record<string, unknown>;
export = value;
}
declare global {
// Extend HTMLInputElement to support webkitdirectory attribute
namespace JSX {
@@ -47,6 +52,7 @@ declare global {
interface NetcattySSHOptions {
sessionId?: string;
hostLabel?: string;
hostname: string;
username: string;
port?: number;
@@ -76,6 +82,8 @@ declare global {
legacyAlgorithms?: boolean;
// Use sudo for SFTP server
sudo?: boolean;
// Session log configuration for real-time streaming
sessionLog?: { enabled: boolean; directory: string; format: string };
}
interface SftpStatResult {
@@ -143,6 +151,7 @@ declare global {
rows?: number;
charset?: string;
env?: Record<string, string>;
sessionLog?: { enabled: boolean; directory: string; format: string };
}): Promise<string>;
startMoshSession?(options: {
sessionId?: string;
@@ -155,8 +164,9 @@ declare global {
rows?: number;
charset?: string;
env?: Record<string, string>;
sessionLog?: { enabled: boolean; directory: string; format: string };
}): Promise<string>;
startLocalSession?(options: { sessionId?: string; cols?: number; rows?: number; shell?: string; cwd?: string; env?: Record<string, string> }): Promise<string>;
startLocalSession?(options: { sessionId?: string; cols?: number; rows?: number; shell?: string; cwd?: string; env?: Record<string, string>; sessionLog?: { enabled: boolean; directory: string; format: string } }): Promise<string>;
startSerialSession?(options: {
sessionId?: string;
path: string;
@@ -165,6 +175,7 @@ declare global {
stopBits?: 1 | 1.5 | 2;
parity?: 'none' | 'even' | 'odd' | 'mark' | 'space';
flowControl?: 'none' | 'xon/xoff' | 'rts/cts';
sessionLog?: { enabled: boolean; directory: string; format: string };
}): Promise<string>;
listSerialPorts?(): Promise<Array<{
path: string;
@@ -244,7 +255,7 @@ declare global {
onSessionData(sessionId: string, cb: (data: string) => void): () => void;
onSessionExit(
sessionId: string,
cb: (evt: { exitCode?: number; signal?: number }) => void
cb: (evt: { exitCode?: number; signal?: number; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => void
): () => void;
onAuthFailed?(
sessionId: string,
@@ -566,6 +577,7 @@ declare global {
// Save dialog for file downloads
showSaveDialog?(defaultPath: string, filters?: Array<{ name: string; extensions: string[] }>): Promise<string | null>;
selectDirectory?(title?: string, defaultPath?: string): Promise<string | null>;
// File watcher for auto-sync feature
startFileWatch?(localPath: string, remotePath: string, sftpId: string, encoding?: SftpFilenameEncoding): Promise<{ watchId: string }>;
@@ -684,13 +696,22 @@ declare global {
logoutOutput?: string;
error?: string;
}>;
aiMcpUpdateSessions?(sessions: Array<{ sessionId: string; hostname: string; label: string; os?: string; username?: string; connected: boolean }>): Promise<{ ok: boolean }>;
aiMcpUpdateSessions?(sessions: Array<{
sessionId: string;
hostname: string;
label: string;
os?: string;
username?: string;
protocol?: string;
shellType?: string;
connected: boolean;
}>, chatSessionId?: string): Promise<{ ok: boolean }>;
aiSpawnAgent?(agentId: string, command: string, args?: string[], env?: Record<string, string>, options?: { closeStdin?: boolean }): Promise<{ ok: boolean; pid?: number; error?: string }>;
aiWriteToAgent?(agentId: string, data: string): Promise<{ ok: boolean; error?: string }>;
aiCloseAgentStdin?(agentId: string): Promise<{ ok: boolean; error?: string }>;
aiKillAgent?(agentId: string): Promise<{ ok: boolean; error?: string }>;
aiAcpStream?(requestId: string, chatSessionId: string, acpCommand: string, acpArgs: string[], prompt: string, cwd?: string, providerId?: string): Promise<{ ok: boolean; error?: string }>;
aiAcpCancel?(requestId: string): Promise<{ ok: boolean; error?: string }>;
aiAcpStream?(requestId: string, chatSessionId: string, acpCommand: string, acpArgs: string[], prompt: string, cwd?: string, providerId?: string, model?: string, existingSessionId?: string, historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>, images?: Array<{ base64Data: string; mediaType: string; filename?: string }>): Promise<{ ok: boolean; error?: string }>;
aiAcpCancel?(requestId: string, chatSessionId?: string): Promise<{ ok: boolean; error?: string }>;
aiAcpCleanup?(chatSessionId: string): Promise<{ ok: boolean }>;
onAiAcpEvent?(requestId: string, cb: (event: Record<string, unknown>) => void): () => void;
onAiAcpDone?(requestId: string, cb: () => void): () => void;
+25 -7
View File
@@ -9,11 +9,12 @@
import type { ExternalAgentConfig } from './types';
export interface AcpAgentCallbacks {
onSessionId?: (sessionId: string) => void;
onTextDelta: (text: string) => void;
onThinkingDelta: (text: string) => void;
onThinkingDone: () => void;
onToolCall: (toolName: string, args: Record<string, unknown>) => void;
onToolResult: (toolCallId: string, result: string) => void;
onToolCall: (toolName: string, args: Record<string, unknown>, toolCallId?: string) => void;
onToolResult: (toolCallId: string, result: string, toolName?: string) => void;
onStatus?: (message: string) => void;
onError: (error: string) => void;
onDone: () => void;
@@ -29,9 +30,11 @@ interface AcpBridge {
cwd?: string,
providerId?: string,
model?: string,
existingSessionId?: string,
historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>,
images?: ImageAttachment[],
): Promise<{ ok: boolean; error?: string }>;
aiAcpCancel(requestId: string): Promise<{ ok: boolean }>;
aiAcpCancel(requestId: string, chatSessionId?: string): Promise<{ ok: boolean }>;
onAiAcpEvent(requestId: string, cb: (event: StreamEvent) => void): () => void;
onAiAcpDone(requestId: string, cb: () => void): () => void;
onAiAcpError(requestId: string, cb: (error: string) => void): () => void;
@@ -47,12 +50,16 @@ interface StreamEvent {
* Sends the prompt to the main process which runs streamText() with the ACP provider.
* Stream events are forwarded back via IPC.
*/
export interface ImageAttachment {
export interface FileAttachment {
base64Data: string;
mediaType: string;
filename?: string;
filePath?: string;
}
/** @deprecated Use FileAttachment instead */
export type ImageAttachment = FileAttachment;
export async function runAcpAgentTurn(
bridge: Record<string, (...args: unknown[]) => unknown>,
requestId: string,
@@ -63,6 +70,8 @@ export async function runAcpAgentTurn(
signal?: AbortSignal,
providerId?: string,
model?: string,
existingSessionId?: string,
historyMessages?: Array<{ role: 'user' | 'assistant'; content: string }>,
images?: ImageAttachment[],
): Promise<void> {
const acpBridge = bridge as unknown as AcpBridge;
@@ -101,7 +110,7 @@ export async function runAcpAgentTurn(
return;
}
const onAbort = () => {
acpBridge.aiAcpCancel(requestId).catch(() => {});
acpBridge.aiAcpCancel(requestId, chatSessionId).catch(() => {});
};
signal.addEventListener('abort', onAbort, { once: true });
cleanupFns.push(() => signal.removeEventListener('abort', onAbort));
@@ -117,6 +126,8 @@ export async function runAcpAgentTurn(
undefined, // cwd
providerId,
model,
existingSessionId,
historyMessages,
images?.length ? images : undefined,
).catch((err: Error) => {
callbacks.onError(err.message);
@@ -160,16 +171,18 @@ function handleStreamEvent(event: StreamEvent, callbacks: AcpAgentCallbacks) {
case 'tool-call': {
const toolName = (event.toolName as string) || 'unknown';
const input = (event.input as Record<string, unknown>) || {};
callbacks.onToolCall(toolName, input);
const toolCallId = (event.toolCallId as string) || undefined;
callbacks.onToolCall(toolName, input, toolCallId);
break;
}
case 'tool-result': {
const toolCallId = (event.toolCallId as string) || '';
const toolName = (event.toolName as string) || undefined;
const output = event.output ?? event.result;
const result = typeof output === 'string'
? output
: JSON.stringify(output);
callbacks.onToolResult(toolCallId, result);
callbacks.onToolResult(toolCallId, result, toolName);
break;
}
case 'status': {
@@ -177,6 +190,11 @@ function handleStreamEvent(event: StreamEvent, callbacks: AcpAgentCallbacks) {
if (msg) callbacks.onStatus?.(msg);
break;
}
case 'session-id': {
const sessionId = (event.sessionId as string) || '';
if (sessionId) callbacks.onSessionId?.(sessionId);
break;
}
case 'error': {
callbacks.onError(String(event.error || 'Unknown error'));
break;
+2
View File
@@ -36,6 +36,8 @@ export interface ExecutorContext {
label: string;
os?: string;
username?: string;
protocol?: string;
shellType?: string;
connected: boolean;
}>;
// Workspace info
+18 -14
View File
@@ -7,6 +7,8 @@ export interface SystemPromptContext {
label: string;
os?: string;
username?: string;
protocol?: string;
shellType?: string;
connected: boolean;
}>;
permissionMode: 'observer' | 'confirm' | 'autonomous';
@@ -20,13 +22,13 @@ export function buildSystemPrompt(context: SystemPromptContext): string {
const hostList = buildHostList(hosts);
const permissionRules = buildPermissionRules(permissionMode);
return `You are **Catty Agent**, a terminal automation assistant built into netcatty. You help users manage remote servers by executing commands, reading files, and performing batch operations across multiple hosts.
return `You are **Catty Agent**, a terminal automation assistant built into netcatty. You help users operate terminal sessions managed by Netcatty, including remote hosts and the user's local terminal.
## Current Scope
${scopeDescription}
## Available Hosts
## Available Sessions
${hostList}
@@ -36,21 +38,21 @@ ${permissionRules}
## Guidelines
1. **Plan before acting.** When a task involves multiple steps, present a brief numbered plan to the user before executing. Wait for acknowledgment on complex or risky operations.
1. **Plan before acting.** When a task involves multiple steps, present a brief numbered plan to the user before executing.
2. **Use the right tool.** For normal shell commands, use \`terminal_execute\` so you receive the command output. When operating on multiple hosts, call \`terminal_execute\` for each host.
2. **Use the right tool.** For normal shell commands, use \`terminal_execute\` so you receive the command output. When operating on multiple sessions, call \`terminal_execute\` for each target session.
3. **Never execute dangerous commands.** Commands matching the blocklist (e.g. \`rm -rf /\`, \`mkfs\`, \`dd\` to disk devices, \`shutdown\`, fork bombs, recursive chmod 777 on root) are strictly forbidden and will be automatically denied. Do not attempt to bypass these restrictions.
4. **Explain before executing.** Before running any command, briefly explain what it does and why. This is especially important for commands that modify the system.
4. **Explain before executing.** Before running any command, briefly explain what it does and why.
5. **Handle errors gracefully.** If a command fails, analyze the error output, explain what went wrong, and suggest alternatives or corrective actions. Do not retry the same failing command without modification.
6. **Stay focused.** Keep responses concise and relevant to terminal and server operations. Avoid unrelated commentary.
7. **Respect connection status.** Only attempt operations on hosts that are currently connected. If a host is disconnected, inform the user and suggest reconnecting.
7. **Respect connection status.** Only attempt operations on sessions that are currently connected. If a session is disconnected, inform the user and suggest reconnecting or reopening it.
8. **Be careful with file operations.** When writing files via shell commands, confirm the target path with the user if there is any ambiguity. Always prefer appending or targeted edits over full file overwrites when possible.
8. **Be careful with file operations.** When writing files via shell commands, prefer appending or targeted edits over full file overwrites when possible.
9. **Fetch URLs when provided.** When the user shares a URL or asks you to read a webpage, use \`url_fetch\` to retrieve its content.${webSearchEnabled ? `
@@ -63,11 +65,11 @@ function buildScopeDescription(
): string {
switch (scopeType) {
case 'terminal':
return `You are scoped to a single terminal session${scopeLabel ? `: **${scopeLabel}**` : ''}. Focus operations on this specific host.`;
return `You are scoped to a single terminal session${scopeLabel ? `: **${scopeLabel}**` : ''}. Focus operations on this specific session.`;
case 'workspace':
return `You are scoped to workspace${scopeLabel ? ` **${scopeLabel}**` : ''}. You can operate on any host within this workspace.`;
return `You are scoped to workspace${scopeLabel ? ` **${scopeLabel}**` : ''}. You can operate on any session within this workspace.`;
case 'global':
return `You have global scope and can operate on any connected host across all workspaces.`;
return `You have global scope and can operate on any connected session across all workspaces.`;
}
}
@@ -75,7 +77,7 @@ function buildHostList(
hosts: SystemPromptContext['hosts'],
): string {
if (hosts.length === 0) {
return '_No hosts are currently available. The user needs to connect to a host first._';
return '_No terminal sessions are currently available. The user needs to open or connect a terminal first._';
}
const lines = hosts.map(host => {
@@ -83,8 +85,10 @@ function buildHostList(
const details = [
`hostname: ${host.hostname}`,
`label: ${host.label}`,
host.protocol ? `protocol: ${host.protocol}` : null,
host.os ? `os: ${host.os}` : null,
host.username ? `user: ${host.username}` : null,
host.shellType ? `shell: ${host.shellType}` : null,
`status: ${status}`,
]
.filter(Boolean)
@@ -112,10 +116,10 @@ function buildPermissionRules(
case 'confirm':
return [
'You are in **confirm** mode. Every write or execute operation requires explicit user approval before it runs:',
'- Command execution (`terminal_execute`)',
'You are in **confirm** mode. The system will automatically show an approval prompt to the user for write and execute operations:',
'- Command execution (`terminal_execute`) will pause and show approval buttons in the UI automatically.',
'',
'Read-only operations are allowed without confirmation. When proposing a command, clearly state what it will do so the user can make an informed decision.',
'You do NOT need to ask the user for confirmation in your text responses. Just call the tool directly — the approval system handles it. Read-only operations are allowed without any approval.',
].join('\n');
case 'autonomous':
+7 -39
View File
@@ -1,47 +1,15 @@
import type { ChatMessage } from './types';
/**
* Classifies a raw error string into structured error info for display.
* Convert a raw error string into display-safe error info.
*
* Intentionally avoids keyword-based "root cause" attribution because upstream
* providers often return generic 4xx/5xx text that would be misclassified.
* We show the sanitized upstream message directly instead.
*/
export function classifyError(error: string): NonNullable<ChatMessage['errorInfo']> {
const lower = error.toLowerCase();
// Network errors
if (lower.includes('econnrefused') || lower.includes('enotfound') || lower.includes('enetunreach') || lower.includes('fetch failed') || lower.includes('network')) {
return { type: 'network', message: 'Network connection failed. Please check your internet connection and API endpoint.', retryable: true };
}
// Timeout
if (lower.includes('timeout') || lower.includes('timed out') || lower.includes('econnreset') || lower.includes('socket hang up')) {
return { type: 'timeout', message: 'Request timed out. The server may be overloaded or unreachable.', retryable: true };
}
// Auth errors
if (lower.includes('401') || lower.includes('403') || lower.includes('unauthorized') || lower.includes('invalid api key') || lower.includes('authentication')) {
return { type: 'auth', message: 'Authentication failed. Please check your API key in Settings \u2192 AI.', retryable: false };
}
// Rate limit
if (lower.includes('429') || lower.includes('rate limit') || lower.includes('too many requests')) {
return { type: 'provider', message: 'Rate limit exceeded. Please wait a moment before retrying.', retryable: true };
}
// Provider errors (5xx)
if (/\b5\d{2}\b/.test(error) || lower.includes('server error') || lower.includes('internal error')) {
return { type: 'provider', message: sanitizeErrorMessage(error), retryable: true };
}
// Model not found
if (lower.includes('model not found') || lower.includes('does not exist') || lower.includes('404')) {
return { type: 'provider', message: 'Model not found. Please check your model selection in Settings \u2192 AI.', retryable: false };
}
// Command blocked
if (lower.includes('blocked by safety')) {
return { type: 'agent', message: sanitizeErrorMessage(error), retryable: false };
}
return { type: 'unknown', message: sanitizeErrorMessage(error), retryable: true };
const message = sanitizeErrorMessage(error).trim() || 'Unknown error';
return { type: 'unknown', message, retryable: false };
}
const MAX_ERROR_MESSAGE_LENGTH = 500;
+16 -2
View File
@@ -86,6 +86,20 @@ function extractHeaders(headers?: HeadersInit): Record<string, string> {
/** Placeholder API key used by the renderer; main process replaces it with the real key. */
export const API_KEY_PLACEHOLDER = '__IPC_SECURED__';
function toSafeStatusText(message: string, fallback: string): string {
const normalized = message
.replace(/[\r\n\t]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
if (!normalized) return fallback;
const byteStringSafe = Array.from(normalized, (char) => {
const code = char.charCodeAt(0);
if (code < 0x20 || code === 0x7f || code > 0xff) return '?';
return char;
}).join('');
return byteStringSafe.slice(0, 120) || fallback;
}
export function createBridgeFetchForSDK(providerId?: string): typeof globalThis.fetch {
return async (
input: string | URL | Request,
@@ -182,7 +196,7 @@ export function createBridgeFetchForSDK(providerId?: string): typeof globalThis.
const jsonBody = JSON.stringify({ error: { message: errorMessage } });
return new Response(jsonBody, {
status: 502,
statusText: 'Bad Gateway',
statusText: toSafeStatusText(errorMessage, 'Bad Gateway'),
headers: { 'content-type': 'application/json' },
});
}
@@ -198,7 +212,7 @@ export function createBridgeFetchForSDK(providerId?: string): typeof globalThis.
const jsonBody = JSON.stringify({ error: { message: errorDetail } });
return new Response(jsonBody, {
status: statusCode,
statusText: `Error ${statusCode}`,
statusText: toSafeStatusText(errorDetail, `Error ${statusCode}`),
headers: { 'content-type': 'application/json' },
});
}
+17 -9
View File
@@ -1,6 +1,6 @@
import { tool } from 'ai';
import { z } from 'zod';
import type { NetcattyBridge, ExecutorContext } from '../cattyAgent/executor';
import type { NetcattyBridge } from '../cattyAgent/executor';
import type { AIPermissionMode } from '../types';
import type { WebSearchConfig } from '../types';
import { isWebSearchReady } from '../types';
@@ -13,6 +13,7 @@ import {
type ToolDeps,
type ToolExecResult,
} from '../shared/toolExecutors';
import { requestApproval } from '../shared/approvalGate';
/** Unwrap a shared ToolExecResult into the shape expected by Vercel AI SDK tool results. */
function unwrap<T>(r: ToolExecResult<T>): T | { error: string } {
@@ -30,32 +31,39 @@ function unwrap<T>(r: ToolExecResult<T>): T | { error: string } {
*/
export function createCattyTools(
bridge: NetcattyBridge,
context: ExecutorContext,
context: ToolDeps['context'],
commandBlocklist?: string[],
permissionMode: AIPermissionMode = 'confirm',
webSearchConfig?: WebSearchConfig,
chatSessionId?: string,
) {
const writeToolNeedsApproval = permissionMode === 'confirm';
const deps: ToolDeps = { bridge, context, commandBlocklist, permissionMode, webSearchConfig };
return {
terminal_execute: tool({
description:
'Execute a shell command on a remote host via the specified terminal session. ' +
'Execute a shell command on the specified terminal session. ' +
"The command runs in the session's shell and output is returned when complete.",
inputSchema: z.object({
sessionId: z.string().describe('The terminal session ID to execute the command on.'),
command: z.string().describe('The shell command to execute on the remote host.'),
command: z.string().describe('The shell command to execute in the target session.'),
}),
needsApproval: writeToolNeedsApproval,
execute: async ({ sessionId, command }) => {
// No needsApproval — approval is handled inside execute via the approval gate.
execute: async ({ sessionId, command }, { toolCallId }) => {
// In confirm mode, await user approval before executing
if (permissionMode === 'confirm') {
const approved = await requestApproval(toolCallId, 'terminal_execute', { sessionId, command }, chatSessionId);
if (!approved) {
return { error: 'User denied command execution.' };
}
}
return unwrap(await executeTerminalExecute(deps, { sessionId, command }));
},
}),
workspace_get_info: tool({
description:
'Get information about the current workspace, including all configured hosts ' +
'Get information about the current workspace, including all terminal sessions ' +
'and their connection status. No parameters required.',
inputSchema: z.object({}),
execute: async () => {
@@ -66,7 +74,7 @@ export function createCattyTools(
workspace_get_session_info: tool({
description:
'Get detailed information about a specific terminal or SFTP session, including ' +
'the host it is connected to, connection status, and session metadata.',
'its connection status, protocol, shell hints, and session metadata.',
inputSchema: z.object({
sessionId: z.string().describe('The session ID to get information about.'),
}),
+260
View File
@@ -0,0 +1,260 @@
/**
* approvalGate — Promise-based approval system for tool execution.
*
* Tools call `requestApproval()` inside their `execute` function. This returns
* a Promise that resolves when the user approves/rejects from the UI, or after
* a timeout (default 5 minutes) to prevent indefinite hangs.
*
* Also supports MCP/ACP tool calls from the Electron main process:
* the main process sends an IPC approval request, and we route it
* through the same listener/UI system. MCP approvals are stored in
* the same pendingApprovals map so they survive ChatMessageList
* unmount/remount cycles via replayPendingApprovals().
*
* Approvals are scoped by optional chatSessionId to prevent cross-session
* interference when stopping or cancelling sessions.
*/
/** Default timeout for unanswered approval prompts (5 minutes). */
const APPROVAL_TIMEOUT_MS = 5 * 60 * 1000;
export interface ApprovalRequest {
toolCallId: string;
toolName: string;
args: Record<string, unknown>;
/** Optional chat session scope — used to clear only relevant approvals on stop */
chatSessionId?: string;
}
// Pending approval entries keyed by toolCallId.
// SDK approvals have a real `resolve` callback; MCP approvals use a no-op
// (the real resolution goes via IPC in resolveApproval).
const pendingApprovals = new Map<string, {
resolve: (approved: boolean) => void;
request: ApprovalRequest;
}>();
// Subscribers for approval request events (UI listens here)
type ApprovalRequestListener = (request: ApprovalRequest) => void;
const listeners = new Set<ApprovalRequestListener>();
// Subscribers for approval cleared/removed events (UI listens to clean up cards)
type ApprovalClearedListener = (toolCallIds: string[]) => void;
const clearedListeners = new Set<ApprovalClearedListener>();
/**
* Called from a tool's `execute` function when it needs user approval.
* Returns a Promise<boolean> that resolves to `true` (approved) or `false` (denied).
* The UI is notified via the listener system to render approval buttons.
*
* If the user does not respond within `timeoutMs` (default 5 minutes), the
* approval is auto-denied to prevent the session from hanging indefinitely.
*/
export function requestApproval(
toolCallId: string,
toolName: string,
args: Record<string, unknown>,
chatSessionId?: string,
timeoutMs: number = APPROVAL_TIMEOUT_MS,
): Promise<boolean> {
const request: ApprovalRequest = { toolCallId, toolName, args, chatSessionId };
return new Promise<boolean>((resolve) => {
let timerId: ReturnType<typeof setTimeout> | null = null;
const wrappedResolve = (approved: boolean) => {
if (timerId) { clearTimeout(timerId); timerId = null; }
resolve(approved);
};
pendingApprovals.set(toolCallId, { resolve: wrappedResolve, request });
// Auto-deny after timeout so the session doesn't hang indefinitely
timerId = setTimeout(() => {
if (pendingApprovals.has(toolCallId)) {
pendingApprovals.delete(toolCallId);
wrappedResolve(false);
// Notify UI to remove the stale card
for (const cl of clearedListeners) {
try { cl([toolCallId]); } catch { /* ignore */ }
}
}
}, timeoutMs);
// Notify all UI listeners
for (const listener of listeners) {
try { listener(request); } catch { /* ignore listener errors */ }
}
});
}
/**
* Called from the UI when the user approves or rejects a tool execution.
* Handles both SDK tool calls (local Promise) and MCP tool calls (IPC to main process).
*/
export function resolveApproval(toolCallId: string, approved: boolean): void {
const entry = pendingApprovals.get(toolCallId);
if (entry) {
pendingApprovals.delete(toolCallId);
// SDK tool calls have a real resolve; MCP tool calls have a no-op resolve
entry.resolve(approved);
}
// MCP tool call: also forward response to main process via IPC
if (toolCallId.startsWith('mcp_approval_')) {
const bridge = (window as unknown as { netcatty?: { respondMcpApproval?: (id: string, approved: boolean) => Promise<unknown> } }).netcatty;
bridge?.respondMcpApproval?.(toolCallId, approved);
}
}
/**
* Subscribe to approval request events. Returns an unsubscribe function.
*/
export function onApprovalRequest(listener: ApprovalRequestListener): () => void {
listeners.add(listener);
return () => { listeners.delete(listener); };
}
/**
* Subscribe to approval cleared/removed events. Returns an unsubscribe function.
* Fired when approvals are cleared (e.g. on session stop) or timed out,
* so the UI can remove stale approval cards.
*/
export function onApprovalCleared(listener: ApprovalClearedListener): () => void {
clearedListeners.add(listener);
return () => { clearedListeners.delete(listener); };
}
/**
* Replay all currently pending approval requests to a listener.
* Useful when ChatMessageList remounts after being unmounted — without this,
* approvals that fired while unmounted would be silently missed and the
* corresponding execute Promises would hang indefinitely.
*
* This covers both SDK and MCP approvals since both are stored in the same map.
*/
export function replayPendingApprovals(listener: ApprovalRequestListener): void {
for (const [, entry] of pendingApprovals) {
try { listener(entry.request); } catch { /* ignore */ }
}
}
/**
* Check if a specific toolCallId has a pending approval.
*/
export function hasPendingApproval(toolCallId: string): boolean {
return pendingApprovals.has(toolCallId);
}
/**
* Clear pending approvals, optionally scoped to a specific chatSessionId.
* Resolves matching entries with `false` (denied) so execute functions don't hang.
* Also notifies cleared-listeners so the UI can remove stale approval cards.
*
* When chatSessionId is provided, only approvals belonging to that session
* are cleared — preventing cross-session interference in concurrent chats.
* When omitted, all pending approvals are cleared (backward-compatible).
*/
export function clearAllPendingApprovals(chatSessionId?: string): void {
const clearedIds: string[] = [];
if (!chatSessionId) {
// Clear everything (legacy / global stop)
for (const [id, entry] of pendingApprovals) {
entry.resolve(false);
clearedIds.push(id);
}
pendingApprovals.clear();
} else {
// Scoped clear: only remove approvals for this chatSessionId
for (const [id, entry] of pendingApprovals) {
if (entry.request.chatSessionId === chatSessionId) {
pendingApprovals.delete(id);
entry.resolve(false);
clearedIds.push(id);
}
}
}
// Notify UI listeners to remove the cards
if (clearedIds.length > 0) {
for (const cl of clearedListeners) {
try { cl(clearedIds); } catch { /* ignore */ }
}
}
}
/**
* Set up a bridge to receive MCP/ACP approval requests from the Electron main process.
* Subscribes to IPC events and stores them in the same pendingApprovals map,
* so the same ToolCall UI handles both SDK and MCP approvals, and approvals
* survive ChatMessageList unmount/remount cycles via replayPendingApprovals().
*
* IMPORTANT: Call this from a component that stays mounted for the lifetime of
* the AI panel (e.g. AIChatSidePanel), NOT from ChatMessageList which unmounts
* on tab switches.
*
* Returns an unsubscribe function.
*/
export function setupMcpApprovalBridge(): () => void {
const bridge = (window as unknown as {
netcatty?: {
onMcpApprovalRequest?: (cb: (payload: {
approvalId: string;
toolName: string;
args: Record<string, unknown>;
chatSessionId?: string;
}) => void) => () => void;
onMcpApprovalCleared?: (cb: (payload: {
approvalIds: string[];
}) => void) => () => void;
};
}).netcatty;
if (!bridge?.onMcpApprovalRequest) return () => {};
const unsubRequest = bridge.onMcpApprovalRequest((payload) => {
const request: ApprovalRequest = {
toolCallId: payload.approvalId,
toolName: payload.toolName,
args: payload.args,
chatSessionId: payload.chatSessionId,
};
// Store in pendingApprovals so it survives unmount/remount
// The resolve is a no-op because MCP approval resolution goes through IPC
// (handled in resolveApproval when toolCallId starts with 'mcp_approval_')
if (!pendingApprovals.has(payload.approvalId)) {
pendingApprovals.set(payload.approvalId, {
resolve: () => {}, // no-op; real resolution is via IPC
request,
});
}
// Notify all UI listeners
for (const listener of listeners) {
try { listener(request); } catch { /* ignore listener errors */ }
}
});
// Subscribe to main-process approval cleared events (timeout, cancel)
// so stale approval cards are removed from the renderer UI.
const unsubCleared = bridge.onMcpApprovalCleared?.((payload) => {
const clearedIds: string[] = [];
for (const id of payload.approvalIds) {
if (pendingApprovals.has(id)) {
pendingApprovals.delete(id);
clearedIds.push(id);
}
}
if (clearedIds.length > 0) {
for (const cl of clearedListeners) {
try { cl(clearedIds); } catch { /* ignore */ }
}
}
});
return () => {
unsubRequest();
unsubCleared?.();
};
}
+15 -6
View File
@@ -27,7 +27,7 @@ export type ToolExecResult<T = unknown> =
export interface ToolDeps {
bridge: NetcattyBridge;
context: ExecutorContext;
context: ExecutorContext | (() => ExecutorContext);
commandBlocklist?: string[];
permissionMode: AIPermissionMode;
webSearchConfig?: WebSearchConfig;
@@ -37,11 +37,16 @@ export interface ToolDeps {
// Helpers
// ---------------------------------------------------------------------------
function validSessionIds(ctx: ExecutorContext): Set<string> {
return new Set(ctx.sessions.map(s => s.sessionId));
function resolveContext(ctx: ToolDeps['context']): ExecutorContext {
return typeof ctx === 'function' ? ctx() : ctx;
}
function validateSessionScope(ctx: ExecutorContext, sessionId: string): string | null {
function validSessionIds(ctx: ToolDeps['context']): Set<string> {
const resolved = resolveContext(ctx);
return new Set(resolved.sessions.map(s => s.sessionId));
}
function validateSessionScope(ctx: ToolDeps['context'], sessionId: string): string | null {
const ids = validSessionIds(ctx);
if (!ids.has(sessionId)) {
return `Session "${sessionId}" is not in the current scope. Available sessions: ${[...ids].join(', ')}`;
@@ -107,10 +112,12 @@ export function executeWorkspaceGetInfo(
label: string;
os?: string;
username?: string;
protocol?: string;
shellType?: string;
connected: boolean;
}>;
}> {
const { context } = deps;
const context = resolveContext(deps.context);
return {
ok: true,
data: {
@@ -122,6 +129,8 @@ export function executeWorkspaceGetInfo(
label: s.label,
os: s.os,
username: s.username,
protocol: s.protocol,
shellType: s.shellType,
connected: s.connected,
})),
},
@@ -132,7 +141,7 @@ export function executeWorkspaceGetSessionInfo(
deps: ToolDeps,
args: { sessionId: string },
): ToolExecResult<ExecutorContext['sessions'][number]> {
const { context } = deps;
const context = resolveContext(deps.context);
const session = context.sessions.find(s => s.sessionId === args.sessionId);
if (!session) {
return { ok: false, error: `Session not found: ${args.sessionId}` };
Loaded 100 of 131 files, more files were not shown because too many files have changed in this diff. Show more