Compare commits

...

80 Commits

Author SHA1 Message Date
陈大猫
9397a781b5 refactor: unify directory download with upload transfer system (#560)
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
* refactor: unify directory download with upload transfer system

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

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

Net reduction of ~260 lines.

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

* chore: remove dead code from directory download refactor

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

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

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

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

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

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

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

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

* fix: add symlink cycle detection and propagate child failures

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: add symlink depth guard to countDirectoryFiles and estimateDirectoryBytes

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

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

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

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

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

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

* fix: add ancestor path cycle detection for symlink directories

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* refactor: remove redundant completed state update for directory transfers

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

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

* fix: route partial directory failures through shared completion path

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

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

* fix: restrict symlink directory recursion to downloadToLocal only

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

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

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

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

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

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

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

---------

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

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

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

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

* fix: sync transfer concurrency setting across windows

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

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

* fix: move setSftpTransferConcurrency after notifySettingsChanged

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

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

---------

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

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

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

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

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

Closes #556

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

* fix: sync workspace focus style across windows

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

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

---------

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

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

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

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

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

Also add popd to FOLDER_ONLY_COMMANDS.

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

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

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

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

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

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

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

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

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

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

Closes #551

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

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

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

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

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

Closes #550

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

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

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

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

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

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

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

* refactor: store default opener in separate localStorage key

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

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

---------

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

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

Local bookmarks remain device-specific by design.

Closes #548

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

* fix: deduplicate global SFTP bookmarks by path during merge

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

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

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

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

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

---------

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

Closes #549

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

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

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

* feat(sftp): add tree view i18n keys

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

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

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

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

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

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

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

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

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

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

* perf(sftp): optimize SftpPaneTreeView render pipeline

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

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

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

* fix: harden SFTP tree view actions and selection

* fix: support tree selection shortcuts and nested create targets

* fix: keep SFTP tree view sorting in sync

* Improve SFTP tree view interactions and refresh behavior

* Optimize SFTP tree refresh and pane state usage

* Reduce remaining SFTP tree performance overhead

* Fix nested SFTP drop target routing

* Restore keyboard access to parent tree entry

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

This reverts commit 6d19413025.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

This reverts commit f739e81e8d7691eb33965f6c431623a257fd8b4b.

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

* fix: invalidate target cache after transfers

* fix: reload tree root after create mutations

* fix: use receive callback for tree drop targets

* fix: trigger pane refresh after transfer completion

* fix: handle transfer refresh tokens only once

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

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

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

* feat: improve list view keyboard and folder drops

* fix: strengthen list view keyboard selection feedback

* style: make list view selection more obvious

* fix: keep list selection visible during keyboard navigation

* fix: rerender list rows when selection changes

* fix: sync list selection highlight updates

* style: align list selection with tree view

* style: hide list selection highlight when pane is unfocused

* feat: clear list selection when clicking empty space

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

* perf: make transfer size discovery asynchronous

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

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

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

* refine sftp transfer panel ux

* fix sftp sidebar and upload task flow

* polish sftp transfer interactions

---------

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

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

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

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

Closes #542

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

* fix: use resetProviderStatus instead of disconnectProvider on auth failure

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

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

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

* fix: add resetProviderStatus to CloudSyncHook interface

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

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

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

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

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

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

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

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

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

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

---------

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

Closes #543 (Nerd Fonts portion)

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

Closes #543 (Nerd Fonts portion)

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

* fix: retarget restored AI sessions on reconnect

* feat: format tool call results with proper line breaks

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

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

* fix: restrict unescape to stdout/stderr fields only

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

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

* fix: address review findings for AI chat reconnect

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

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

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

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

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

* fix: address round 2 review findings

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

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

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

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

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

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

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

* fix: address round 3 review findings

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: restore MCP sync for hidden panels

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

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

* chore: remove unused deletedIds variable

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

---------

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

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

* feat: pass deviceType through session metadata pipeline

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: suppress deviceType metadata for Mosh sessions

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: move serialEncoding declaration before session object creation

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

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

* fix: tighten isNetworkDevice logic and clean up edge cases

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

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

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

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

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

* fix: include deviceType in get_environment and guard execViaChannel fallback

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

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

* feat: add charset setting to serial host configuration UI

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

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

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

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

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

* fix: constrain serial connect modal height with scrollable content

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

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

* fix: propagate charset through all serial session creation paths

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

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

* fix: propagate charset in remaining session creation paths

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

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

---------

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

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

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

* fix: flush final resize value before canceling RAF

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

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

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

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

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

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

* fix: revert useDeferredValue for QuickSwitcher search

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

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

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

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

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

* fix: defer initialConnectDoneRef flag until auto-connect executes

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

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

* fix: capture resizingRef fields before setState updater

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

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

* fix: remove activeTabId from activityTrackedSessions to stabilize subscriptions

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: reset fetchInFlightRef on disconnect to unblock reconnect stats

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

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

* fix: clear fetchInFlightRef when tab becomes hidden

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

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

* fix: use generation counter to invalidate stale stats responses

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

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

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

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

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

---------

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

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

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

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

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

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

---------

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

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

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

* fix: buffer incomplete escape sequences across data chunks

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

Also fix missing trailing newline in sessionActivity.ts.

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: reset download state when downloadUpdate() rejects

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: clamp max_tokens to minimum of 1 after rounding

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

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

* fix: reject non-finite max_tokens before persisting

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* chore: 清理全部调试日志

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

* fix: toggle bookmark correctly removes global-only bookmarks

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

Closes #520

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

* fix: review 问题全量修复

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

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

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

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

* fix: 第二轮 review 问题修复

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

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

* fix: Codex review 问题修复

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

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

* fix: Codex 第二轮 review 修复

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

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

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

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

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

* fix: noResponseTimer 增加无输出提示

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

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

* fix: Codex 第 5 轮 review 修复

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

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

---------

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

* feat: 沉浸模式默认开启

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 02:47:50 +08:00
陈大猫
cb63f105aa Merge pull request #513 from crawt/feat/remove-root-paint-polling-use-renderer-ready
Feat/remove root paint polling use renderer ready
2026-03-26 00:20:21 +08:00
panwk
316e46de4b Mod:Removed waitForRootPaint polling helper from electron/bridges/windowManager.cjs.
Removed did-finish-load polling trigger that called markRendererReady via DOM child count checks.
Kept deferred show behavior based on:
ready-to-show
renderer-ready IPC from renderer
timeout fallback (dev and prod values unchanged)
2026-03-25 23:48:56 +08:00
panwk
1af5182b59 Merge branch 'main' of https://github.com/crawt/Netcatty 2026-03-25 23:42:06 +08:00
panwk
7b2590e54e Merge branch 'main' of https://github.com/crawt/Netcatty 2026-03-25 01:03:00 +08:00
149 changed files with 14024 additions and 4387 deletions

452
App.tsx
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -48,7 +48,7 @@ export const joinPath = (base: string, name: string): string => {
return `${normalizedBase}\\${name}`;
}
if (base === "/") return `/${name}`;
return `${base}/${name}`;
return `${base.replace(/\/+$/, "")}/${name}`;
};
export const getParentPath = (path: string): string => {

View File

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

View File

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

View File

@@ -6,7 +6,7 @@
* Uses useSyncExternalStore for real-time state synchronization across all components.
*/
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
import {
type CloudProvider,
type SecurityState,
@@ -24,7 +24,6 @@ import {
isProviderReadyForSync,
} from '../../domain/sync';
import {
CloudSyncManager,
getCloudSyncManager,
type SyncManagerState,
} from '../../infrastructure/services/CloudSyncManager';
@@ -83,7 +82,8 @@ export interface CloudSyncHook {
redirectUri: string
) => Promise<void>;
disconnectProvider: (provider: CloudProvider) => Promise<void>;
resetProviderStatus: (provider: CloudProvider) => void;
// Sync Actions
syncNow: (payload: SyncPayload) => Promise<Map<CloudProvider, SyncResult>>;
syncToProvider: (provider: CloudProvider, payload: SyncPayload) => Promise<SyncResult>;
@@ -103,12 +103,6 @@ export interface CloudSyncHook {
refresh: () => void;
}
export interface GitHubAuthState {
isAuthenticating: boolean;
deviceFlowState: DeviceFlowState | null;
error: string | null;
}
// ============================================================================
// Hook Implementation
// ============================================================================
@@ -127,17 +121,6 @@ const getSnapshot = (): SyncManagerState => {
};
export const useCloudSync = (): CloudSyncHook => {
// Force update mechanism to ensure React re-renders
const [, forceUpdate] = useState(0);
// Subscribe to state changes and force update
useEffect(() => {
const unsubscribe = manager.subscribeToStateChanges(() => {
forceUpdate(n => n + 1);
});
return unsubscribe;
}, []);
// Use useSyncExternalStore for real-time state sync across all components
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
@@ -273,7 +256,7 @@ export const useCloudSync = (): CloudSyncHook => {
throw new Error('Unexpected auth type');
}
const data = result.data as { url: string; redirectUri: string };
// Start OAuth callback server in Electron and wait for authorization
const bridge = netcattyBridge.get();
const startCallback = bridge?.startOAuthCallback;
@@ -281,32 +264,48 @@ export const useCloudSync = (): CloudSyncHook => {
// Get state from adapter for CSRF protection
const adapter = manager.getAdapter('google') as { getPKCEState?: () => string | null } | undefined;
const expectedState = adapter?.getPKCEState?.() || undefined;
// Start callback server and open browser
const callbackPromise = startCallback(expectedState);
// Open browser after starting server
setTimeout(() => {
window.open(data.url, "_blank", "width=600,height=700,noopener,noreferrer");
// Open browser after starting server — omit noopener/noreferrer so we can track the popup
let popup: Window | null = null;
let popupPollTimer: ReturnType<typeof setInterval> | null = null;
const openTimer = setTimeout(() => {
popup = window.open(data.url, "_blank", "width=600,height=700");
// Poll for popup closure — if user closes it, cancel the OAuth flow
if (popup) {
popupPollTimer = setInterval(() => {
if (popup?.closed) {
if (popupPollTimer) clearInterval(popupPollTimer);
bridge?.cancelOAuthCallback?.();
}
}, 500);
}
}, 100);
// Wait for callback
const { code } = await callbackPromise;
// Complete auth with the received code
await manager.completePKCEAuth('google', code, data.redirectUri);
try {
// Wait for callback
const { code } = await callbackPromise;
// Complete auth with the received code
await manager.completePKCEAuth('google', code, data.redirectUri);
} finally {
clearTimeout(openTimer);
if (popupPollTimer) clearInterval(popupPollTimer);
}
}
return data.url;
}, []);
const connectOneDrive = useCallback(async (): Promise<string> => {
const result = await manager.startProviderAuth('onedrive');
if (result.type !== 'url') {
throw new Error('Unexpected auth type');
}
const data = result.data as { url: string; redirectUri: string };
// Start OAuth callback server in Electron and wait for authorization
const bridge = netcattyBridge.get();
const startCallback = bridge?.startOAuthCallback;
@@ -314,22 +313,38 @@ export const useCloudSync = (): CloudSyncHook => {
// Get state from adapter for CSRF protection
const adapter = manager.getAdapter('onedrive') as { getPKCEState?: () => string | null } | undefined;
const expectedState = adapter?.getPKCEState?.() || undefined;
// Start callback server and open browser
const callbackPromise = startCallback(expectedState);
// Open browser after starting server
setTimeout(() => {
window.open(data.url, "_blank", "width=600,height=700,noopener,noreferrer");
// Open browser after starting server — omit noopener/noreferrer so we can track the popup
let popup: Window | null = null;
let popupPollTimer: ReturnType<typeof setInterval> | null = null;
const openTimer = setTimeout(() => {
popup = window.open(data.url, "_blank", "width=600,height=700");
// Poll for popup closure — if user closes it, cancel the OAuth flow
if (popup) {
popupPollTimer = setInterval(() => {
if (popup?.closed) {
if (popupPollTimer) clearInterval(popupPollTimer);
bridge?.cancelOAuthCallback?.();
}
}, 500);
}
}, 100);
// Wait for callback
const { code } = await callbackPromise;
// Complete auth with the received code
await manager.completePKCEAuth('onedrive', code, data.redirectUri);
try {
// Wait for callback
const { code } = await callbackPromise;
// Complete auth with the received code
await manager.completePKCEAuth('onedrive', code, data.redirectUri);
} finally {
clearTimeout(openTimer);
if (popupPollTimer) clearInterval(popupPollTimer);
}
}
return data.url;
}, []);
@@ -345,6 +360,10 @@ export const useCloudSync = (): CloudSyncHook => {
await manager.disconnectProvider(provider);
}, []);
const resetProviderStatus = useCallback((provider: CloudProvider): void => {
manager.resetProviderStatus(provider);
}, []);
const connectWebDAV = useCallback(async (config: WebDAVConfig): Promise<void> => {
await manager.connectConfigProvider('webdav', config);
}, []);
@@ -451,7 +470,8 @@ export const useCloudSync = (): CloudSyncHook => {
connectS3,
completePKCEAuth,
disconnectProvider,
resetProviderStatus,
// Sync Actions
syncNow: syncNowWithUnlock,
syncToProvider: syncToProviderWithUnlock,
@@ -472,60 +492,4 @@ export const useCloudSync = (): CloudSyncHook => {
};
};
// ============================================================================
// Convenience Hooks
// ============================================================================
/**
* Hook for just the security state (lighter weight)
*/
export const useSecurityState = () => {
const [manager] = useState<CloudSyncManager>(() => getCloudSyncManager());
const [securityState, setSecurityState] = useState<SecurityState>(
() => manager.getSecurityState()
);
useEffect(() => {
const unsubscribe = manager.subscribe((event) => {
if (event.type === 'SECURITY_STATE_CHANGED') {
setSecurityState(event.state);
}
});
return unsubscribe;
}, [manager]);
return {
securityState,
isUnlocked: securityState === 'UNLOCKED',
isLocked: securityState === 'LOCKED',
hasNoKey: securityState === 'NO_KEY',
};
};
/**
* Hook for provider status indicators
*/
export const useProviderStatus = (provider: CloudProvider) => {
const [manager] = useState<CloudSyncManager>(() => getCloudSyncManager());
const [connection, setConnection] = useState<ProviderConnection>(
() => manager.getProviderConnection(provider)
);
useEffect(() => {
const unsubscribe = manager.subscribe(() => {
setConnection(manager.getProviderConnection(provider));
});
return unsubscribe;
}, [manager, provider]);
return {
...connection,
isConnected: isProviderReadyForSync(connection),
isSyncing: connection.status === 'syncing',
hasError: connection.status === 'error',
dotColor: getSyncDotColor(connection.status),
lastSyncFormatted: formatLastSync(connection.lastSync),
};
};
export default useCloudSync;

View File

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

View File

@@ -0,0 +1,214 @@
/**
* Immersive Mode — makes the entire UI chrome adapt colors to match the active terminal's theme.
*
* Performance strategy:
* - All built-in themes' CSS strings are pre-computed at module load (zero cost at switch time)
* - Custom/unknown themes are computed lazily and cached
* - A single `<style>` tag with `!important` overrides inline CSS variables atomically
* - `useLayoutEffect` ensures the update happens before browser paint (no flash)
*/
import { useEffect, useLayoutEffect, useRef } from 'react';
import { TerminalTheme } from '../../domain/models';
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
// ---------------------------------------------------------------------------
// Hex → HSL conversion (returns "H S% L%" without the hsl() wrapper)
// ---------------------------------------------------------------------------
function hexToHsl(hex: string): string {
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0;
let s = 0;
const l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
case g: h = ((b - r) / d + 2) / 6; break;
case b: h = ((r - g) / d + 4) / 6; break;
}
}
return `${Math.round(h * 3600) / 10} ${Math.round(s * 1000) / 10}% ${Math.round(l * 1000) / 10}%`;
}
function adjustLightness(hsl: string, delta: number): string {
const parts = hsl.split(/\s+/);
const newL = Math.max(0, Math.min(100, parseFloat(parts[2]) + delta));
return `${parts[0]} ${parts[1]} ${Math.round(newL * 10) / 10}%`;
}
function adjustSaturation(hsl: string, factor: number): string {
const parts = hsl.split(/\s+/);
const newS = Math.max(0, Math.min(100, parseFloat(parts[1]) * factor));
return `${parts[0]} ${Math.round(newS * 10) / 10}% ${parts[2]}`;
}
// ---------------------------------------------------------------------------
// Build the CSS rule string from a TerminalTheme
// ---------------------------------------------------------------------------
const CSS_VARS = [
'background', 'foreground', 'card', 'card-foreground',
'popover', 'popover-foreground', 'primary', 'primary-foreground',
'secondary', 'secondary-foreground', 'muted', 'muted-foreground',
'accent', 'accent-foreground', 'destructive', 'destructive-foreground',
'border', 'input', 'ring',
] as const;
function buildImmersiveCss(theme: TerminalTheme): string {
const bg = hexToHsl(theme.colors.background);
const fg = hexToHsl(theme.colors.foreground);
const cursor = hexToHsl(theme.colors.cursor);
const isDark = theme.type === 'dark';
const card = adjustLightness(bg, isDark ? 4 : -3);
const secondary = adjustLightness(bg, isDark ? 6 : -5);
const muted = adjustLightness(bg, isDark ? 10 : -8);
const mutedFg = adjustSaturation(adjustLightness(fg, isDark ? -20 : 20), 0.5);
const border = adjustLightness(bg, isDark ? 12 : -10);
const cursorL = parseFloat(cursor.split(' ')[2] ?? '50');
const primaryFg = cursorL > 55 ? '0 0% 0%' : '0 0% 100%';
const values = [
bg, fg, card, fg, // background, foreground, card, card-foreground
card, fg, // popover, popover-foreground
cursor, primaryFg, // primary, primary-foreground
secondary, fg, // secondary, secondary-foreground
muted, mutedFg, // muted, muted-foreground
cursor, primaryFg, // accent, accent-foreground
'0 70% 50%', '0 0% 100%', // destructive, destructive-foreground
border, border, cursor, // border, input, ring
];
const rules = CSS_VARS.map((name, i) => `--${name}: ${values[i]} !important`).join('; ');
return `:root { ${rules}; }`;
}
// ---------------------------------------------------------------------------
// Pre-compute CSS for all built-in themes at module load — O(1) lookup at switch time
// ---------------------------------------------------------------------------
const cssCache = new Map<string, string>();
// Fingerprint: id + type + 3 key colors (detects in-place edits including dark↔light)
function themeFingerprint(t: TerminalTheme): string {
return `${t.id}\0${t.type}\0${t.colors.background}\0${t.colors.foreground}\0${t.colors.cursor}`;
}
// Pre-compute built-in themes
for (const theme of TERMINAL_THEMES) {
cssCache.set(themeFingerprint(theme), buildImmersiveCss(theme));
}
/** Get (or lazily compute & cache) the immersive CSS for a theme. */
function getImmersiveCss(theme: TerminalTheme): string {
const fp = themeFingerprint(theme);
let css = cssCache.get(fp);
if (!css) {
css = buildImmersiveCss(theme);
cssCache.set(fp, css);
}
return css;
}
// ---------------------------------------------------------------------------
// Style tag management
// ---------------------------------------------------------------------------
const STYLE_ID = 'netcatty-immersive-override';
function applyImmersiveStyle(css: string, isDark: boolean, bg: string) {
const root = document.documentElement;
const targetClass = isDark ? 'dark' : 'light';
if (!root.classList.contains(targetClass)) {
root.classList.remove('light', 'dark');
root.classList.add(targetClass);
}
let style = document.getElementById(STYLE_ID) as HTMLStyleElement | null;
if (!style) {
style = document.createElement('style');
style.id = STYLE_ID;
document.head.appendChild(style);
}
style.textContent = css;
// Sync native Electron window chrome
netcattyBridge.get()?.setTheme?.(isDark ? 'dark' : 'light');
netcattyBridge.get()?.setBackgroundColor?.(bg);
}
function removeImmersiveStyle() {
document.getElementById(STYLE_ID)?.remove();
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
export function useImmersiveMode({
isImmersive,
activeTabId,
activeTerminalTheme,
restoreOriginalTheme,
}: {
isImmersive: boolean;
activeTabId: string;
activeTerminalTheme: TerminalTheme | null;
restoreOriginalTheme: () => void;
}) {
const overrideActiveRef = useRef(false);
const appliedFpRef = useRef<string | null>(null);
const restoreRef = useRef(restoreOriginalTheme);
restoreRef.current = restoreOriginalTheme;
const isTerminalTab = activeTabId !== 'vault' && activeTabId !== 'sftp' && !activeTabId.startsWith('log-');
// APPLY: useLayoutEffect — runs before paint, O(1) Map lookup, single DOM write
useLayoutEffect(() => {
if (isImmersive && isTerminalTab && activeTerminalTheme) {
const fp = themeFingerprint(activeTerminalTheme);
if (appliedFpRef.current === fp) return;
overrideActiveRef.current = true;
appliedFpRef.current = fp;
applyImmersiveStyle(getImmersiveCss(activeTerminalTheme), activeTerminalTheme.type === 'dark', activeTerminalTheme.colors.background);
}
}, [isImmersive, isTerminalTab, activeTerminalTheme]);
// RESTORE: useEffect — runs after paint, with fade overlay
useEffect(() => {
if (isImmersive && isTerminalTab && activeTerminalTheme) return;
if (!overrideActiveRef.current) return;
overrideActiveRef.current = false;
appliedFpRef.current = null;
const bg = getComputedStyle(document.documentElement).getPropertyValue('--background').trim();
const overlay = document.createElement('div');
overlay.className = 'immersive-fade-overlay';
overlay.style.backgroundColor = `hsl(${bg})`;
document.body.appendChild(overlay);
removeImmersiveStyle();
restoreOriginalTheme();
requestAnimationFrame(() => {
overlay.classList.add('fade-out');
overlay.addEventListener('transitionend', () => overlay.remove(), { once: true });
});
const fallback = setTimeout(() => { if (overlay.parentNode) overlay.remove(); }, 400);
return () => { clearTimeout(fallback); if (overlay.parentNode) overlay.remove(); };
}, [isImmersive, isTerminalTab, activeTerminalTheme, restoreOriginalTheme]);
// Cleanup on unmount
useEffect(() => {
return () => {
removeImmersiveStyle();
appliedFpRef.current = null;
if (overrideActiveRef.current) {
overrideActiveRef.current = false;
restoreRef.current();
}
};
}, []);
}

View File

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

View File

@@ -22,6 +22,8 @@ import {
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR,
STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY,
STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE,
STORAGE_KEY_EDITOR_WORD_WRAP,
STORAGE_KEY_SESSION_LOGS_ENABLED,
STORAGE_KEY_SESSION_LOGS_DIR,
@@ -30,6 +32,8 @@ import {
STORAGE_KEY_CLOSE_TO_TRAY,
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
STORAGE_KEY_AUTO_UPDATE_ENABLED,
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
STORAGE_KEY_IMMERSIVE_MODE,
} from '../../infrastructure/config/storageKeys';
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
@@ -64,6 +68,7 @@ const DEFAULT_SFTP_AUTO_SYNC = false;
const DEFAULT_SFTP_SHOW_HIDDEN_FILES = false;
const DEFAULT_SFTP_USE_COMPRESSED_UPLOAD = true;
const DEFAULT_SFTP_AUTO_OPEN_SIDEBAR = false;
const DEFAULT_SFTP_DEFAULT_VIEW_MODE: 'list' | 'tree' = 'list';
// Editor defaults
const DEFAULT_EDITOR_WORD_WRAP = false;
@@ -120,8 +125,11 @@ const applyThemeTokens = (
accentOverride: string,
) => {
const root = window.document.documentElement;
root.classList.remove('light', 'dark');
root.classList.add(resolvedTheme);
// If immersive mode is active (style tag present), it owns the dark/light class — don't override
if (!document.getElementById('netcatty-immersive-override')) {
root.classList.remove('light', 'dark');
root.classList.add(resolvedTheme);
}
root.style.setProperty('--background', tokens.background);
root.style.setProperty('--foreground', tokens.foreground);
root.style.setProperty('--card', tokens.card);
@@ -235,6 +243,14 @@ export const useSettingsState = () => {
const stored = readStoredString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
return stored === 'true' ? true : DEFAULT_SFTP_AUTO_OPEN_SIDEBAR;
});
const [sftpDefaultViewMode, setSftpDefaultViewMode] = useState<'list' | 'tree'>(() => {
const stored = readStoredString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE);
return (stored === 'list' || stored === 'tree') ? stored : DEFAULT_SFTP_DEFAULT_VIEW_MODE;
});
const [sftpTransferConcurrency, setSftpTransferConcurrencyState] = useState<number>(() => {
const stored = localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY);
return stored != null && stored >= 1 && stored <= 16 ? stored : 4;
});
// Editor Settings
const [editorWordWrap, setEditorWordWrapState] = useState<boolean>(() => {
@@ -324,6 +340,38 @@ export const useSettingsState = () => {
}
}, []);
const [immersiveMode, setImmersiveModeState] = useState<boolean>(() => {
const stored = localStorageAdapter.readString(STORAGE_KEY_IMMERSIVE_MODE);
if (stored === null || stored === '') {
// Persist default so collectSyncableSettings() can include it
localStorageAdapter.writeString(STORAGE_KEY_IMMERSIVE_MODE, 'true');
return true;
}
return stored === 'true';
});
const setImmersiveMode = useCallback((enabled: boolean) => {
setImmersiveModeState(enabled);
localStorageAdapter.writeString(STORAGE_KEY_IMMERSIVE_MODE, String(enabled));
notifySettingsChanged(STORAGE_KEY_IMMERSIVE_MODE, enabled);
}, [notifySettingsChanged]);
const setSftpTransferConcurrency = useCallback((value: number) => {
const clamped = Math.max(1, Math.min(16, Math.round(value)));
setSftpTransferConcurrencyState(clamped);
localStorageAdapter.writeString(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY, String(clamped));
notifySettingsChanged(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY, clamped);
}, [notifySettingsChanged]);
const [workspaceFocusStyle, setWorkspaceFocusStyleState] = useState<'dim' | 'border'>(() => {
const stored = localStorageAdapter.readString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
return stored === 'border' ? 'border' : 'dim';
});
const setWorkspaceFocusStyle = useCallback((style: 'dim' | 'border') => {
setWorkspaceFocusStyleState(style);
localStorageAdapter.writeString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE, style);
notifySettingsChanged(STORAGE_KEY_WORKSPACE_FOCUS_STYLE, style);
}, [notifySettingsChanged]);
const syncAppearanceFromStorage = useCallback(() => {
const storedTheme = readStoredString(STORAGE_KEY_THEME);
const nextTheme = storedTheme && isValidTheme(storedTheme) ? storedTheme : theme;
@@ -414,10 +462,24 @@ export const useSettingsState = () => {
if (storedCompress === 'true' || storedCompress === 'false') setSftpUseCompressedUpload(storedCompress === 'true');
const storedAutoOpenSidebar = readStoredString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR);
if (storedAutoOpenSidebar === 'true' || storedAutoOpenSidebar === 'false') setSftpAutoOpenSidebar(storedAutoOpenSidebar === 'true');
const storedDefaultViewMode = readStoredString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE);
if (storedDefaultViewMode === 'list' || storedDefaultViewMode === 'tree') setSftpDefaultViewMode(storedDefaultViewMode);
// Immersive mode
const storedImmersive = readStoredString(STORAGE_KEY_IMMERSIVE_MODE);
if (storedImmersive === 'true' || storedImmersive === 'false') {
const val = storedImmersive === 'true';
setImmersiveModeState(val);
notifySettingsChanged(STORAGE_KEY_IMMERSIVE_MODE, val);
}
// Workspace focus style
const storedFocusStyle = readStoredString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE);
if (storedFocusStyle === 'dim' || storedFocusStyle === 'border') setWorkspaceFocusStyleState(storedFocusStyle);
// Custom terminal themes
customThemeStore.loadFromStorage();
}, [syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings]);
}, [syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings, notifySettingsChanged]);
useLayoutEffect(() => {
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
@@ -558,6 +620,20 @@ export const useSettingsState = () => {
if (key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && typeof value === 'boolean') {
setSftpAutoOpenSidebar((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE && typeof value === 'string') {
if (value === 'list' || value === 'tree') {
setSftpDefaultViewMode((prev) => (prev === value ? prev : value));
}
}
if (key === STORAGE_KEY_IMMERSIVE_MODE && typeof value === 'boolean') {
setImmersiveModeState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_WORKSPACE_FOCUS_STYLE && (value === 'dim' || value === 'border')) {
setWorkspaceFocusStyleState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY && typeof value === 'number') {
setSftpTransferConcurrencyState((prev) => (prev === value ? prev : value));
}
});
return () => {
try {
@@ -593,18 +669,18 @@ export const useSettingsState = () => {
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
globalHotkeyEnabled, autoUpdateEnabled,
globalHotkeyEnabled, autoUpdateEnabled, immersiveMode,
});
settingsSnapshotRef.current = {
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
terminalThemeId, terminalFontFamilyId, terminalFontSize,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
sftpUseCompressedUpload, sftpAutoOpenSidebar,
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
globalHotkeyEnabled, autoUpdateEnabled,
globalHotkeyEnabled, autoUpdateEnabled, immersiveMode,
};
// Listen for storage changes from other windows (cross-window sync)
@@ -753,6 +829,12 @@ export const useSettingsState = () => {
setSftpAutoOpenSidebar(newValue);
}
}
// Sync SFTP default view mode from other windows
if (e.key === STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE && e.newValue) {
if ((e.newValue === 'list' || e.newValue === 'tree') && e.newValue !== s.sftpDefaultViewMode) {
setSftpDefaultViewMode(e.newValue);
}
}
// Sync global hotkey enabled setting from other windows
if (e.key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && e.newValue !== null) {
const newValue = e.newValue === 'true';
@@ -767,6 +849,26 @@ export const useSettingsState = () => {
setAutoUpdateEnabled(newValue);
}
}
// Sync immersive mode from other windows
if (e.key === STORAGE_KEY_IMMERSIVE_MODE && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== s.immersiveMode) {
setImmersiveModeState(newValue);
}
}
// Sync workspace focus style from other windows
if (e.key === STORAGE_KEY_WORKSPACE_FOCUS_STYLE && e.newValue !== null) {
if (e.newValue === 'dim' || e.newValue === 'border') {
setWorkspaceFocusStyleState(e.newValue);
}
}
// Sync transfer concurrency from other windows
if (e.key === STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY && e.newValue !== null) {
const num = Number(e.newValue);
if (num >= 1 && num <= 16) {
setSftpTransferConcurrencyState(num);
}
}
};
window.addEventListener('storage', handleStorageChange);
@@ -874,6 +976,13 @@ export const useSettingsState = () => {
notifySettingsChanged(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, sftpAutoOpenSidebar);
}, [sftpAutoOpenSidebar, notifySettingsChanged]);
// Persist SFTP default view mode
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE, sftpDefaultViewMode);
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE, sftpDefaultViewMode);
}, [sftpDefaultViewMode, notifySettingsChanged]);
// Persist Session Logs settings
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_ENABLED, sessionLogsEnabled ? 'true' : 'false');
@@ -1053,6 +1162,12 @@ export const useSettingsState = () => {
setTerminalSettings(prev => ({ ...prev, [key]: value }));
}, [setTerminalSettings]);
/** Re-apply the current UI theme tokens (used to restore after immersive mode override). */
const reapplyCurrentTheme = useCallback(() => {
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
return {
theme,
setTheme,
@@ -1102,6 +1217,10 @@ export const useSettingsState = () => {
setSftpUseCompressedUpload,
sftpAutoOpenSidebar,
setSftpAutoOpenSidebar,
sftpDefaultViewMode,
setSftpDefaultViewMode,
sftpTransferConcurrency,
setSftpTransferConcurrency,
// Editor Settings
editorWordWrap,
setEditorWordWrap: useCallback((enabled: boolean) => {
@@ -1127,6 +1246,11 @@ export const useSettingsState = () => {
globalHotkeyEnabled,
setGlobalHotkeyEnabled,
rehydrateAllFromStorage,
reapplyCurrentTheme,
immersiveMode,
setImmersiveMode,
workspaceFocusStyle,
setWorkspaceFocusStyle,
// Opaque version that changes when any synced setting changes, used by useAutoSync.
// eslint-disable-next-line react-hooks/exhaustive-deps
settingsVersion: useMemo(() => Math.random(), [
@@ -1134,8 +1258,8 @@ export const useSettingsState = () => {
uiFontFamilyId, uiLanguage, customCSS,
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
customKeyBindings, editorWordWrap,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar,
customThemes,
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
customThemes, immersiveMode, workspaceFocusStyle,
]),
};
};

View File

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

View File

@@ -110,6 +110,30 @@ export const useSftpState = (
}
}, []);
const getPaneByConnectionId = useCallback((connectionId: string) => {
for (const tab of leftTabsRef.current.tabs) {
if (tab.connection?.id === connectionId) return tab;
}
for (const tab of rightTabsRef.current.tabs) {
if (tab.connection?.id === connectionId) return tab;
}
return null;
}, [leftTabsRef, rightTabsRef]);
const getTabByConnectionId = useCallback((connectionId: string) => {
for (const tab of leftTabsRef.current.tabs) {
if (tab.connection?.id === connectionId) {
return { side: "left" as const, tabId: tab.id, pane: tab };
}
}
for (const tab of rightTabsRef.current.tabs) {
if (tab.connection?.id === connectionId) {
return { side: "right" as const, tabId: tab.id, pane: tab };
}
}
return null;
}, [leftTabsRef, rightTabsRef]);
// Ref to track pending reconnections to avoid multiple reconnect attempts
const reconnectingRef = useRef<{ left: boolean; right: boolean }>({
left: false,
@@ -183,10 +207,14 @@ export const useSftpState = (
selectAll,
getFilteredFiles,
createDirectory,
createDirectoryAtPath,
createFile,
createFileAtPath,
deleteFiles,
deleteFilesAtPath,
renameFile,
renameFileAtPath,
moveEntriesToPath,
changePermissions,
} = useSftpPaneActions({
hosts,
@@ -244,6 +272,7 @@ export const useSftpState = (
conflicts,
activeTransfersCount,
startTransfer,
downloadToLocal,
addExternalUpload,
updateExternalUpload,
cancelTransfer,
@@ -254,7 +283,11 @@ export const useSftpState = (
resolveConflict,
} = useSftpTransfers({
getActivePane,
getPaneByConnectionId,
getTabByConnectionId,
updateTab,
refresh,
clearCacheForConnection,
sftpSessionsRef,
listLocalFiles,
listRemoteFiles,
@@ -310,10 +343,14 @@ export const useSftpState = (
setFilenameEncoding,
setShowHiddenFiles,
createDirectory,
createDirectoryAtPath,
createFile,
createFileAtPath,
deleteFiles,
deleteFilesAtPath,
renameFile,
renameFileAtPath,
moveEntriesToPath,
changePermissions,
readTextFile,
readBinaryFile,
@@ -324,6 +361,7 @@ export const useSftpState = (
cancelExternalUpload,
selectApplication,
startTransfer,
downloadToLocal,
addExternalUpload,
updateExternalUpload,
cancelTransfer,
@@ -332,6 +370,7 @@ export const useSftpState = (
dismissTransfer,
resolveConflict,
getSftpIdForConnection,
reportSessionError: handleSessionError,
});
methodsRef.current = {
getFilteredFiles,
@@ -357,10 +396,14 @@ export const useSftpState = (
setFilenameEncoding,
setShowHiddenFiles,
createDirectory,
createDirectoryAtPath,
createFile,
createFileAtPath,
deleteFiles,
deleteFilesAtPath,
renameFile,
renameFileAtPath,
moveEntriesToPath,
changePermissions,
readTextFile,
readBinaryFile,
@@ -371,6 +414,7 @@ export const useSftpState = (
cancelExternalUpload,
selectApplication,
startTransfer,
downloadToLocal,
addExternalUpload,
updateExternalUpload,
cancelTransfer,
@@ -379,6 +423,7 @@ export const useSftpState = (
dismissTransfer,
resolveConflict,
getSftpIdForConnection,
reportSessionError: handleSessionError,
};
// Create stable method wrappers that call through methodsRef
@@ -409,11 +454,17 @@ export const useSftpState = (
setShowHiddenFiles: (...args: Parameters<typeof setShowHiddenFiles>) =>
methodsRef.current.setShowHiddenFiles(...args),
createDirectory: (...args: Parameters<typeof createDirectory>) => methodsRef.current.createDirectory(...args),
createDirectoryAtPath: (...args: Parameters<typeof createDirectoryAtPath>) =>
methodsRef.current.createDirectoryAtPath(...args),
createFile: (...args: Parameters<typeof createFile>) => methodsRef.current.createFile(...args),
createFileAtPath: (...args: Parameters<typeof createFileAtPath>) =>
methodsRef.current.createFileAtPath(...args),
deleteFiles: (...args: Parameters<typeof deleteFiles>) => methodsRef.current.deleteFiles(...args),
deleteFilesAtPath: (...args: Parameters<typeof deleteFilesAtPath>) =>
methodsRef.current.deleteFilesAtPath(...args),
renameFile: (...args: Parameters<typeof renameFile>) => methodsRef.current.renameFile(...args),
renameFileAtPath: (...args: Parameters<typeof renameFileAtPath>) => methodsRef.current.renameFileAtPath(...args),
moveEntriesToPath: (...args: Parameters<typeof moveEntriesToPath>) => methodsRef.current.moveEntriesToPath(...args),
changePermissions: (...args: Parameters<typeof changePermissions>) => methodsRef.current.changePermissions(...args),
readTextFile: (...args: Parameters<typeof readTextFile>) => methodsRef.current.readTextFile(...args),
readBinaryFile: (...args: Parameters<typeof readBinaryFile>) => methodsRef.current.readBinaryFile(...args),
@@ -425,6 +476,7 @@ export const useSftpState = (
cancelExternalUpload: () => methodsRef.current.cancelExternalUpload(),
selectApplication: () => methodsRef.current.selectApplication(),
startTransfer: (...args: Parameters<typeof startTransfer>) => methodsRef.current.startTransfer(...args),
downloadToLocal: (...args: Parameters<typeof downloadToLocal>) => methodsRef.current.downloadToLocal(...args),
addExternalUpload: (...args: Parameters<typeof addExternalUpload>) => methodsRef.current.addExternalUpload(...args),
updateExternalUpload: (...args: Parameters<typeof updateExternalUpload>) => methodsRef.current.updateExternalUpload(...args),
cancelTransfer: (...args: Parameters<typeof cancelTransfer>) => methodsRef.current.cancelTransfer(...args),
@@ -433,6 +485,7 @@ export const useSftpState = (
dismissTransfer: (...args: Parameters<typeof dismissTransfer>) => methodsRef.current.dismissTransfer(...args),
resolveConflict: (...args: Parameters<typeof resolveConflict>) => methodsRef.current.resolveConflict(...args),
getSftpIdForConnection: (...args: Parameters<typeof getSftpIdForConnection>) => methodsRef.current.getSftpIdForConnection(...args),
reportSessionError: (...args: Parameters<typeof handleSessionError>) => methodsRef.current.reportSessionError(...args),
activeFileWatchCountRef,
}), [activeFileWatchCountRef]); // activeFileWatchCountRef is a stable ref

View File

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

View File

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

View File

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

View File

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

View File

@@ -611,7 +611,7 @@ interface SyncDashboardProps {
onClearLocalData?: () => void;
}
export const SyncDashboard: React.FC<SyncDashboardProps> = ({
const SyncDashboard: React.FC<SyncDashboardProps> = ({
onBuildPayload,
onApplyPayload,
onClearLocalData,
@@ -800,6 +800,9 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
toast.success(t('cloudSync.connect.github.success'));
} catch (error) {
setIsPollingGitHub(false);
setShowGitHubModal(false);
// Reset provider status so button is clickable again (without tearing down existing connections)
sync.resetProviderStatus('github');
const message = getNetworkErrorMessage(error, t('common.unknownError'));
toast.error(message, t('cloudSync.connect.github.failedTitle'));
}
@@ -813,10 +816,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
// Note: Auth flow is handled automatically by oauthBridge
toast.info(t('cloudSync.connect.browserContinue'));
} catch (error) {
toast.error(
error instanceof Error ? error.message : t('common.unknownError'),
t('cloudSync.connect.google.failedTitle'),
);
// Reset provider status so button is clickable again (without tearing down existing connections)
sync.resetProviderStatus('google');
const msg = error instanceof Error ? error.message : t('common.unknownError');
// Don't show toast for user-initiated cancellation (popup closed)
if (!msg.includes('cancelled')) {
toast.error(msg, t('cloudSync.connect.google.failedTitle'));
}
}
};
@@ -828,10 +834,13 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
// Note: Auth flow is handled automatically by oauthBridge
toast.info(t('cloudSync.connect.browserContinue'));
} catch (error) {
toast.error(
error instanceof Error ? error.message : t('common.unknownError'),
t('cloudSync.connect.onedrive.failedTitle'),
);
// Reset provider status so button is clickable again (without tearing down existing connections)
sync.resetProviderStatus('onedrive');
const msg = error instanceof Error ? error.message : t('common.unknownError');
// Don't show toast for user-initiated cancellation (popup closed)
if (!msg.includes('cancelled')) {
toast.error(msg, t('cloudSync.connect.onedrive.failedTitle'));
}
}
};
@@ -1250,6 +1259,9 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
onClose={() => {
setShowGitHubModal(false);
setIsPollingGitHub(false);
// Reset provider status so button is clickable again.
// The background polling will continue until expiry but is harmless.
sync.resetProviderStatus('github');
}}
/>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -63,6 +63,8 @@ const SettingsTerminalTabContainer: React.FC<{ settings: SettingsState }> = ({ s
terminalSettings={settings.terminalSettings}
updateTerminalSetting={settings.updateTerminalSetting}
availableFonts={availableFonts}
workspaceFocusStyle={settings.workspaceFocusStyle}
setWorkspaceFocusStyle={settings.setWorkspaceFocusStyle}
/>
);
};
@@ -149,9 +151,13 @@ const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = (
const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }) => {
const { t } = useI18n();
const { notifyRendererReady, closeSettingsWindow } = useWindowControls();
const { updateState, checkNow, installUpdate, openReleasePage } = useUpdateCheck({ autoUpdateEnabled: settings.autoUpdateEnabled });
const { updateState, checkNow, installUpdate, openReleasePage, startDownload, isUpdateDemoMode } = useUpdateCheck({ autoUpdateEnabled: settings.autoUpdateEnabled });
const [activeTab, setActiveTab] = useState("application");
const [mountedTabs, setMountedTabs] = useState(() => new Set(["application"]));
const isImmersive = settings.immersiveMode;
const toggleImmersive = useCallback(() => {
settings.setImmersiveMode(!isImmersive);
}, [settings, isImmersive]);
useEffect(() => {
notifyRendererReady();
@@ -256,6 +262,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
checkNow={checkNow}
openReleasePage={openReleasePage}
installUpdate={installUpdate}
startDownload={startDownload}
isUpdateDemoMode={isUpdateDemoMode}
/>
)}
@@ -277,6 +285,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
setUiLanguage={settings.setUiLanguage}
customCSS={settings.customCSS}
setCustomCSS={settings.setCustomCSS}
isImmersive={isImmersive}
onToggleImmersive={toggleImmersive}
/>
)}
@@ -331,6 +341,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
checkNow={checkNow}
installUpdate={installUpdate}
openReleasePage={openReleasePage}
startDownload={startDownload}
/>
)}
</div>

View File

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

View File

@@ -19,10 +19,11 @@ import { useI18n } from "../application/i18n/I18nProvider";
import { useIsSftpActive } from "../application/state/activeTabStore";
import { useSftpState } from "../application/state/useSftpState";
import { useSftpBackend } from "../application/state/useSftpBackend";
import { useSettingsState } from "../application/state/useSettingsState";
import { HotkeyScheme, KeyBinding } from "../domain/models";
import { logger } from "../lib/logger";
import { useRenderTracker } from "../lib/useRenderTracker";
import { cn } from "../lib/utils";
import { useInstantThemeSwitch } from "../lib/useInstantThemeSwitch";
import { Host, Identity, SSHKey } from "../types";
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
import { toast } from "./ui/toast";
@@ -49,21 +50,37 @@ interface SftpViewProps {
keys: SSHKey[];
identities: Identity[];
updateHosts: (hosts: Host[]) => void;
sftpDefaultViewMode: "list" | "tree";
sftpDoubleClickBehavior: "open" | "transfer";
sftpAutoSync: boolean;
sftpShowHiddenFiles: boolean;
sftpUseCompressedUpload: boolean;
hotkeyScheme: HotkeyScheme;
keyBindings: KeyBinding[];
editorWordWrap: boolean;
setEditorWordWrap: (enabled: boolean) => void;
}
const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updateHosts }) => {
const SftpViewInner: React.FC<SftpViewProps> = ({
hosts,
keys,
identities,
updateHosts,
sftpDefaultViewMode,
sftpDoubleClickBehavior,
sftpAutoSync,
sftpShowHiddenFiles,
sftpUseCompressedUpload,
hotkeyScheme,
keyBindings,
editorWordWrap,
setEditorWordWrap,
}) => {
const { t } = useI18n();
const isActive = useIsSftpActive();
const {
sftpDoubleClickBehavior,
sftpAutoSync,
sftpShowHiddenFiles,
sftpUseCompressedUpload,
hotkeyScheme,
keyBindings,
editorWordWrap,
setEditorWordWrap,
} = useSettingsState();
const rootRef = useRef<HTMLDivElement>(null);
useInstantThemeSwitch(rootRef);
// File watch event handlers (stable refs to avoid re-creating the useSftpState options)
const fileWatchHandlers = useMemo(() => ({
@@ -94,6 +111,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
listSftp,
mkdirLocal,
deleteLocalFile,
listLocalDir,
} = useSftpBackend();
// Store sftp in a ref so callbacks can access the latest instance
@@ -190,10 +208,11 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
selectDirectory,
startStreamTransfer,
getSftpIdForConnection: sftp.getSftpIdForConnection,
listLocalFiles: listLocalDir,
});
const visibleTransfers = useMemo(
() => [...sftp.transfers].reverse().slice(0, 5),
() => [...sftp.transfers].filter((t) => !t.parentTaskId).reverse().slice(0, 5),
[sftp.transfers],
);
@@ -246,6 +265,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
rightCallbacks={rightCallbacks}
>
<div
ref={rootRef}
className={cn(
"absolute inset-0 min-h-0 flex flex-col",
isActive ? "z-20" : "",
@@ -293,6 +313,8 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
<SftpPaneView
side="left"
pane={pane}
isPaneFocused={focusedSide === "left"}
sftpDefaultViewMode={sftpDefaultViewMode}
showHeader
showEmptyHeader={false}
onToggleShowHiddenFiles={() => handleToggleHiddenFiles("left", pane.id)}
@@ -350,6 +372,8 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
<SftpPaneView
side="right"
pane={pane}
isPaneFocused={focusedSide === "right"}
sftpDefaultViewMode={sftpDefaultViewMode}
showHeader
showEmptyHeader={false}
onToggleShowHiddenFiles={() => handleToggleHiddenFiles("right", pane.id)}
@@ -408,7 +432,18 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
};
const sftpViewAreEqual = (prev: SftpViewProps, next: SftpViewProps): boolean =>
prev.hosts === next.hosts && prev.keys === next.keys && prev.identities === next.identities;
prev.hosts === next.hosts &&
prev.keys === next.keys &&
prev.identities === next.identities &&
prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
prev.sftpDoubleClickBehavior === next.sftpDoubleClickBehavior &&
prev.sftpAutoSync === next.sftpAutoSync &&
prev.sftpShowHiddenFiles === next.sftpShowHiddenFiles &&
prev.sftpUseCompressedUpload === next.sftpUseCompressedUpload &&
prev.hotkeyScheme === next.hotkeyScheme &&
prev.keyBindings === next.keyBindings &&
prev.editorWordWrap === next.editorWordWrap &&
prev.setEditorWordWrap === next.setEditorWordWrap;
export const SftpView = memo(SftpViewInner, sftpViewAreEqual);
SftpView.displayName = "SftpView";

View File

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

View File

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

View File

@@ -151,6 +151,7 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
// Ref to store the latest save function to avoid stale closure in keyboard shortcut
const handleSaveRef = useRef<() => Promise<void>>(() => Promise.resolve());
const handlePasteRef = useRef<() => Promise<void>>(() => Promise.resolve());
const readClipboardTextRef = useRef<() => Promise<string | null>>(() => Promise.resolve(null));
// Track theme from document.documentElement class (syncs with app theme)
const [isDarkTheme, setIsDarkTheme] = useState(() =>
@@ -254,6 +255,10 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
}
}, [readClipboardTextFromBridge]);
useEffect(() => {
readClipboardTextRef.current = readClipboardText;
}, [readClipboardText]);
const handlePaste = useCallback(async () => {
const editor = editorRef.current;
if (!editor) return;
@@ -316,7 +321,30 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
});
// Fallback paste path for Electron environments where Monaco paste can fail.
// Skip custom paste when focus is inside the find/replace widget so that
// its input fields receive the pasted text via default browser behavior.
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyV, () => {
const active = document.activeElement;
if (active?.closest('.find-widget')) {
// Read clipboard and insert into the find/replace input field.
void (async () => {
try {
const text = await readClipboardTextRef.current();
if (!text) return;
// Monaco find widget inputs are <textarea> elements inside .monaco-inputbox
if (active instanceof HTMLTextAreaElement || active instanceof HTMLInputElement) {
const start = active.selectionStart ?? active.value.length;
const end = active.selectionEnd ?? active.value.length;
active.focus();
active.setSelectionRange(start, end);
document.execCommand('insertText', false, text);
}
} catch {
// Ignore paste simply won't work
}
})();
return;
}
void handlePasteRef.current();
});
}, []);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -154,13 +154,6 @@ function getAgentIconKey(agent: AgentLike | 'add-more'): AgentIconKey {
return 'terminal';
}
export function getAgentCommandLabel(agent: AgentLike): string | undefined {
if (agent.type === 'builtin') {
return 'Built-in terminal assistant';
}
return agent.command ? `CLI: ${agent.command}` : 'External CLI agent';
}
export const AgentIconBadge: React.FC<{
agent: AgentLike | 'add-more';
size?: 'xs' | 'sm' | 'md' | 'lg';
@@ -187,18 +180,27 @@ export const AgentIconBadge: React.FC<{
if (variant === 'plain') {
return (
<img
src={visual.src}
alt=""
<div
aria-hidden="true"
draggable={false}
className={cn('shrink-0', imageSize, visual.imageClassName, className)}
className={cn('shrink-0', imageSize, className)}
style={{
maskImage: `url(${visual.src})`,
WebkitMaskImage: `url(${visual.src})`,
maskSize: 'contain',
WebkitMaskSize: 'contain',
maskRepeat: 'no-repeat',
WebkitMaskRepeat: 'no-repeat',
maskPosition: 'center',
WebkitMaskPosition: 'center',
backgroundColor: 'currentColor',
}}
/>
);
}
return (
<div
data-agent-badge=""
className={cn(
'flex shrink-0 items-center justify-center overflow-hidden border',
badgeSize,

View File

@@ -208,7 +208,7 @@ const AgentSelector: React.FC<AgentSelectorProps> = ({
<DropdownContent
align="start"
sideOffset={6}
className="w-[288px] rounded-2xl border border-border/50 bg-popover p-0 text-foreground shadow-lg supports-[backdrop-filter]:backdrop-blur-xl"
className="w-[288px] overflow-hidden rounded-2xl border border-border/50 bg-popover p-0 text-foreground shadow-lg supports-[backdrop-filter]:backdrop-blur-xl"
>
{BUILTIN_AGENTS.map((agent) => (
<AgentMenuRow

View File

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

View File

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

View File

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

View File

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

View File

@@ -25,6 +25,8 @@ export default function SettingsAppearanceTab(props: {
setUiLanguage: (language: string) => void;
customCSS: string;
setCustomCSS: (css: string) => void;
isImmersive?: boolean;
onToggleImmersive?: () => void;
}) {
const { t } = useI18n();
const availableUIFonts = useAvailableUIFonts();
@@ -45,6 +47,8 @@ export default function SettingsAppearanceTab(props: {
setUiLanguage,
customCSS,
setCustomCSS,
isImmersive,
onToggleImmersive,
} = props;
const getHslStyle = useCallback((hsl: string) => ({ backgroundColor: `hsl(${hsl})` }), []);
@@ -254,6 +258,19 @@ export default function SettingsAppearanceTab(props: {
</SettingRow>
</div>
<SectionHeader title={t("settings.appearance.immersiveMode")} />
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
<SettingRow
label={t("settings.appearance.immersiveMode")}
description={t("settings.appearance.immersiveMode.desc")}
>
<Toggle
checked={!!isImmersive}
onChange={() => onToggleImmersive?.()}
/>
</SettingRow>
</div>
<SectionHeader title={t("settings.appearance.customCss")} />
<div className="space-y-2">
<p className="text-xs text-muted-foreground">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@
import type {
AIProviderId,
ExternalAgentConfig,
ProviderAdvancedParams,
} from "../../../../infrastructure/ai/types";
export type CodexIntegrationState =
@@ -42,6 +43,7 @@ export interface ProviderFormState {
baseURL: string;
defaultModel: string;
skipTLSVerify: boolean;
advancedParams: ProviderAdvancedParams;
}
export interface FetchedModel {

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -147,7 +147,8 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
container.scrollLeft += tabRect.right - containerRect.right + 8;
}
}
setTimeout(updateScrollState, 100);
const timer = setTimeout(updateScrollState, 100);
return () => clearTimeout(timer);
}, [activeTabId, updateScrollState]);
// Drag handlers

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,180 @@
/**
* Ghost Text addon for xterm.js.
* Renders inline suggestion text after the cursor in a dimmed style,
* similar to fish shell's autosuggestions.
*
* Uses a CSS overlay positioned relative to the terminal cursor,
* avoiding modification of the terminal buffer.
*/
import type { Terminal as XTerm, IDisposable } from "@xterm/xterm";
import { getXTermCellDimensions, invalidateCellDimensionCache } from "./xtermUtils";
export class GhostTextAddon implements IDisposable {
private term: XTerm | null = null;
private ghostElement: HTMLSpanElement | null = null;
private containerElement: HTMLDivElement | null = null;
private currentSuggestion: string = "";
private currentInput: string = "";
private disposed = false;
private disposables: IDisposable[] = [];
private lastLeft = -1;
private lastTop = -1;
activate(term: XTerm): void {
this.term = term;
const termElement = term.element;
if (!termElement) return;
this.containerElement = document.createElement("div");
this.containerElement.className = "xterm-ghost-text-container";
Object.assign(this.containerElement.style, {
position: "absolute",
top: "0",
left: "0",
width: "100%",
height: "100%",
pointerEvents: "none",
overflow: "hidden",
zIndex: "1",
});
this.ghostElement = document.createElement("span");
this.ghostElement.className = "xterm-ghost-text";
Object.assign(this.ghostElement.style, {
position: "absolute",
opacity: "0.4",
pointerEvents: "none",
whiteSpace: "pre",
fontFamily: "inherit",
fontSize: "inherit",
lineHeight: "inherit",
color: "inherit",
display: "none",
});
this.containerElement.appendChild(this.ghostElement);
const screenEl = termElement.querySelector(".xterm-screen");
if (screenEl) {
screenEl.appendChild(this.containerElement);
} else {
termElement.appendChild(this.containerElement);
}
// Update position on scroll and render to keep ghost text aligned
this.disposables.push(
term.onRender(() => {
if (this.isVisible()) this.updatePosition();
}),
);
// Invalidate cell dimension cache on resize so measurements stay accurate
this.disposables.push(
term.onResize(() => {
invalidateCellDimensionCache();
}),
);
}
/**
* Show ghost text suggestion.
* @param fullSuggestion The complete suggested command
* @param currentInput The text the user has typed so far
*/
show(fullSuggestion: string, currentInput: string): void {
if (this.disposed || !this.ghostElement || !this.term) return;
const ghostText = fullSuggestion.startsWith(currentInput)
? fullSuggestion.substring(currentInput.length)
: "";
if (!ghostText) {
this.hide();
return;
}
this.currentSuggestion = fullSuggestion;
this.currentInput = currentInput;
this.updatePosition();
this.ghostElement.textContent = ghostText;
this.ghostElement.style.display = "block";
// Set font properties once per show (not per frame in updatePosition)
this.ghostElement.style.fontSize = `${this.term.options.fontSize}px`;
this.ghostElement.style.fontFamily = this.term.options.fontFamily || "inherit";
}
hide(): void {
if (this.ghostElement) {
this.ghostElement.style.display = "none";
this.ghostElement.textContent = "";
}
this.currentSuggestion = "";
this.currentInput = "";
}
getSuggestion(): string {
return this.currentSuggestion;
}
isVisible(): boolean {
return !!(this.ghostElement && this.ghostElement.style.display !== "none" &&
this.currentSuggestion);
}
getGhostText(): string {
if (!this.currentSuggestion || !this.currentInput) return "";
return this.currentSuggestion.startsWith(this.currentInput)
? this.currentSuggestion.substring(this.currentInput.length)
: "";
}
getNextWord(): string {
const ghost = this.getGhostText();
if (!ghost) return "";
const trimmed = ghost.replace(/^\s+/, "");
const leadingSpace = ghost.length - trimmed.length;
if (trimmed.length === 0) return ghost; // Only whitespace
// Search for word boundary starting from index 1 (skip leading separator chars like /)
const wordEnd = trimmed.substring(1).search(/[\s/\\-]/);
if (wordEnd < 0) return ghost; // Single word, accept all
// Include leading whitespace + the word up to (and including) the separator
return ghost.substring(0, leadingSpace + 1 + wordEnd + 1);
}
private updatePosition(): void {
if (!this.term || !this.ghostElement) return;
const dims = getXTermCellDimensions(this.term);
const buffer = this.term.buffer.active;
const left = buffer.cursorX * dims.width;
const top = buffer.cursorY * dims.height;
// Skip DOM writes if position hasn't changed (avoids unnecessary style recalc)
if (left === this.lastLeft && top === this.lastTop) return;
this.lastLeft = left;
this.lastTop = top;
this.ghostElement.style.left = `${left}px`;
this.ghostElement.style.top = `${top}px`;
this.ghostElement.style.lineHeight = `${dims.height}px`;
this.ghostElement.style.height = `${dims.height}px`;
}
dispose(): void {
this.disposed = true;
for (const d of this.disposables) d.dispose();
this.disposables = [];
this.containerElement?.remove();
this.containerElement = null;
this.ghostElement = null;
this.term = null;
}
}

View File

@@ -0,0 +1,424 @@
/**
* Persistent command history store for terminal autocomplete.
* Stores commands per host with frequency tracking and timestamp ordering.
* Uses localStorageAdapter as the persistence layer (works in renderer process).
*/
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
const STORAGE_KEY = "netcatty:commandHistory";
const MAX_ENTRIES = 10000;
const MAX_ENTRIES_PER_HOST = 5000;
export interface HistoryEntry {
command: string;
hostId: string;
/** OS type for cross-host matching */
os: "linux" | "windows" | "macos";
/** Number of times this exact command was executed */
frequency: number;
/** Timestamp of last execution */
lastUsedAt: number;
/** Timestamp of first execution */
createdAt: number;
}
interface HistoryStore {
entries: HistoryEntry[];
version: number;
}
let cachedStore: HistoryStore | null = null;
function loadStore(): HistoryStore {
if (cachedStore) return cachedStore;
try {
const parsed = localStorageAdapter.read<HistoryStore>(STORAGE_KEY);
if (parsed) {
cachedStore = parsed;
return parsed;
}
} catch {
// Corrupted data, reset
}
cachedStore = { entries: [], version: 1 };
return cachedStore;
}
let saveTimer: ReturnType<typeof setTimeout> | null = null;
function saveStore(store: HistoryStore): void {
cachedStore = store;
// Debounce saves to avoid excessive writes
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
const ok = localStorageAdapter.write(STORAGE_KEY, store);
if (!ok) {
// Storage full — evict lowest scored entries (not just oldest by insertion)
const now = Date.now();
store.entries.sort((a, b) => scoreEntryAt(b, now) - scoreEntryAt(a, now));
store.entries = store.entries.slice(0, Math.floor(MAX_ENTRIES / 2));
localStorageAdapter.write(STORAGE_KEY, store);
}
saveTimer = null;
}, 500);
}
/**
* Record a command execution. Updates frequency if the command already exists
* for this host, otherwise creates a new entry.
*/
export function recordCommand(
command: string,
hostId: string,
os: "linux" | "windows" | "macos" = "linux",
): void {
const trimmed = command.trim();
if (!trimmed || trimmed.length > 2000) return;
const store = loadStore();
const now = Date.now();
// Find existing entry for same command + host
const existingIdx = store.entries.findIndex(
(e) => e.command === trimmed && e.hostId === hostId,
);
if (existingIdx >= 0) {
store.entries[existingIdx].frequency++;
store.entries[existingIdx].lastUsedAt = now;
} else {
store.entries.push({
command: trimmed,
hostId,
os,
frequency: 1,
lastUsedAt: now,
createdAt: now,
});
}
// Enforce per-host limit (evict by score, not insertion order)
const hostEntries = store.entries.filter((e) => e.hostId === hostId);
if (hostEntries.length > MAX_ENTRIES_PER_HOST) {
hostEntries.sort((a, b) => scoreEntryAt(a, now) - scoreEntryAt(b, now));
const toRemove = new Set(
hostEntries.slice(0, hostEntries.length - MAX_ENTRIES_PER_HOST).map((e) => e.command),
);
store.entries = store.entries.filter(
(e) => e.hostId !== hostId || !toRemove.has(e.command),
);
}
// Enforce global limit
if (store.entries.length > MAX_ENTRIES) {
store.entries.sort((a, b) => scoreEntryAt(b, now) - scoreEntryAt(a, now));
store.entries = store.entries.slice(0, MAX_ENTRIES);
}
saveStore(store);
}
/**
* Score an entry for ranking at a specific timestamp.
* Caches Date.now() at query boundaries to avoid repeated syscalls during sort.
*/
function scoreEntryAt(entry: HistoryEntry, now: number): number {
const ageMs = now - entry.lastUsedAt;
const ageHours = ageMs / (1000 * 60 * 60);
// Exponential decay: halve relevance every 24 hours
const recencyScore = Math.pow(0.5, ageHours / 24);
return entry.frequency * recencyScore;
}
export interface HistoryQueryOptions {
/** Filter by host ID (strict isolation — only this host's history) */
hostId?: string;
/** Maximum number of results */
limit?: number;
}
export interface RecentHistoryQueryOptions extends HistoryQueryOptions {
/** Base command name, e.g. `cd` or `ls` */
commandName: string;
/** Exact command text to exclude from results */
excludeCommand?: string;
/** Optional path prefix to require on the current argument */
argumentPrefix?: string;
}
/**
* Query history entries matching a prefix.
* Returns entries sorted by relevance (frequency * recency).
*/
export function queryHistory(
prefix: string,
options: HistoryQueryOptions = {},
): HistoryEntry[] {
const { hostId, limit = 20 } = options;
if (limit <= 0) return [];
const store = loadStore();
const lowerPrefix = prefix.toLowerCase();
const now = Date.now(); // Cache once per query
const filtered = store.entries.filter((entry) => {
// Must match prefix
if (!entry.command.toLowerCase().startsWith(lowerPrefix)) return false;
// Must not be identical to prefix
if (entry.command === prefix) return false;
// Host filtering: strict per-host isolation
if (hostId) {
return entry.hostId === hostId;
}
return true;
});
// Sort by score (frequency * recency)
filtered.sort((a, b) => scoreEntryAt(b, now) - scoreEntryAt(a, now));
// Deduplicate by command text (keep highest scored)
const seen = new Set<string>();
const results: HistoryEntry[] = [];
for (const entry of filtered) {
if (seen.has(entry.command)) continue;
seen.add(entry.command);
results.push(entry);
if (results.length >= limit) break;
}
return results;
}
/**
* Fuzzy query: matches commands containing all characters of the query
* in order (not necessarily contiguous). Used as a fallback when prefix
* matching yields few results.
*/
export function fuzzyQueryHistory(
query: string,
options: HistoryQueryOptions = {},
): HistoryEntry[] {
const { hostId, limit = 10 } = options;
if (limit <= 0) return [];
const store = loadStore();
const lowerQuery = query.toLowerCase();
const now = Date.now(); // Cache once per query
const scored: { entry: HistoryEntry; matchScore: number }[] = [];
for (const entry of store.entries) {
// Host filtering
if (hostId) {
if (entry.hostId !== hostId) continue;
}
const matchScore = fuzzyScore(lowerQuery, entry.command.toLowerCase());
if (matchScore > 0 && entry.command !== query) {
scored.push({ entry, matchScore });
}
}
scored.sort((a, b) =>
b.matchScore * scoreEntryAt(b.entry, now) - a.matchScore * scoreEntryAt(a.entry, now),
);
const seen = new Set<string>();
const results: HistoryEntry[] = [];
for (const { entry } of scored) {
if (seen.has(entry.command)) continue;
seen.add(entry.command);
results.push(entry);
if (results.length >= limit) break;
}
return results;
}
/**
* Query the most recently used history entries for the same command name.
* Useful when the user is currently completing a path argument and wants
* a few recent command-line examples (e.g. recent `cd ...` commands).
*/
export function queryRecentHistoryByCommand(
options: RecentHistoryQueryOptions,
): HistoryEntry[] {
const {
commandName,
excludeCommand,
argumentPrefix,
hostId,
limit = 3,
} = options;
if (!commandName || limit <= 0) return [];
const store = loadStore();
const trimmedCommandName = commandName.trim().toLowerCase();
const commandPrefix = `${trimmedCommandName} `;
const normalizedArgumentPrefix = normalizeArgumentToken(argumentPrefix ?? "");
const filtered = store.entries.filter((entry) => {
const lowerCommand = entry.command.toLowerCase();
if (lowerCommand !== trimmedCommandName && !lowerCommand.startsWith(commandPrefix)) {
return false;
}
if (excludeCommand && entry.command === excludeCommand) return false;
if (normalizedArgumentPrefix) {
const currentToken = normalizeArgumentToken(getCurrentCommandToken(entry.command));
if (!currentToken.startsWith(normalizedArgumentPrefix)) {
return false;
}
}
if (hostId) {
return entry.hostId === hostId;
}
return true;
});
filtered.sort((a, b) => b.lastUsedAt - a.lastUsedAt);
const seen = new Set<string>();
const results: HistoryEntry[] = [];
for (const entry of filtered) {
if (seen.has(entry.command)) continue;
seen.add(entry.command);
results.push(entry);
if (results.length >= limit) break;
}
return results;
}
function getCurrentCommandToken(command: string): string {
const tokens = tokenizeShellLike(command);
return tokens.length > 0 ? (tokens[tokens.length - 1] || "") : "";
}
function normalizeArgumentToken(token: string): string {
return token
.trim()
.replace(/^['"]/, "")
.replace(/['"]$/, "")
.replace(/\\ /g, " ")
.toLowerCase();
}
function tokenizeShellLike(input: string): string[] {
const tokens: string[] = [];
let current = "";
let inSingleQuote = false;
let inDoubleQuote = false;
let escaped = false;
for (let i = 0; i < input.length; i++) {
const ch = input[i];
if (escaped) {
current += ch;
escaped = false;
continue;
}
if (ch === "\\") {
escaped = true;
current += ch;
continue;
}
if (ch === "'" && !inDoubleQuote) {
inSingleQuote = !inSingleQuote;
current += ch;
continue;
}
if (ch === '"' && !inSingleQuote) {
inDoubleQuote = !inDoubleQuote;
current += ch;
continue;
}
if (ch === " " && !inSingleQuote && !inDoubleQuote) {
if (current.length > 0) {
tokens.push(current);
current = "";
}
continue;
}
current += ch;
}
tokens.push(current);
return tokens;
}
/**
* Compute a fuzzy match score. Returns 0 for no match.
* Higher score = better match quality.
* Rewards: first-char match, consecutive matches, word-boundary matches.
*/
function fuzzyScore(query: string, target: string): number {
if (query.length === 0) return 0;
if (query.length > target.length) return 0;
let score = 0;
let queryIdx = 0;
let prevMatchIdx = -2;
for (let i = 0; i < target.length && queryIdx < query.length; i++) {
if (target[i] === query[queryIdx]) {
queryIdx++;
// First character bonus
if (i === 0) score += 10;
// Consecutive match bonus
if (i === prevMatchIdx + 1) score += 5;
// Word boundary bonus
if (i === 0 || target[i - 1] === " " || target[i - 1] === "/" ||
target[i - 1] === "-" || target[i - 1] === "_") {
score += 3;
}
score += 1;
prevMatchIdx = i;
}
}
// All query characters must be matched
return queryIdx === query.length ? score : 0;
}
/**
* Delete a specific command from history for a host.
*/
export function deleteHistoryEntry(command: string, hostId: string): void {
const store = loadStore();
store.entries = store.entries.filter(
(e) => !(e.command === command && e.hostId === hostId),
);
saveStore(store);
}
/**
* Clear all history for a specific host, or all history if no hostId given.
*/
export function clearHistory(hostId?: string): void {
const store = loadStore();
if (hostId) {
store.entries = store.entries.filter((e) => e.hostId !== hostId);
} else {
store.entries = [];
}
saveStore(store);
}
/**
* Get total number of stored history entries.
*/
export function getHistoryCount(hostId?: string): number {
const store = loadStore();
if (hostId) {
return store.entries.filter((e) => e.hostId === hostId).length;
}
return store.entries.length;
}

View File

@@ -0,0 +1,619 @@
/**
* Context-aware completion engine.
* Combines multiple data sources:
* 1. Command history (highest priority)
* 2. @withfig/autocomplete specs (subcommands, options, args)
* 3. Fuzzy history matching (fallback)
*
* Parses the current command line to determine context (command, subcommand,
* option, or argument position) and provides appropriate suggestions.
*/
import {
queryHistory,
queryRecentHistoryByCommand,
fuzzyQueryHistory,
type HistoryQueryOptions,
} from "./commandHistoryStore";
import {
loadSpec,
hasSpec,
getAvailableSpecs,
normalizeCommandName,
resolveNames,
type FigSpec,
type FigSubcommand,
type FigOption,
} from "./figSpecLoader";
import {
shouldDoPathCompletion,
getPathSuggestions,
resolvePathComponents,
} from "./remotePathCompleter";
/** Source indicator for where a suggestion came from */
export type SuggestionSource = "history" | "command" | "subcommand" | "option" | "arg" | "path";
export interface CompletionSuggestion {
/** The text to insert */
text: string;
/** Display text (may differ from insert text) */
displayText: string;
/** Optional description */
description?: string;
/** Source of this suggestion */
source: SuggestionSource;
/** Relevance score (higher = more relevant) */
score: number;
/** For history entries: execution frequency */
frequency?: number;
/** For path suggestions: file type */
fileType?: "file" | "directory" | "symlink";
}
export interface CompletionContext {
/** Full command line text */
commandLine: string;
/** Current word being typed */
currentWord: string;
/** Index of the current word in the parsed tokens */
wordIndex: number;
/** Parsed command tokens */
tokens: string[];
/** The base command name (first token) */
commandName: string;
/** Whether the current position is after a recognized option that expects an argument */
isOptionArg: boolean;
}
/**
* Parse a command line string into tokens, handling quoting.
*/
function tokenize(input: string): string[] {
const tokens: string[] = [];
let current = "";
let inSingleQuote = false;
let inDoubleQuote = false;
let escaped = false;
for (let i = 0; i < input.length; i++) {
const ch = input[i];
if (escaped) {
current += ch;
escaped = false;
continue;
}
if (ch === "\\") {
escaped = true;
current += ch;
continue;
}
if (ch === "'" && !inDoubleQuote) {
inSingleQuote = !inSingleQuote;
current += ch;
continue;
}
if (ch === '"' && !inSingleQuote) {
inDoubleQuote = !inDoubleQuote;
current += ch;
continue;
}
if (ch === " " && !inSingleQuote && !inDoubleQuote) {
if (current.length > 0) {
tokens.push(current);
current = "";
}
continue;
}
current += ch;
}
// Always include the last token (even if empty, to indicate trailing space)
tokens.push(current);
return tokens;
}
/**
* Parse the current command line into a CompletionContext.
*/
export function parseCommandLine(input: string): CompletionContext {
const tokens = tokenize(input);
const wordIndex = tokens.length - 1;
const currentWord = tokens[wordIndex] || "";
const commandName = tokens.length > 0 ? normalizeCommandName(tokens[0]) : "";
return {
commandLine: input,
currentWord,
wordIndex,
tokens,
commandName,
isOptionArg: false,
};
}
/**
* Main completion function. Returns sorted suggestions from all sources.
* Ghost text should use completions[0].text instead of a separate query.
*/
export async function getCompletions(
input: string,
options: {
hostId?: string;
os?: "linux" | "windows" | "macos";
maxResults?: number;
/** Session ID for remote path completion */
sessionId?: string;
/** Connection protocol (ssh, local, telnet, serial) */
protocol?: string;
/** Current working directory (from OSC 7) */
cwd?: string;
} = {},
): Promise<CompletionSuggestion[]> {
const { hostId, maxResults = 15 } = options;
if (!input || input.trim().length === 0) return [];
const ctx = parseCommandLine(input);
const suggestions: CompletionSuggestion[] = [];
const seenSuggestionTexts = new Set<string>();
const pathCheck = ctx.commandName && ctx.wordIndex >= 1
? shouldDoPathCompletion(ctx, undefined)
: { shouldComplete: false, foldersOnly: false };
const preferPathSuggestions = pathCheck.shouldComplete;
const resultLimit = preferPathSuggestions ? Math.max(maxResults, 24) : maxResults;
// 1. History suggestions (full command line prefix match)
// Cap history to leave room for spec suggestions in the popup
const historyOpts: HistoryQueryOptions = {
hostId,
limit: preferPathSuggestions ? 0 : 5,
};
const historyMatches = queryHistory(input, historyOpts);
for (const entry of historyMatches) {
const suggestion = {
text: entry.command,
displayText: entry.command,
source: "history",
score: 1000 + entry.frequency,
frequency: entry.frequency,
} satisfies CompletionSuggestion;
suggestions.push(suggestion);
seenSuggestionTexts.add(suggestion.text);
}
if (preferPathSuggestions && ctx.commandName) {
// When path completion is active (file-related commands like cat, vim, cd),
// recent history is still useful but should rank below actual path matches
// from the current directory.
const recentHistory = queryRecentHistoryByCommand({
commandName: ctx.commandName,
excludeCommand: input,
argumentPrefix: normalizeHistoryPathPrefix(ctx.currentWord),
hostId,
limit: 5,
});
for (let index = 0; index < recentHistory.length; index++) {
const entry = recentHistory[index];
if (seenSuggestionTexts.has(entry.command)) continue;
const suggestion = {
text: entry.command,
displayText: entry.command,
source: "history",
score: 720 - index,
frequency: entry.frequency,
} satisfies CompletionSuggestion;
suggestions.push(suggestion);
seenSuggestionTexts.add(suggestion.text);
}
}
const canQueryPaths = options.protocol === "local" || options.sessionId !== undefined;
const specPromise = ctx.commandName && ctx.wordIndex >= 0
? getSpecSuggestions(ctx)
: Promise.resolve([]);
const pathPromise = canQueryPaths && pathCheck.shouldComplete
? getPathSuggestions(ctx, {
sessionId: options.sessionId,
protocol: options.protocol,
cwd: options.cwd,
foldersOnly: pathCheck.foldersOnly,
})
: Promise.resolve([]);
const [specSugs, pathEntries] = await Promise.all([specPromise, pathPromise]);
for (const suggestion of specSugs) {
suggestions.push(suggestion);
seenSuggestionTexts.add(suggestion.text);
}
if (pathEntries.length > 0) {
const { pathPrefix, quoteSuffix } = resolvePathComponents(ctx.currentWord, options.cwd);
const isQuotedPath = ctx.currentWord.startsWith('"') || ctx.currentWord.startsWith("'");
for (const entry of pathEntries) {
const insertName = isQuotedPath || !entry.name.includes(" ")
? entry.name
: entry.name.replace(/ /g, "\\ ");
const suffix = entry.type === "directory" ? "/" : "";
const fullPath = pathPrefix + insertName + suffix + quoteSuffix;
const suggestion = {
text: rebuildCommand(ctx.tokens, ctx.wordIndex, fullPath),
displayText: entry.name + suffix,
source: "path",
score: 750,
fileType: entry.type,
} satisfies CompletionSuggestion;
suggestions.push(suggestion);
seenSuggestionTexts.add(suggestion.text);
}
}
// 3. Fuzzy history fallback (if prefix match yields few results)
if (!preferPathSuggestions && suggestions.length < 3 && input.length >= 2) {
const fuzzyMatches = fuzzyQueryHistory(input, {
...historyOpts,
limit: 5,
});
for (const entry of fuzzyMatches) {
if (seenSuggestionTexts.has(entry.command)) continue;
const suggestion = {
text: entry.command,
displayText: entry.command,
source: "history",
score: 500 + entry.frequency,
frequency: entry.frequency,
} satisfies CompletionSuggestion;
suggestions.push(suggestion);
seenSuggestionTexts.add(suggestion.text);
}
}
// Sort by score descending
suggestions.sort((a, b) => b.score - a.score);
// Deduplicate
const seen = new Set<string>();
const unique: CompletionSuggestion[] = [];
for (const s of suggestions) {
if (seen.has(s.text)) continue;
seen.add(s.text);
unique.push(s);
if (unique.length >= resultLimit) break;
}
return unique;
}
function normalizeHistoryPathPrefix(token: string): string {
return token
.trim()
.replace(/^['"]/, "")
.replace(/['"]$/, "")
.replace(/\\ /g, " ");
}
/**
* Get suggestions from Fig spec + return resolved args (for path detection reuse).
*/
async function getSpecSuggestions(ctx: CompletionContext): Promise<CompletionSuggestion[]> {
const suggestions: CompletionSuggestion[] = [];
const specAvailable = await hasSpec(ctx.commandName);
if (!specAvailable) {
if (ctx.wordIndex === 0 && ctx.currentWord.length >= 1) {
return await getCommandNameSuggestions(ctx.currentWord);
}
return [];
}
const spec = await loadSpec(ctx.commandName);
if (!spec) return [];
// If we're still typing the command name (partial match, not yet complete)
if (ctx.wordIndex === 0) {
const typedLower = ctx.currentWord.toLowerCase();
const specNames = resolveNames(spec.name);
const isExactMatch = specNames.some((n) => n.toLowerCase() === typedLower);
if (!isExactMatch) return [];
// Show subcommands as preview (user typed full command but no space yet)
if (spec.subcommands) {
for (const sub of spec.subcommands) {
const names = resolveNames(sub.name);
suggestions.push({
text: ctx.currentWord + " " + names[0],
displayText: names[0],
description: sub.description,
source: "subcommand",
score: 800,
});
if (suggestions.length >= 10) break;
}
}
return suggestions;
}
// Navigate the spec tree based on typed tokens
let resolved = resolveSpecContext(spec, ctx.tokens.slice(1, ctx.wordIndex));
const currentToken = ctx.currentWord;
// Check if currentToken exactly matches a subcommand — if so, navigate into it
// and show its children as preview (e.g., "git commit" shows commit's options)
if (currentToken && resolved.subcommands) {
const exactMatch = resolved.subcommands.find((s) => {
const names = resolveNames(s.name);
return names.includes(currentToken);
});
if (exactMatch) {
// Navigate into the matched subcommand and show its children
const childResolved = resolveSpecContext(spec, ctx.tokens.slice(1, ctx.wordIndex + 1));
// Show child subcommands
if (childResolved.subcommands) {
for (const sub of childResolved.subcommands) {
const names = resolveNames(sub.name);
suggestions.push({
text: ctx.commandLine + " " + names[0],
displayText: names[0],
description: sub.description,
source: "subcommand",
score: 800,
});
if (suggestions.length >= 10) break;
}
}
// Show child options
appendOptionPreviewSuggestions(
suggestions,
ctx.commandLine,
childResolved.options?.length ? childResolved.options : childResolved.fallbackOptions,
15,
);
return suggestions;
}
}
// Suggest subcommands (prefix match, excluding exact matches)
if (resolved.subcommands) {
for (const sub of resolved.subcommands) {
const names = resolveNames(sub.name);
for (const name of names) {
if (name.startsWith(currentToken) && name !== currentToken) {
suggestions.push({
text: rebuildCommand(ctx.tokens, ctx.wordIndex, name),
displayText: name,
description: sub.description,
source: "subcommand",
score: 800,
});
}
}
}
}
// Suggest options
const hasDirectOptionSuggestions = appendOptionSuggestions(
suggestions,
ctx,
currentToken,
resolved.options,
);
if (!hasDirectOptionSuggestions) {
appendOptionSuggestions(suggestions, ctx, currentToken, resolved.fallbackOptions);
}
// Suggest argument values from suggestions in the spec
if (resolved.args) {
const args = Array.isArray(resolved.args) ? resolved.args : [resolved.args];
for (const arg of args) {
if (arg.suggestions) {
for (const sug of arg.suggestions) {
const sugName = typeof sug === "string" ? sug : (Array.isArray(sug.name) ? sug.name[0] : sug.name);
const sugDesc = typeof sug === "string" ? undefined : sug.description;
if (sugName.startsWith(currentToken) && sugName !== currentToken) {
suggestions.push({
text: rebuildCommand(ctx.tokens, ctx.wordIndex, sugName),
displayText: sugName,
description: sugDesc,
source: "arg",
score: 600,
});
}
}
}
}
}
return suggestions;
}
/**
* Get command name suggestions by matching against available specs.
* Uses the already-imported getAvailableSpecs directly (no dynamic self-import).
*/
async function getCommandNameSuggestions(prefix: string): Promise<CompletionSuggestion[]> {
const specs = await getAvailableSpecs();
const lower = prefix.toLowerCase();
const suggestions: CompletionSuggestion[] = [];
for (const name of specs) {
// Skip sub-path specs like "aws/s3", "dotnet/dotnet-build" — not direct shell commands
if (name.includes("/")) continue;
if (name.startsWith(lower) && name !== lower) {
suggestions.push({
text: name,
displayText: name,
source: "command",
score: 600,
});
if (suggestions.length >= 10) break;
}
}
return suggestions;
}
interface ResolvedContext {
subcommands?: FigSubcommand[];
options?: FigOption[];
fallbackOptions?: FigOption[];
args?: FigSubcommand["args"];
}
/**
* Walk the spec tree following the typed tokens to find the current context.
* Handles options with arguments (e.g., --name value) by skipping the value token.
*/
function resolveSpecContext(spec: FigSpec, consumedTokens: string[]): ResolvedContext {
let current: FigSubcommand = spec;
let inheritedOptions: FigOption[] = [];
let skipNext = false;
let lastOptionArgs: FigSubcommand["args"] | undefined;
for (const token of consumedTokens) {
// Skip this token if it's the argument value of a previous option
if (skipNext) {
skipNext = false;
lastOptionArgs = undefined;
continue;
}
// Handle option flags
if (token.startsWith("-")) {
// Check if this option expects an argument
const opt = [...(current.options ?? []), ...inheritedOptions].find((candidate) => {
const names = resolveNames(candidate.name);
return names.includes(token);
});
if (opt?.args) {
// This option expects an argument — the next token is its value
const args = Array.isArray(opt.args) ? opt.args : [opt.args];
if (args.length > 0 && !args[0].isOptional) {
skipNext = true;
lastOptionArgs = opt.args; // Track for the case where next token is currentWord
}
}
continue;
}
// Try to find a matching subcommand
if (current.subcommands) {
const sub = current.subcommands.find((s) => {
const names = resolveNames(s.name);
return names.includes(token);
});
if (sub) {
inheritedOptions = mergeOptionLists(inheritedOptions, current.options);
current = sub;
continue;
}
}
// If no subcommand matched, we're at the args level
break;
}
// If skipNext is still true, the currentWord is an option's arg value
// (e.g., "git archive --format |" — currentWord is the format value)
// Return the option's args instead of the subcommand's args.
if (skipNext && lastOptionArgs) {
return {
subcommands: undefined,
options: undefined,
fallbackOptions: inheritedOptions.length > 0 ? inheritedOptions : undefined,
args: lastOptionArgs,
};
}
return {
subcommands: current.subcommands,
options: current.options ? [...current.options] : undefined,
fallbackOptions: inheritedOptions.length > 0 ? inheritedOptions : undefined,
args: current.args,
};
}
function mergeOptionLists(
left: FigOption[] | undefined,
right: FigOption[] | undefined,
): FigOption[] {
const merged: FigOption[] = [];
const seen = new Set<string>();
for (const option of [...(left ?? []), ...(right ?? [])]) {
const key = resolveNames(option.name).sort().join("\0");
if (seen.has(key)) continue;
seen.add(key);
merged.push(option);
}
return merged;
}
function appendOptionSuggestions(
suggestions: CompletionSuggestion[],
ctx: CompletionContext,
currentToken: string,
options: FigOption[] | undefined,
): boolean {
if (!options || options.length === 0) return false;
let added = false;
for (const opt of options) {
const names = resolveNames(opt.name);
for (const name of names) {
if (name.startsWith(currentToken) && name !== currentToken) {
suggestions.push({
text: rebuildCommand(ctx.tokens, ctx.wordIndex, name),
displayText: name,
description: opt.description,
source: "option",
score: 700,
});
added = true;
}
}
}
return added;
}
function appendOptionPreviewSuggestions(
suggestions: CompletionSuggestion[],
commandLine: string,
options: FigOption[] | undefined,
limit: number,
): void {
if (!options || options.length === 0 || suggestions.length >= limit) return;
for (const opt of options) {
const names = resolveNames(opt.name);
suggestions.push({
text: commandLine + " " + names[0],
displayText: names[0],
description: opt.description,
source: "option",
score: 700,
});
if (suggestions.length >= limit) break;
}
}
/**
* Rebuild the full command text with a replacement at a specific token index.
*/
function rebuildCommand(tokens: string[], replaceIndex: number, replacement: string): string {
const rebuilt = [...tokens];
rebuilt[replaceIndex] = replacement;
return rebuilt.join(" ");
}

View File

@@ -0,0 +1,198 @@
/**
* Loader for @withfig/autocomplete command specifications.
* Loads specs via Electron main process IPC (Node.js require),
* which reliably accesses node_modules in both dev and production.
*/
/** Minimal Fig spec types — mirrors @withfig/autocomplete-types */
export interface FigOption {
name: string | string[];
description?: string;
args?: FigArg | FigArg[];
isRequired?: boolean;
isPersistent?: boolean;
exclusiveOn?: string[];
}
export interface FigArg {
name?: string;
description?: string;
suggestions?: (string | FigSuggestion)[];
template?: string | string[];
isOptional?: boolean;
isVariadic?: boolean;
generators?: unknown;
}
export interface FigSuggestion {
name: string | string[];
description?: string;
icon?: string;
type?: string;
priority?: number;
}
export interface FigSubcommand {
name: string | string[];
description?: string;
subcommands?: FigSubcommand[];
options?: FigOption[];
args?: FigArg | FigArg[];
}
export interface FigSpec extends FigSubcommand {
// Top-level spec may include additional metadata
}
// Bridge type augmentation
interface FigSpecBridge {
listFigSpecs?: () => Promise<string[]>;
loadFigSpec?: (commandName: string) => Promise<FigSpec | null>;
}
function getBridge(): FigSpecBridge | undefined {
return (window as Window & { netcatty?: FigSpecBridge }).netcatty;
}
// Cache loaded specs
const specCache = new Map<string, FigSpec | null>();
// In-flight loading promises to avoid duplicate loads
const inFlightLoads = new Map<string, Promise<FigSpec | null>>();
// All available spec names
let availableSpecs: string[] | null = null;
let availableSpecsSet: Set<string> | null = null;
/**
* Get the list of all available command specs via IPC.
*/
export async function getAvailableSpecs(): Promise<string[]> {
// Only return cache if it has actual specs (not an empty failure)
if (availableSpecs && availableSpecs.length > 0) return availableSpecs;
try {
const bridge = getBridge();
if (bridge?.listFigSpecs) {
const specs = await bridge.listFigSpecs();
if (Array.isArray(specs) && specs.length > 0) {
availableSpecs = specs;
availableSpecsSet = new Set(specs);
return specs;
}
}
} catch (err) {
console.warn("[Autocomplete] figspec bridge error:", err);
}
// Don't cache empty — allow retry on next call
return [];
}
/**
* Load a command specification by name via IPC.
* Uses in-flight deduplication to avoid loading the same spec twice concurrently.
*/
export async function loadSpec(commandName: string): Promise<FigSpec | null> {
if (specCache.has(commandName)) {
return specCache.get(commandName) ?? null;
}
const existing = inFlightLoads.get(commandName);
if (existing) return existing;
const loadPromise = (async (): Promise<FigSpec | null> => {
try {
const bridge = getBridge();
if (!bridge?.loadFigSpec) {
// Don't cache — bridge may not be ready yet (dev reload, non-Electron preview)
return null;
}
const spec = await bridge.loadFigSpec(commandName);
if (spec) {
specCache.set(commandName, spec);
}
// Don't cache null — the load may have failed transiently (bridge not ready, etc.)
// Only cache null when we're confident the spec doesn't exist (hasSpec returned false)
return spec;
} catch {
// Don't cache failures — allow retry on next request
return null;
} finally {
inFlightLoads.delete(commandName);
}
})();
inFlightLoads.set(commandName, loadPromise);
return loadPromise;
}
/**
* Check if a spec exists for a given command name (without loading it).
*/
export async function hasSpec(commandName: string): Promise<boolean> {
// Only trust positive cache hits (spec loaded successfully).
// Null entries may be stale failures from preload — ignore them.
const cached = specCache.get(commandName);
if (cached) return true;
await getAvailableSpecs();
return availableSpecsSet?.has(commandName) ?? false;
}
/**
* Preload commonly used specs in batches to avoid overwhelming IPC.
* Only call this when autocomplete is enabled.
*/
export function preloadCommonSpecs(): void {
const common = [
"git", "docker", "kubectl", "npm", "yarn", "pnpm",
"ls", "cd", "cat", "grep", "find", "ssh", "scp",
"curl", "wget", "tar", "zip", "unzip", "make",
"python", "python3", "pip", "pip3", "node",
"systemctl", "journalctl", "apt", "yum", "brew",
"vim", "nano", "less", "head", "tail", "sort",
"awk", "sed", "chmod", "chown", "cp", "mv", "rm", "mkdir",
];
const BATCH_SIZE = 8;
let offset = 0;
const loadBatch = () => {
const batch = common.slice(offset, offset + BATCH_SIZE);
if (batch.length === 0) return;
for (const name of batch) {
loadSpec(name).catch(() => {});
}
offset += BATCH_SIZE;
if (offset < common.length) {
if (typeof requestIdleCallback === "function") {
requestIdleCallback(() => loadBatch());
} else {
setTimeout(loadBatch, 100);
}
}
};
setTimeout(loadBatch, 200);
}
/**
* Get normalized name variants (e.g., "git" from "/usr/bin/git").
*/
export function normalizeCommandName(rawCommand: string): string {
const parts = rawCommand.split("/");
let name = parts[parts.length - 1];
name = name.replace(/\.(exe|cmd|bat|sh|bash|zsh|fish)$/i, "");
return name.toLowerCase();
}
/**
* Resolve names from a Fig spec name field (which can be string or string[]).
*/
export function resolveNames(name: string | string[]): string[] {
return Array.isArray(name) ? name : [name];
}

View File

@@ -0,0 +1,5 @@
export { useTerminalAutocomplete, DEFAULT_AUTOCOMPLETE_SETTINGS } from "./useTerminalAutocomplete";
export type { AutocompleteSettings, AutocompleteState, TerminalAutocompleteHandle } from "./useTerminalAutocomplete";
export { default as AutocompletePopup } from "./AutocompletePopup";
export type { CompletionSuggestion, SuggestionSource } from "./completionEngine";
export { recordCommand, clearHistory, deleteHistoryEntry, getHistoryCount } from "./commandHistoryStore";

View File

@@ -0,0 +1,225 @@
/**
* Prompt detector for terminal autocomplete.
* Detects whether the user is currently at a shell prompt (vs. inside a running program).
* Uses xterm.js buffer analysis to identify common prompt patterns.
*
* Strategy: scan left-to-right for the FIRST prompt-ending character ($ # % > etc.)
* followed by a space. Exclude false positives like $HOME, $PATH, etc.
*/
import type { Terminal as XTerm } from "@xterm/xterm";
/**
* Patterns that indicate the user is NOT at a prompt
* (e.g., inside vim, less, man, top, etc.)
*/
const NON_PROMPT_PATTERNS = [
/^~$/, // vim empty line marker
/^\s*--\s*More\s*--/, // less/more pager
/^\s*\(END\)/, // less end marker
/^:\s*$/, // vim command mode
/^\s*~\s*$/, // vim tilde lines
/^>{1,3}\s/, // Bare > (bash PS2 continuation), >> or >>> (python REPL)
/^\w+>\s/, // mysql> / sqlite> / redis-cli> REPL prompts
];
export interface PromptDetectionResult {
/** Whether a prompt is detected on the current line */
isAtPrompt: boolean;
/** The detected prompt text (everything before user input) */
promptText: string;
/** The user's current input (after the prompt) */
userInput: string;
/** The cursor column position within the user input */
cursorOffset: number;
}
const NO_PROMPT: PromptDetectionResult = {
isAtPrompt: false, promptText: "", userInput: "", cursorOffset: 0,
};
/**
* Detect whether the terminal cursor is at a shell prompt and extract the current user input.
*/
export function detectPrompt(term: XTerm): PromptDetectionResult {
const buffer = term.buffer.active;
const cursorY = buffer.cursorY + buffer.baseY;
const cursorX = buffer.cursorX;
const line = buffer.getLine(cursorY);
if (!line) return NO_PROMPT;
// translateToString(false) preserves trailing spaces — important for cursor-based
// input extraction (trailing space triggers empty token for option suggestions)
const lineText = line.translateToString(false);
// Check for non-prompt patterns (pagers, editors, etc.)
for (const pattern of NON_PROMPT_PATTERNS) {
if (pattern.test(lineText)) return NO_PROMPT;
}
// Empty line
if (lineText.trim().length === 0) return NO_PROMPT;
// Try to find the prompt boundary on the current line
const promptEnd = findPromptBoundary(lineText);
if (promptEnd >= 0) {
const promptText = lineText.substring(0, promptEnd);
// Use cursor position to determine actual input length — don't trim trailing
// spaces since they're significant for autocomplete (e.g., "git commit " should
// produce an empty trailing token to trigger option suggestions).
const rawInput = lineText.substring(promptEnd);
const userInput = rawInput.substring(0, Math.max(0, cursorX - promptEnd));
const cursorOffset = Math.max(0, cursorX - promptEnd);
return { isAtPrompt: true, promptText, userInput, cursorOffset };
}
// Handle wrapped lines: if the prompt is on a previous row (e.g., long path or
// long command wrapped onto multiple rows), look upward for the prompt line.
// The current row's content is continuation of the command.
if (line.isWrapped) {
// Walk up to find the first non-wrapped line (the prompt line)
let promptRow = cursorY - 1;
while (promptRow >= 0) {
const prevLine = buffer.getLine(promptRow);
if (!prevLine) break;
if (!prevLine.isWrapped) break;
promptRow--;
}
const promptLine = buffer.getLine(promptRow);
if (promptLine) {
const promptLineText = promptLine.translateToString(false);
const pEnd = findPromptBoundary(promptLineText);
if (pEnd >= 0) {
const promptText = promptLineText.substring(0, pEnd);
// Concatenate all rows from promptRow to cursorY to get full input
let fullInput = promptLineText.substring(pEnd);
for (let row = promptRow + 1; row <= cursorY; row++) {
const rowLine = buffer.getLine(row);
if (rowLine) fullInput += rowLine.translateToString(false);
}
// Trim to cursor position on the last row
const totalCols = term.cols;
const charsBeforeCursorRow = (cursorY - promptRow) * totalCols - pEnd;
const userInput = fullInput.substring(0, charsBeforeCursorRow + cursorX);
const cursorOffset = userInput.length;
return { isAtPrompt: true, promptText, userInput, cursorOffset };
}
}
}
return NO_PROMPT;
}
/** Characters that commonly end a shell prompt */
const PROMPT_CHARS = new Set(["$", "#", "%", ">", "", "", "→", "➜", "➤", "⟩", "»", ""]);
/**
* Find the boundary between prompt and user input.
* Scans left-to-right within the first 80 chars for a prompt character followed by space.
* Avoids false positives: $VAR, $(...), ${...} are not prompt endings.
* Returns the character index where user input begins, or -1 if no prompt detected.
*/
function findPromptBoundary(lineText: string): number {
// Scan for prompt boundary. Take the LAST candidate.
// For ambiguous chars like >, limit scan to first 60% to avoid matching redirections.
// For unambiguous prompt chars ($, #), scan the full line since they're rarely
// confused with shell syntax in a prompt position.
const lineLen = lineText.trimEnd().length;
const scanLimit = Math.min(lineLen, 200);
let lastBoundary = -1;
// Ambiguous chars (>) only scan first 60% to avoid matching redirections
const ambiguousScanLimit = Math.min(scanLimit, Math.max(40, Math.floor(lineLen * 0.6)));
for (let i = 0; i < scanLimit; i++) {
const ch = lineText[i];
if (!PROMPT_CHARS.has(ch)) continue;
// For ambiguous prompt chars like >, only accept in the first 60% of the line
if ((ch === ">" || ch === "") && i >= ambiguousScanLimit) continue;
// Must be followed by a space or end-of-line.
const nextChar = i + 1 < lineText.length ? lineText[i + 1] : null;
if (nextChar !== null && nextChar !== " ") {
// Special case: cmd.exe prompt `C:\path>command` — allow > without space
// only if preceded by a path-like pattern (drive letter or backslash)
if (ch === ">" && i > 1 && (lineText[i - 1] === "\\" || lineText[i - 1] === "/" || /^[A-Za-z]:/.test(lineText))) {
// Looks like a path ending — accept as prompt
} else {
continue;
}
}
// For '$': exclude shell variable references ($HOME, $PATH, ${...}, $(...))
if (ch === "$") {
// Check what comes AFTER the space — but more importantly check what
// comes BEFORE to see if this looks like a prompt ending vs mid-command $.
// A prompt $ is typically preceded by: space, ), ], digit, username chars, or is at position 0.
// A variable $ is typically inside a command: echo $HOME, export PATH=$PATH:...
//
// Heuristic: if the $ is preceded by a letter/digit/underscore without a space before it
// (i.e., it's part of a token like "echo" or "=$PATH"), it's likely a variable.
if (i > 0) {
const prev = lineText[i - 1];
// If preceded by = or / or another non-separator, it's a variable reference
if (prev === "=" || prev === "/" || prev === ":") continue;
// If preceded by a letter and there's no space between, it could be $HOME-style
// But actually: "user@host:~$ " has letter before $. So check if there's
// a valid prompt pattern before the $.
}
// Check what follows: if after "$ " there's more content with $ in variable positions
// Actually the simplest reliable check: if the character after the space is alphanumeric
// or $ or (, this is likely the START of a command (i.e., this $ IS the prompt ending).
// That's always true for a prompt. So the $ check is really about false positives mid-line.
//
// Better heuristic: if we haven't seen a space before this $ (meaning the $ is inside
// the first token), it's likely a prompt. If we've already passed spaces (meaning
// we're past the first "word"), a $ is more likely a variable.
let seenSpaceBeforeDollar = false;
for (let j = 0; j < i; j++) {
if (lineText[j] === " ") { seenSpaceBeforeDollar = true; break; }
}
// If there was a space before this $, it might be mid-command (like "echo $HOME")
// Only accept if the $ is reasonably close to common prompt patterns
if (seenSpaceBeforeDollar) {
// Check if this looks like a bracketed prompt ending: "]$ " or ")$ "
if (i > 0 && (lineText[i - 1] === "]" || lineText[i - 1] === ")" ||
lineText[i - 1] === " " || lineText[i - 1] === "~")) {
// Likely a prompt ending like [user@host ~]$
} else {
continue; // Skip — likely a variable reference mid-command
}
}
}
// Record this as a candidate boundary
lastBoundary = nextChar === " " ? i + 2 : i + 1;
}
return lastBoundary;
}
/**
* Simplified prompt detection: just check if we're likely at a prompt.
*/
export function isLikelyAtPrompt(term: XTerm): boolean {
const buffer = term.buffer.active;
const cursorY = buffer.cursorY + buffer.baseY;
const line = buffer.getLine(cursorY);
if (!line) return false;
const lineText = line.translateToString(false);
if (lineText.trim().length === 0) return false;
for (const pattern of NON_PROMPT_PATTERNS) {
if (pattern.test(lineText)) return false;
}
return findPromptBoundary(lineText) >= 0;
}

View File

@@ -0,0 +1,457 @@
/**
* Remote path completion for terminal autocomplete.
* Lists files/directories on the remote (or local) machine
* when the user types commands that expect path arguments.
*/
import type { CompletionContext } from "./completionEngine";
import type { FigArg } from "./figSpecLoader";
/** Directory entry returned from IPC */
export interface DirEntry {
name: string;
type: "file" | "directory" | "symlink";
}
/** Bridge interface for directory listing */
interface PathBridge {
listAutocompleteRemoteDir?: (
sessionId: string,
path: string,
foldersOnly: boolean,
filterPrefix?: string,
limit?: number,
) => Promise<{ success: boolean; entries: DirEntry[] }>;
listAutocompleteLocalDir?: (
path: string,
foldersOnly: boolean,
filterPrefix?: string,
limit?: number,
) => Promise<{ success: boolean; entries: DirEntry[] }>;
}
function getBridge(): PathBridge | undefined {
return (window as Window & { netcatty?: PathBridge }).netcatty;
}
// Cache directory listings for 5 seconds. Full-directory cache is shared between
// popup suggestions and cascading sub-directory panels; filtered cache avoids
// repeated round-trips while the user keeps typing within the same directory.
const fullDirCache = new Map<string, { entries: DirEntry[]; timestamp: number }>();
const filteredDirCache = new Map<string, { entries: DirEntry[]; timestamp: number }>();
const inFlightRequests = new Map<string, Promise<DirEntry[]>>();
const CACHE_TTL_MS = 5000;
const MAX_CACHE_SIZE = 30;
const MAX_FILTERED_CACHE_SIZE = 60;
/** Commands that commonly accept file/directory path arguments.
* Subcommand-first tools (docker, kubectl, go, cargo, make) are excluded —
* their path arguments are better handled via Fig specs. */
const PATH_COMMANDS = new Set([
// Navigation & listing
"cd", "pushd", "ls", "ll", "la", "dir", "tree", "exa", "eza", "lsd",
// Viewing & editing
"cat", "less", "more", "head", "tail", "bat", "tac", "nl", "tee",
"vim", "vi", "nvim", "nano", "emacs", "code", "subl", "micro", "helix", "hx", "joe", "mcedit",
// File operations
"cp", "mv", "rm", "mkdir", "rmdir", "touch", "ln", "install", "shred",
// Permissions & metadata
"chmod", "chown", "chgrp", "stat", "file", "lsattr", "chattr",
// Search & filter
"find", "rg", "grep", "egrep", "fgrep", "ag", "fd", "locate",
"wc", "sort", "uniq", "cut", "awk", "sed",
// Archive & compression
"tar", "zip", "unzip", "gzip", "gunzip", "bzip2", "bunzip2", "xz", "unxz", "zstd",
"7z", "rar", "unrar",
// Transfer & sync
"scp", "rsync", "diff", "cmp", "patch",
// Scripting & execution
"source", ".", "bash", "sh", "zsh", "fish",
"python", "python3", "node", "ruby", "perl", "php", "rustc", "gcc", "g++",
"deno", "bun", "tsx", "ts-node",
// Disk & filesystem
"du", "df", "chroot",
// Misc
"realpath", "readlink", "basename", "dirname", "md5sum", "sha256sum", "xxd", "hexdump",
"xdg-open", "open", "start",
]);
/** Commands that only accept directories (not files) */
const FOLDER_ONLY_COMMANDS = new Set(["cd", "mkdir", "rmdir", "pushd"]);
/**
* Check if the current command context expects a path argument.
*/
export function shouldDoPathCompletion(
ctx: CompletionContext,
resolvedArgs?: FigArg | FigArg[],
): { shouldComplete: boolean; foldersOnly: boolean } {
const currentWord = stripWrappingQuotes(ctx.currentWord);
// 1. Typed path trigger: if current word starts with path-like prefix, always complete
if (currentWord.startsWith("/") || currentWord.startsWith("./") ||
currentWord.startsWith("../") || currentWord.startsWith("~/") ||
currentWord === "." || currentWord === ".." || currentWord === "~") {
const foldersOnly = FOLDER_ONLY_COMMANDS.has(ctx.commandName);
return { shouldComplete: true, foldersOnly };
}
// 2. Fig spec template check
if (resolvedArgs) {
const args = Array.isArray(resolvedArgs) ? resolvedArgs : [resolvedArgs];
for (const arg of args) {
const templates = Array.isArray(arg.template) ? arg.template : arg.template ? [arg.template] : [];
if (templates.includes("filepaths") || templates.includes("folders")) {
return {
shouldComplete: true,
foldersOnly: templates.includes("folders") && !templates.includes("filepaths"),
};
}
// Generators field often indicates path completion (e.g., cd)
if (arg.generators) {
const foldersOnly = FOLDER_ONLY_COMMANDS.has(ctx.commandName);
return { shouldComplete: true, foldersOnly };
}
}
}
// 3. Hardcoded command list (for commands without fig specs)
if (ctx.wordIndex >= 1 && PATH_COMMANDS.has(ctx.commandName)) {
// Only if we're past the command name and not typing an option
if (!currentWord.startsWith("-")) {
return {
shouldComplete: true,
foldersOnly: FOLDER_ONLY_COMMANDS.has(ctx.commandName),
};
}
}
return { shouldComplete: false, foldersOnly: false };
}
/**
* Parse the current word into directory-to-list and filter prefix.
*/
export function resolvePathComponents(
currentWord: string,
cwd: string | undefined,
): { dirToList: string; filterPrefix: string; pathPrefix: string; quoteSuffix: string } {
const quotePrefix = getLeadingQuote(currentWord);
const quoteSuffix = getTrailingMatchingQuote(currentWord, quotePrefix);
const unquotedWord = stripWrappingQuotes(currentWord);
// Handle empty input — list CWD
if (!unquotedWord || unquotedWord === "." || unquotedWord === "~" || unquotedWord === "..") {
const dir = unquotedWord === "~"
? "~"
: unquotedWord === ".."
? resolveDirLookup("../", cwd)
: (cwd || ".");
const visiblePrefix = unquotedWord ? `${quotePrefix}${unquotedWord}/` : quotePrefix;
return { dirToList: dir, filterPrefix: "", pathPrefix: visiblePrefix, quoteSuffix };
}
// Find the last path separator
const lastSlash = unquotedWord.lastIndexOf("/");
if (lastSlash >= 0) {
const dirPart = unquotedWord.substring(0, lastSlash + 1); // includes trailing /
const filterPart = unquotedWord.substring(lastSlash + 1);
const decodedDirPart = decodeShellPathFragment(dirPart);
const decodedFilterPart = decodeShellPathFragment(filterPart);
const dirToList = resolveDirLookup(decodedDirPart, cwd);
return { dirToList, filterPrefix: decodedFilterPart, pathPrefix: quotePrefix + dirPart, quoteSuffix };
}
// No slash — filter CWD entries by the typed prefix
return {
dirToList: cwd || ".",
filterPrefix: decodeShellPathFragment(unquotedWord),
pathPrefix: quotePrefix,
quoteSuffix,
};
}
export function normalizePathTokenForLookup(token: string, cwd?: string): string {
const { dirToList, filterPrefix } = resolvePathComponents(token, cwd);
if (!filterPrefix) return dirToList;
if (!dirToList || dirToList === ".") {
return filterPrefix;
}
const needsSeparator = !dirToList.endsWith("/");
return `${dirToList}${needsSeparator ? "/" : ""}${filterPrefix}`;
}
/**
* Get path completion suggestions.
*/
export async function getPathSuggestions(
ctx: CompletionContext,
options: {
sessionId?: string;
protocol?: string;
cwd?: string;
foldersOnly: boolean;
},
): Promise<{ name: string; type: DirEntry["type"] }[]> {
const { sessionId, protocol, cwd, foldersOnly } = options;
const { dirToList, filterPrefix } = resolvePathComponents(ctx.currentWord, cwd);
const entries = await listDirectoryEntries(dirToList, {
sessionId,
protocol,
foldersOnly,
filterPrefix,
limit: 100,
});
return sortPathEntries(entries);
}
/**
* List directory contents via IPC, with shared caching and in-flight dedup.
*/
export async function listDirectoryEntries(
dirPath: string,
options: {
sessionId?: string;
protocol?: string;
foldersOnly: boolean;
filterPrefix?: string;
limit?: number;
},
): Promise<DirEntry[]> {
const {
sessionId,
protocol,
foldersOnly,
filterPrefix = "",
limit = 100,
} = options;
const normalizedPrefix = filterPrefix.toLowerCase();
const maxEntries = clampLimit(limit);
const baseKey = `${protocol || "auto"}:${sessionId || "local"}:${dirPath}:${foldersOnly}`;
const fullCacheKey = `${baseKey}:all`;
const filteredCacheKey = `${baseKey}:prefix:${normalizedPrefix}:${maxEntries}`;
// Full directory cache can satisfy both full and filtered lookups.
const fullCached = fullDirCache.get(fullCacheKey);
if (isFresh(fullCached)) {
return filterEntries(fullCached.entries, normalizedPrefix, maxEntries);
}
if (normalizedPrefix) {
const filteredCached = filteredDirCache.get(filteredCacheKey);
if (isFresh(filteredCached)) {
return filteredCached.entries;
}
}
const inFlightFull = inFlightRequests.get(fullCacheKey);
if (inFlightFull) {
return filterEntries(await inFlightFull, normalizedPrefix, maxEntries);
}
const requestKey = normalizedPrefix ? filteredCacheKey : fullCacheKey;
const inFlight = inFlightRequests.get(requestKey);
if (inFlight) return inFlight;
// Make IPC call
const promise = (async (): Promise<DirEntry[]> => {
try {
const bridge = getBridge();
if (!bridge) return [];
let result: { success: boolean; entries: DirEntry[] };
if (protocol === "local" || !sessionId) {
if (!bridge.listAutocompleteLocalDir) return [];
result = await bridge.listAutocompleteLocalDir(
dirPath,
foldersOnly,
normalizedPrefix || undefined,
maxEntries,
);
} else {
if (!bridge.listAutocompleteRemoteDir) return [];
result = await bridge.listAutocompleteRemoteDir(
sessionId,
dirPath,
foldersOnly,
normalizedPrefix || undefined,
maxEntries,
);
}
if (result.success) {
const timestamp = Date.now();
if (normalizedPrefix) {
filteredDirCache.set(requestKey, { entries: result.entries, timestamp });
evictOldest(filteredDirCache, MAX_FILTERED_CACHE_SIZE);
return result.entries;
}
fullDirCache.set(requestKey, { entries: result.entries, timestamp });
evictOldest(fullDirCache, MAX_CACHE_SIZE);
return result.entries;
}
return [];
} catch {
return [];
} finally {
inFlightRequests.delete(requestKey);
}
})();
inFlightRequests.set(requestKey, promise);
return promise;
}
function clampLimit(limit: number): number {
if (!Number.isFinite(limit)) return 100;
return Math.max(1, Math.min(200, Math.floor(limit)));
}
function resolveDirLookup(pathToken: string, cwd: string | undefined): string {
if (!pathToken) return cwd || ".";
if (pathToken.startsWith("/")) return normalizePosixLikePath(pathToken);
if (pathToken === "~" || pathToken.startsWith("~/")) return normalizePosixLikePath(pathToken);
if (cwd) return normalizePosixLikePath(`${cwd}/${pathToken}`);
return normalizePosixLikePath(pathToken);
}
function normalizePosixLikePath(input: string): string {
if (!input) return ".";
const hasLeadingSlash = input.startsWith("/");
const hasTildeRoot = input === "~" || input.startsWith("~/");
const hasTrailingSlash = input.length > 1 && input.endsWith("/");
const fixedRootSegments = hasTildeRoot ? 1 : 0;
const raw = hasLeadingSlash
? input.slice(1)
: hasTildeRoot
? input.slice(2)
: input;
const segments = hasTildeRoot ? ["~"] : [];
for (const segment of raw.split("/")) {
if (!segment || segment === ".") continue;
if (segment === "..") {
if (
segments.length > fixedRootSegments &&
segments[segments.length - 1] !== ".."
) {
segments.pop();
} else if (!hasLeadingSlash || hasTildeRoot) {
segments.push(segment);
}
continue;
}
segments.push(segment);
}
let result: string;
if (hasLeadingSlash) {
result = "/" + segments.join("/");
if (result === "/") return result;
} else if (segments.length > 0) {
result = segments.join("/");
} else if (hasTildeRoot) {
result = "~";
} else {
result = ".";
}
if (hasTrailingSlash && result !== "/" && result !== "." && result !== "~") {
result += "/";
} else if (hasTrailingSlash && result === "~") {
result = "~/";
}
return result;
}
function isFresh(
cached: { entries: DirEntry[]; timestamp: number } | undefined,
): cached is { entries: DirEntry[]; timestamp: number } {
return Boolean(cached && Date.now() - cached.timestamp < CACHE_TTL_MS);
}
function filterEntries(entries: DirEntry[], filterPrefix: string, limit: number): DirEntry[] {
if (!filterPrefix) return entries.slice(0, limit);
const filtered: DirEntry[] = [];
for (const entry of entries) {
if (entry.name.toLowerCase().startsWith(filterPrefix)) {
filtered.push(entry);
if (filtered.length >= limit) break;
}
}
return filtered;
}
function evictOldest(
cache: Map<string, { entries: DirEntry[]; timestamp: number }>,
maxSize: number,
): void {
while (cache.size > maxSize) {
const oldestKey = cache.keys().next().value;
if (!oldestKey) break;
cache.delete(oldestKey);
}
}
function decodeShellPathFragment(value: string): string {
let result = "";
let escaped = false;
for (const ch of value) {
if (escaped) {
result += ch;
escaped = false;
continue;
}
if (ch === "\\") {
escaped = true;
continue;
}
result += ch;
}
if (escaped) result += "\\";
return result;
}
function getLeadingQuote(value: string): string {
return value.startsWith('"') || value.startsWith("'") ? value[0] : "";
}
function getTrailingMatchingQuote(value: string, quotePrefix: string): string {
return quotePrefix && value.endsWith(quotePrefix) ? quotePrefix : "";
}
function stripWrappingQuotes(value: string): string {
if (!value) return value;
let result = value;
if (result.startsWith('"') || result.startsWith("'")) {
result = result.slice(1);
}
if (result.endsWith('"') || result.endsWith("'")) {
result = result.slice(0, -1);
}
return result;
}
function sortPathEntries(entries: DirEntry[]): DirEntry[] {
return [...entries].sort((left, right) => {
const leftRank = left.type === "directory" ? 0 : left.type === "symlink" ? 1 : 2;
const rightRank = right.type === "directory" ? 0 : right.type === "symlink" ? 1 : 2;
if (leftRank !== rightRank) return leftRank - rightRank;
return left.name.localeCompare(right.name, undefined, { sensitivity: "base" });
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,89 @@
/**
* Utility functions for xterm.js cell dimension access.
* Centralizes access to xterm's internal renderer API to reduce upgrade risk.
* Falls back to DOM measurement if the internal API is unavailable.
*/
import type { Terminal as XTerm } from "@xterm/xterm";
export interface CellDimensions {
width: number;
height: number;
}
// Cache to avoid repeated DOM measurements (invalidated on resize)
let cachedDims: CellDimensions | null = null;
let cachedTermId: number = 0;
let termIdCounter = 0;
const termIdMap = new WeakMap<XTerm, number>();
function getTermId(term: XTerm): number {
let id = termIdMap.get(term);
if (id === undefined) {
id = ++termIdCounter;
termIdMap.set(term, id);
}
return id;
}
/**
* Get cell dimensions (width/height in CSS pixels) from an xterm instance.
* Tries the internal renderer API first (fast path), falls back to DOM measurement.
*/
export function getXTermCellDimensions(term: XTerm): CellDimensions {
// Try xterm core renderer API (fast path)
const coreAccess = term as XTerm & {
_core?: { _renderService?: { dimensions?: { css?: { cell?: CellDimensions } } } };
};
const coreDims = coreAccess._core?._renderService?.dimensions?.css?.cell;
if (coreDims && coreDims.width > 0 && coreDims.height > 0) {
// Update cache while we have a good value
const id = getTermId(term);
cachedDims = { width: coreDims.width, height: coreDims.height };
cachedTermId = id;
return cachedDims;
}
// Check cache (same terminal instance)
const id = getTermId(term);
if (cachedDims && cachedTermId === id) {
return cachedDims;
}
// Fallback: measure from DOM (triggers single reflow)
const dims = measureCellFromDOM(term);
cachedDims = dims;
cachedTermId = id;
return dims;
}
/**
* Measure cell dimensions by inserting a temporary span into the terminal element.
* Triggers a single reflow (reading offsetWidth + offsetHeight).
*/
function measureCellFromDOM(term: XTerm): CellDimensions {
const element = term.element;
if (!element) return { width: 8, height: 16 };
const span = document.createElement("span");
span.textContent = "W";
Object.assign(span.style, {
position: "absolute",
visibility: "hidden",
fontFamily: term.options.fontFamily || "monospace",
fontSize: `${term.options.fontSize}px`,
lineHeight: "normal",
});
element.appendChild(span);
const width = span.offsetWidth || 8;
const height = span.offsetHeight || 16;
span.remove();
return { width, height };
}
/**
* Invalidate the cached cell dimensions (call on terminal resize).
*/
export function invalidateCellDimensionCache(): void {
cachedDims = null;
}

View File

@@ -50,6 +50,7 @@ interface UseServerStatsOptions {
refreshInterval: number; // Refresh interval in seconds
isSupportedOs: boolean; // Only collect stats for Linux/macOS servers
isConnected: boolean; // Only collect when connected
isVisible: boolean; // Pause background polling for hidden terminals
}
export function useServerStats({
@@ -58,6 +59,7 @@ export function useServerStats({
refreshInterval,
isSupportedOs,
isConnected,
isVisible,
}: UseServerStatsOptions) {
const [stats, setStats] = useState<ServerStats>({
cpu: null,
@@ -84,9 +86,12 @@ export function useServerStats({
const [error, setError] = useState<string | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const isMountedRef = useRef(true);
const hasFetchedRef = useRef(false);
const connectedAtRef = useRef(0);
const fetchGenerationRef = useRef(0);
const fetchStats = useCallback(async () => {
if (!enabled || !isSupportedOs || !isConnected || !sessionId) {
if (!enabled || !isSupportedOs || !isConnected || !isVisible || !sessionId) {
return;
}
@@ -95,15 +100,18 @@ export function useServerStats({
return;
}
const generation = ++fetchGenerationRef.current;
setIsLoading(true);
setError(null);
try {
const result = await bridge.getServerStats(sessionId);
if (!isMountedRef.current) return;
// Discard stale responses from before a hide/show cycle or reconnect
if (!isMountedRef.current || generation !== fetchGenerationRef.current) return;
if (result.success && result.stats) {
hasFetchedRef.current = true;
setStats({
cpu: result.stats.cpu,
cpuCores: result.stats.cpuCores,
@@ -129,15 +137,15 @@ export function useServerStats({
setError(result.error);
}
} catch (err) {
if (isMountedRef.current) {
if (isMountedRef.current && generation === fetchGenerationRef.current) {
setError(err instanceof Error ? err.message : 'Unknown error');
}
} finally {
if (isMountedRef.current) {
if (isMountedRef.current && generation === fetchGenerationRef.current) {
setIsLoading(false);
}
}
}, [sessionId, enabled, isSupportedOs, isConnected]);
}, [sessionId, enabled, isSupportedOs, isConnected, isVisible]);
// Initial fetch and periodic refresh
useEffect(() => {
@@ -150,7 +158,10 @@ export function useServerStats({
}
if (!enabled || !isSupportedOs || !isConnected) {
// Reset stats when disabled or not connected
// Reset stats and fetch state when disabled or not connected
hasFetchedRef.current = false;
connectedAtRef.current = 0;
setStats({
cpu: null,
cpuCores: null,
@@ -175,10 +186,43 @@ export function useServerStats({
return;
}
// Initial fetch with a small delay to let the connection stabilize
const initialTimer = setTimeout(() => {
fetchStats();
}, 2000);
// Track when the connection became available for delay calculation
// (must be before the isVisible check so hidden tabs record connection time)
if (connectedAtRef.current === 0) {
connectedAtRef.current = Date.now();
}
if (!isVisible) {
return () => {
isMountedRef.current = false;
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}
// Invalidate any in-flight request from a previous visible/hidden cycle
// so stale responses don't overwrite the reset network stats below.
fetchGenerationRef.current++;
// Fetch immediately when resuming from hidden, or with a delay on first connect.
// When resuming, reset delta-based network stats (both aggregate and per-interface)
// so the first sample doesn't show averaged-over-hidden-interval throughput.
if (hasFetchedRef.current) {
setStats(prev => ({
...prev,
netRxSpeed: 0,
netTxSpeed: 0,
netInterfaces: prev.netInterfaces.map(iface => ({ ...iface, rxSpeed: 0, txSpeed: 0 })),
}));
}
// Skip the warmup delay if the connection has been established long enough
// (e.g., tab was hidden while connected and is now becoming visible).
const connectionAge = Date.now() - connectedAtRef.current;
const needsWarmup = !hasFetchedRef.current && connectionAge < 2000;
const initialTimer = setTimeout(fetchStats, needsWarmup ? 2000 : 0);
// Set up periodic refresh
const intervalMs = Math.max(5, refreshInterval) * 1000; // Minimum 5 seconds
@@ -192,7 +236,7 @@ export function useServerStats({
intervalRef.current = null;
}
};
}, [enabled, isSupportedOs, isConnected, refreshInterval, fetchStats]);
}, [enabled, isSupportedOs, isConnected, isVisible, refreshInterval, fetchStats]);
// Manual refresh function
const refresh = useCallback(() => {

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