Compare commits

...

338 Commits

Author SHA1 Message Date
陈大猫
a24e27586a Merge pull request #257 from binaricat/fix/issue-254-sftp-bugs
Some checks failed
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
fix: resolve multiple SFTP bugs (#254)
2026-03-04 13:20:14 +08:00
bincxz
ca24d3861c fix: limit depth guard to symlink dirs only, allow deep real dirs
Real directories cannot form cycles, so remove depth limit for them.
Only track and limit symlink-directory nesting (MAX_SYMLINK_DEPTH=32)
to prevent cycles like `loop -> .` while allowing legitimate deep
directory structures to download without error.
2026-03-04 13:07:52 +08:00
bincxz
eb3b99b164 fix: cancel active child transfer directly from cancelTask
Add activeChildTransferIdsRef (Map<parentId, childId>) to track the
currently in-flight child transfer for directory downloads. cancelTask
now cancels both the parent ID and the active child transfer ID,
making folder download cancellation immediate and reliable.
2026-03-04 12:56:43 +08:00
bincxz
681f4cb3df fix: fail on depth exceeded + hide folder download for local sessions
- Throw error when MAX_RECURSION_DEPTH exceeded instead of silently
  returning, so download is marked failed with a clear message (P1)
- Hide folder download context menu item for local sessions where
  handleDownload only supports files (P2)
2026-03-04 12:05:59 +08:00
bincxz
6fae312981 fix: add max depth limit to prevent symlink cycle infinite recursion
SFTP doesn't expose realpath, so raw path strings can't detect cycles
like `loop -> .` that produce unique paths each level. Add a hard
MAX_RECURSION_DEPTH=32 guard alongside the existing visitedPaths set
to reliably prevent unbounded recursion.
2026-03-04 11:56:11 +08:00
bincxz
ed199eae8c fix: prevent symlink cycle recursion + handle undefined stream result
- Add visitedPaths Set to prevent infinite recursion from symlink
  cycles (e.g. symlink to parent directory)
- Handle undefined result from startStreamTransfer (bridge unavailable)
  by rejecting immediately instead of hanging indefinitely
2026-03-04 11:45:08 +08:00
bincxz
e38af76bfd fix: handle child transfer result errors + precise mkdir error handling
- Handle resolved result.error from startStreamTransfer to prevent
  hung Promises on cancellation (P1)
- Only ignore EEXIST from subdirectory mkdirLocal, propagate other
  errors like permission failures (P2)
2026-03-04 11:34:42 +08:00
bincxz
1726917db0 fix: abort in-flight child transfer on cancel + handle symlink dirs
- Cancel active child transfer from onProgress callback immediately
  when parent folder download is cancelled (P1)
- Handle symlink -> directory entries in recursive descent so they
  are treated as directories instead of files (P2)
2026-03-04 11:26:39 +08:00
bincxz
1712762305 fix: address code review feedback
- Revert mkdirLocal to safe original (no silent file deletion)
- Move EEXIST handling to download-overwrite flow only (deleteLocalFile)
- Add cancellation support for recursive folder downloads:
  - Track active child transfer ID for cancellation
  - Check cancelledTransferIdsRef between files
  - Cancel in-flight child transfer when parent is cancelled
2026-03-04 11:17:05 +08:00
bincxz
5d75f1acd4 fix: resolve multiple SFTP bugs (#254)
- Fix new folder input not resetting after deletion (SftpPaneToolbar/View)
- Fix folder download stuck at 95% by replacing simulated progress with real child-file progress tracking (useSftpTransfers)
- Add download menu item for directories in SFTP modal context menu (SftpModalFileList)
- Implement recursive folder download in SFTP modal with real-time progress (useSftpModalTransfers, SFTPModal)
- Fix mkdirLocal EEXIST error when target path is an existing file (localFsBridge)
- Close settings window when main window is minimized to tray (windowManager)

Closes #254
2026-03-04 11:04:34 +08:00
陈大猫
18b77f9a87 fix(ci): build linux-arm64 in Debian Buster container for GLIBC 2.28 compat (#255)
* fix(ci): build linux-arm64 in Debian Buster container for GLIBC 2.28 compat\n\nSplit linux-arm64 out of the build matrix into a dedicated job that\nruns inside a debian:buster container (GLIBC 2.28) on the ARM64 runner.\nThis ensures the compiled node-pty native module is compatible with\nolder distros like UOS/Deepin.\n\nCloses #253

* fix(ci): use archive.debian.org for EOL Buster repos

* fix(ci): switch to debian:bullseye for Python 3.9 + GLIBC 2.31 compat\n\nBuster's Python 3.7 is too old for node-gyp@11 (walrus operator).\nBullseye provides Python 3.9 and GLIBC 2.31 which is still below\nthe critical 2.34 boundary (libpthread merge into libc).
2026-03-04 10:23:35 +08:00
陈大猫
ade95c1cab Merge pull request #250 from binaricat/fix/linux-arm64-rebuild-error
Some checks failed
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / release (push) Has been cancelled
fix: prevent x64 native module rebuild on ARM64 CI runner
2026-03-03 21:03:43 +08:00
bincxz
7e8893003a fix: use conditional step to avoid setting empty npm_config_arch
Use a dedicated step with `if` condition so npm_config_arch is only
set for linux-arm64. The previous approach set it to an empty string
for other jobs, which could interfere with node-gyp arch detection
on macOS, Windows, and linux-x64 builds.
2026-03-03 21:02:02 +08:00
bincxz
f42cd8cdd1 fix: prevent x64 native module rebuild on ARM64 CI runner
On ubuntu-24.04-arm runners, electron-builder's post-build
@electron/rebuild incorrectly tries to restore native modules
to x64 architecture. The ARM64 g++ compiler doesn't support the
-m64 flag, causing the build to fail.

Setting npm_config_arch=arm64 ensures node-gyp correctly identifies
the host architecture, preventing the erroneous x64 rebuild.
2026-03-03 20:54:28 +08:00
陈大猫
2d34e162c0 Merge pull request #248 from binaricat/fix/unify-settings-dropdowns
fix: unify settings dropdowns to use custom Radix-based Select
2026-03-03 19:51:38 +08:00
bincxz
cdee9c7867 fix: widen terminal emulation type dropdown to prevent truncation 2026-03-03 19:51:07 +08:00
bincxz
45de960618 fix: unify settings dropdowns to use custom Radix-based Select\n\nReplace native <select> in settings-ui.tsx with @radix-ui/react-select\nto match the app's custom dropdown design (FontSelect pattern).\n\nAll settings tabs now use consistent styled popover dropdowns with\ncheck indicators instead of OS-native select menus. 2026-03-03 19:49:07 +08:00
Thomas
2669fc57c4 fix: SSH certificate authentication with RSA key algorithm negotiation (#246)
* 修复 SSH 证书认证问题,增强日志以调试证书解析和签名过程。

* fix: clean up ssh2 patch and optimize netcattyAgent\n\n- Remove ~1187 lines of build artifacts from ssh2+1.17.0.patch\n  (Makefile, config.gypi, .o binaries, sshcrypto.node etc. with\n  hardcoded /Users/idouying paths). Keep only meaningful patches:\n  client.js, Protocol.js, SFTP.js\n- Cache parsed private key during Agent construction to avoid\n  re-parsing on every sign() call\n- Fix missing space in comment

* chore: revert package-lock.json noise and fix trailing whitespace\n\n- Revert package-lock.json to main (peer flag changes were noise\n  from different Node.js version, not intentional)\n- Fix trailing whitespace in netcattyAgent.cjs

---------

Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-03-03 19:43:50 +08:00
陈大猫
10ede85ae3 feat: Custom terminal themes with .itermcolors import (#245)
* feat: implement custom terminal themes with .itermcolors import (#228)\n\n- Add customThemeStore with CRUD operations and localStorage persistence\n- Create .itermcolors parser with RGB-to-hex conversion and auto theme type detection\n- Add CustomThemeEditor component with inline color pickers\n- Refactor ThemeCustomizeModal with Custom tab for create/import/edit/delete\n- Update all theme consumers (Terminal, TerminalLayer, LogView, ThemeSelectPanel,\n  SettingsTerminalTab, useSettingsState) to resolve custom themes\n- Add i18n keys for custom theme features (en + zh-CN)\n- Add isCustom flag to TerminalTheme model and STORAGE_KEY_CUSTOM_THEMES constant

* feat: add import .itermcolors button to Settings Terminal tab\n\nAllows importing .itermcolors files directly from Settings → Terminal\nwithout opening the theme modal first.

* fix: move delete button from editor to modal footer for clean layout\n\nThe delete button was rendering inside the CustomThemeEditor left panel,\ncausing misalignment with the full-width Cancel/Save footer. Now the\nfooter shows: [Delete] (left) | [Cancel] [Save] (right) when editing\nan existing custom theme.

* refactor: extract custom theme editor into standalone modal\n\nThe inline CustomThemeEditor was causing layout conflicts in the\nThemeCustomizeModal (editor + footer overlapping). Extracted into\na dedicated CustomThemeModal with:\n- Two-column layout: editor panel (left) + terminal preview (right)\n- Own footer: Delete (left) | Cancel + Save (right)\n- z-index 300 layering above the main theme modal\n- Proper scroll containment for the color editor

* fix: correct z-index stacking for custom theme modal\n\nRemoved inline style zIndex: 99999 from ThemeCustomizeModal that was\npushing it above CustomThemeModal. Now uses Tailwind z-[200] for the\nmain modal and z-[300] for the custom theme editor modal.

* feat: add new custom theme button to settings terminal tab\n\nReuses CustomThemeModal from the settings page. Creates a new theme\nbased on the currently selected theme, opens the editor modal, and\nautomatically selects the new theme on save.

* feat: add cross-window IPC sync for custom themes\n\nCustom themes created/imported/deleted in the Settings window are now\nimmediately synced to the main window (and vice versa) using the\nexisting netcatty:settings:changed IPC channel. Each mutation\nbroadcasts the change, and each window listens for incoming changes\nand reloads themes from localStorage.

* fix: show custom themes in ThemeSelectModal\n\nThemeSelectModal was only displaying built-in TERMINAL_THEMES.\nNow imports useCustomThemes hook and renders custom themes in a\nseparate section at the bottom of the theme list.

* feat: add edit/delete buttons for custom themes in settings\n\nWhen a custom theme is selected, Edit and Delete buttons appear next\nto the New/Import buttons. Edit opens the CustomThemeModal in edit\nmode, Delete removes the theme and falls back to the default theme.

* refactor: remove redundant header from CustomThemeEditor\n\nThe inner header with back arrow and title was duplicating the\nparent CustomThemeModal header. Removed the header block,\nArrowLeft import, and prefixed unused props with underscore.

* fix: add missing common.edit i18n key\n\nAdded 'Edit' / '编辑' translations for the common.edit key\nthat was showing as raw key text in the Settings page.

* fix: add error feedback for .itermcolors import in settings\n\nAdded step-by-step console logging for debugging import issues.\nShows user-visible alert on parse failure with localized message.\nAlso added terminal.customTheme.importError i18n keys.

* fix: handle extra keys in .itermcolors color dicts\n\nThe parseColorDict function assumed keys[i] aligned with reals[i],\nbut .itermcolors files with extra keys like 'Alpha Component' (real)\nand 'Color Space' (string) broke the index mapping.\n\nNow iterates through dict children properly, pairing each <key>\nwith its next sibling and skipping non-<real> values.

* fix: subscribe to custom theme store for reactive re-renders\n\nReplaced imperative customThemeStore.getThemeById() calls with reactive\nuseCustomThemes() hook in useMemo dependencies across 5 files:\n- useSettingsState.ts (currentTerminalTheme)\n- Terminal.tsx (effectiveTheme for host-override)\n- TerminalLayer.tsx (composeBarThemeColors)\n- LogView.tsx (currentTheme for log replay)\n- SettingsTerminalTab.tsx (currentTheme)\n\nThis ensures editing a custom theme in-place (same ID) triggers\nre-renders in all consuming components, instead of showing stale colors\nuntil the user switches theme IDs or reloads.

* fix: theme editor hex validation, import error feedback, and Escape propagation\n\n1. ColorInput: Use local state for text field so partial hex values\n   (#1, #abc) are held locally while typing. Only complete #rgb (auto-\n   normalized to #rrggbb) or #rrggbb values are committed to the theme.\n   On blur, partial values revert to the last valid color.\n\n2. ThemeCustomizeModal handleFileSelected: Added error feedback via\n   window.alert when .itermcolors parsing fails, reusing the existing\n   terminal.customTheme.importError i18n key. Also extended filename\n   regex to strip .xml extension.\n\n3. ThemeCustomizeModal Escape handler: Skip parent modal cancelation\n   when editingTheme is active, so pressing Escape only closes the\n   child CustomThemeModal without reverting the parent dialog.

* fix: backdrop click closes CustomThemeModal + remove nested buttons in ThemeItem\n\n1. CustomThemeModal: Attach onClick={onCancel} directly to the backdrop\n   div instead of checking e.target === e.currentTarget on the container.\n   The modal content div now stops event propagation to prevent\n   accidental dismissal when clicking inside the dialog.\n\n2. ThemeItem: Replace outer <button> with <div role=\"button\"> and inner\n   edit <button> with <div role=\"button\"> to eliminate invalid nested\n   interactive elements. Added keyboard handlers (Enter/Space) for\n   accessibility parity.

* fix: restore Escape key in CustomThemeModal + stabilize store snapshots\n\n1. CustomThemeModal: Add Escape key handler (capture phase) so pressing\n   Escape dismisses the child editor. Fixes regression where parent\n   ThemeCustomizeModal skips Escape when editingTheme is active but\n   the child had no handler of its own.\n\n2. customThemeStore: Cache the merged allThemes array (built-in +\n   custom) and only rebuild it when the store is mutated. The previous\n   getAllThemes() created a new array every call, violating the\n   useSyncExternalStore contract that getSnapshot must return a stable\n   reference between mutations.

* fix: accept <integer> plist nodes and guard NaN in itermcolors parser\n\nparseColorDict now accepts both <real> (float 0.0-1.0) and <integer>\n(0-255) plist value types for RGB components. Integer values are\nnormalized by dividing by 255. Also added isNaN guard on parseFloat\nresults to prevent malformed '#NaNNaNNaN' color strings from being\npersisted as custom themes.

* fix: use customThemeStore.getThemeById in HostDetailsPanel\n\nHostDetailsPanel used TERMINAL_THEMES.find() for both SSH and Telnet\ntheme previews, which only searched built-in themes. When a custom\ntheme was selected for a host, the preview fell back to Flexoki Dark\ndefaults. Now uses customThemeStore.getThemeById() which searches\nboth built-in and custom themes.

* chore: remove fake user counts from ThemeSelectPanel\n\nRemoved Math.random() generated fake user counts for Kanagawa and\nHacker themes, 'new' badges for Flexoki themes, and the Users icon.\nOnly meaningful labels remain: 'Default' for netcatty-dark and\n'Light mode' for netcatty-light.
2026-03-03 19:27:37 +08:00
陈大猫
21ccc7906b feat: add compose bar for pre-composing commands (#198) (#244)
* feat: add compose bar for pre-composing commands (#198)\n\nAdd an XShell-style compose bar at the bottom of each terminal.\nThe bar lets users type and review commands before sending,\nwhich is helpful for password prompts (no echo) and complex\ncommands. When broadcast mode is active the composed text\nis sent to all sessions in the workspace.\n\nNew files:\n- TerminalComposeBar.tsx (auto-sizing textarea, Enter/Shift+Enter/Esc)\n\nModified:\n- TerminalToolbar.tsx — toggle button (TextCursorInput icon)\n- Terminal.tsx — state, send handler, flex-col layout\n- en.ts / zh-CN.ts — i18n strings"

* refactor: modernize compose bar styling and add global workspace bar\n\n- Rewrite TerminalComposeBar with modern styling: gradient background,\n  rounded bottom corners (8px), themed focus rings, native hover buttons\n- In workspace mode, render a single global compose bar at the bottom\n  of TerminalLayer instead of per-terminal bars\n- Non-broadcast: sends to the currently focused terminal session\n- Broadcast mode: sends to all sessions in the workspace\n- Add onToggleComposeBar/isWorkspaceComposeBarOpen props for\n  toolbar-to-TerminalLayer communication"

* fix: vertically center compose bar buttons and increase button contrast\n\n- Change flex alignment from items-end to items-center\n- Increase button background opacity (8%→20% for send, 0→12% for close)\n- Use solid bg color-mix instead of transparent for better visibility"

* fix: increase compose bar border contrast and fix IME composition\n\n- Increase border opacity from 12% to 25% (unfocused) and 25% to 40% (focused)\n- Add onCompositionStart/End handlers to prevent Enter key from\n  triggering send while IME composition is active (Chinese input)\n- Remove unnecessary wrapper div around textarea for better flex alignment"

* fix: refocus terminal when closing workspace compose bar\n\nAfter closing the compose bar in workspace mode, focus is now restored\nto the focused terminal pane via its xterm-helper-textarea, matching\nthe solo-session behavior. Uses requestAnimationFrame to ensure the\nDOM update completes before focusing."

* fix: fallback to first session when focusedSessionId is missing\n\nWhen broadcast is disabled and focusedSessionId is null (e.g. stale\nworkspace data), the compose bar now falls back to the first available\nsession in the workspace instead of silently dropping the input."

* fix: validate focusedSessionId is a live session before sending\n\nAfter closing a pane, focusedSessionId may point to a stale session.\nNow validates that focusedSessionId exists among the workspace's live\nsessions before using it, falling back to the first available session."
2026-03-03 17:01:57 +08:00
陈大猫
28d9a8e4db feat: add bracketed paste mode toggle (#233) (#243)
* feat: add bracketed paste mode toggle (#233)

Add a setting to disable bracketed paste mode, which prevents
^[[200~ artifacts in terminals that don't support it.

- Add disableBracketedPaste field to TerminalSettings
- Wire to xterm.js ignoreBracketedPasteMode option
- Add toggle in Settings > Terminal > Behavior
- Add en/zh-CN translations

* fix: update bracketed-paste option on live terminals

Apply ignoreBracketedPasteMode at runtime via the terminal settings
sync useEffect, so flipping the toggle takes effect immediately on
active sessions without requiring a reconnect.

* fix: respect disableBracketedPaste in all manual paste paths

The xterm.js ignoreBracketedPasteMode option only affects xterm's
own paste handling, not the modes getter. The 3 manual paste wrappers
(hotkey, context menu, middle-click) still checked
term.modes.bracketedPasteMode which reports true regardless of the
option. Now all 3 paths also check the user setting before wrapping.
2026-03-03 16:06:28 +08:00
bincxz
090ab82bde fix(host-details): prevent proxy and legacy text overflow 2026-03-03 15:34:50 +08:00
bincxz
157c73536b fix: prevent content from expanding aside panel width
Add overflow-hidden to AsidePanelContent inner wrapper to prevent
long text (like proxy hostnames) from expanding the panel beyond
its fixed width. The Radix ScrollArea Viewport allows content to
grow horizontally; this clips it at the container boundary.
2026-03-03 15:29:42 +08:00
bincxz
d74f47c38f fix: properly constrain proxy address text in flex layout
Use block truncate min-w-0 on the proxy address span to prevent
the long text from expanding the parent card's intrinsic width.
2026-03-03 15:27:05 +08:00
bincxz
f6cf915792 fix: constrain proxy address width to prevent overflow
Add overflow-hidden to the inner flex container holding the badge
and address text to ensure proper text truncation within the card.
2026-03-03 15:23:43 +08:00
bincxz
9d3b0056a5 fix: wrap Tooltip with TooltipProvider in proxy card
Fix 'Tooltip must be used within TooltipProvider' runtime error by
wrapping the proxy address Tooltip with TooltipProvider.
2026-03-03 15:22:41 +08:00
bincxz
ce16bd449f feat: add tooltip to show full proxy address on hover
Wrap the truncated proxy host:port in a Tooltip component so users
can hover to see the full address when it's too long to display.
2026-03-03 15:17:57 +08:00
bincxz
e645c5ee53 fix: truncate long proxy hostname in HostDetail card
Add overflow-hidden to the proxy summary button so long hostnames
are properly truncated with ellipsis instead of overflowing.
2026-03-03 15:16:21 +08:00
bincxz
07ac90b110 style: improve SOCKS5 proxy section layout in HostDetail
Redesign the proxy configuration card to match the Jump Hosts and
Environment Variables pattern:
- When configured: clickable summary card with proxy type badge,
  address, and X button to clear
- When unconfigured: simple + button to configure
- Removes cramped Badge-next-to-title layout that caused text wrapping
2026-03-03 15:13:29 +08:00
陈大猫
e8faecc37a fix: filter dotfiles as hidden on Linux/Unix systems (#242)
* fix: filter dotfiles as hidden on Linux/Unix systems (#194)

Previously the hidden file filter only checked the Windows hidden
attribute, leaving Unix/Linux dotfiles (starting with '.') always
visible regardless of the "show hidden files" setting.

- Rename isWindowsHiddenFile to isHiddenFile with both checks
- Add dotfile detection (name.startsWith('.'))
- Keep backward-compatible alias for isWindowsHiddenFile
- Update filterHiddenFiles to use the new isHiddenFile function

* fix: limit dotfile filtering to remote connections only

Address review feedback: dotfile filtering was applied unconditionally,
which would hide .gitignore, .env, etc. on local Windows panes.

- Add isLocal param to isHiddenFile/filterHiddenFiles
- When isLocal=true, only check Windows hidden attribute
- When isLocal=false (remote SFTP), also filter dotfiles
- Update all 3 callers to pass connection.isLocal
- Fix useMemo dependency arrays

* fix: preserve isWindowsHiddenFile backward compatibility

isWindowsHiddenFile alias now explicitly passes isLocal=true to
isHiddenFile, so existing callers that don't pass isLocal won't
accidentally filter dotfiles.
2026-03-03 15:09:44 +08:00
陈大猫
166633414a fix: split Linux build into x64 and arm64 jobs (#222) (#241)
The ARM64 AppImage contained x86-64 native modules (node-pty, ssh2)
because both architectures were built on the same x86 runner.

- Split Linux build into linux-x64 (ubuntu-latest) and linux-arm64
  (ubuntu-24.04-arm) jobs so native modules compile on the correct arch
- Add pack:linux-x64 and pack:linux-arm64 npm scripts with explicit
  --x64/--arm64 flags
- Unify CI build step using matrix variables instead of per-OS conditions
2026-03-03 14:49:23 +08:00
陈大猫
9ecefc6959 feat: add SFTP path bookmarks for dual-pane view (#240)
* feat: add SFTP path bookmarks for dual-pane view

- Add SftpBookmark interface and sftpBookmarks field to Host model
- Create useSftpBookmarks hook with toggle/delete/list operations
- Add updateHosts callback through SftpContext for persistence
- Add bookmark star button with Popover dropdown in SftpPaneToolbar
- Wire bookmarks from App.tsx → SftpView → SftpContextProvider → SftpPaneView
- Add i18n translations for en and zh-CN

Closes #193

* refactor: replace encoding Select with compact icon Popover in SFTP toolbar

Replace the wide Select dropdown for filename encoding with a compact
Languages icon button + Popover menu, matching the SftpModal style.

* feat: add bookmark support to SFTPModal with shared hook

Refactor useSftpBookmarks to accept host/onUpdateHost params directly
instead of reading from SftpContext, enabling reuse in both SftpPaneView
(dual-pane) and SFTPModal (terminal).

- Refactor useSftpBookmarks hook to be context-agnostic
- Add bookmark star + Popover UI to SftpModalHeader
- Wire onUpdateHost from Terminal.tsx through SFTPModal
- Update SftpPaneView to use the new hook interface
2026-03-03 14:41:17 +08:00
陈大猫
afcc33b7fb fix: add missing passphrase to SFTP dual-pane credentials (#238) (#239)
useSftpHostCredentials.ts omitted `passphrase` when building the
credentials object for the target host, causing SFTP connections with
passphrase-protected private keys to fail with:

  Error: Cannot parse privateKey: Encrypted private OpenSSH key
  detected, but no passphrase given

The jump host path (L50) already included passphrase correctly.
This adds the same pattern to the main host credentials.
2026-03-03 13:59:02 +08:00
陈大猫
4c2702b7ff fix: SFTP modal create file/folder and shortcut key translations (#229) (#237)
Bug 2: Replace prompt() with state-based dialog for new file/folder
- Electron does not support window.prompt() (returns null)
- Added create dialog following the existing rename dialog pattern
- Dialog renders in SftpModalDialogs with proper input + submit

Bug 3: Add Chinese translations for shortcut key labels
- SettingsShortcutsTab now uses t() for binding labels with fallback
- Added 29 Chinese translations for all keyboard shortcut bindings
2026-03-03 13:53:48 +08:00
陈大猫
fdcd8547d3 fix: reverse SFTP transfer queue order to show newest tasks first (#223) (#236)
- Reverse transfer list in dual-pane SftpView (visibleTransfers)
- Reverse transfer list in sidebar SftpModalUploadTasks
- Newest/active transfers now appear at the top without scrolling
2026-03-03 13:33:48 +08:00
陈大猫
16ae3ff2ed fix(sftp): prevent stale session race when reopening modal (#235)
* fix(sftp): prevent stale session race when reopening modal

* fix(sftp): close session on external modal hide

* fix(sftp): clean up late-created sessions after modal hide
2026-03-03 13:11:37 +08:00
陈大猫
80e6e3c4c1 fix(transfer): make fast-transfer cancellation actually abort (#234)
* fix(transfer): make fastPut/fastGet cancellation effective

* fix(transfer): settle fast-transfer promise on abort

* fix(transfer): handle isolated SFTP channel errors
2026-03-03 12:04:23 +08:00
陈大猫
b58120998f fix: improve SFTP transfer speed with parallel requests and accurate progress (#226) (#231)
- Replace sequential stream piping with ssh2 fastPut/fastGet (64 concurrent SFTP requests)
- Use 512KB chunk size instead of default 32KB for better throughput
- Fix speed calculation with sliding window to prevent inflated burst speeds
- Throttle progress IPC to 100ms intervals to reduce event loop contention
- Simplify frontend speed display by removing ref-based smoothing layer
- Update memo comparison for smoother progress bar re-renders
2026-03-03 11:27:42 +08:00
Rory Chou
c671943d49 fix(terminal): avoid incorrect WebGL addon constructor args (#217)
Co-authored-by: rorychou <roryechou@gmail.com>
2026-03-02 10:12:49 +08:00
陈大猫
664fe90c10 feat: add legacy SSH algorithm support for older network equipment (#216)
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
2026-03-01 07:45:13 +08:00
Rory Chou
2215d52b09 feat: credential protection guards for enc:v1: placeholders (#212)
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
* feat: add credential protection guards for enc:v1: placeholders

Prevent encrypted credential placeholders from being sent as
actual passwords when safeStorage decryption is unavailable
(e.g. different device/user profile). Adds guards at terminal
connection, cloud sync, and settings boundaries with user-facing
warnings and i18n support.

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

* fix: validate base64 format in encrypted credential detection

Only treat values as encrypted placeholders when the content after
the enc:v1: prefix is valid base64. Prevents false positives if a
real password happens to start with the prefix literal.

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

* fix: resolve regressions in master-key change flow and credential placeholder detection

- Make ensureSyncablePayload non-blocking in changeMasterKey handler so
  success toast and dialog close always fire after a successful key change,
  even when the payload contains unresolved enc:v1: placeholders
- Add MIN_CIPHERTEXT_BASE64_LENGTH (32) threshold to
  isEncryptedCredentialPlaceholder to avoid false-positive matches on
  plaintext credentials that happen to start with enc:v1: (e.g. enc:v1:hello)

* fix: clean up chain-progress listener on credential reentry and gate proxy check on auth usage

- Unsubscribe onChainProgress before returning in needsCredentialReentry
  branch to prevent listener leaks across connection attempts
- Only block connection for undecryptable proxy password when proxy
  authentication is actually in use (has a username)

* fix: reduce enc:v1 placeholder false positives

* fix: require syncable payload before master key rotation

---------

Co-authored-by: rorychou <roryechou@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-02-25 15:32:24 +08:00
Rory Chou
c9059a4f29 feat: add swap usage display in server stats (#210)
Collect SwapTotal and SwapFree from /proc/meminfo and display swap
usage in the memory HoverCard with a dedicated progress bar (rose color).
Only shown when the server has swap configured (swapTotal > 0).

Co-authored-by: rorychou <roryechou@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 10:49:11 +08:00
Rory Chou
4445bf578c security: harden external navigation / window.open (#209) 2026-02-14 16:21:11 +08:00
Rory Chou
f719350507 fix(macos): restore main window on Dock activate (#208) 2026-02-14 12:20:48 +08:00
Copilot
cfaee48553 Remove extra space next to close button on Windows (#207)
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
* Initial plan

* fix: remove extra right spacing next to close button on Windows

On Windows/Linux, the frameless title bar had ~20px of dead space
(12px right padding + 8px drag shim) to the right of the close button.

- Replace Tailwind `px-3` with conditional inline paddingRight (0 on
  Windows, 12px on macOS) so the close button sits at the window edge
- Render the drag shim only on macOS where it's useful as a drag region

macOS layout is unchanged.

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-02-13 23:51:26 +08:00
bincxz
1f05fe3efa fix: remove extra space next to close button on Windows (#207) 2026-02-13 23:50:13 +08:00
copilot-swe-agent[bot]
e9c3b82c16 fix: remove extra right spacing next to close button on Windows
On Windows/Linux, the frameless title bar had ~20px of dead space
(12px right padding + 8px drag shim) to the right of the close button.

- Replace Tailwind `px-3` with conditional inline paddingRight (0 on
  Windows, 12px on macOS) so the close button sits at the window edge
- Render the drag shim only on macOS where it's useful as a drag region

macOS layout is unchanged.

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-02-13 23:45:22 +08:00
copilot-swe-agent[bot]
83fce70b20 Initial plan 2026-02-13 23:45:22 +08:00
bincxz
d36c8bcbea fix: add missing </p> closing tags in VaultView empty hosts state
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 23:44:50 +08:00
bincxz
5346752994 Merge branch 'fix/encrypt-credentials-at-rest' - encrypt sensitive credentials at rest via safeStorage (#203) 2026-02-13 23:40:17 +08:00
bincxz
d267c4b6fc fix: prevent stale cross-window writes and deferred-read init races
- CloudSyncManager: bump providerWriteSeq on storage events so an
  in-flight local save is discarded when newer cross-window data arrives
- useVaultState: defer reads of keys/identities/groups/snippets to just
  before their processing stage instead of reading all upfront, so data
  updated during a prior async decrypt gap is not overwritten by a stale
  pre-await snapshot

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 23:20:46 +08:00
bincxz
1a1da02e92 fix: guard storage decrypt callbacks against local writes and sync updates
- useVaultState: storage-event decrypt callbacks now also check
  writeVersion so a local edit during the decrypt gap causes the stale
  cross-window result to be discarded
- CloudSyncManager: bump providerDecryptSeq in uploadToProvider before
  mutating lastSync/lastSyncVersion so a pending cross-window decrypt
  cannot overwrite the newer sync metadata

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 22:54:28 +08:00
bincxz
1adcffa7a8 fix: split provider sequence counters so status-only updates don't drop writes
Split the single providerSeq into providerDecryptSeq (bumped by all state
mutations to guard async decrypt callbacks) and providerWriteSeq (bumped
only by saveProviderConnection). This prevents status-only transitions
like 'error' or 'syncing' from discarding an in-flight encrypted write
from disconnect/auth, which would leave stale credentials in localStorage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 22:43:09 +08:00
bincxz
7a2bedc4f4 fix: guard provider save writes and validate sentinel prefix on encrypt
- CloudSyncManager: add providerSeq write guard to saveProviderConnection
  so overlapping async saves don't let an older encryption overwrite newer
  provider state in localStorage
- credentialBridge: verify enc:v1: prefix by attempting trial decryption
  instead of prefix-only check, so plaintext values that happen to start
  with the sentinel are still encrypted rather than silently skipped

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 22:33:52 +08:00
bincxz
5e753334ed fix: capture write version before async init decryption to prevent startup race
Move hostsWriteVersion/keysWriteVersion/identitiesWriteVersion increments
to before the await decryptHosts/Keys/Identities calls, and guard both
setstate and re-encrypt with the version check. This prevents a write
that occurs during the decryption await (storage event, user edit) from
being overwritten by stale init data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 22:23:28 +08:00
bincxz
a488bc466b fix: invalidate in-flight async operations on local and cross-window state changes
- useVaultState: bump writeVersion counters on storage events so pending
  local encrypts are discarded when newer cross-window data arrives
- CloudSyncManager: bump providerSeq on all local provider mutations
  (connect, disconnect, status updates, save) so in-flight decrypt
  callbacks from startup or storage events cannot revert newer state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 22:14:52 +08:00
bincxz
2748cd5363 fix: add sequence guards to all async decrypt paths
Prevent out-of-order async decrypt results from overwriting newer state:

- useVaultState: add per-key readSeq counters for cross-window storage
  event decrypt callbacks (hosts, keys, identities)
- CloudSyncManager: add per-provider sequence counters shared between
  initProviderDecryption and cross-window storage handler, so stale
  decrypt results are discarded in both paths

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 22:04:59 +08:00
bincxz
033165561d fix: add version guards to migration writes and fix stale prev in cross-window sync
Addresses remaining Codex review feedback:
- Add writeVersion checks to startup migration re-encrypt paths to prevent
  stale async writes from overwriting newer user edits
- Move `prev` read inside .then() in CloudSyncManager storage event handler
  so it compares against latest state rather than a stale snapshot

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 21:52:43 +08:00
陈大猫
8e514f1008 fix: localize vault hosts empty state copy 2026-02-13 20:32:33 +08:00
Misaka21
0acd39603f feat: localize empty hosts message to Chinese 2026-02-13 19:40:08 +08:00
rorychou
4bdb0bbbf7 fix: address Codex review — serialize async writes & fix WebDAV token detection
1. Race condition: rapid updateHosts/Keys/Identities calls could cause
   out-of-order async writes. Added per-collection write-version counters
   so only the latest encryption Promise persists to localStorage.

2. WebDAV token-auth: using "password" in config as discriminator failed
   for token-auth configs because JSON.stringify drops undefined keys.
   Switched to "authType" in config which is a required WebDAVConfig field.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 19:12:21 +08:00
rorychou
6b2c58f8f0 fix: encrypt sensitive credentials at rest via safeStorage
Passwords, OAuth tokens, SSH private keys, and cloud sync secrets were
stored as plaintext JSON in browser localStorage.  Any XSS or local
file read could extract all credentials in one shot.

This commit adds field-level encryption using Electron's safeStorage
API.  Encrypted values are stored with an `enc:v1:` prefix sentinel
so plaintext values migrate transparently on first read — no version
bumps or flags needed.

New files:
- electron/bridges/credentialBridge.cjs — IPC handlers (encrypt/decrypt/available)
- infrastructure/persistence/secureFieldAdapter.ts — per-model encrypt/decrypt helpers

Modified files:
- electron/main.cjs, preload.cjs, global.d.ts — bridge wiring + types
- useVaultState.ts — async encrypted writes, decrypted reads, migration on init
- CloudSyncManager.ts — async provider token/config encryption

Sensitive fields encrypted:
- Host: password, telnetPassword, proxyConfig.password
- SSHKey: passphrase, privateKey
- Identity: password
- CloudSync: accessToken, refreshToken, WebDAV password/token, S3 secretAccessKey

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 18:30:37 +08:00
陈大猫
c0199c43cf fix: prevent zombie processes and improve window recovery on restart (#201)
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
- Destroy trayPanelWindow and clear refresh timer during cleanup, preventing
  hidden BrowserWindows from keeping the Electron process alive
- Add SIGTERM/SIGINT handlers for graceful shutdown
- Detect crashed webContents in focusMainWindow() and recreate the window
  instead of silently failing on second-instance activation

Closes the issue where restarting the app shows "Failed to load the UI"
and leaves multiple zombie processes in the task manager.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 22:16:42 +08:00
陈大猫
7940b9a0a7 fix: tray quit button, tree view multi-select, and SFTP banner handling (#200)
* fix: tray quit button, tree view multi-select, and SFTP banner handling

- Add "Quit Netcatty" button pinned to the bottom of TrayPanel so users
  can exit the app when close-to-tray is enabled
- Support multi-select mode in HostTreeView (checkboxes, click-to-select)
  so tree view behaves the same as grid/list views
- Patch ssh2 SFTP parser to skip non-SFTP preamble data (MOTD/banner text)
  that causes "Packet length exceeds max length" errors on misconfigured
  servers, with proper cross-frame buffering

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

* fix: gate SFTP preamble scan to client-mode only

Server-mode SFTP expects SSH_FXP_INIT (0x01) as the first packet, not
SSH_FXP_VERSION (0x02). Skip the preamble scan entirely when running in
server mode to avoid stalling server-side SFTP sessions.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 21:31:01 +08:00
Copilot
920914e3ee Fix ERR_FAILED on second instance by moving single-instance lock before app.whenReady() (#199)
* Initial plan

* Fix ERR_FAILED when second instance launches by moving single-instance lock before app.whenReady()

Move app.requestSingleInstanceLock() before app.whenReady() registration
and wrap all lifecycle handlers (whenReady, window-all-closed, before-quit,
will-quit) inside the else block. This prevents a second instance from
attempting to register the app:// protocol or create a BrowserWindow,
which would fail with ERR_FAILED.

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-02-12 16:53:50 +08:00
Copilot
b5feb888d2 Fix incorrect character encoding over Telnet and Serial connections (#196)
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
* Initial plan

* fix: use UTF-8 encoding for Telnet and Serial data instead of binary (latin1)

Fixes incorrect character encoding where accented characters (e.g. ç, ã, é)
were displayed as garbled text (e.g. ç, ã, é) over Telnet connections.

The root cause was Buffer.toString('binary') which uses latin1 encoding,
corrupting multi-byte UTF-8 sequences. Changed to toString('utf8') for both
Telnet and Serial data handlers.

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

* fix: use streaming StringDecoder for UTF-8 decoding in Telnet and Serial

Buffer.toString('utf8') on individual chunks loses multibyte characters
when a UTF-8 sequence is split across TCP/serial data events. Use
StringDecoder to carry incomplete trailing bytes into the next event.

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

* fix: respect configured charset for Telnet and use latin1 for Serial

Telnet: use the user-configured charset (options.charset) to select the
StringDecoder encoding instead of hardcoding UTF-8, so non-UTF-8
endpoints (e.g. ISO-8859-1) decode correctly.

Serial: use latin1 (byte-preserving) instead of UTF-8 to avoid
corrupting 8-bit/binary serial traffic and legacy single-byte encodings.

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 12:05:05 +08:00
陈大猫
62d19974c9 fix: show sessions on first TrayPanel open (#192)
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
2026-02-07 15:13:25 +08:00
陈大猫
932bb5032d fix: wrap terminal paste in bracketed paste escape sequences (#191)
All three paste paths (hotkey, context menu, middle-click) were sending
raw clipboard text directly to the session backend via writeToSession(),
bypassing xterm's built-in term.paste() which handles bracketed paste
wrapping. When a remote application like vim enables bracketed paste
mode (CSI ?2004h), pasted text must be wrapped in \e[200~ / \e[201~
so the application can distinguish paste from typed input.

Without these markers, vim's autoindent treats each pasted newline as
a manual Enter keypress, causing indentation to accumulate
progressively with each line (the "staircase effect").

Now checks term.modes.bracketedPasteMode before sending and wraps
the text accordingly on all paste paths.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-07 14:33:51 +08:00
陈大猫
3020d422fe fix: restore built-in text editor paste behavior (#190)
* fix: restore built-in editor paste reliability

* fix: prevent Cmd+R window reload while editing in Monaco

Replace the Electron menu `{ role: "reload" }` with a manual click
handler so that Cmd+R no longer registers as a native accelerator.
This prevents accidental window reloads (and loss of unsaved edits)
when the text editor has focus, since the app's hotkey early-return
skips preventDefault for editor surfaces.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: fall back to Monaco native paste when clipboard read is unavailable

When both navigator.clipboard.readText() and the Electron bridge fail,
readClipboardText now returns null instead of '' so handlePaste can
distinguish "read failed" from "clipboard empty" and trigger Monaco's
built-in paste action as a fallback.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: let clipboard bridge errors propagate for proper paste fallback

useClipboardBackend.readClipboardText was swallowing bridge
absence/errors as "", making TextEditorModal's catch-based null
fallback unreachable. Now throws when the bridge is unavailable or
the call fails, so the caller can detect failure and fall back to
Monaco's native paste action.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: preserve multi-cursor paste distribution semantics

When multiple cursors are active and the clipboard line count matches
the cursor count, distribute one line per cursor instead of pasting
the full text at every cursor. This matches Monaco's default
multicursorPaste:'spread' behavior.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-07 13:44:19 +08:00
bincxz
bb526601bb fix(settings): prevent local terminal updates from being swallowed during sync 2026-02-07 10:26:23 +08:00
bincxz
d349c31cd6 fix(settings): avoid terminal settings sync race when skipping rebroadcast 2026-02-07 08:30:23 +08:00
bincxz
8313cf780d fix(settings): stop terminal sync echo and decouple editor modal state 2026-02-07 08:29:24 +08:00
陈大猫
29c0cc30a4 fix(settings): sync editor word wrap across windows (#189) 2026-02-07 08:29:24 +08:00
lolo
ee80048ece fix: correct Linux artifact naming in release notes
- Fix electron-builder Linux package architecture naming differences:
  - AppImage x64: x64 -> x86_64
  - deb x64: x64 -> amd64
  - rpm x64: x64 -> x86_64
  - rpm arm64: arm64 -> aarch64

- Update electron-builder config with separate artifactName for Windows NSIS

- Optimize Windows build config to build x64 and arm64 separately
2026-02-06 07:49:32 +08:00
bincxz
ec5dfcf1fa fix: add notifyStateChange after syncAllProviders completes to update UI sync status
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
2026-02-05 02:30:16 +08:00
bincxz
ab3b2c2055 Adds collapsable Vault sidebar with state persistence
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
Introduces a collapsable sidebar in the Vault view to enhance user experience and optimize screen real estate.

The sidebar's collapsed or expanded state is now persisted across sessions using local storage. Tooltips are added to navigation items, providing clear labels when the sidebar is in its collapsed state.

Additionally, improves tooltip activation behavior in the SFTP modal, delaying their appearance slightly to prevent flickering on modal open.
2026-02-05 01:42:23 +08:00
bincxz
ca8691d53d feat(TrayPanel): add humorous empty state message
When no sessions are active and no port forwarding rules exist,
display a friendly empty state with emoji and encouraging message.
2026-02-05 01:00:18 +08:00
bincxz
dddaf1a1cd fix(TrayPanel): sync port forwarding rules on delete via storage event
Add storage event listener in usePortForwardingState to sync rules between
main window and TrayPanel. When port forwarding rules are added, updated,
or deleted in the main window, the TrayPanel now receives the update via
localStorage storage event and re-renders accordingly.

Also fix TypeScript type inference in normalizeRulesWithConnections by
adding explicit return type annotations.
2026-02-05 00:57:22 +08:00
bincxz
d2469f93c8 refactor(QuickSwitcher): always show categorized view with Hosts/Tabs/Quick connect
- Remove conditional showCategorized logic, always display full categorized panel
- Remove unused "All Hosts" simplified view dead code (~45 lines)
- Remove unused Button import and onCreateWorkspace prop
- Clean up unused i18n keys: qs.recentConnections, qs.allHosts, qs.createWorkspace, qs.restore
2026-02-05 00:52:49 +08:00
bincxz
521c9141e9 fix(tray): correct IPC handler signatures and main window identification
- Fix handlerJump/handlerConnect signatures to match preload callback
  (preload strips event, callback receives only sessionId/hostId)
- Use windowManager.getMainWindow() for reliable main window reference
- Add fallback filtering for tray panel and destroyed windows
2026-02-05 00:46:32 +08:00
bincxz
d068cc99c8 feat(tray): implement workspace grouping in TrayPanel
- Group sessions by workspaceId in collapsible WorkspaceGroup component
- Add workspaceId/workspaceTitle fields to tray session types
- Update App.tsx to send workspace data in updateTrayMenuData
- Fix session focus handlers to navigate to workspace and focus session
- Align bridge typings for consistent workspace session fields
2026-02-05 00:32:51 +08:00
bincxz
a89130d732 refactor(sftp): change focus indicator from border to corner triangle 2026-02-04 23:45:51 +08:00
bincxz
dcea13172d fix: allow tray panel button clicks
- Avoid pointerdown capture handler interfering with button clicks

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-02-04 17:50:10 +08:00
bincxz
0c420d99ed fix: enable tray panel jump and simplify to current tab
- Fix main window listeners by using netcatty bridge events (not window.electron)
- Remove recent sessions section; show only current active tab entry

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-02-04 17:47:52 +08:00
bincxz
a3ffefa067 fix: handle tray panel clicks by dispatching actions to main window
- Tray panel click now asks main process to open main window and send jump/connect events
- Main window listens for these events and switches tab or starts a new connection

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-02-04 17:40:16 +08:00
bincxz
80b6485d02 fix: show sessions and recent hosts in tray panel via injected menu data
- Inject main-window session list into tray panel via IPC
- Tray panel derives session jump + recent hosts from injected data
- Remove temporary tray panel debug logging

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-02-04 17:35:44 +08:00
bincxz
884d643150 chore: improve tray panel debug logs
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-02-04 17:32:50 +08:00
bincxz
96fde364df chore: forward tray panel renderer logs to main
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-02-04 17:30:48 +08:00
bincxz
920d829299 chore: add tray panel debug render logs
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-02-04 17:23:00 +08:00
bincxz
4f7d3bccc8 chore: log tray panel url in dev
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-02-04 17:20:45 +08:00
bincxz
a3eb6ab26c feat: add tray panel session jump and recent hosts
- Show connected/connecting sessions for quick jump to tab
- Add recent hosts (last 5) and allow starting a new session from tray panel

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-02-04 17:18:04 +08:00
bincxz
c402c45e1d ui: add tooltip for truncated tray panel labels
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-02-04 17:12:35 +08:00
bincxz
d4978f267a fix: refresh tray panel data while open
- Periodically signal tray panel to refresh while visible
- Add preload/api typings + backend hook support for refresh events

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-02-04 17:10:51 +08:00
bincxz
41ee8e7160 ui: improve tray panel i18n and status indicators
- Wrap tray panel with I18nProvider so translations render
- Add colored status dots and spinner for connecting
- Stop setting tray context menu to avoid Quit showing alongside panel

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-02-04 17:09:22 +08:00
bincxz
3d9f153fe9 ui: polish tray panel and avoid context menu
- Keep tray context menu minimal to avoid menu + panel overlap
- Add tray panel i18n keys for labels/status (en/zh-CN)
- Show active tab highlight and localized status text in tray panel

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-02-04 17:05:11 +08:00
bincxz
4332537da0 feat: add tray panel popover window
- Replace tray click menu with a custom panel window (#/tray)
- Panel lists sessions + port forwards and allows toggling without closing
- Add tray panel IPC (hide panel, open main window)

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-02-04 17:00:54 +08:00
bincxz
4284b9a9dc Revert "ui: localize tray menu labels and status text"
This reverts commit 1d06e48966.
2026-02-04 16:50:31 +08:00
bincxz
3afe6ed489 fix: restore dev startup after tray i18n attempt
- Remove invalid require() of TS i18n modules from electron main
- Keep tray status text in main process to avoid module resolution issues

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-02-04 16:49:32 +08:00
bincxz
1d06e48966 ui: localize tray menu labels and status text
- Replace status dots with localized status text suffix
- Localize tray menu labels via i18n keys

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-02-04 16:47:05 +08:00
bincxz
3ce2481753 ui: simplify tray port forwarding toggle
- Remove submenu; click rule to start/stop directly
- Use colored status dots for sessions and port forwards

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-02-04 16:39:02 +08:00
bincxz
ce61c28162 fix: localize tray port forwarding errors
- Use existing i18n key pf.error.hostNotFound for tray action toast

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-02-04 16:27:08 +08:00
bincxz
55463441f0 feat: expand tray menu with sessions and port forwarding
- Click tray icon to open menu (Open Main Window, sessions, port forwards, quit)
- Sync session + port forward data from renderer to main via IPC
- Handle tray actions in renderer (focus session, start/stop port forward)
- Fix tray listener unsubscribe to remove only registered handler

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-02-04 16:03:41 +08:00
Copilot
c17833ae31 feat: Add global hotkey to toggle window visibility (Quake mode) (#187)
* Initial plan

* feat: implement global hotkey toggle and system tray functionality

- Add globalShortcutBridge.cjs for global keyboard shortcuts and system tray
- Register/unregister global shortcuts using Electron's globalShortcut API
- Implement system tray with context menu for show/hide/quit
- Add close-to-tray behavior option
- Add new storage keys for toggle window hotkey and close-to-tray settings
- Add settings UI in System tab for configuring global hotkey
- Add i18n translations for English and Chinese
- Update preload.cjs with new IPC methods for global hotkey
- Update global.d.ts with TypeScript type definitions

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

* fix: address code review feedback for global hotkey feature

- Use imported fs module instead of inline require in globalShortcutBridge
- Simplify macOS platform detection (remove mobile device patterns)
- Add warning logs for hotkey registration and tray errors

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

* fix: map Control modifier to Control

* Add default tray icon fallback

* fix: bypass close-to-tray during quit

* Fix global hotkey failure handling

* fix: improve tray icon with platform-specific PNG files

- Replace SVG tray icon with PNG format for better compatibility
- Add macOS template images (tray-iconTemplate.png) for menu bar
- Add colored icons (tray-icon.png) for Windows/Linux
- Include @2x retina variants for high-DPI displays
- Remove unused mainWindow variable from globalShortcutBridge

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* feat: enable global hotkey and close-to-tray by default

- Default hotkey: Ctrl+` (⌃+` on macOS) - similar to VS Code terminal toggle
- Default close-to-tray: enabled
- Properly distinguish between 'never set' and 'explicitly cleared'

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

* fix: address code review findings for global hotkey feature

P1: Fix toggleWindowVisibility to restore minimized windows
- Check isMinimized() first before checking isVisible()
- Ensures hotkey/tray toggle works when window is minimized

P2: Save window state before hiding to tray on close
- Persist window bounds before returning from close-to-tray handler
- Prevents losing window position/size on quick close after resize

P2: Surface hotkey registration failures to UI
- Add hotkeyRegistrationError state to useSettingsState
- Display error message in SettingsSystemTab instead of silently clearing

P2: Remove arbitrary renderer-provided tray icon paths
- Only use known packaged icon locations for security
- Remove iconPath parameter from setCloseToTray API

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-02-04 14:52:28 +08:00
bincxz
56bab12b5c Add README badges and assets 2026-02-04 00:21:01 +08:00
bincxz
d1482e47d6 Add logo and GIF previews 2026-02-04 00:19:11 +08:00
bincxz
4ee3c63768 Refreshes README content and introduces GIF demos
Overhauls the README files across all languages to enhance readability and user experience.

Updates feature descriptions to better reflect the application's current capabilities, including new sections like "Why Netcatty" and "Demos". Integrates animated GIF demos for core features such as Vault views, split terminals, SFTP workflows, and personalization, offering dynamic visual explanations.

Streamlines the header by replacing image-based badges with concise text links. Replaces outdated screenshots with new, relevant images and removes a large number of old screenshot files. Updates the Electron framework version in the technology stack.
2026-02-04 00:14:40 +08:00
bincxz
2b067a9aae fix: apply host-level keyword highlight rules immediately after runtime creation
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
This fixes a timing issue where the useEffect for keyword highlighting
runs before the runtime is created, causing host-level rules to be missed
for fresh sessions. Now merged global and host rules are applied right
after the XTermRuntime is created in the boot() function.
2026-02-03 14:04:00 +08:00
bincxz
2d4a3a5602 Cleans up SFTP module imports and catch blocks
Removes unused React hook imports (`useState`, `useCallback`) from SFTP components to improve code clarity.

Simplifies a `catch` block in SFTP keyboard shortcuts by removing the unused `error` variable, making the code more concise.
2026-02-03 13:50:00 +08:00
Copilot
6c57ce7b28 Feature: Host-level keyword highlighting with toolbar popover (#185)
* Initial plan

* Add host-level keyword highlighting feature with popover UI

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

* Add accessibility labels to color inputs in HostKeywordHighlightPopover

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

* Disable host highlight popover for serial sessions

* Fix keyword highlight rule merging

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-02-03 13:45:48 +08:00
Copilot
6a2bd0a6a1 Fix SFTP jump connection unsupported algorithm chacha20-poly1305 error (#184)
* Initial plan

* Fix SFTP jump connection unsupported algorithm chacha20-poly1305 error

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-02-03 11:53:49 +08:00
bincxz
0c4900c73d fix: exclude cpu-features to fix native module build failure
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
The cpu-features package fails to compile with newer Node.js/Electron
due to deprecated V8 APIs. Since it's an optional dependency of ssh2,
replace it with an empty package via npm overrides.
2026-02-03 02:35:41 +08:00
陈大猫
3174e9ad27 feat(sftp): add visual focus indicator for pane selection (#181)
- Add inset ring border to focused SFTP pane for clear visual distinction
- Fix useSftpKeyboardShortcuts context error by passing showHiddenFiles as parameter
- Use sftpFocusStore to track which pane is currently focused
2026-02-03 02:17:11 +08:00
Copilot
f517c85d07 feat: Add SFTP keyboard shortcuts for copy, paste, cut, select all, rename, delete (#180)
* Initial plan

* feat: Add SFTP keyboard shortcuts support for copy, paste, cut, select all, rename, delete, refresh, and new folder operations

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

* feat: Add keyboard shortcuts support to SFTPModal for select all, rename, delete, refresh, and new folder

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

* fix: Address code review feedback - optimize useMemo deps and add same-pane paste notification

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

* Fix SFTP paste source path

* fix: delete sources after SFTP cut paste

* Fix SFTP delete key matching

* Fix cut delete after successful SFTP transfers

* fix: finalize cut-paste deletes after conflicts

* fix: track original names for cut transfers

* Fix delete key matching for shortcuts

* fix: respect visible files for sftp select all

* Fix modal selection and cut cleanup

* Throw on missing SFTP delete prerequisites

* Fix dialog action handler memo deps

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-02-03 01:45:53 +08:00
陈大猫
0b9e3c430d fix: remove chacha20-poly1305 cipher and upgrade Electron to 40.1.0 (#179)
- Remove chacha20-poly1305@openssh.com from SSH cipher list as Electron's
  BoringSSL (from Chromium) does not support standalone chacha20 cipher
- Upgrade Electron from 39.2.6 to 40.1.0 (Node.js 24.11.1)
- Keep AES-GCM and AES-CTR ciphers which are fully supported

The chacha20-poly1305 algorithm requires OpenSSL's chacha20 cipher which
is not available in Electron's bundled BoringSSL. This caused connection
failures with 'Unsupported algorithm' error when connecting to SSH servers.
2026-02-02 21:43:24 +08:00
Copilot
1c526e6965 Add keyboard shortcuts for snippets (#174)
* Initial plan

* Add snippet shortkey feature for sending commands via keyboard shortcuts

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

* Address code review feedback: extract isMacPlatform utility and improve comments

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

* Fix snippet shortcut validation

* Improve snippet shortcut conflict checks

* Append newline for snippet shortcuts

* Allow snippet shortcuts to fall through when disconnected

* Broadcast snippet shortcuts

* Fix snippet shortcut validation when hotkeys disabled

* Prevent cross-platform snippet shortcut matches

* Record snippet shortcuts in command history

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-02-02 21:12:00 +08:00
Copilot
70ff5299b6 Expand SSH algorithm support for modern servers (#178)
* Initial plan

* feat: expand SSH algorithm support for modern servers

Add additional cipher and key exchange algorithms to improve
compatibility with modern SSH servers:

Ciphers:
- chacha20-poly1305@openssh.com
- aes192-ctr

Key Exchange:
- ecdh-sha2-nistp521
- diffie-hellman-group16-sha512
- diffie-hellman-group18-sha512
- diffie-hellman-group-exchange-sha256

Fixes issue with "no matching key exchange algorithm" error.

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-02-02 20:24:34 +08:00
Copilot
3ef53faef5 Add tooltip to port forwarding rules showing relay host details (#175)
* Initial plan

* Add tooltip to port forwarding rule card showing relay host info

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-02-02 20:10:04 +08:00
陈大猫
554bc3d2ab Show connection details in host selector (#173)
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
* Show user@host:port in host selector

Replace the host selector subtitle with username, hostname, and port to
surface the actual connection target details.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Filter serial hosts from selector

Exclude serial protocol entries from SelectHostPanel results and counts to
avoid offering non-SSH targets in selection flows.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 01:56:07 +08:00
陈大猫
951a89e91e Enable opt-in MFA for SSH exec export (#172)
Add execCommand options to opt into keyboard-interactive auth and wire MFA only
for export-key flows, preserving non-interactive exec usage elsewhere.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 01:32:41 +08:00
bincxz
339e34e722 Refactor port forwarding initialization and remove unused state.
This simplifies async auth prep before opening the SSH connection and cleans up unused variables in UI and SFTP hooks.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 01:05:59 +08:00
bincxz
fe1a5ca0e5 Ignore local Claude settings to avoid committing machine-specific state.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 00:58:44 +08:00
陈大猫
3e89a65b39 Optimize Cloud Sync Performance (#159)
* Optimize syncAllProviders to run concurrently and encrypt once

Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.

Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.

This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

* Optimize syncAllProviders to run concurrently and encrypt once

Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.

Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.

This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

* Optimize syncAllProviders to run concurrently and encrypt once

Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.

Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.

This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

* Optimize syncAllProviders to run concurrently and encrypt once

Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.

Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.

This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

* Optimize syncAllProviders to run concurrently and encrypt once

Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.

Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.

This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

* Optimize syncAllProviders to run concurrently and encrypt once

Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.

Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.

This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

* Optimize syncAllProviders to run concurrently and encrypt once

Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.

Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.

This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

* Optimize syncAllProviders to run concurrently and encrypt once

Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.

Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.

This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

* Optimize syncAllProviders to run concurrently and encrypt once

Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.

Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.

This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

* Normalize conflict check errors in sync (#164)

* Optimize syncAllProviders to run concurrently and encrypt once

Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.

Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.

This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

* Optimize syncAllProviders to run concurrently and encrypt once

Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.

Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.

This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

* Return errors when sync is attempted while locked (#165)

* Optimize syncAllProviders to run concurrently and encrypt once

Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.

Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.

This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

* Optimize syncAllProviders to run concurrently and encrypt once

Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.

Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.

This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

* Optimize syncAllProviders to run concurrently and encrypt once

Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.

Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.

This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

* Set lastError when parallel uploads all fail (#167)

* Optimize syncAllProviders to run concurrently and encrypt once

Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.

Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.

This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

* Optimize syncAllProviders to run concurrently and encrypt once

Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.

Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.

This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

* Optimize syncAllProviders to run concurrently and encrypt once

Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.

Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.

This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

* Block uploads on conflict check errors (#168)

* Optimize syncAllProviders to run concurrently and encrypt once

Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.

Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.

This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

* Optimize syncAllProviders to run concurrently and encrypt once

Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.

Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.

This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

* Optimize syncAllProviders to run concurrently and encrypt once

Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.

Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.

This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

* Fix lastError on upload failures (#170)

* Optimize syncAllProviders to run concurrently and encrypt once

Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.

Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.

This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

* Optimize syncAllProviders to run concurrently and encrypt once

Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.

Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.

This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

* Normalize conflict check errors in parallel sync (#171)

* Optimize syncAllProviders to run concurrently and encrypt once

Refactored CloudSyncManager to support concurrent cloud provider synchronization.
Previously, synchronization was sequential: check -> encrypt -> upload for each provider.
Now, it performs:
1. Parallel checks for conflicts/remote versions.
2. Single encryption of the payload (saving CPU/time).
3. Parallel uploads to all valid providers.

Helper methods `checkProviderConflict` and `uploadToProvider` were introduced to share logic between `syncToProvider` and `syncAllProviders`.

This results in a ~3x performance improvement in benchmarks (from ~1650ms to ~550ms for 3 providers).

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-02-01 23:06:01 +08:00
Copilot
090aae1833 Add passphrase input support to SSH key import panel (#169)
* Initial plan

* Add passphrase input field and save checkbox to SSH key import panel

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-02-01 23:04:08 +08:00
陈大猫
8810b3cf0f Sync port forwarding rules (#161)
* feat: Sync port forwarding rules

- Refactor `usePortForwardingState` to use a global store pattern, ensuring state consistency across the application.
- Integrate `usePortForwardingState` into `App.tsx` to retrieve and update port forwarding rules.
- Update `useAutoSync` in `App.tsx` to include `portForwardingRules` in the sync payload and handle incoming updates via `importRules`.

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

* Normalize imported port forwarding statuses

* fix: correct indentation in usePortForwardingState.ts

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

* Normalize imported port forwarding rule status

* Stabilize port forwarding rules for auto-sync

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-02-01 14:55:45 +08:00
陈大猫
087ce0f3b1 feat: implement workspace creation from Quick Switcher (#162)
- Added `CreateWorkspaceDialog` component for creating named workspaces with multiple hosts.
- Implemented `createWorkspaceWithHosts` in `useSessionState`.
- Integrated dialog into `App.tsx` and triggered from Quick Switcher.
- Updated `QuickSwitcher` logic to improve visibility of recent connections.
- Added i18n keys for the dialog.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-02-01 14:31:12 +08:00
陈大猫
14e07741ae Implement setDeviceName in CloudSyncManager and useCloudSync (#160)
- Added `setDeviceName` method to `CloudSyncManager` to update state, persist to local storage, and notify listeners.
- Updated `useCloudSync` hook to expose the `setDeviceName` function from the manager.
- Ensures device name updates are correctly handled and persisted across sessions.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-02-01 14:28:29 +08:00
陈大猫
fe9b1b1011 perf: Optimize managed source host filtering to O(N) (#158)
Refactored the host filtering logic in `useManagedSourceSync` to index hosts by `managedSourceId` before iterating through sources. This reduces the complexity from O(N*M) to O(N+M), where N is the number of sources and M is the number of hosts.

Benchmarks showed a ~74x speedup (from ~600ms to ~8ms) with 500 sources and 25,000 hosts.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-02-01 14:22:30 +08:00
陈大猫
7941aa6d08 perf: make window state saving async to avoid blocking main thread (#157)
* perf: make window state saving async to avoid blocking main thread

- Convert `saveWindowState` to use `fs.promises.writeFile`
- Keep `saveWindowStateSync` for use in `close` handler
- Update `scheduleSaveState` to use async version
- Reduces blocking time from ~0.38ms to ~0.10ms per write

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

* Serialize async window state saves

* fix: avoid async window state overwrite on close

* fix: guard queued window state saves on close

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-02-01 14:13:24 +08:00
陈大猫
b3d9908814 perf: make key persistence asynchronous in main process (#154)
- Refactor `writeKeyToDisk` and `ensureKeyDir` in `electron/main.cjs` to use `fs.promises` instead of synchronous `fs` methods.
- This prevents blocking the main thread during file I/O operations, improving application responsiveness.
- Added error handling with try/catch blocks to ensure safety.
- Verified performance improvement with a benchmark script (deleted before commit).
- Verified code quality with `npm run lint`.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-02-01 13:44:21 +08:00
陈大猫
1006fa1da0 perf: optimize SFTP directory existence check (#155)
Reduces the complexity of `ensureRemoteDirInternal` from O(N) to O(1) for the common case where the directory already exists.

- Adds a check for the full path at the beginning of the function.
- If the directory exists, it returns immediately.
- If not, it falls back to the existing recursive check/creation logic.

Benchmarks showed a reduction from ~8 calls to 1 call for a deep existing directory structure.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-02-01 13:43:53 +08:00
陈大猫
721b9596f5 Optimize SSH key discovery to use async I/O (#156)
Refactored synchronous file operations in SSH key discovery to use `fs.promises` and `Promise.all`, preventing main thread blocking during connection initialization. Updated all bridge modules to handle asynchronous key retrieval.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-02-01 13:43:35 +08:00
陈大猫
b3fbc0972d feat: use dynamic package version in CloudSyncManager (#153)
Replaced the hardcoded '1.0.0' version string in CloudSyncManager.ts with the version from package.json.
Enabled resolveJsonModule in tsconfig.json to support JSON imports.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2026-02-01 13:42:49 +08:00
bincxz
6edc4213f4 feat(sftp): show download progress in SftpView transfer queue
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
- Expose addExternalUpload and updateExternalUpload methods from useSftpState
- Add download task to transfer queue when starting stream download
- Update progress during download with transferred bytes, total, and speed
- Update task status on completion, failure, or cancellation

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-31 02:08:11 +08:00
陈大猫
4313977bd4 feat: stream-based SFTP download for large files (#151)
* feat: stream-based SFTP download for large files

- Add showSaveDialog API for native file save dialog
- Modify handleDownload to use streaming transfer for remote files
- Show save dialog first, then stream directly to disk
- Avoid loading entire file into memory
- Fallback to memory-based download for local files or when streaming unavailable

This fixes the issue where downloading large files would cause high memory
usage as the entire file was loaded into memory before saving.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>

* feat: stream-based download for SftpView

- Add getSftpIdForConnection to useSftpState for accessing SFTP session IDs
- Modify handleDownloadFileForSide in useSftpViewFileOps to use streaming
- Pass showSaveDialog, startStreamTransfer to SftpView hook chain
- For remote SFTP files: show save dialog then stream directly to disk
- For local files: fallback to memory-based download

This extends the stream download optimization to SftpView (dual-pane browser),
not just SFTPModal.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>

* fix: improve download task handling and cancellation

- Add per-task cancellation for downloads via onCancelTask prop
- Add i18n translation keys for download status messages
- Prevent duplicate error toasts with errorHandled flag
- Add bridge capability check (result === undefined)
- Make direction field required in TransferTask interface

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>

---------

Co-authored-by: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-31 02:03:18 +08:00
陈大猫
dae58ef64f Merge pull request #150 from binaricat/fix/reset-upload-trigger-on-new-entries
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
fix: reset upload trigger when new drop entries arrive
2026-01-30 22:26:29 +08:00
bincxz
945a09bdef fix: reset upload trigger when new drop entries arrive
Track the previous initialEntriesToUpload reference and reset the
upload trigger flag when a new array is provided. This fixes the issue
where subsequent drag-to-upload operations would be ignored if the
SFTP modal remained open after the first upload completed.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-30 22:05:43 +08:00
Copilot
4711fea969 Add drag-and-drop support for terminal file path insertion and SFTP upload (#149)
* Initial plan

* Add drag-and-drop functionality for terminal

- Local terminal: Insert absolute file paths when files are dropped
- Remote terminal: Open SFTP modal and trigger file upload
- Add visual feedback with drag overlay
- Support both files and folders
- Add translations for English and Chinese

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

* Remove unintended package-lock.json changes

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

* Improve folder path handling in drag-and-drop

- Extract folder path from nested files when folder is dropped
- Show folder path once instead of individual file paths
- Handle both Windows and Unix path separators

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

* fix: address code review feedback for drag-and-drop

- Replace fixed 500ms delay with proper connection state check
  (wait for files to load instead of arbitrary timeout)
- Add duplicate upload prevention with ref flag
- Simplify redundant ternary (dropEffect was always 'copy')
- Improve folder path extraction logic for better reliability

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>

* fix: open SFTP at current directory when dropping files

Get the current working directory from terminal session before opening
SFTP modal for file uploads, matching the behavior of the SFTP toolbar button.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>

* fix: preserve directory structure when uploading folders via drag-drop

- Pass DropEntry[] instead of File[] to preserve relativePath info
- Add handleUploadEntries function that uses uploadEntriesDirect
- This maintains folder structure when uploading directories to remote

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>

* fix: support drag-drop upload to empty remote directories

Use loading state transition detection instead of files.length check
to determine when SFTP connection is ready. This fixes the issue where
drag-drop uploads to empty directories would silently fail because
files.length was always 0.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>

* fix: preserve empty directories in terminal drop uploads

Pass full dropEntries array including directory markers to SFTP upload
instead of filtering to only file entries. This ensures empty folders
are created on the remote side via uploadEntriesDirect which uses
isDirectory entries to call ensureDirectory.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>

* fix: remove unused handleUploadMultiple import

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>

* refactor: improve drag-drop code readability

- Extract path extraction logic into extractRootPathsFromDropEntries function
- Add comment explaining flushSync usage for state synchronization
- Remove redundant dropEntries.length check (already checked earlier)

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-30 21:41:26 +08:00
陈大猫
f59ca56e23 Merge pull request #148 from binaricat/fix/keyword-highlight-scroll
fix: refresh keyword decorations after scroll stops
2026-01-30 17:48:27 +08:00
bincxz
3d1ab2de05 fix: refresh keyword decorations after scroll stops
The onScroll event may not fire for all scroll methods (e.g., mouse wheel).
Add onRender listener to detect viewport position changes and trigger
decoration refresh when scrolling stops.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-30 17:27:04 +08:00
陈大猫
adc3343d76 Merge pull request #147 from binaricat/fix/local-terminal-connection-dialog
fix: skip connection dialog for local terminal and show correct protocol label
2026-01-30 17:17:04 +08:00
bincxz
944d590162 fix: use telnetPort for telnet connections in dialog
Telnet connections store their port in host.telnetPort, not host.port.
Refactored getProtocolInfo to return the correct port for each protocol.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-30 17:16:13 +08:00
bincxz
15ae17f918 fix: detect Mosh via moshEnabled flag for protocol label
Mosh sessions use protocol: "ssh" with moshEnabled: true, so check
moshEnabled first before falling back to host.protocol.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-30 17:12:28 +08:00
bincxz
65c15d8931 fix: only use protocol to determine local connection
Remove hostname === "localhost" check to avoid incorrectly treating
SSH connections to localhost as local terminal connections.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-30 16:59:31 +08:00
bincxz
cbd1c84cdf fix: address code review feedback
- Reuse component-level isLocalConnection/isSerialConnection in useEffect
- Add i18n support for protocol labels (en/zh-CN)
- Use correct default port per protocol (SSH: 22, Telnet: 23, Mosh: 22)

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-30 16:50:34 +08:00
bincxz
0839e41b07 fix: skip connection dialog for local terminal and show correct protocol label
- Local and serial connections no longer show connection dialog during connecting phase
- Connection dialog now displays correct protocol label based on host.protocol
  (SSH, Telnet, Mosh, Local Shell, Serial) instead of hardcoded "SSH"
- Removed unnecessary timeout/progress UI for local terminal connections

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-30 16:36:58 +08:00
陈大猫
c27788280c Merge pull request #145 from RiceWays/feature/folder-upload
Feature/folder upload
2026-01-30 16:09:25 +08:00
bincxz
fd7f516b00 fix: P3 review issues - icon consistency and popover auto-close
- Use FolderUp icon for folder upload in context menu (matches toolbar)
- Auto-close encoding popover when an option is selected
- Add trailing newline to uploadService.ts

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-30 16:02:20 +08:00
bincxz
33780fecde refactor(sftp): compact encoding selector with icon button and popover
Replace wide dropdown encoding selector with a compact icon button (Languages)
that opens a popover menu. Also add tooltips to navigation buttons (up, home,
refresh).

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-30 15:55:12 +08:00
bincxz
89ea1c43c5 refactor(sftp): convert toolbar buttons to icon-only with tooltips
Replace text+icon buttons with icon-only buttons and tooltips for Upload,
Upload Folder, New Folder, and New File actions. Uses more distinctive
icons (FolderPlus, FilePlus) and adds a new Tooltip component.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-30 15:49:40 +08:00
bincxz
bd2936aab2 refactor: move compressed upload setting from Terminal to SFTP tab
The folder compression transfer setting is SFTP-specific functionality,
so it makes more sense to place it alongside other SFTP settings like
double-click behavior, auto-sync, and show hidden files.

- Move setting from SettingsTerminalTab to SettingsFileAssociationsTab
- Add new i18n keys under settings.sftp.compressedUpload namespace
- Remove unused settings.terminal.uploadDownload translations
- Update SettingsPage props accordingly

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-30 15:34:22 +08:00
bincxz
c48ac93500 chore: clean up P3 review issues
- Remove debug console.log statements from preload.cjs
- Remove redundant try-catch block in compressUploadBridge.cjs
- Remove unused stdout variable in extractRemoteArchive
- Add missing trailing newlines to files

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-30 15:17:54 +08:00
bincxz
34a94df831 refactor: clean up debug logs and fix review issues
- Remove verbose console.log statements from upload components
- Add maximum timeout cap (10 min) for extraction to prevent hangs
- Fix cleanup race condition by checking connection state
- Prefix unused speed parameter with underscore
- Fix duplicate return statement and unnecessary hook dependencies

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-30 15:08:04 +08:00
bincxz
e87ce831b4 refactor(compressedUpload): simplify to auto-detect with toggle setting
- Replace 3-option setting (ask/enabled/disabled) with boolean toggle
- Remove CompressedUploadDialog component - no more user prompts
- Auto-detect tar availability and silently fallback to regular upload
- Apply compressed upload setting to drag-and-drop uploads
- Fix upload progress updates for compressed uploads via callback
- Add i18n for upload phase labels (compressing/uploading/extracting)
- Fix empty folder handling - fallback to regular mkdir
- Preserve error message on failed upload tasks for UI display
- Fix fallback logic to only re-upload failed folders, not successful ones
- Handle cancellation during all phases (compression/transfer/extraction)
- Fix filename display to use explicit phase markers instead of substring match
- Fix Toggle onChange prop for settings to work correctly
- Use DropEntry.relativePath for correct drag-drop folder paths
- Dynamic extraction timeout based on archive size (60s base + 30s per 10MB)

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-30 14:35:47 +08:00
Rice
11c0c744f5 fix(compressUploadBridge): cancel associated transfer when compression is cancelled
- Add transfer cancellation logic to cancelCompression function
- Cancel the associated transfer using transferId pattern `compress-{compressionId}`
- Check for transferBridge availability before attempting cancellation
- Add error handling and logging for transfer cancellation failures
- Ensures cleanup of both compression process and related file transfer operations
2026-01-29 21:25:34 +08:00
陈大猫
9546f27ca1 Merge pull request #144 from Nightsuki/feat/managed-source-sync-improvements
feat(vault): improve managed source sync and host management
2026-01-29 21:17:38 +08:00
bincxz
8a465a9adf chore: remove unused currentHostLine variable
Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 21:12:14 +08:00
Rice
f3b28d2283 feat(uploadService): add standalone file upload after compressed folder uploads
- Extract standalone file entries separately from folder entries during compressed uploads
- Add logic to upload standalone files using regular upload after compressed folders complete
- Combine results from both compressed folder uploads and standalone file uploads
- Ensure all files are uploaded correctly when mixed with compressed folder uploads
- This allows proper handling of mixed file and folder uploads in a single operation
2026-01-29 20:57:10 +08:00
bincxz
8cfa62d945 fix(vault): remove managed sources atomically when clearing multiple
Add clearAndRemoveSources function that clears multiple SSH config files
in parallel but removes all sources in a single atomic update. This
prevents race conditions where concurrent removals could re-add sources
that were already deleted.

When deleting a group path with multiple managed sources, the batch
removal is now used instead of Promise.all with individual removals.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 20:52:02 +08:00
bincxz
b31ea0b9ca fix(vault): preserve Match blocks when merging SSH config
Match blocks were being dropped because they have no host patterns,
causing flush() to treat them as fully-managed blocks. Now explicitly
track Match blocks and always preserve them since we don't manage them.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 20:21:12 +08:00
bincxz
b2f6cabd75 fix(vault): thread managedSources through to HostDetailsPanel in all contexts
Pass managedSources prop through SelectHostPanel and all components that
use it (SnippetsManager, PortForwarding, KeychainManager) so hosts created
in these contexts can properly receive managedSourceId when placed in a
managed group.

This ensures hosts added from any panel will sync back to managed SSH
config files.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 20:13:14 +08:00
bincxz
92af5a5675 fix(vault): check all group sources when generating managed group name
When generating a unique managed group name, now check against:
- Existing managed sources
- Custom groups
- Existing host groups

This prevents accidentally reusing an existing group name which could
merge unrelated hosts into the managed group.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 19:41:20 +08:00
bincxz
d50e854cbe fix(vault): batch lastSyncedAt updates to prevent race conditions
When multiple managed sources sync concurrently via Promise.all, each
sync was independently updating the managedSources array, causing race
conditions where one source's lastSyncedAt would be overwritten.

Now syncManagedSource returns success status, and lastSyncedAt updates
are batched in a single update after all syncs complete.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 19:28:44 +08:00
bincxz
d92dbd6091 fix(vault): include managedSources in VaultView memo comparator
Add managedSources to vaultViewAreEqual so managed source changes
(import, unmanage, rename) trigger proper re-renders and update
managed badges/actions in the UI.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 18:25:46 +08:00
bincxz
3732bce989 fix(vault): only strip label spaces for SSH hosts in managed groups
Non-SSH hosts (telnet, etc.) in managed groups should keep their labels
unchanged since they cannot be synced to SSH config. The label space
sanitization now checks canBeManaged before modifying the label.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 18:10:40 +08:00
bincxz
ec0994288f fix(vault): preserve SSH config file contents when unmanaging group
The "Unmanage" action now only removes the source association without
modifying the SSH config file. This prevents data loss when users want
to stop syncing but keep their host entries in the file.

Use onUnmanageSource instead of onClearAndRemoveManagedSource for the
unmanage flow. The clearAndRemove variant is still available for cases
where file cleanup is explicitly desired (e.g., deleting a managed group).

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 17:57:04 +08:00
bincxz
b14c5d6147 fix(vault): preserve SSH config comments and validate managed file path
- Preserve top-level comments and blank lines when merging SSH config
  by tracking preamble content before the first Host/Match block
- Validate file path availability before enabling managed sync; show
  error if getPathForFile and file.path are both unavailable instead
  of falling back to bare filename which would sync to wrong location

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 17:07:34 +08:00
bincxz
c55f5dbdb8 fix(vault): preserve non-managed patterns in multi-host SSH blocks
When a Host line contains multiple patterns (e.g., "Host prod1 prod2")
and only some are managed, now only the managed patterns are removed
while non-managed patterns are preserved with the block's directives.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 16:50:44 +08:00
bincxz
a7f3008904 fix(vault): preserve non-managed SSH config and bracket IPv6 in ProxyJump
- Keep preserved SSH config content outside managed block markers to
  prevent data loss when first bringing existing config under management
- Always bracket IPv6 addresses in ProxyJump values regardless of port,
  as OpenSSH requires brackets to disambiguate colon separators

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 16:40:49 +08:00
bincxz
6833000038 fix(vault): remove duplicate Host blocks when managing existing SSH config
When creating a managed source from an existing SSH config file without
Netcatty markers, the code was appending the managed block without removing
original Host entries. This left duplicate Host blocks, and since OpenSSH
uses the first matching block, edits via Netcatty wouldn't take effect.

Now uses mergeWithExistingSshConfig to filter out existing Host blocks
that match managed hostnames before wrapping with markers.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 16:30:32 +08:00
bincxz
485f28160d fix(vault): clear managed SSH config block when unmanaging group
Use onClearAndRemoveManagedSource to write an empty managed block
before removing the source, preventing stale entries in the SSH
config file after unmanaging a group.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 15:49:29 +08:00
bincxz
9df9f9fdfb fix(vault): wrap IPv6 addresses in brackets for ProxyJump with port
IPv6 addresses like 2001:db8::1 need brackets when appending a port,
otherwise SSH cannot parse the address correctly (colons are ambiguous).
Now outputs [2001:db8::1]:2222 instead of 2001:db8::1:2222.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 15:30:58 +08:00
陈大猫
b2720d1fd5 Merge pull request #146 from RiceWays/feature/Start-command-package
feat(HostDetailsPanel): replace Input with Textarea for startup command
2026-01-29 15:28:51 +08:00
bincxz
71419b65cd fix(vault): unique managed group names and correct space stripping
- Generate unique managed group names by adding suffix when conflicts exist
- Only strip spaces from label based on target group, not form.managedSourceId
- Check protocol when determining if label spaces should be stripped

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 15:19:42 +08:00
bincxz
ba935099c4 fix(vault): handle duplicate hosts and initial sync on managed import
- Filter duplicates on managed import and convert existing hosts to managed
- Trigger initial sync when prevHosts is empty but managed sources exist
- Update previousHostsRef even when no managed sources to maintain baseline

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 14:40:47 +08:00
Rice
1a45d39c98 feat(HostDetailsPanel): replace Input with Textarea for startup command
- Replace Input component with Textarea for startup command field
- Update className to use min-h-[80px] with font-mono and text-sm styling
- Add rows={3} prop to Textarea for better multi-line command support
- Import Textarea component from ui/textarea module
- Improve UX for entering longer or multi-line startup commands
2026-01-29 14:17:43 +08:00
bincxz
3f06cb638a fix(vault): detect changes to external jump hosts in managed sync
When a managed host references a jump host outside its managed source,
changes to that external jump host now trigger a sync. This ensures
ProxyJump entries stay up-to-date when jump hosts are edited.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 14:01:09 +08:00
bincxz
a225f0e207 fix(vault): sanitize ProxyJump aliases and fall back to hostname
- Sanitize Host aliases and ProxyJump references by removing spaces
- Only use label as ProxyJump alias if jump host is in managed hosts
- Fall back to hostname for jump hosts outside managed config

This ensures ProxyJump directives reference valid, resolvable hosts.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 13:54:56 +08:00
bincxz
3438f4bc88 fix(vault): include hostChain in managed source change detection
ProxyJump changes now trigger sync since hostChain.hostIds is compared
alongside other host fields like hostname, port, username, etc.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 13:45:10 +08:00
bincxz
9343cfda84 fix(vault): only allow SSH hosts to be managed in SSH config sync
Non-SSH protocol hosts (telnet, serial, local) are now correctly
excluded from managed source assignment since SSH config files only
support the SSH protocol.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 13:41:06 +08:00
Rice
89b5b2f6b1 fix(compressUploadBridge): escape shell arguments to prevent injection attacks
- Add escapeShellArg() utility function to safely wrap arguments in single quotes
- Escape targetDir and archivePath in extractRemoteArchive() command construction
- Escape targetPath in cleanup command for ._* files removal
- Escape remoteArchivePath in archive cleanup command
- Replace double-quoted arguments with properly escaped single-quoted arguments throughout shell commands
- Prevents potential shell injection vulnerabilities when paths contain special characters or malicious input
2026-01-29 13:40:04 +08:00
bincxz
d080c805c2 feat(vault): serialize ProxyJump directive in managed SSH config sync
When syncing managed hosts to SSH config files, properly serialize the
hostChain to ProxyJump directive instead of just adding a comment.

- Add serializeJumpHost() to format jump host as [user@]host[:port]
- Add buildProxyJumpValue() to convert hostChain.hostIds to ProxyJump
- Pass allHosts to serializer for looking up jump host details
- Supports multiple jump hosts (comma-separated)

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 13:30:22 +08:00
bincxz
4b41b2c20f fix(vault): sync managed file before removing source on group delete
When deleting a managed group, clear the managed block in the SSH config
file before removing the source. This prevents stale host entries from
remaining in the file after the group is deleted.

- Add clearAndRemoveSource function to useManagedSourceSync
- Pass callback to VaultView and call it before removing managed sources
- Ensures SSH config files stay in sync when managed groups are deleted

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 13:21:06 +08:00
Rice
62c4aa3ea6 fix(uploadService): improve folder path extraction and cross-platform compatibility
- Declare tempArchivePath in outer scope for proper cleanup access in error handlers
- Add path separator normalization to handle both forward and backslash separators
- Improve folder path extraction logic to correctly identify the target folder path
- Add fallback pattern matching for both Unix and Windows path separators
- Preserve original path separator style when reconstructing folder paths
- Enhance robustness of path parsing for cross-platform file uploads
2026-01-29 13:13:03 +08:00
Rice
5d164b4150 feat(uploadService): add compression cancellation tracking to UploadController
- Add activeCompressionIds Set to track ongoing compression operations
- Implement addActiveCompression() and removeActiveCompression() methods for lifecycle management
- Update cancel() method to iterate through active compression IDs and call cancelCompressedUpload()
- Enhance getActiveTransferIds() to include compression IDs alongside file transfer IDs
- Clear activeCompressionIds in reset() method to ensure clean state
- Register compression ID with controller before starting folder compression
- Add cancellation checks before and during compression progress updates
- Remove compression ID from tracking on completion or error
- Distinguish between cancellation and error states in result handling
- Improve logging to separately track file transfer IDs and compression IDs
- Enables proper cancellation of compressed uploads when user cancels the operation
2026-01-29 13:06:49 +08:00
Rice
ac62d571ef fix(uploadService): improve error handling and regex escaping in compressed uploads
- Fix regex pattern escaping in trailing slash removal (use forward slash instead of escaped forward slash)
- Declare taskId outside try block to ensure it's accessible in catch block for proper error handling
- Update onTaskFailed callback to pass taskId instead of folderName for consistency with task tracking
- Add guard condition to only call onTaskFailed when taskId exists (task was successfully created)
- Prevents undefined taskId from being passed to error callbacks and improves error state management
2026-01-29 12:51:19 +08:00
Rice
e8d060c62f fix(uploadService): improve folder path extraction logic for compressed uploads
- Refactor folder path calculation to handle nested directory structures correctly
- Remove the filename-based extraction approach in favor of relativePath-based logic
- Add fallback mechanisms to handle edge cases where relativePath doesn't match localFilePath
- Implement folder name-based path detection as secondary fallback strategy
- Preserve original logic as last resort for single file scenarios
- Fix issue where deeply nested folders were not correctly identified during compression
2026-01-29 12:31:59 +08:00
bincxz
653164bee8 fix(vault): choose most specific managed source for nested groups
When multiple managed sources match a group path (nested managed groups),
select the one with the longest groupName (deepest/most specific match)
instead of the first match. This ensures hosts are assigned to the
correct managed source and sync to the right SSH config file.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 12:27:48 +08:00
Rice
2a67667c0b feat(uploadService): add fallback handling for compressed upload failures
- Add try-catch wrapper around uploadFoldersCompressed to handle compression failures gracefully
- Implement fallback to regular upload when compressed upload is not supported
- Check for failed folders in compressed results and trigger full fallback if needed
- Return error indicator from uploadFoldersCompressed instead of attempting inline fallback
- Improve error logging to distinguish between compression support issues and other failures
- Ensure all entries are uploaded via regular upload path when compression is unavailable
- This prevents upload failures when the server doesn't support compressed folder uploads
2026-01-29 12:27:05 +08:00
Rice
e07e5cf442 feat(sftp): add compressed folder upload with settings
- Add CompressedUploadDialog component to let users choose between compressed and regular transfer methods
- Implement compressUploadService for handling folder compression and extraction on the server
- Add compressUploadBridge to expose compression functionality to the renderer process
- Add sftpUseCompressedUpload setting with three modes: ask, enabled, disabled
- Add new upload progress states: compressing, extracting, scanning, completed
- Add i18n translations for upload dialog and settings in English and Chinese
- Update SFTP modal to support compressed upload workflow with progress tracking
- Add storage key for persisting compressed upload preference across sessions
- Significantly reduces transfer time for folders by using tar compression when available on server
2026-01-29 12:18:02 +08:00
bincxz
92a9eed6bf fix(vault): use ref for managedSources to avoid stale closure in sync
Use a ref to access the latest managedSources when updating lastSyncedAt
after sync completes. This prevents overwriting concurrent changes made
while a sync was in flight (e.g., user unmanaging a source or importing
a new managed file).

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 12:16:18 +08:00
bincxz
65afa21711 fix(vault): remove managed sources under deleted parent group
When deleting a parent group, also remove all managed sources whose
groupName is under that path (not just exact matches). This prevents
stale managed entries from remaining after parent group deletion.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 11:57:49 +08:00
bincxz
f413ccfba1 fix(vault): preserve managedSourceId when managedSources prop is not provided
When HostDetailsPanel is used in contexts that don't pass managedSources
(e.g., SelectHostPanel), preserve the existing managedSourceId instead of
clearing it. This ensures hosts created/edited in managed groups retain
their sync relationship.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 11:37:13 +08:00
bincxz
58b6879c71 fix(vault): remove leaked config and preserve managedSourceId for subgroups
- Remove config file containing real SSH hosts (security)
- When deleting a subgroup under a managed group, keep managedSourceId
  so hosts remain managed and continue syncing to SSH config

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 11:21:07 +08:00
bincxz
7fe7193344 fix(vault): preserve SSH config content and sync group renames
- Use marker blocks to preserve existing SSH config directives (Match, Include, Host *, etc.)
- Only replace content between BEGIN/END NETCATTY MANAGED markers
- Update managedSources.groupName when groups are renamed or moved
- Prevents data loss for users with custom SSH config entries

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 11:10:36 +08:00
bincxz
7a19b73f54 fix(vault): address Codex review feedback for managed source sync
- Add protocol to change detection to trigger sync when protocol changes
- Sanitize labels (remove spaces) when moving hosts to managed groups via drag/drop
- Prevent duplicate managed imports by checking if file is already managed
- Add i18n keys for already managed file warning

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 10:44:34 +08:00
bincxz
5160230426 fix(vault): handle pending syncs and strip spaces from managed host labels
- Add pending sync tracking to process host changes that occur during sync
- Strip spaces from labels when host is/will be in a managed source group
- Remove unused FileSymlink import

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-29 10:21:33 +08:00
Rice
42b1a808a1 feat(sftp): add folder upload functionality
- Add i18n translations for "Upload folder" in English and Chinese locales
- Add folderInputRef to track folder input element in SFTPModal component
- Implement handleFolderSelect handler for processing folder uploads
- Add "Upload folder" button to SftpModalHeader with FolderUp icon
- Add hidden file input with webkitdirectory attribute for folder selection
- Update SftpModalFileList context menu to include folder upload option
- Pass folderInputRef and folder handlers through component hierarchy
- Enable users to upload entire folder structures via SFTP modal
2026-01-29 09:40:03 +08:00
Nightsuki
9dd3db4c14 feat(vault): improve managed source sync and host management
- Fix managed host sync: add group field to change detection
- Auto-set managedSourceId when moving host to managed group
- Add 'managed' badge to managed hosts in VaultView
- Fix file path resolution for managed sources using webUtils
- Add managedSources prop to HostDetailsPanel for proper sync
- Restrict spaces in label for managed hosts
- Reorder HostDetailsPanel sections: General > Address > Port & Credentials
- Add debug logging for managed source sync troubleshooting
2026-01-29 00:07:14 +08:00
陈大猫
e74f65729c Merge pull request #143 from binaricat/feat/snippet-package-rename
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
feat(snippets): add rename functionality for packages
2026-01-28 20:23:29 +08:00
bincxz
97f53ed87f fix(snippets): sync editingSnippet state when renaming packages
Update editingSnippet.package when the package being edited is renamed
or is nested under a renamed package. This prevents the stale package
path from being persisted when the user saves their edits after a rename.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-28 20:12:00 +08:00
bincxz
ec4512eb06 feat(snippets): add rename functionality for packages
Add context menu option to rename snippet packages with a modal dialog.
Includes validation for empty names, duplicate names (case-insensitive),
and invalid characters (only letters, numbers, hyphens, underscores allowed).

When a package is renamed, all nested packages and snippets are updated
to reflect the new path.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-28 19:52:54 +08:00
陈大猫
93c1f1b427 Merge pull request #142 from RiceWays/fix/code-package-grouping
fix: Fix multiple bugs in code package creation and display
2026-01-28 19:26:50 +08:00
bincxz
58ccd4bfb9 fix(snippets): normalize trailing slashes in package paths
Strip trailing slashes before saving package paths to ensure consistent
path handling across the UI. This prevents issues where 'foo/' would not
match 'foo' in the package browser.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-28 19:16:02 +08:00
bincxz
2fb82e1cb7 Update SnippetsManager component 2026-01-28 18:32:22 +08:00
bincxz
159589a09f fix: persist implicit parent paths when selected in package dropdown
Address Codex review: when selecting a parent path from the package
dropdown that was generated from existing child packages (e.g., /foo
derived from /foo/bar), the path is now added to the packages array.
This prevents orphaned snippets when the child package is deleted.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-28 18:18:59 +08:00
Rice
04e1ed569d fix(snippets): preserve absolute path prefix in breadcrumb navigation
- Detect if selected package path is absolute (starts with '/')
- Reconstruct breadcrumb paths with leading slash when applicable
- Prevent loss of absolute path context when navigating package hierarchy
- Ensures consistent path handling between package selection and breadcrumb display
2026-01-28 17:32:54 +08:00
Rice
38fb5e8dd4 fix(snippets): normalize snippet path construction to prevent double slashes
- Strip leading slash from snippet names when creating paths inside packages
- Preserve leading slash for snippets created at root level
- Prevent double slashes in constructed package paths (e.g., "package//snippet")
- Improve path handling consistency between root and nested snippet creation
2026-01-28 17:22:50 +08:00
Rice
6f2b27206a fix(snippets): improve package name validation regex pattern
- Update validation regex to allow hyphens anywhere in package names
- Simplify regex pattern from `^\/?\w+([\w/-]*\w+)*\/?$` to `^\/?([\w-]+(\/[\w-]+)*)\/?$`
- Update HTML input pattern attribute to match validation logic
- Improve comment clarity to reflect hyphen handling in package names
- Ensures consistent validation between JavaScript regex and HTML5 pattern attribute
2026-01-28 17:11:01 +08:00
Rice
f6eb693fac refactor(snippets): improve package path handling and filtering logic
- Separate absolute paths (starting with /) from relative paths for clearer processing
- Process relative and absolute paths independently with distinct handling logic
- Add type annotations to filter callbacks for better type safety
- Simplify path matching logic by removing redundant checks for both slash variants
- Display absolute paths with "/" prefix to distinguish them from relative paths
- Improve code readability by extracting path processing into separate sections
- Maintain backward compatibility while fixing edge cases in package hierarchy
2026-01-28 16:58:42 +08:00
Rice
32935e4e87 fix: Fix multiple bugs in code package creation and display
## Overview
This PR addresses multiple critical bugs in code package creation and display functionality, and includes validation enhancements and performance optimizations to improve overall stability and user experience.

## Fixed Bugs
- Fixed issue where package paths starting with a slash (e.g., /name/xx/xx) failed to display
- Fixed package count showing only direct code snippets instead of including nested package content
- Fixed path conflict bug in movePackage() caused by improper string replacement
- Fixed dropdown selector displaying only full paths (missing parent path options)
- Added package name validation to block invalid characters and duplicate package names
- Optimized package deletion performance by only saving actually modified code snippets
- Added support for creating packages in /100/200/300 format, with dropdown selector showing all hierarchical paths

## Key Improvements
* displayedPackages: Correctly handle slash-leading paths and accurately calculate nested package counts
* createPackage: Added regex validation and duplicate check, support paths starting with a slash
* movePackage: Replaced replace() with substring() to avoid substring-based path conflicts
* packageOptions: Automatically generate all parent path options, sorted by depth and alphabetical order
* deletePackage: Improved performance by only persisting actually modified code snippets (instead of full dataset)
2026-01-28 16:49:45 +08:00
陈大猫
f55c21fc0e Merge pull request #140 from RiceWays/feature/vault-tree-view-mode
feat: Add tree view mode for host list with sorting and persistence
2026-01-28 16:07:06 +08:00
bincxz
26d03ace3f fix: improve tree view UX and address code review feedback
- Display folders above ungrouped hosts in tree view
- Change host connection from double-click to single-click
- Sanitize host before connecting to handle whitespace in hostname
- Guard optional host.tags to prevent crash on legacy data
- Show telnet-specific credentials (telnetUsername/telnetPort) for telnet hosts
- Remove unused groupTree variable and prefix unused moveHostToGroup param

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-28 15:40:45 +08:00
Rice
d85709d42d fix: apply search and tag filters to grouped hosts in tree view
- Use filtered treeViewHosts instead of all hosts when building tree view group tree
- Ensure grouped hosts respect search queries and tag filters
- Reorder useMemo dependencies to fix circular dependency issue
- Now tree view filtering behavior is consistent with grid and list views

Fixes issue where grouped hosts would still appear even when they didn't
match active search or tag filters, breaking the expected filtering UX.
2026-01-28 12:31:43 +08:00
Rice
5470e19ae0 chore: remove obsolete TODO comment 2026-01-28 11:16:31 +08:00
Rice
cd2c18b77c feat: add tree view mode for host list with sorting and persistence
- Add tree view mode alongside existing grid and list views
- Implement hierarchical display of hosts organized by groups
- Add expand/collapse all controls with Chinese translations
- Support all sorting modes (A-Z, Z-A, newest, oldest) in tree view
- Persist expand/collapse state across view switches and app restarts
- Hide Groups section in tree view to avoid duplication
- Display ungrouped hosts at root level instead of "General" group
- Add missing delete group dialog with proper translations
- Maintain full functionality: search, filtering, drag-drop, context menus

Technical changes:
- Create HostTreeView component with TreeNode recursive structure
- Add useTreeExpandedState hook for persistent state management
- Extend ViewMode type to include "tree" option
- Add sortMode prop to enable dynamic sorting in tree structure
- Separate group tree logic for tree view vs other view modes
- Add comprehensive English and Chinese translations
2026-01-28 11:13:17 +08:00
陈大猫
7355e29b89 Merge pull request #137 from Nightsuki/fix/ssh-jump-host-default-key-auth
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
fix: add default SSH key fallback for jump host connections
2026-01-27 17:07:50 +08:00
bincxz
64686cc237 fix: pass unlocked encrypted keys to jump host auth handler
When auth failure triggers the passphrase flow and user unlocks
encrypted default keys, the retry connection now correctly passes
these unlocked keys to connectThroughChain/connectThroughChainForSftp.

Previously, options._unlockedEncryptedKeys was only used for the
final target host, so jump hosts requiring encrypted default keys
would still fail even after successful passphrase entry.

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-27 16:54:54 +08:00
bincxz
d65440ace7 feat: add passphrase modal for encrypted SSH key authentication
- Add PassphraseModal component for interactive passphrase input
- Add passphraseHandler bridge to manage passphrase requests/responses
- Add sshAuthHelper for centralized SSH key decryption with passphrase support
- Update sshBridge, sftpBridge, and portForwardingBridge to use new auth helper
- Add passphrase-related IPC channels in preload and type definitions
- Add i18n translations for passphrase modal UI (en/zh-CN)

Co-Authored-By: Claude (gemini-claude-opus-4-5-thinking) <noreply@anthropic.com>
2026-01-27 16:35:03 +08:00
Nightsuki
2dbeddd9aa fix: add default SSH key fallback for jump host connections
Previously, jump host connections (connectThroughChain) did not try
default SSH keys from ~/.ssh/ when no explicit auth was configured.
This caused authentication failures when using jump hosts without
manually specifying SSH keys.

Changes:
- Add ssh-agent support for jump host connections
- Try all default SSH keys (id_ed25519, id_ecdsa, id_rsa) for jump hosts
- Use dynamic authHandler to try each key in sequence
- Match the same fallback behavior as direct connections
2026-01-27 11:55:18 +08:00
陈大猫
4758345448 Merge pull request #136 from Nightsuki/fix/ssh-default-key-fallback-all-keys
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
fix: try all default SSH keys for fallback authentication
2026-01-26 23:33:38 +08:00
Nightsuki
4d3fa93083 fix: try all default SSH keys for fallback authentication
Previously, when no explicit auth method was configured, the code would
only try the first available key (id_ed25519) even if the server only
accepted a different key (id_rsa). This caused authentication failures
when users had multiple SSH keys but only some were authorized.

Changes:
- Add findAllDefaultPrivateKeys() to discover all available keys
- Try ssh-agent first (matching regular SSH behavior)
- Try ALL default keys (id_ed25519, id_ecdsa, id_rsa) in order
- Add debug logging for ssh2 auth flow diagnostics
- Improve auth method ordering: agent -> keys -> password -> keyboard
2026-01-26 23:11:26 +08:00
陈大猫
2746aae274 Merge pull request #135 from binaricat/fix/sftp-local-files-freeze
fix: use async exec for Windows hidden file check to prevent UI freeze
2026-01-26 19:39:22 +08:00
bincxz
a7b22b3580 fix: use async exec for Windows hidden file check to prevent UI freeze
The isWindowsHiddenFile function was using execSync which blocks the
main process. When listing directories with many files on Windows,
this caused the app to freeze and show "No response" until all attrib
commands completed.

Changed to async exec with promisify to allow non-blocking execution.

Fixes #134

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 19:36:44 +08:00
陈大猫
a66fcdba02 Merge pull request #133 from binaricat/fix/mfa-partial-success-auth
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
fix: handle partialSuccess in SSH multi-factor authentication
2026-01-26 16:10:08 +08:00
bincxz
73c95fa08e fix: handle partialSuccess in SSH multi-factor authentication
When servers require multi-step authentication (e.g., password + MFA, or
publickey + keyboard-interactive), the previous implementation did not
properly handle the partialSuccess flag from ssh2's authHandler callback.

This caused MFA-only servers to fail connection because keyboard-interactive
was not triggered after the initial auth method succeeded with partialSuccess.

Changes:
- Add partialSuccess handling to try server-requested auth methods
- Track attempted methods to avoid re-trying failed or already-used methods
- Cache the first successful method (not the last) for multi-step flows
  to ensure correct auth order on subsequent connections

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 16:08:55 +08:00
陈大猫
3337cd620e Merge pull request #132 from binaricat/fix/ssh-key-fallback-auth
fix: improve SSH authentication fallback to system keys
2026-01-26 15:37:04 +08:00
bincxz
97bd105564 fix: reorder auth methods - password before agent
Agent may be auto-configured via SSH_AUTH_SOCK rather than explicit
user choice. On servers with PubkeyAuthentication disabled or low
MaxAuthTries, the agent attempt could exhaust auth tries before the
valid password is attempted.

New order: user key -> password -> agent -> default key -> keyboard-interactive

This follows ssh2's default order (None -> Password -> Private Key -> Agent)
more closely and prioritizes explicit user configuration.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 15:29:53 +08:00
bincxz
554c43dfa8 fix: avoid logging agent object which may contain private keys
When connectOpts.agent is a NetcattyAgent (for certificate auth),
it contains _meta with privateKey/passphrase. Logging the full object
would leak credentials to log files. Now only logs a safe identifier.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 15:19:36 +08:00
bincxz
c678f36504 fix: set privateKey when adding publickey fallback in agent mode
ssh2's simple auth handler (array mode) only enables publickey auth
when connectOpts.privateKey is set. Without setting the key, the
"publickey" entry in auth order would be silently skipped.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 15:11:40 +08:00
bincxz
f40a3f075b fix: include agent auth method in dynamic authHandler fallback
The dynamic authHandler for fallback authentication was missing the
"agent" type, which broke agentForwarding functionality. This fix:
- Adds "agent" to the default availableMethods list
- Updates methodName mapping to treat "agent" as "publickey" (since
  agent-based auth uses publickey verification under the hood)
- Adds handler case for agent type that returns "agent" string
- Checks both methodName and method.type for availability

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 15:02:42 +08:00
bincxz
bb40ab464e fix: return string for keyboard-interactive in authHandler
ssh2 requires a prompt function when returning an object for
keyboard-interactive auth. Without it, the method is skipped.

Return the string "keyboard-interactive" instead, which lets ssh2
use its default handling and properly trigger the keyboard-interactive
event for 2FA/MFA prompts.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 14:45:21 +08:00
bincxz
4977add389 fix: avoid retrying same default key twice
When no explicit auth method is configured, the default key was being
promoted to connectOpts.privateKey and then added again as publickey-default.
This caused the same key to be attempted twice, wasting auth slots.

Now track when default key is used as primary to skip redundant fallback.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 14:39:21 +08:00
bincxz
2d14655af4 fix: improve SSH authentication fallback to system keys
- Always search for default SSH keys (~/.ssh/id_ed25519, id_ecdsa, id_rsa)
  as fallback authentication method
- Add dynamic authHandler that tries multiple auth methods in sequence:
  user key -> password -> default system key -> keyboard-interactive
- Cache successful auth methods per host to speed up subsequent connections
- Clear auth cache on failure to retry all methods
- Fix password validation to only use non-empty strings
- Add detailed logging for auth flow debugging

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 14:30:55 +08:00
陈大猫
025df8788b Merge pull request #131 from binaricat/fix/context-menu-shortcuts-from-settings
fix: display actual user-configured shortcuts in terminal context menu
2026-01-26 14:08:51 +08:00
bincxz
9e6d110766 fix: display actual user-configured shortcuts in terminal context menu
Previously, the keyboard shortcuts shown in the right-click context menu
were hardcoded and did not reflect user's custom keybindings from settings.

Changes:
- Pass keyBindings prop from Terminal to TerminalContextMenu
- Dynamically look up shortcuts from user's configured keybindings
- Format shortcuts with spaces between keys for better readability
- Handle 'Disabled' shortcuts by hiding the shortcut hint

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 14:01:48 +08:00
陈大猫
347d0a445b Merge pull request #130 from binaricat/feat/copy-tab-context-menu
feat: add Copy Tab option to SSH session context menu
2026-01-26 13:48:28 +08:00
bincxz
e8be0d72de feat: add Copy Tab option to SSH session context menu
Add the ability to duplicate an SSH session by right-clicking on a tab
and selecting "Copy Tab". This creates a new session with the same
connection parameters (host, port, protocol, etc.).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 13:41:11 +08:00
陈大猫
ce34f1bba8 Merge pull request #129 from binaricat/fix/sftp-large-file-upload-and-cancel
fix: use stream-based transfers to prevent OOM and support cancellation
2026-01-26 13:32:24 +08:00
bincxz
9f4272f83c fix: use getPathForFile directly for nested folder files
The previous approach tried to reconstruct paths for nested files using
filePathMap keyed by f.name (base file names), but for folder drops
rootName is the folder name which doesn't exist in the map.

Now we call getPathForFile directly on each result.file, which should
work for all files in Electron. The filePathMap reconstruction is kept
as a fallback.

This ensures large files inside dropped folders use stream transfers
instead of falling back to arrayBuffer() which causes OOM.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 13:17:46 +08:00
bincxz
c158d52dd5 fix: handle close event for local writeStream cancellation
When fs.createWriteStream is destroyed, it emits 'close' but not 'finish'.
Added close event handlers for downloadWithStreams and local-to-local
copy to properly resolve the promise when cancelled.

The 'finished' flag in cleanup() ensures we don't call resolve/reject twice
when both finish and close fire during normal completion.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 13:08:50 +08:00
bincxz
ec8dba360c fix: ensure stream cancellation settles the promise
When streams are destroyed during cancellation, the close/finish event
handler was not calling cleanup if transfer.cancelled was true. This left
the promise pending forever, causing the UI to stay stuck in "uploading".

Now we call cleanup(new Error('Transfer cancelled')) when the stream
closes/finishes and the transfer was cancelled.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 13:00:33 +08:00
bincxz
8b5cc5c302 fix: use stream-based transfers to prevent OOM and support cancellation
- Replace memory-based file uploads with stream transfers for large files
- Add uploadWithStreams and downloadWithStreams functions in transferBridge
- Fix cancel transfer by properly destroying streams instead of throwing
  errors in callbacks (which corrupted SSH connection)
- Fix upload button not triggering upload by copying FileList before
  clearing input (clearing input also clears FileList reference)
- Export getPathForFile utility for obtaining local file paths
- Add startStreamTransfer and cancelTransfer bridge methods

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-26 12:24:58 +08:00
陈大猫
bae0c078f5 Merge pull request #126 from binaricat/fix/terminal-blackscreen-on-rightclick-setting-change
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
fix: prevent terminal blackscreen when changing right-click behavior
2026-01-23 11:04:58 +08:00
bincxz
e0cda4dc5a fix: prevent terminal blackscreen when changing right-click behavior
The TerminalContextMenu component previously returned different JSX
structures based on rightClickBehavior setting:
- context-menu mode: <ContextMenu> wrapper
- other modes: <div> wrapper

This caused React to unmount and remount the entire Terminal subtree
when the setting changed, destroying the xterm instance and causing
a black screen.

Fix: Always use <ContextMenu> as the wrapper to maintain consistent
React tree structure. Control behavior via:
- disabled prop on ContextMenuTrigger
- onContextMenu handler for non-menu modes
- Conditional rendering of ContextMenuContent

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 10:57:27 +08:00
陈大猫
4c0fc897a0 Merge pull request #125 from binaricat/fix/sftp-date-format-and-vault-general-group
fix: improve SFTP date format and hide General group in vault
2026-01-23 10:41:50 +08:00
bincxz
9ba150de82 fix: preserve user-created General group in vault
Address Codex review: only hide "General" group at root level when it's
auto-generated (not in customGroups and has no subgroups). This ensures
user-created "General" groups and their subtrees remain accessible.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 10:33:48 +08:00
bincxz
a78647a2e8 fix: improve SFTP date format and hide General group in vault
- Change SFTP date format from ISO 8601 to readable YYYY-MM-DD HH:mm
- Fix lastModifiedFormatted to use formatDate instead of raw ISO string
- Hide "General" group at root level in vault (hosts already shown below)
- Fix General group filter to match hosts with empty/undefined group
- Exclude .github/** from ESLint (CI scripts don't need local linting)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 10:18:38 +08:00
陈大猫
4b7812d27f Merge pull request #124 from binaricat/codex/format-date-in-yyyy-mm-dd-hh-mm-ss 2026-01-23 04:25:18 +08:00
陈大猫
62b3cf658e Format SFTP timestamps consistently 2026-01-23 04:13:06 +08:00
陈大猫
74401a2084 Merge pull request #123 from binaricat/codex/fix-github-action-error-in-script 2026-01-23 03:14:24 +08:00
陈大猫
44d25c10e1 Fix release note script for ESM 2026-01-23 03:11:56 +08:00
bincxz
d67c458730 Merge branch 'feat/host-export-and-improvements' 2026-01-23 02:41:45 +08:00
bincxz
44e8167300 refactor: improve toast notifications and credentials copy format
- Use toast.success/warning instead of toast({title:}) for better UX
- Change credentials copy format to labeled multi-line format
- Add ESLint global declarations for Node.js globals

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 02:40:32 +08:00
陈大猫
02b16dee9b Merge pull request #122 from binaricat/feat/host-export-and-improvements
feat: add host export, password visibility, copy credentials and shortcut fixes
2026-01-23 02:31:54 +08:00
bincxz
adaa8ee524 fix: export telnet username correctly in CSV export
For telnet hosts, use telnetUsername instead of username since the UI
stores telnet credentials separately from SSH credentials.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 02:16:58 +08:00
bincxz
f429eb8f28 fix: export mosh-enabled hosts as SSH instead of skipping them
Exporting partial data (SSH without mosh flag) is better than completely
losing the host entry. Only serial hosts are now skipped since they truly
cannot be represented in CSV format.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 02:08:10 +08:00
bincxz
eaae884cd7 fix: also skip moshEnabled hosts in CSV export
Mosh hosts are typically represented as protocol=ssh with moshEnabled=true,
not protocol=mosh. The export filter now also skips hosts with moshEnabled
to prevent data loss when the mosh setting would be silently dropped on import.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 01:57:57 +08:00
bincxz
363f0ea87f fix: only use telnet credentials when protocol is explicitly telnet
When copying credentials, only treat host as telnet when protocol is
explicitly set to "telnet". Having telnetEnabled=true just means telnet
is available as an alternative protocol, not the primary one.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 01:49:00 +08:00
bincxz
b5533a73b6 fix: bracket IPv6 addresses when copying credentials with non-default port
When copying credentials for hosts with IPv6 addresses and non-default ports,
the address is now properly formatted as [2001:db8::1]:2222 instead of the
ambiguous 2001:db8::1:2222.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 01:39:45 +08:00
bincxz
6353f2c58a fix: skip mosh hosts in CSV export
Address Codex review: mosh hosts are now filtered out during CSV export
alongside serial hosts, as the CSV import's normalizeProtocol only
recognizes ssh/telnet/local. Exporting mosh hosts would silently convert
them to SSH on re-import.

Updated toast message to say "unsupported hosts" instead of "serial hosts"
since both serial and mosh are now skipped.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 01:32:51 +08:00
bincxz
7e14f73769 fix: bracket IPv6 hostnames in CSV export for round-trip compatibility
Address Codex review: IPv6 addresses are now wrapped in brackets when
exported to CSV (e.g., [2001:db8::1]). This ensures they can be correctly
parsed on re-import, as the CSV import parser treats unbracketed colons
as port separators.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 01:22:53 +08:00
bincxz
60c2687144 fix: use telnet-specific credentials when copying
Address Codex review: for telnet protocol hosts, now uses telnetUsername,
telnetPassword, and telnetPort instead of the SSH credentials. This ensures
the copied credentials match what the telnet connection actually uses.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 01:15:07 +08:00
bincxz
7a1597bdc1 fix: export telnet hosts with correct telnetPort
Address Codex review: for telnet protocol hosts, now exports telnetPort
instead of port (which is the SSH port). This ensures that re-importing
the CSV will preserve the correct telnet connection port.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 00:58:20 +08:00
bincxz
d5ae6e5cba fix: reset password visibility when switching hosts
Address Codex review: the showPassword state is now reset to false
when initialData changes, ensuring each host's password defaults
to masked mode. This prevents a privacy issue where switching hosts
would keep the password visible if it was previously shown.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 00:46:02 +08:00
bincxz
a46080a378 fix: exclude serial hosts from CSV export
Address Codex review: serial hosts (protocol === "serial") are now
filtered out during CSV export since the CSV import format doesn't
support serial port configuration. Importing serial hosts would result
in invalid SSH entries.

When serial hosts are skipped, users are notified via a toast message
showing how many were excluded.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 00:31:12 +08:00
bincxz
f0972cc6c1 fix: prevent CSV formula injection in host export
Address Codex security review: values starting with =, +, -, @, tab or
carriage return are now prefixed with a single quote to prevent
spreadsheet applications from interpreting them as formulas.

This mitigates CSV injection attacks when exported files are opened
in Excel, Google Sheets, or similar applications.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-23 00:08:15 +08:00
bincxz
1442b42d66 fix: resolve credentials from identity when copying host credentials
Address Codex review feedback: when a host is configured with an identityId,
the copy credentials function now correctly resolves username and password
from the identity first, falling back to host fields if not available.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 23:55:28 +08:00
bincxz
24f1dc3f36 feat: add host export, password visibility toggle, and copy credentials
- Add export button to export hosts to CSV file using import template format
- Add eye icon to toggle password visibility in host edit panel
- Add "Copy Credentials" option to host context menu for sharing host info
- Make label field optional when creating new host (defaults to hostname/IP)
- Fix duplicate keyboard shortcuts:
  - clear-buffer: changed from ⌘+K to ⌘+⌃+K (Mac) and Ctrl+L to Ctrl+Shift+K (PC)
  - open-sftp: changed from ⌘+Shift+S to ⌘+Shift+O (Mac) and Ctrl+Shift+O (PC)
  - snippets: changed PC shortcut from Ctrl+Alt+S to Ctrl+Shift+S to match Mac

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 23:40:23 +08:00
陈大猫
b0251e1eaf Merge pull request #121 from binaricat/fix/release-note-version
fix: align release-note version with built artifacts
2026-01-22 23:19:55 +08:00
bincxz
f55a1a4c15 Revert "fix: accept any v* tag to match build.yml trigger pattern"
This reverts commit d4b64d564b.
2026-01-22 23:07:39 +08:00
bincxz
d4b64d564b fix: accept any v* tag to match build.yml trigger pattern
The workflow triggers on `v*` tags and build.yml extracts version from
any v-prefixed tag. Updated the regex from `^v\d+\.\d+\.\d+` to `^v\d`
to accept tags like v1, v1.2, v1.2.3, etc.

This ensures version parsing stays in sync with build.yml behavior.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 23:06:12 +08:00
bincxz
64d3b1f26a fix: set version in build job for both tag and workflow_dispatch
- Tag release: use version from tag (e.g., v1.2.3 -> 1.2.3)
- workflow_dispatch: use short commit ID (first 7 chars)

This ensures electron-builder artifacts match the release notes
download links in all scenarios.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 22:52:20 +08:00
bincxz
f6148d3578 fix: use short commit ID as version fallback before package.json
Version priority is now:
1. VERSION env variable
2. Valid version tag (v1.2.3 format)
3. Short commit ID (first 7 chars)
4. package.json version as final fallback

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 22:52:20 +08:00
bincxz
c4d6d999c1 fix: align release-note version with built artifact version
When workflow is triggered via workflow_dispatch, GITHUB_REF_NAME is
the branch name (e.g., "main") instead of a version tag. This caused
the generated release notes to have incorrect download URLs.

Now the script:
1. Checks if GITHUB_REF_NAME is a valid version tag (v1.2.3 format)
2. Falls back to reading version from package.json if not

This ensures the release notes always match the actual artifact
filenames produced by electron-builder.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 22:52:20 +08:00
github-actions[bot]
2ca5c730b8 Merge remote-tracking branch 'upstream/main' 2026-01-22 14:06:42 +00:00
TachibanaLolo
b3a2063ca4 ci: add upstream sync workflow 2026-01-22 22:05:44 +08:00
TachibanaLolo
e6f2da48a7 ci: remove unused artifacts (zip, blockmap, yml) from upload 2026-01-22 21:59:35 +08:00
TachibanaLolo
a9fad5295c docs: simplify platform support table in all languages 2026-01-22 21:55:55 +08:00
TachibanaLolo
41822838f1 docs: update readme with platform support and new features 2026-01-22 21:54:13 +08:00
TachibanaLolo
f98c578761 Remove Android download placeholder from release notes 2026-01-22 21:42:57 +08:00
TachibanaLolo
449d63ca3e feat: enhance release workflow and sftp sudo support 2026-01-22 21:40:19 +08:00
bincxz
f6f0d0ead1 feat: sync Monaco editor background with UI theme
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
Define custom Monaco themes (netcatty-dark, netcatty-light) that inherit from built-in themes but use the app's background color from CSS variables. This ensures the text editor background matches the current UI theme, including Pure Black mode.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 20:41:48 +08:00
bincxz
dbfd50a8e0 feat: add Pure Black and Lavender UI themes
Add Pure Black dark theme with true black background (#000000) for AMOLED screens, and Lavender light theme to balance theme count. Also increase sidebar nav item selection contrast from 5% to 10% for better visibility in dark themes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 20:22:51 +08:00
陈大猫
17ffe5d1ee Merge pull request #119 from binaricat/copilot/add-quick-host-copy-function
Add duplicate host and save password toggle features
2026-01-22 19:13:38 +08:00
copilot-swe-agent[bot]
394cd539b3 Fix duplicate host not being saved - check host existence instead of editingHost flag
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-22 10:37:58 +00:00
copilot-swe-agent[bot]
1289223523 Address code review: improve savePassword toggle clarity with nullish coalescing
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-22 10:13:32 +00:00
copilot-swe-agent[bot]
1d29167b97 Add duplicate host and save password features
- Add "Duplicate" option to host context menu to quickly copy hosts
- Add "Save Password" toggle in host details panel
- Add savePassword field to Host interface
- Add i18n translations for both features (en, zh-CN)
- Internationalize existing hardcoded context menu items

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-22 10:09:14 +00:00
copilot-swe-agent[bot]
143f6d993e Initial plan 2026-01-22 10:03:15 +00:00
bincxz
7ee45ed7aa fix: intercept aria-hidden via property descriptor and setAttribute
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
Use both Object.defineProperty for the ariaHidden property and
setAttribute override to catch all ways aria-hidden can be set.
The MutationObserver approach was too late (async) to prevent
the browser warning.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 09:17:18 +08:00
bincxz
fb7b0aee86 fix: use MutationObserver to prevent aria-hidden on context menu portal
The previous setAttribute override wasn't catching all cases where
aria-hidden was being set. Using MutationObserver to watch for
attribute changes and immediately remove aria-hidden when the
context menu is open (has children).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 09:14:53 +08:00
bincxz
54cd97d3f1 fix: remove duplicate encodePathForSession declaration
Remove residual code from merge conflict that caused duplicate variable
declaration error. The lazy-loading pattern is now correctly placed in
handleFileChange function only.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 09:11:19 +08:00
陈大猫
0bb876ea32 Avoid circular require in file watcher 2026-01-22 08:55:21 +08:00
bincxz
77a80f6dcb perf: only force reload when filenameEncoding changes
The useEffect that triggers loadFiles with force:true was running on
every navigation because currentPath was in its dependencies. This
caused redundant SFTP list calls that bypassed caching.

Now we track the previous encoding with a ref and only force reload
when filenameEncoding actually changes, not on every path change.
Regular navigation still works through useSftpModalSession's own
path change handling.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 03:52:52 +08:00
bincxz
0989328afa fix: preserve encoding when directory listing is empty
When auto-detection mode is used and a directory is empty (or all
filenames are valid UTF-8), detectEncodingFromList now returns null
instead of defaulting to "utf-8". The caller then preserves the
previously resolved session encoding.

This fixes the issue where navigating into an empty directory on a
GB18030 server would silently flip the session to UTF-8, causing
subsequent creates/renames/uploads to send UTF-8 bytes and produce
garbled filenames.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 03:35:44 +08:00
bincxz
c8621960d5 fix: address reviewer feedback for encoding and aria-hidden issues
1. Fix getStringLen to honor encoding parameter in SFTP patch
   - When a non-UTF-8 encoding is provided, the function now correctly
     calculates byte length using that encoding instead of defaulting to UTF-8
   - This prevents protocol errors when writing data with custom encodings

2. Fix aria-hidden warning on context menu portal
   - Override setAttribute on the portal container to block aria-hidden="true"
     when the menu is open (has children)
   - This prevents "Blocked aria-hidden on an element because its descendant
     retained focus" warnings when context menu opens inside a dialog

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 03:21:28 +08:00
bincxz
8fddc63777 fix: filter out serial hosts from SFTP host picker
Serial connections don't support SFTP, so they shouldn't appear
in the host picker when adding a new SFTP connection.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 02:59:57 +08:00
bincxz
185143cedc fix: pass rootTaskId through nested transfers for cancellation
The previous fix only checked the direct parent task ID, which doesn't
work for deeply nested folders:
- Top-level task "A" (user cancels this)
- Child folder "B" with parentTaskId="A" (detected cancellation)
- Grandchild file "C" with parentTaskId="B" (NOT detected!)

Now we pass rootTaskId through all levels of transferDirectory and
transferFile calls, so all nested operations check against the
original top-level task ID.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 02:57:36 +08:00
bincxz
5ec91ea89d fix: properly cancel pane-to-pane transfers
Previously, clicking cancel only updated the task status but didn't
stop the actual file transfer operations. The transferFile and
transferDirectory functions would continue running.

Changes:
- Add cancelledTasksRef to track cancelled task IDs
- Check cancellation status at start of transferFile/transferDirectory
- Check cancellation status during directory iteration
- Add cancelled task ID to ref in cancelTransfer
- Handle cancellation errors gracefully in processTransfer
- Clean up cancelled IDs after 5s delay

Now cancelling a folder transfer actually stops the operation.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 02:53:40 +08:00
bincxz
77c700e666 fix: adapt UploadBridge to NetcattyBridge interface
The UploadBridge interface has a different parameter order than
NetcattyBridge for writeSftpBinaryWithProgress:
- UploadBridge: (sftpId, path, data, taskId, onProgress, ...)
- NetcattyBridge: (sftpId, path, content, transferId, encoding, onProgress, ...)

Previously, the bridge was passed directly which caused callbacks
to be passed where encoding was expected, leading to
"An object could not be cloned" errors in Electron IPC.

Now we wrap the function to properly adapt the interfaces.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 02:41:57 +08:00
bincxz
9590d3a67d fix: reset encoding when connecting to new host
When connecting to a new host, the pane's filenameEncoding was being
preserved from the previous connection. This caused issues when:
1. A pane was set to explicit encoding (e.g., UTF-8)
2. User connects to a host configured for GB18030
3. The bridge wouldn't auto-detect because encoding wasn't "auto"

Now the encoding is reset to the host's configured sftpEncoding or
"auto" when a new connection is established, ensuring proper
auto-detection and respecting host-level encoding settings.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 02:36:29 +08:00
bincxz
2de37c3d53 fix: address code review feedback
- Fix temporal dead zone issue in useSftpTransfers.ts by moving
  sourceEncoding declaration before its usage (P1 issue)
- Initialize filenameEncoding from host.sftpEncoding in SFTPModal.tsx
  and add useEffect to update when host changes (P2 issue)
- Fix duplicate Host import in SftpModalHeader.tsx

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 02:23:58 +08:00
bincxz
994b5d1325 Merge main into feature/gb18030-encoding
Merged the latest changes from main including:
- Bundled folder uploads with UploadController
- Fast delete using SSH exec
- UI improvements and bug fixes

Resolved conflicts in:
- useSftpExternalOperations.ts
- SFTPModal.tsx
- sftpBridge.cjs

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 02:20:56 +08:00
陈大猫
5bdd187365 Merge pull request #116 from binaricat/feature/bundled-folder-uploads-and-fast-delete
feat(sftp): bundle folder uploads and improve cancel/delete operations
2026-01-22 02:08:32 +08:00
bincxz
c1959adbf6 fix: distinguish upload errors from user cancellations
- uploadService.ts: Mark all bundle tasks as cancelled on early loop exit
- uploadService.ts: Don't set wasCancelled for actual errors, preserving
  the error result for proper UI feedback
- useSftpModalTransfers.ts: Re-throw real errors instead of masking them
  as cancellations, only return cancelled:true for user-initiated cancels

This ensures genuine failures (permission denied, disk full, connection
loss) are reported as errors with proper toast messages, while user
cancellations are correctly identified and handled.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 02:04:13 +08:00
bincxz
6196c6e3c3 Optimizes render tracking and dialog accessibility
Disables render tracking by default, making it an opt-in debugging feature. This change significantly reduces development log noise by only logging render information when explicitly enabled via a debug flag.

Also, explicitly sets `aria-describedby={undefined}` on dialog content. This ensures proper accessibility semantics when a description is not provided, preventing potential issues or warnings.
2026-01-22 01:44:01 +08:00
bincxz
7980a62d55 Generalizes SFTP upload error handling
Adopts a "fail-fast" strategy for SFTP uploads, stopping the entire transfer process upon encountering any error during file or directory operations.

Introduces a new `isFatalUploadError` helper to consolidate and expand the definition of errors that should halt an upload, now explicitly including cases where the target directory is deleted or inaccessible during the transfer.

Removes specific fatal error checks from various components, streamlining error propagation and simplifying the overall error handling logic.

Ensures "Scanning files..." placeholder tasks are consistently removed when actual upload tasks are added, preventing potential state inconsistencies.
2026-01-22 01:39:31 +08:00
bincxz
52b18d825d feat(sftp): Bundle folder uploads and improve cancellation
Refactors the SFTP upload mechanism to provide a more unified and robust experience.

- **Bundles folder uploads**: When uploading a folder from the local machine, it now appears as a single, aggregated task in the UI, showing overall progress instead of individual files.
- **Enhances cancellation**: Implements a new upload service and controller to manage transfers, allowing for more immediate and reliable cancellation of both individual files and bundled folder uploads.
- **Improves UI feedback**: Adds dedicated buttons for cancelling active uploads and dismissing completed, failed, or cancelled tasks.
- **Faster folder deletion**: Utilizes SSH `rm -rf` command for rapid remote folder deletion, falling back to SFTP rmdir if SSH exec is unavailable.
- Updates internationalization keys for single item deletion confirmation.
2026-01-22 01:09:41 +08:00
bincxz
70a172216a feat(sftp): bundle folder uploads and improve cancel/delete operations
- Bundle folder uploads as single tasks showing aggregate progress
- Add unique file transfer IDs for proper cancellation tracking
- Fix cancel button to call cancelExternalUpload for external uploads
- Improve backend cancel detection using cancelled flag instead of error message
- Use SSH exec with rm -rf for fast folder deletion on remote servers
- Add FolderUp icon for folder upload tasks in transfer queue
- Add i18n key for upload cancelled message

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-22 00:19:46 +08:00
TachibanaLolo
24edf6e1df chore: sync package-lock 2026-01-21 23:52:36 +08:00
TachibanaLolo
a4abcab019 Merge feature/gb18030 into main 2026-01-21 23:30:11 +08:00
TachibanaLolo
ca91483d01 Merge upstream/main 2026-01-21 23:13:31 +08:00
bincxz
dfcdcda189 Refactors module imports and updates hook deps
Adjusts relative import paths across the application to reflect recent changes in the project's directory structure. This primarily affects SFTP state management and modal components.

Updates React hook dependency arrays for `useCallback` and `useEffect` to ensure proper memoization and re-execution, addressing `react-hooks/exhaustive-deps` linting rules.

Improves type safety for translation function parameters by changing `Record<string, any>` to `Record<string, unknown>`.

Adds the `ls` command to the allowed commands for the Claude AI assistant in local settings.
2026-01-21 22:25:38 +08:00
TachibanaLolo
7514f2eae3 Harden GB18030 SFTP listing against buffer path errors 2026-01-21 22:07:54 +08:00
bincxz
951d1307cd Refactors SFTP features for improved modularity and readability
Decomposes the core SFTP state management hook and related UI components into smaller, domain-specific custom hooks and specialized sub-components.

This refactoring aims to:
- Break down the monolithic `useSftpState` hook into focused hooks like `useSftpConnections`, `useSftpTransfers`, and `useSftpPaneActions`.
- Extract complex logic and rendering from `SFTPModal` and `SftpView` into dedicated presentational components and custom hooks.
- Centralize SFTP-related types and utility functions into new `sftp/types.ts` and `sftp/utils.ts` modules.

The changes enhance code organization, maintainability, and testability across the SFTP functionality.
2026-01-21 21:41:37 +08:00
陈大猫
0c14aed55a Merge pull request #113 from binaricat/fix/issue-80-performance-and-serial-port
fix: add renderer selection setting and fix serial port dropdown
2026-01-21 19:08:00 +08:00
bincxz
2fde490ee7 fix: use 3-value renderer option to preserve low-memory auto fallback
Change rendererType from boolean to 'auto' | 'webgl' | 'canvas':
- 'auto' (default): Uses Canvas on low-memory devices (<=4GB), WebGL otherwise
- 'webgl': Force WebGL renderer
- 'canvas': Force Canvas renderer

This preserves the automatic low-memory device detection that was
inadvertently disabled when always passing a boolean value.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 18:39:39 +08:00
bincxz
b6d0a3c698 fix: add renderer selection setting and fix serial port dropdown
- Add renderer selection (WebGL/Canvas) in terminal settings
- Remove macOS restriction for Canvas renderer, allow all platforms
- Fix Combobox dropdown to show all options when opened instead of
  filtering by current value
- Add i18n translations for new rendering settings

Closes #80

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 18:28:58 +08:00
陈大猫
5b2cf535e5 Merge pull request #112 from binaricat/feat/server-stats-display
feat: display Linux server stats in terminal statusbar
2026-01-21 18:08:42 +08:00
bincxz
3c8ff48b4e fix: calculate CPU usage from delta instead of cumulative values
The previous implementation read /proc/stat once and calculated CPU
percentage from cumulative counters since boot. This gave a long-term
average that barely reflected short-term load changes.

Now we store the previous /proc/stat reading and calculate the delta
between consecutive samples, giving accurate real-time CPU usage that
properly reflects current system load. This is the same approach we
already use for network speed calculation.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 18:07:58 +08:00
bincxz
5dcddfd0ff fix: prevent server stats statusbar from wrapping on small windows
Add overflow-hidden and flex-nowrap to the stats container so items
are hidden instead of wrapping to a new line when window is resized.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 18:03:50 +08:00
bincxz
95bc7f018c feat: display Linux server stats in terminal statusbar
Add real-time monitoring of Linux server resources in the terminal statusbar:
- CPU usage with per-core breakdown in hover popup
- Memory usage with htop-style colored bar (Used/Buffers/Cache/Free)
- Top 10 processes by memory consumption
- Disk usage for all mounted partitions
- Network speed (RX/TX) with per-interface details

Features:
- Only enabled for Linux servers (detected automatically)
- Configurable refresh interval (default 5 seconds)
- Toggle in Settings > Terminal tab
- Hover-triggered popups using Radix UI HoverCard
- BusyBox compatibility with fallback commands

Closes #108

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 17:58:55 +08:00
陈大猫
7dee34f7d8 Merge pull request #110 from binaricat/fix/quick-switcher-improvements
fix: improve QuickSwitcher performance and remove host limit
2026-01-21 15:53:21 +08:00
bincxz
9b9cbb6068 fix: improve QuickSwitcher performance and remove host limit
- Remove 8-host limit to display all hosts in QuickSwitcher
- Move isMac detection to module level (computed once)
- Memoize orphanSessions with useMemo
- Memoize flatItems and build itemIndexMap for O(1) lookup
- Extract HostItem as a memo component to prevent re-renders
- Wrap getHotkeyLabel with useCallback

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 15:44:31 +08:00
陈大猫
940e49d2db Merge pull request #109 from binaricat/feat/session-logs-export
feat: add session logs export and auto-save functionality
2026-01-21 15:28:04 +08:00
bincxz
bb25285349 fix: escape HTML in session log export to prevent XSS
- Add escapeHtml function to sanitize special characters
- Split text on ANSI sequences and escape only text parts
- Escape hostLabel and dateStr in HTML template

Addresses code review feedback on PR #109.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 15:26:32 +08:00
bincxz
be44f38911 fix: prevent syncWithBackend spam when opening Settings
- Move autoStartExecutedRef assignment before async work to ensure
  effect only runs once regardless of auto-start rules
- Memoize keys array in App.tsx to prevent new references on each render

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 15:20:12 +08:00
bincxz
4749bef906 feat: add session logs export and auto-save functionality
- Add manual export button in LogView to save session logs to file
- Add auto-save settings in System tab to automatically save logs when sessions end
- Support three formats: plain text (.txt), raw ANSI (.log), and HTML (.html)
- Logs are organized by host in subdirectories with timestamp filenames
- Add i18n support for English and Chinese

Closes #104

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 14:50:07 +08:00
陈大猫
797c607b0a Merge pull request #107 from binaricat/copilot/add-font-setting-option
Add UI font customization in Appearance settings
2026-01-21 13:56:52 +08:00
bincxz
7e3e4ce3b8 Ensures UI font family applies after loading
Introduces a `useUIFontsLoaded` hook to track the loading status of UI fonts.

Updates the `useLayoutEffect` responsible for applying the UI font family to re-run once fonts are loaded. This ensures that local fonts, whose family properties might only be correctly resolved after loading, are applied accurately to the UI.

Also updates local Claude settings to allow GitHub CLI commands for development convenience.
2026-01-21 13:55:30 +08:00
bincxz
929d7dbe74 Enhances UI font management and connection logs
Refactors UI font handling to support dynamic font loading and improves selection in settings.

Implements "Load More" pagination for connection logs, enhancing performance and user experience with large datasets. Adds lazy loading for the Connection Logs Manager.

Addresses an issue in `usePortForwardingState` to prevent duplicate backend sync calls in React StrictMode. Improves IPC communication robustness by checking window destruction status. Refines the visual presentation of the snippets empty state.
2026-01-21 12:23:21 +08:00
copilot-swe-agent[bot]
33313f71fb feat: add UI font customization in Appearance settings
- Add UI fonts configuration with 21 font options
- Add state management for UI font preference
- Add i18n translations (English/Chinese)
- Apply font via CSS variable --font-sans
- Support cross-window sync via IPC/localStorage

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-01-21 03:33:47 +00:00
copilot-swe-agent[bot]
266e9f637c Initial plan 2026-01-21 03:18:47 +00:00
陈大猫
4d03c469e8 Merge pull request #106 from binaricat/feature/decoration-based-keyword-highlight
refactor: use xterm Decoration API for keyword highlighting
2026-01-21 11:15:08 +08:00
bincxz
7846c5e046 perf: pre-compile regex patterns for keyword highlighting
Address reviewer feedback: avoid creating new RegExp objects on every
viewport refresh. Now patterns are compiled once in setRules() and
reused across refreshes.

Changes:
- Add CompiledRule interface with pre-compiled regex + color
- Move regex compilation from refreshViewport() to setRules()
- Reset regex.lastIndex before each line scan for correct reuse
- Remove try/catch from hot path (errors caught at compile time)

This reduces CPU/GC pressure especially with many rules or high-frequency
terminal output.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 11:11:45 +08:00
bincxz
a03df92dd1 fix: map string indices to cell columns for wide character support
Address review feedback: regex match indices are JS string positions,
but xterm Decoration API expects terminal cell columns. This caused
highlight misalignment on lines with tabs, CJK characters, or emoji.

Add buildStringToCellMap() helper that iterates buffer cells to build
a mapping from string index to cell column, correctly handling:
- Wide characters (CJK, emoji): 1 char = 2 cells
- Combining characters: multiple chars = 1 cell
- Regular ASCII: 1 char = 1 cell

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 11:08:55 +08:00
bincxz
a855323912 refactor: use xterm Decoration API for keyword highlighting
Replace ANSI escape sequence-based text processing with xterm.js's
Decoration API for keyword highlighting. This "lazy" approach only
processes visible viewport rows and uses debounced refresh, ensuring
zero impact on scrolling performance.

Key changes:
- New KeywordHighlighter class using IDecoration/IMarker APIs
- Decorations refresh on scroll, write, and resize events
- Skip highlighting in alternate buffer (vim, htop, etc.)
- Add configurable debounce delay (200ms default)
- Remove inline ANSI color injection from data stream

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-21 11:00:34 +08:00
TachibanaLolo
1958648f63 Fix ssh2 patch file to exclude build artifacts 2026-01-21 01:10:26 +08:00
TachibanaLolo
e830b9362a Add GB18030 filename encoding support 2026-01-21 00:58:13 +08:00
232 changed files with 35899 additions and 12483 deletions

View File

@@ -1,9 +0,0 @@
{
"permissions": {
"allow": [
"Bash(npx tsc:*)",
"Bash(npm run lint:*)",
"Bash(npm run build:*)"
]
}
}

108
.github/scripts/generate-release-note.js vendored Normal file
View File

@@ -0,0 +1,108 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Determine version priority:
// 1. VERSION env variable
// 2. Valid version tag (v1.2.3 format)
// 3. Short commit ID (first 7 chars of GITHUB_SHA)
// 4. package.json version as fallback
function getVersion() {
if (process.env.VERSION) {
return process.env.VERSION;
}
const refName = process.env.GITHUB_REF_NAME;
// Check if refName is a valid version tag (e.g., v1.2.3)
if (refName && /^v\d+\.\d+\.\d+/.test(refName)) {
return refName.replace(/^v/, '');
}
// Use short commit ID
const sha = process.env.GITHUB_SHA;
if (sha) {
return sha.substring(0, 7);
}
// Fall back to package.json version
try {
const pkgPath = path.join(__dirname, '..', '..', 'package.json');
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
return pkg.version;
} catch {
return '0.0.0';
}
}
const version = getVersion();
const repo = process.env.GITHUB_REPOSITORY || 'binaricat/netcatty';
// For tag releases, use the tag; for workflow_dispatch, create a tag from version
const tag = (process.env.GITHUB_REF_NAME && /^v\d+\.\d+\.\d+/.test(process.env.GITHUB_REF_NAME))
? process.env.GITHUB_REF_NAME
: `v${version}`;
const baseUrl = `https://github.com/${repo}/releases/download/${tag}`;
// Filename patterns based on electron-builder.config.cjs artifactName: '${productName}-${version}-${os}-${arch}.${ext}'
// Note: electron-builder uses different arch names for Linux packages:
// - AppImage: x64 -> x86_64, arm64 -> arm64
// - deb: x64 -> amd64, arm64 -> arm64
// - rpm: x64 -> x86_64, arm64 -> aarch64
const files = {
mac: {
arm64: `Netcatty-${version}-mac-arm64.dmg`,
x64: `Netcatty-${version}-mac-x64.dmg`
},
win: {
x64: `Netcatty-${version}-win-x64.exe`,
arm64: `Netcatty-${version}-win-arm64.exe`
},
linux: {
appimage: {
x64: `Netcatty-${version}-linux-x86_64.AppImage`,
arm64: `Netcatty-${version}-linux-arm64.AppImage`
},
deb: {
x64: `Netcatty-${version}-linux-amd64.deb`,
arm64: `Netcatty-${version}-linux-arm64.deb`
},
rpm: {
x64: `Netcatty-${version}-linux-x86_64.rpm`,
arm64: `Netcatty-${version}-linux-aarch64.rpm`
}
}
};
const badges = {
win: {
setup_x64: `[![Setup x64](https://img.shields.io/badge/Setup-x64-0078D6?style=flat-square&logo=windows)](${baseUrl}/${files.win.x64})`,
setup_arm64: `[![Setup arm64](https://img.shields.io/badge/Setup-arm64-0078D6?style=flat-square&logo=windows)](${baseUrl}/${files.win.arm64})`
},
mac: {
apple_silicon: `[![DMG Apple Silicon](https://img.shields.io/badge/DMG-Apple_Silicon-000000?style=flat-square&logo=apple)](${baseUrl}/${files.mac.arm64})`,
intel: `[![DMG Intel X64](https://img.shields.io/badge/DMG-Intel_X64-000000?style=flat-square&logo=apple)](${baseUrl}/${files.mac.x64})`
},
linux: {
appimage_x64: `[![AppImage x64](https://img.shields.io/badge/AppImage-x64-FCC624?style=flat-square&logo=linux)](${baseUrl}/${files.linux.appimage.x64})`,
appimage_arm64: `[![AppImage arm64](https://img.shields.io/badge/AppImage-arm64-FCC624?style=flat-square&logo=linux)](${baseUrl}/${files.linux.appimage.arm64})`,
deb_x64: `[![DebPackage x64](https://img.shields.io/badge/DebPackage-x64-A80030?style=flat-square&logo=debian)](${baseUrl}/${files.linux.deb.x64})`,
deb_arm64: `[![DebPackage arm64](https://img.shields.io/badge/DebPackage-arm64-A80030?style=flat-square&logo=debian)](${baseUrl}/${files.linux.deb.arm64})`,
rpm_x64: `[![RpmPackage x64](https://img.shields.io/badge/RpmPackage-x64-CC0000?style=flat-square&logo=redhat)](${baseUrl}/${files.linux.rpm.x64})`,
rpm_arm64: `[![RpmPackage arm64](https://img.shields.io/badge/RpmPackage-arm64-CC0000?style=flat-square&logo=redhat)](${baseUrl}/${files.linux.rpm.arm64})`
}
};
const content = `
## Download based on your OS:
| OS | Download |
| :--- | :--- |
| **Windows** | ${badges.win.setup_x64} ${badges.win.setup_arm64} |
| **macOS** | ${badges.mac.apple_silicon} ${badges.mac.intel} |
| **Linux** | ${badges.linux.appimage_x64} ${badges.linux.deb_x64} ${badges.linux.rpm_x64} <br> ${badges.linux.appimage_arm64} ${badges.linux.deb_arm64} ${badges.linux.rpm_arm64} |
`;
fs.writeFileSync('release_notes.md', content);
console.log('Generated release_notes.md');

View File

@@ -13,12 +13,21 @@ on:
jobs:
build:
name: build-${{ matrix.os }}
name: build-${{ matrix.name }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [macos-latest, windows-latest, ubuntu-latest]
include:
- name: macos
os: macos-latest
pack_script: pack:mac
- name: windows
os: windows-latest
pack_script: pack:win
- name: linux-x64
os: ubuntu-latest
pack_script: pack:linux-x64
env:
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
@@ -37,54 +46,97 @@ jobs:
- name: Install deps
run: npm ci
- name: Set version from tag
if: startsWith(github.ref, 'refs/tags/v')
- name: Set version
shell: bash
run: |
VERSION="${GITHUB_REF_NAME#v}"
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
# Tag release: use version from tag
VERSION="${GITHUB_REF_NAME#v}"
else
# workflow_dispatch: use short commit ID
VERSION="${GITHUB_SHA:0:7}"
fi
echo "Setting version to ${VERSION}"
npm pkg set version="${VERSION}"
- name: Build package (macOS)
if: matrix.os == 'macos-latest'
- name: Build package
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
CSC_IDENTITY_AUTO_DISCOVERY: ${{ matrix.name == 'macos' && 'false' || '' }}
ELECTRON_BUILDER_PUBLISH: "never"
run: npm run pack:mac
- name: Build package (Windows)
if: matrix.os == 'windows-latest'
env:
ELECTRON_BUILDER_PUBLISH: "never"
run: npm run pack:win
- name: Build package (Linux)
if: matrix.os == 'ubuntu-latest'
env:
ELECTRON_BUILDER_PUBLISH: "never"
run: npm run pack:linux
run: npm run ${{ matrix.pack_script }}
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: netcatty-${{ matrix.os }}
name: netcatty-${{ matrix.name }}
path: |
release/*.dmg
release/*.zip
release/*.exe
release/*.msi
release/*.AppImage
release/*.deb
release/*.rpm
release/*.tar.gz
release/*.blockmap
release/latest*.yml
if-no-files-found: ignore
# Dedicated job for Linux ARM64 — builds inside Debian Bullseye (GLIBC 2.31)
# to ensure compatibility with older distros like UOS/Deepin (GLIBC 2.28).
# Key: GLIBC < 2.34 avoids the libpthread-merge symbol requirement.
build-linux-arm64:
name: build-linux-arm64
runs-on: ubuntu-24.04-arm
container:
image: debian:bullseye
env:
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_SECRET: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_SECRET }}
VITE_SYNC_ONEDRIVE_CLIENT_ID: ${{ secrets.VITE_SYNC_ONEDRIVE_CLIENT_ID }}
steps:
- name: Install build dependencies
run: |
apt-get update
apt-get install -y curl build-essential python3 git libfuse2 file rpm
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y nodejs
- name: Checkout
uses: actions/checkout@v4
- name: Install deps
run: npm ci
- name: Set version
shell: bash
run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
VERSION="${GITHUB_REF_NAME#v}"
else
VERSION="${GITHUB_SHA:0:7}"
fi
echo "Setting version to ${VERSION}"
npm pkg set version="${VERSION}"
- name: Build package
env:
npm_config_arch: arm64
ELECTRON_BUILDER_PUBLISH: "never"
run: npm run pack:linux-arm64
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: netcatty-linux-arm64
path: |
release/*.AppImage
release/*.deb
release/*.rpm
if-no-files-found: ignore
release:
name: release
runs-on: ubuntu-latest
needs: build
needs: [build, build-linux-arm64]
if: startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.publish_release)
permissions:
contents: write
@@ -101,20 +153,23 @@ jobs:
- name: List artifacts
run: ls -la artifacts/
- name: Generate Release Body
run: node .github/scripts/generate-release-note.js
env:
GITHUB_REF_NAME: ${{ github.ref_name }}
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_SHA: ${{ github.sha }}
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
body_path: release_notes.md
files: |
artifacts/*.dmg
artifacts/*.zip
artifacts/*.exe
artifacts/*.msi
artifacts/*.AppImage
artifacts/*.deb
artifacts/*.rpm
artifacts/*.tar.gz
artifacts/*.blockmap
artifacts/latest*.yml
generate_release_notes: true
fail_on_unmatched_files: false
token: ${{ secrets.RELEASE_TOKEN }}

3
.gitignore vendored
View File

@@ -33,3 +33,6 @@ coverage
*.njsproj
*.sln
*.sw?
# Claude Code local settings
/.claude/settings.local.json

364
App.tsx
View File

@@ -1,16 +1,20 @@
import React, { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { activeTabStore, useActiveTabId, useIsSftpActive, useIsTerminalLayerVisible, useIsVaultActive } from './application/state/activeTabStore';
import { useAutoSync } from './application/state/useAutoSync';
import { useManagedSourceSync } from './application/state/useManagedSourceSync';
import { usePortForwardingAutoStart } from './application/state/usePortForwardingAutoStart';
import { usePortForwardingState } from './application/state/usePortForwardingState';
import { useSessionState } from './application/state/useSessionState';
import { useSettingsState } from './application/state/useSettingsState';
import { useUpdateCheck } from './application/state/useUpdateCheck';
import { useVaultState } from './application/state/useVaultState';
import { useWindowControls } from './application/state/useWindowControls';
import { initializeFonts } from './application/state/fontStore';
import { initializeUIFonts } from './application/state/uiFontStore';
import { I18nProvider, useI18n } from './application/i18n/I18nProvider';
import { matchesKeyBinding } from './domain/models';
import { resolveHostAuth } from './domain/sshAuth';
import { getCredentialProtectionAvailability } from './infrastructure/services/credentialProtection';
import { netcattyBridge } from './infrastructure/services/netcattyBridge';
import { TopTabs } from './components/TopTabs';
import { Button } from './components/ui/button';
@@ -20,6 +24,7 @@ import { Label } from './components/ui/label';
import { ToastProvider, toast } from './components/ui/toast';
import { VaultView, VaultSection } from './components/VaultView';
import { KeyboardInteractiveModal, KeyboardInteractiveRequest } from './components/KeyboardInteractiveModal';
import { PassphraseModal, PassphraseRequest } from './components/PassphraseModal';
import { cn } from './lib/utils';
import { ConnectionLog, Host, HostProtocol, SerialConfig, TerminalTheme } from './types';
import { LogView as LogViewType } from './application/state/useSessionState';
@@ -28,6 +33,7 @@ import type { TerminalLayer as TerminalLayerComponent } from './components/Termi
// Initialize fonts eagerly at app startup
initializeFonts();
initializeUIFonts();
// Visibility container for VaultView - isolates isActive subscription
const VaultViewContainer: React.FC<{ children: React.ReactNode }> = ({ children }) => {
@@ -82,6 +88,9 @@ const LazyProtocolSelectDialog = lazy(() => import('./components/ProtocolSelectD
const LazyQuickSwitcher = lazy(() =>
import('./components/QuickSwitcher').then((m) => ({ default: m.QuickSwitcher })),
);
const LazyCreateWorkspaceDialog = lazy(() =>
import('./components/CreateWorkspaceDialog').then((m) => ({ default: m.CreateWorkspaceDialog })),
);
const IS_DEV = import.meta.env.DEV;
const HOTKEY_DEBUG =
@@ -146,6 +155,7 @@ function App({ settings }: { settings: SettingsState }) {
const { t } = useI18n();
const [isQuickSwitcherOpen, setIsQuickSwitcherOpen] = useState(false);
const [isCreateWorkspaceOpen, setIsCreateWorkspaceOpen] = useState(false);
const [quickSearch, setQuickSearch] = useState('');
// Protocol selection dialog state for QuickSwitcher
const [protocolSelectHost, setProtocolSelectHost] = useState<Host | null>(null);
@@ -153,6 +163,8 @@ function App({ settings }: { settings: SettingsState }) {
const [navigateToSection, setNavigateToSection] = useState<VaultSection | null>(null);
// Keyboard-interactive authentication queue (2FA/MFA) - queue-based to handle multiple concurrent sessions
const [keyboardInteractiveQueue, setKeyboardInteractiveQueue] = useState<KeyboardInteractiveRequest[]>([]);
// Passphrase request queue for encrypted SSH keys
const [passphraseQueue, setPassphraseQueue] = useState<PassphraseRequest[]>([]);
const {
theme,
@@ -167,6 +179,9 @@ function App({ settings }: { settings: SettingsState }) {
hotkeyScheme,
keyBindings,
isHotkeyRecording,
sessionLogsEnabled,
sessionLogsDir,
sessionLogsFormat,
} = settings;
const {
@@ -179,6 +194,7 @@ function App({ settings }: { settings: SettingsState }) {
knownHosts,
shellHistory,
connectionLogs,
managedSources,
updateHosts,
updateKeys,
updateIdentities,
@@ -186,6 +202,7 @@ function App({ settings }: { settings: SettingsState }) {
updateSnippetPackages,
updateCustomGroups,
updateKnownHosts,
updateManagedSources,
addShellHistoryEntry,
addConnectionLog,
updateConnectionLog,
@@ -221,6 +238,7 @@ function App({ settings }: { settings: SettingsState }) {
closeSession,
closeWorkspace,
updateSessionStatus,
createWorkspaceWithHosts,
createWorkspaceFromSessions,
addSessionToWorkspace,
updateSplitSizes,
@@ -237,11 +255,26 @@ function App({ settings }: { settings: SettingsState }) {
logViews,
openLogView,
closeLogView,
copySession,
} = useSessionState();
// isMacClient is used for window controls styling
const isMacClient = typeof navigator !== 'undefined' && /Mac|Macintosh/.test(navigator.userAgent);
// Get port forwarding rules and import function
const { rules: portForwardingRules, importRules: importPortForwardingRules, startTunnel, stopTunnel } = usePortForwardingState();
const portForwardingRulesForSync = useMemo(
() =>
portForwardingRules.map((rule) => ({
...rule,
status: "inactive",
error: undefined,
lastUsedAt: undefined,
})),
[portForwardingRules],
);
// Auto-sync hook for cloud sync
const { syncNow: handleSyncNow } = useAutoSync({
hosts,
@@ -249,7 +282,7 @@ function App({ settings }: { settings: SettingsState }) {
identities,
snippets,
customGroups,
portForwardingRules: undefined, // TODO: Add port forwarding rules from usePortForwardingState
portForwardingRules: portForwardingRulesForSync,
knownHosts,
onApplyPayload: (payload) => {
importDataFromString(JSON.stringify({
@@ -259,9 +292,19 @@ function App({ settings }: { settings: SettingsState }) {
snippets: payload.snippets,
customGroups: payload.customGroups,
}));
if (payload.portForwardingRules) {
importPortForwardingRules(payload.portForwardingRules);
}
},
});
const { clearAndRemoveSource, clearAndRemoveSources, unmanageSource } = useManagedSourceSync({
hosts,
managedSources,
onUpdateManagedSources: updateManagedSources,
});
const handleSyncNowManual = useCallback(() => {
return handleSyncNow({ trigger: 'manual' });
}, [handleSyncNow]);
@@ -288,12 +331,138 @@ function App({ settings }: { settings: SettingsState }) {
}
}, [updateState.hasUpdate, updateState.latestRelease, t, openReleasePage, dismissUpdate]);
// Memoize keys for port forwarding to prevent unnecessary re-renders
const portForwardingKeys = useMemo(
() => keys.map((k) => ({ id: k.id, privateKey: k.privateKey })),
[keys]
);
// Auto-start port forwarding rules on app launch
usePortForwardingAutoStart({
hosts,
keys: keys.map((k) => ({ id: k.id, privateKey: k.privateKey })),
keys: portForwardingKeys,
});
// Sync tray menu data + handle tray actions
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.updateTrayMenuData) return;
let cancelled = false;
const timer = setTimeout(() => {
if (cancelled) return;
const sessionsForTray = sessions.map((s) => {
const ws = s.workspaceId ? workspaces.find((w) => w.id === s.workspaceId) : undefined;
return {
id: s.id,
label: s.hostname,
hostLabel: s.hostLabel,
status: s.status,
workspaceId: s.workspaceId,
workspaceTitle: ws?.title,
};
});
void bridge.updateTrayMenuData({
sessions: sessionsForTray,
portForwardRules: portForwardingRules,
});
}, 250);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [sessions, portForwardingRules, workspaces]);
useEffect(() => {
const bridge = netcattyBridge.get();
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);
}
});
const unsubscribeToggle = bridge.onTrayTogglePortForward((ruleId, start) => {
const rule = portForwardingRules.find((r) => r.id === ruleId);
if (!rule) return;
const host = rule.hostId ? hosts.find((h) => h.id === rule.hostId) : undefined;
if (!host) {
toast.error(t("pf.error.hostNotFound"));
return;
}
const keysForPf = keys.map((k) => ({ id: k.id, privateKey: k.privateKey }));
if (start) {
void startTunnel(rule, host, keysForPf, (status, error) => {
if (status === "error" && error) toast.error(error);
}, rule.autoStart);
} else {
void stopTunnel(ruleId);
}
});
return () => {
unsubscribeFocus?.();
unsubscribeToggle?.();
};
}, [hosts, keys, portForwardingRules, sessions, setActiveTabId, setWorkspaceFocusedSession, startTunnel, stopTunnel, t]);
// Tray panel actions (from main process)
useEffect(() => {
const handlerJump = (sessionId: string) => {
// Find the session to check if it belongs to a workspace
const session = sessions.find((s) => s.id === sessionId);
if (session?.workspaceId) {
// Session is in a workspace - navigate to workspace and focus the session
setActiveTabId(session.workspaceId);
setWorkspaceFocusedSession(session.workspaceId, sessionId);
} else {
// Standalone session or session not found - just set tab
setActiveTabId(sessionId);
}
};
const handlerConnect = (hostId: string) => {
const host = hosts.find((h) => h.id === hostId);
if (!host) {
toast.error(t("pf.error.hostNotFound"));
return;
}
addConnectionLog({
hostId: host.id,
hostLabel: host.label,
hostname: host.hostname,
username: host.username,
localHostname: "",
saved: false,
});
connectToHost(host);
};
const bridge = netcattyBridge.get();
if (!bridge?.onTrayPanelJumpToSession || !bridge?.onTrayPanelConnectToHost) return;
const unsubscribeJump = bridge.onTrayPanelJumpToSession(handlerJump);
const unsubscribeConnect = bridge.onTrayPanelConnectToHost(handlerConnect);
return () => {
unsubscribeJump?.();
unsubscribeConnect?.();
};
}, [addConnectionLog, connectToHost, hosts, sessions, setActiveTabId, setWorkspaceFocusedSession, t]);
// Keyboard-interactive authentication (2FA/MFA) event listener
useEffect(() => {
const bridge = netcattyBridge.get();
@@ -337,6 +506,76 @@ function App({ settings }: { settings: SettingsState }) {
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
}, []);
// Passphrase request event listener for encrypted SSH keys
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onPassphraseRequest) return;
const unsubscribe = bridge.onPassphraseRequest((request) => {
console.log('[App] Passphrase request received:', request);
setPassphraseQueue(prev => [...prev, {
requestId: request.requestId,
keyPath: request.keyPath,
keyName: request.keyName,
hostname: request.hostname,
}]);
});
return () => {
unsubscribe?.();
};
}, []);
// Handle passphrase submit
const handlePassphraseSubmit = useCallback((requestId: string, passphrase: string) => {
const bridge = netcattyBridge.get();
if (bridge?.respondPassphrase) {
void bridge.respondPassphrase(requestId, passphrase, false);
}
setPassphraseQueue(prev => prev.filter(r => r.requestId !== requestId));
}, []);
// Handle passphrase cancel
const handlePassphraseCancel = useCallback((requestId: string) => {
const bridge = netcattyBridge.get();
if (bridge?.respondPassphrase) {
// Cancel = stop the entire passphrase flow
void bridge.respondPassphrase(requestId, '', true);
}
setPassphraseQueue(prev => prev.filter(r => r.requestId !== requestId));
}, []);
// Handle passphrase skip (skip this key, continue with others)
const handlePassphraseSkip = useCallback((requestId: string) => {
const bridge = netcattyBridge.get();
if (bridge?.respondPassphraseSkip) {
// Skip = skip this key but continue asking for others
void bridge.respondPassphraseSkip(requestId);
} else if (bridge?.respondPassphrase) {
// Fallback for older API
void bridge.respondPassphrase(requestId, '', false);
}
setPassphraseQueue(prev => prev.filter(r => r.requestId !== requestId));
}, []);
// Handle passphrase timeout (request expired on backend)
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onPassphraseTimeout) return;
const unsubscribe = bridge.onPassphraseTimeout((event) => {
console.log('[App] Passphrase request timed out:', event.requestId);
// Remove from queue - the modal will close automatically
setPassphraseQueue(prev => prev.filter(r => r.requestId !== event.requestId));
// Show a toast notification to inform user
toast.error('Passphrase request timed out. Please try connecting again.');
});
return () => {
unsubscribe?.();
};
}, []);
// Debounce ref for moveFocus to prevent double-triggering when focus switches
const lastMoveFocusTimeRef = useRef<number>(0);
const MOVE_FOCUS_DEBOUNCE_MS = 200;
@@ -531,11 +770,15 @@ function App({ settings }: { settings: SettingsState }) {
// 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");
if (isFormElement && !isXtermInput && e.key !== 'Escape') {
// Monaco is not always contentEditable/input, so treat it as an editor surface.
if ((isFormElement || isMonacoElement) && !isXtermInput && e.key !== 'Escape') {
return;
}
@@ -559,6 +802,10 @@ function App({ settings }: { settings: SettingsState }) {
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'];
@@ -614,7 +861,7 @@ function App({ settings }: { settings: SettingsState }) {
(h.group || '').toLowerCase().includes(term)
)
: hosts;
return filtered.slice(0, 8);
return filtered;
}, [hosts, quickSearch, isQuickSwitcherOpen]);
const handleDeleteHost = useCallback((hostId: string) => {
@@ -751,10 +998,32 @@ function App({ settings }: { settings: SettingsState }) {
terminalData: data,
});
if (IS_DEV) console.log('[handleTerminalDataCapture] Updated log with terminalData');
// Auto-save session log if enabled
if (sessionLogsEnabled && sessionLogsDir && data) {
import('./infrastructure/services/netcattyBridge').then(({ netcattyBridge }) => {
const bridge = netcattyBridge.get();
if (bridge?.autoSaveSessionLog) {
bridge.autoSaveSessionLog({
terminalData: data,
hostLabel: matchingLog.hostLabel,
hostname: matchingLog.hostname,
hostId: matchingLog.hostId,
startTime: matchingLog.startTime,
format: sessionLogsFormat,
directory: sessionLogsDir,
}).then(result => {
if (IS_DEV) console.log('[handleTerminalDataCapture] Auto-save result:', result);
}).catch(err => {
console.error('[handleTerminalDataCapture] Auto-save failed:', err);
});
}
});
}
} else {
if (IS_DEV) console.log('[handleTerminalDataCapture] No matching log found!');
}
}, [sessions, connectionLogs, updateConnectionLog]);
}, [sessions, connectionLogs, updateConnectionLog, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat]);
// Check if host has multiple protocols enabled
const hasMultipleProtocols = useCallback((host: Host) => {
@@ -814,12 +1083,64 @@ function App({ settings }: { settings: SettingsState }) {
})();
}, [openSettingsWindow, t]);
const hasShownCredentialProtectionWarningRef = useRef(false);
useEffect(() => {
if (hasShownCredentialProtectionWarningRef.current) return;
let cancelled = false;
void (async () => {
const available = await getCredentialProtectionAvailability();
if (cancelled || available !== false) return;
hasShownCredentialProtectionWarningRef.current = true;
toast.warning(t('credentials.protectionUnavailable.message'), {
title: t('credentials.protectionUnavailable.title'),
actionLabel: t('credentials.protectionUnavailable.action'),
duration: 10000,
onClick: handleOpenSettings,
});
})();
return () => {
cancelled = true;
};
}, [handleOpenSettings, t]);
const handleEndSessionDrag = useCallback(() => {
setDraggingSessionId(null);
}, [setDraggingSessionId]);
const handleRootContextMenu = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const editableSelector =
"input, textarea, [contenteditable], .monaco-editor, .monaco-diff-editor, .monaco-inputbox, .monaco-menu-container";
const nativeEvent = e.nativeEvent;
const path = typeof nativeEvent.composedPath === "function" ? nativeEvent.composedPath() : [];
const allowFromPath = path.some(
(node) => node instanceof Element && !!node.closest(editableSelector),
);
const target = e.target;
const targetElement =
target instanceof Element
? target
: target instanceof Node
? target.parentElement
: null;
const allowFromTarget = !!targetElement?.closest(editableSelector);
const allowNativeContextMenu = allowFromPath || allowFromTarget;
if (allowNativeContextMenu) {
return;
}
e.preventDefault();
}, []);
return (
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" onContextMenu={(e) => e.preventDefault()}>
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" onContextMenu={handleRootContextMenu}>
<TopTabs
theme={theme}
sessions={sessions}
@@ -831,6 +1152,7 @@ function App({ settings }: { settings: SettingsState }) {
isMacClient={isMacClient}
onCloseSession={closeSession}
onRenameSession={startSessionRename}
onCopySession={copySession}
onRenameWorkspace={startWorkspaceRename}
onCloseWorkspace={closeWorkspace}
onCloseLogView={closeLogView}
@@ -855,7 +1177,10 @@ function App({ settings }: { settings: SettingsState }) {
knownHosts={knownHosts}
shellHistory={shellHistory}
connectionLogs={connectionLogs}
managedSources={managedSources}
sessions={sessions}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
onOpenSettings={handleOpenSettings}
onOpenQuickSwitcher={handleOpenQuickSwitcher}
onCreateLocalTerminal={handleCreateLocalTerminal}
@@ -869,6 +1194,10 @@ function App({ settings }: { settings: SettingsState }) {
onUpdateSnippetPackages={updateSnippetPackages}
onUpdateCustomGroups={updateCustomGroups}
onUpdateKnownHosts={updateKnownHosts}
onUpdateManagedSources={updateManagedSources}
onClearAndRemoveManagedSource={clearAndRemoveSource}
onClearAndRemoveManagedSources={clearAndRemoveSources}
onUnmanageSource={unmanageSource}
onConvertKnownHost={convertKnownHostToHost}
onToggleConnectionLogSaved={toggleConnectionLogSaved}
onDeleteConnectionLog={deleteConnectionLog}
@@ -880,7 +1209,7 @@ function App({ settings }: { settings: SettingsState }) {
/>
</VaultViewContainer>
<SftpViewMount hosts={hosts} keys={keys} identities={identities} />
<SftpViewMount hosts={hosts} keys={keys} identities={identities} updateHosts={updateHosts} />
<TerminalLayerMount
hosts={hosts}
@@ -959,8 +1288,8 @@ function App({ settings }: { settings: SettingsState }) {
setQuickSearch('');
}}
onCreateWorkspace={() => {
// TODO: Implement workspace creation
setIsQuickSwitcherOpen(false);
setIsCreateWorkspaceOpen(true);
}}
onClose={() => {
setIsQuickSwitcherOpen(false);
@@ -1025,6 +1354,17 @@ function App({ settings }: { settings: SettingsState }) {
</DialogContent>
</Dialog>
{isCreateWorkspaceOpen && (
<Suspense fallback={null}>
<LazyCreateWorkspaceDialog
isOpen={isCreateWorkspaceOpen}
onClose={() => setIsCreateWorkspaceOpen(false)}
hosts={hosts}
onCreate={createWorkspaceWithHosts}
/>
</Suspense>
)}
{/* Protocol Select Dialog for QuickSwitcher */}
{protocolSelectHost && (
<Suspense fallback={null}>
@@ -1048,6 +1388,14 @@ function App({ settings }: { settings: SettingsState }) {
{keyboardInteractiveQueue.length - 1} more pending
</div>
)}
{/* Global Passphrase Modal for encrypted SSH keys */}
<PassphraseModal
request={passphraseQueue[0] || null}
onSubmit={handlePassphraseSubmit}
onCancel={handlePassphraseCancel}
onSkip={handlePassphraseSkip}
/>
</div>
);
}

View File

@@ -11,13 +11,13 @@
<p align="center">
Electron、React、xterm.js で構築された機能豊富な SSH ワークスペース。<br/>
ホスト管理、分割ターミナル、SFTP、ポートフォワーディング、クラウド同期 — すべてが一つに。
分割ターミナル、Vault ビュー、SFTP ワークフロー、カスタムテーマ、キーワードハイライト — すべてが一つに。
</p>
<p align="center">
<a href="https://github.com/binaricat/Netcatty/releases/latest"><img alt="GitHub Release" src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=Release"></a>
&nbsp;
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows-blue?style=for-the-badge&logo=electron"></a>
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows%20%7C%20Linux-blue?style=for-the-badge&logo=electron"></a>
&nbsp;
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/License-GPL--3.0-green?style=for-the-badge"></a>
</p>
@@ -40,22 +40,20 @@
---
[![Netcatty メインインターフェース](screenshots/vault_grid_view.png)](screenshots/vault_grid_view.png)
[![Netcatty メインインターフェース](screenshots/main-window-dark.png)](screenshots/main-window-dark.png)
---
# 目次 <!-- omit in toc -->
- [Netcatty とは](#netcatty-とは)
- [なぜ Netcatty](#なぜ-netcatty)
- [機能](#機能)
- [デモ](#デモ)
- [スクリーンショット](#スクリーンショット)
- [ホスト管理](#ホスト管理)
- [ターミナル](#ターミナル)
- [SFTP](#sftp)
- [キーチェーン](#キーチェーン)
- [ポートフォワーディング](#ポートフォワーディング)
- [クラウド同期](#クラウド同期)
- [テーマとカスタマイズ](#テーマとカスタマイズ)
- [メインウィンドウ](#メインウィンドウ)
- [Vault ビュー](#vault-ビュー)
- [分割ターミナル](#分割ターミナル)
- [対応ディストリビューション](#対応ディストリビューション)
- [はじめに](#はじめに)
- [ビルドとパッケージ](#ビルドとパッケージ)
@@ -71,188 +69,119 @@
**Netcatty** は、複数のリモートサーバーを効率的に管理する必要がある開発者、システム管理者、DevOps エンジニア向けに設計された、モダンなクロスプラットフォーム SSH クライアントおよびターミナルマネージャーです。
- **Netcatty は** PuTTY、Termius、SecureCRT、macOS Terminal.app の代替となる SSH 接続ツール
- **Netcatty は** デュアルペインファイルブラウザを備えた強力な SFTP クライアント
- **Netcatty は** 強力な SFTP クライアント(ドラッグ&ドロップ + 内蔵エディタ)
- **Netcatty は** 分割ペイン、タブ、セッション管理を備えたターミナルワークスペース
- **Netcatty は** シェルの代替ではありません — SSH/Telnet またはローカルターミナル経由でリモートシェルに接続します
- **Netcatty は** シェルの代替ではありません — SSH/Telnet/Mosh やローカル/シリアル経由でシェルに接続します(環境により異なります)
---
<a name="なぜ-netcatty"></a>
# なぜ Netcatty
複数サーバーを日常的に扱うなら、Netcatty は「スピード」と「流れ」を重視した作りになっています:
- **ワークスペース中心** — 分割ペインで複数セッションを並行操作
- **Vault の見やすさ** — グリッド/リスト/ツリーで状況に合わせて切り替え
- **SFTP の作業感** — ドラッグ&ドロップと内蔵エディタでサクッと編集
---
<a name="機能"></a>
# 機能
### 🖥 ターミナルとセッション
- **xterm.js ベースのターミナル**、GPU アクセラレーションレンダリング対応
### 🗂 Vault
- **複数ビュー** — グリッド / リスト / ツリー
- **高速検索** — ホストやグループを素早く見つける
### 🖥️ ターミナルワークスペース
- **分割ペイン** — 水平・垂直分割でマルチタスク
- **タブ管理** — ドラッグ&ドロップで並べ替え可能な複数セッション
- **セッション永続化** — 再起動後もセッションを復元
- **ブロードキャストモード** — 一度の入力で複数のターミナルに送信
- **セッション管理** — 複数の接続を並行して扱う
### 🔐 SSH クライアント
- **SSH2 プロトコル**、完全な認証サポート
- **パスワード&キー認証**
- **SSH 証明書**サポート
- **ジャンプホスト / 踏み台サーバー** — 複数ホストを経由した接続
- **プロキシサポート** — HTTP CONNECT および SOCKS5 プロキシ
- **エージェント転送** — OpenSSH Agent および Pageant 対応
- **環境変数** — ホストごとにカスタム環境変数を設定
### 📁 SFTP + 内蔵エディタ
- **ファイル作業** — ドラッグ&ドロップでアップロード/ダウンロード
- **その場で編集** — 内蔵エディタで小さな修正を素早く
### 📁 SFTP
- **デュアルペインファイルブラウザ** — ローカル ↔ リモート または リモート ↔ リモート
- **ドラッグ&ドロップ**ファイル転送
- **キュー管理**でバッチ転送
- **進捗追跡**、転送速度表示
### 🎨 パーソナライズ
- **カスタムテーマ** — UI の見た目を好みに調整
- **キーワードハイライト** — ターミナル出力の強調表示ルールをカスタマイズ
### 🔑 キーチェーン
- **SSH キー生成** — RSA、ECDSA、ED25519
- **既存キーのインポート** — PEM、OpenSSH 形式
- **SSH 証明書**サポート
- **アイデンティティ管理** — 再利用可能なユーザー名+認証方式の組み合わせ
- **公開鍵をエクスポート**してリモートホストへ
---
### 🔌 ポートフォワーディング
- **ローカルフォワーディング** — リモートサービスをローカルに公開
- **リモートフォワーディング** — ローカルサービスをリモートに公開
- **ダイナミックフォワーディング** — SOCKS5 プロキシ
- **ビジュアルトンネル管理**
<a name="デモ"></a>
# デモ
### ☁️ クラウド同期
- **エンドツーエンド暗号化同期** — デバイスを離れる前にデータを暗号化
- **複数のプロバイダー** — GitHub Gist、S3 互換ストレージ、WebDAV、Google Drive、OneDrive
- **ホスト、キー、スニペット、設定を同期**
GIF で機能をさっと確認できます(素材は `screenshots/gifs/`
### 🎨 テーマとカスタマイズ
- **ライト&ダークモード**
- **カスタムアクセントカラー**
- **50以上のターミナル配色**
- **フォントカスタマイズ** — JetBrains Mono、Fira Code など
- **多言語対応** — English、简体中文 など
### Vault ビュー:グリッド / リスト / ツリー
状況に合わせて見え方を切り替え。グリッドで全体像、リストで密度、ツリーで階層を扱えます。
![Vault ビュー:グリッド/リスト/ツリー](screenshots/gifs/gird-list-tre-views.gif)
### 分割ターミナル + セッション管理
複数セッションを分割ペインで並べて作業。関連タスクを横並びにしてコンテキストスイッチを減らします。
![分割ターミナル + セッション管理](screenshots/gifs/dual-terminal--split-manage.gif)
### SFTPドラッグドロップ + 内蔵エディタ
ドラッグ&ドロップでファイルを移動し、内蔵エディタでそのまま編集できます。
![SFTPドラッグドロップ + 内蔵エディタ](screenshots/gifs/sftpview-with-drag-and-built-in-editor.gif)
### ドラッグでアップロード
ファイルをそのままドロップしてアップロードを開始。ダイアログ操作を減らせます。
![ドラッグでアップロード](screenshots/gifs/drag-file-upload.gif)
### カスタムテーマ
テーマを調整して自分の好みに合わせた見た目に。
![カスタムテーマ](screenshots/gifs/custom-themes.gif)
### キーワードハイライト
重要な出力(エラー/警告/マーカーなど)を見つけやすくするために、ハイライトをカスタマイズできます。
![キーワードハイライト](screenshots/gifs/custom-highlight.gif)
---
<a name="スクリーンショット"></a>
# スクリーンショット
<a name="ホスト管理"></a>
## ホスト管理
<a name="メインウィンドウ"></a>
## メインウィンドウ
Vault ビューはすべての SSH 接続を管理するコマンドセンターです。右クリックメニューで階層的なグループを作成し、グループ間でホストをドラッグ、パンくずナビゲーションでホストツリーを素早く移動できます。各ホストは接続状態、OS アイコン、クイック接続ボタンを表示。グリッドとリストビューを切り替え、強力な検索で名前、ホスト名、タグ、グループでフィルタリングできます。
メインウィンドウは、長時間の SSH 作業を前提に設計されています。セッション、ナビゲーション、主要ツールへ素早くアクセスできます。
**ダークモード**
![メインウィンドウ(ダーク)](screenshots/main-window-dark.png)
![ホスト管理](screenshots/vault_grid_view.png)
![メインウィンドウ(ライト)](screenshots/main-window-light.png)
**ネストされたフォルダと整理**
<a name="vault-ビュー"></a>
## Vault ビュー
![ネストされたフォルダ](screenshots/nested_folder_structure.png)
作業に合わせて見え方を切り替え:グリッドで全体像、リストでスキャン、ツリーで整理と階層ナビゲーション。
**リストビュー**
![Vault グリッドビュー](screenshots/vault_grid_view.png)
![リストビュー](screenshots/vault_list_view.png)
![Vault リストビュー](screenshots/vault_list_view.png)
<a name="ターミナル"></a>
## ターミナル
![Vault ツリービュー(ダーク)](screenshots/treeview-dark.png)
WebGL アクセラレーション対応の xterm.js ベースのターミナルで、スムーズでレスポンシブな体験を提供。ワークスペースを水平または垂直に分割して、複数のセッションを同時に監視。ブロードキャストモードを有効にすると、すべてのターミナルに一度にコマンドを送信できます — フリート管理に最適。テーマカスタマイズパネルでは、50以上の配色スキームをライブプレビュー、フォントサイズの調整、JetBrains Mono や Fira Code を含む複数のフォントファミリーを選択できます。
![Vault ツリービュー(ライト)](screenshots/treeview-light.png)
**分割ウィンドウ**
<a name="分割ターミナル"></a>
## 分割ターミナル
**ブロードキャストモード**
分割ペインで複数のサーバー/タスクを同時に扱えます(例:デプロイ + ログ + 監視)。
一度入力すれば、どこでも実行できます。複数のサーバーを同時にメンテナンスするのに最適です。
![ブロードキャストモード](screenshots/broadcast_mode.png)
**パフォーマンス情報とカスタマイズ**
接続の健全性を監視し、ターミナルのあらゆる側面をカスタマイズします。
![ターミナルパフォーマンス](screenshots/terminal_performance.png)
<a name="sftp"></a>
## SFTP
デュアルペイン SFTP ブラウザは、ローカルからリモート、リモートからリモートへのファイル転送をサポート。シングルクリックでディレクトリを移動、ペイン間でファイルをドラッグ&ドロップ、転送進捗をリアルタイムで監視。インターフェースにはファイル権限、サイズ、変更日時を表示。複数の転送をキューに入れ、詳細な速度と進捗インジケーターで完了を確認。コンテキストメニューから名前変更、削除、ダウンロード、アップロード操作にすばやくアクセス。
![SFTP デュアルペイン](screenshots/sftp_dual_pane.png)
**転送キュー**
![転送キュー](screenshots/sftp_transfer_queue.png)
<a name="キーチェーン"></a>
## キーチェーン
キーチェーンは SSH 認証情報を保管する安全な保管庫です。新しいキーを生成、既存のキーをインポート、エンタープライズ認証用の SSH 証明書を管理できます。
| キータイプ | アルゴリズム | 推奨用途 |
|----------|------------|---------|
| **ED25519** | EdDSA | モダン、高速、最も安全(推奨) |
| **ECDSA** | NIST P-256/384/521 | 高いセキュリティ、広くサポート |
| **RSA** | RSA 2048/4096 | レガシー互換性、ユニバーサルサポート |
| **証明書** | CA 署名 | エンタープライズ環境、短期認証 |
**機能:**
- 🔑 カスタマイズ可能なビット長でキーを生成
- 📥 PEM/OpenSSH 形式のキーをインポート
- 👤 再利用可能なアイデンティティを作成(ユーザー名+認証方式)
- 📤 ワンクリックで公開鍵をリモートホストにエクスポート
![キーマネージャー](screenshots/key-manager.png)
**キー生成**
![キー生成](screenshots/key_generator_ui.png)
<a name="ポートフォワーディング"></a>
## ポートフォワーディング
直感的なビジュアルインターフェースで SSH トンネルをセットアップ。各トンネルはリアルタイムステータスを表示し、アクティブ、接続中、エラー状態を明確に示します。トンネル設定を保存してセッション間で素早く再利用。
| タイプ | 方向 | ユースケース | 例 |
|-------|-----|------------|---|
| **ローカル** | リモート → ローカル | リモートサービスをローカルマシンでアクセス | リモート MySQL `3306``localhost:3306` に転送 |
| **リモート** | ローカル → リモート | ローカルサービスをリモートサーバーと共有 | ローカル開発サーバーをリモートマシンに公開 |
| **ダイナミック** | SOCKS5 プロキシ | SSH トンネル経由で安全にブラウジング | 暗号化された SSH 接続経由でインターネットをブラウズ |
![ポートフォワーディング](screenshots/port-forwadring.png)
<a name="クラウド同期"></a>
## クラウド同期
エンドツーエンド暗号化で、すべてのデバイス間でホスト、キー、スニペット、設定を同期。マスターパスワードがアップロード前にすべてのデータをローカルで暗号化 — クラウドプロバイダーは平文を見ることはありません。
| プロバイダー | 最適な用途 | セットアップ複雑度 |
|------------|----------|-----------------|
| **GitHub Gist** | クイックセットアップ、バージョン履歴 | ⭐ 簡単 |
| **Google Drive** | 個人利用、大容量ストレージ | ⭐ 簡単 |
| **OneDrive** | Microsoft エコシステムユーザー | ⭐ 簡単 |
| **S3 互換** | AWS、MinIO、Cloudflare R2、セルフホスト | ⭐⭐ 中程度 |
| **WebDAV** | Nextcloud、ownCloud、セルフホスト | ⭐⭐ 中程度 |
**同期対象:**
- ✅ ホストと接続設定
- ✅ SSH キーと証明書
- ✅ アイデンティティと認証情報
- ✅ スニペットとスクリプト
- ✅ カスタムグループとタグ
- ✅ ポートフォワーディングルール
- ✅ アプリケーション設定
![クラウド同期](screenshots/cloud-sync.png)
<a name="テーマとカスタマイズ"></a>
## テーマとカスタマイズ
Netcatty を自分だけのものに。ライトモードとダークモードを切り替えたり、システム設定に従わせたり。好みに合わせてアクセントカラーを選択。アプリケーションは English や简体中文を含む複数の言語をサポートしており、コミュニティによる翻訳貢献を歓迎しています。クラウド同期を有効にすると、すべての設定がデバイス間で同期され、パーソナライズされた体験がどこでも利用できます。
![テーマと国際化](screenshots/app-themes-i18n.png)
![分割ウィンドウ](screenshots/split-window.png)
---
<a name="対応ディストリビューション"></a>
# 対応ディストリビューション
Netcatty は接続したホストの OS アイコンを自動的に検出・表示します:
Netcatty は接続したホストの OS を検出し、ホスト一覧でアイコンとして表示します:
<p align="center">
<img src="public/distro/ubuntu.svg" width="48" alt="Ubuntu" title="Ubuntu">
@@ -278,11 +207,11 @@ Netcatty は接続したホストの OS アイコンを自動的に検出・表
[GitHub Releases](https://github.com/binaricat/Netcatty/releases/latest) からお使いのプラットフォームに対応した最新版をダウンロードしてください。
| プラットフォーム | アーキテクチャ | ステータス |
|------------------|----------------|------------|
| **macOS** | Apple Silicon (M1/M2/M3) | ✅ サポート |
| **macOS** | Intel | ✅ サポート |
| **Windows** | x64 | ✅ サポート |
| OS | サポート状況 |
| :--- | :--- |
| **macOS** | Universal (x64 / arm64) |
| **Windows** | x64 / arm64 |
| **Linux** | x64 / arm64 |
または [GitHub Releases](https://github.com/binaricat/Netcatty/releases) ですべてのリリースを参照してください。
@@ -344,7 +273,7 @@ npm run pack
# 特定のプラットフォーム用にパッケージ
npm run pack:mac # macOS (DMG + ZIP)
npm run pack:win # Windows (NSIS インストーラー)
npm run pack:linux # Linux (AppImage, deb, rpm)
npm run pack:linux # Linux (AppImage + DEB + RPM)
```
---
@@ -354,7 +283,7 @@ npm run pack:linux # Linux (AppImage, deb, rpm)
| カテゴリ | テクノロジー |
|--------|------------|
| フレームワーク | Electron 39 |
| フレームワーク | Electron 40 |
| フロントエンド | React 19, TypeScript |
| ビルドツール | Vite 7 |
| ターミナル | xterm.js 5 |
@@ -380,17 +309,6 @@ npm run pack:linux # Linux (AppImage, deb, rpm)
---
<a name="コントリビューター"></a>
# コントリビューター
貢献してくれたすべての人々に感謝します!
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" alt="contributors" />
</a>
---
<a name="ライセンス"></a>
# ライセンス

250
README.md
View File

@@ -11,7 +11,7 @@
<p align="center">
A beautiful, feature-rich SSH workspace built with Electron, React, and xterm.js.<br/>
Host management, split terminals, SFTP, port forwarding, and cloud sync — all in one.
Split terminals, Vault views, SFTP workflows, custom themes, and keyword highlighting — all in one.
</p>
<p align="center">
@@ -40,22 +40,20 @@
---
[![Netcatty Main Interface](screenshots/vault_grid_view.png)](screenshots/vault_grid_view.png)
[![Netcatty Main Interface](screenshots/main-window-dark.png)](screenshots/main-window-dark.png)
---
# Contents <!-- omit in toc -->
- [What is Netcatty](#what-is-netcatty)
- [Why Netcatty](#why-netcatty)
- [Features](#features)
- [Demos](#demos)
- [Screenshots](#screenshots)
- [Host Management](#host-management)
- [Terminal](#terminal)
- [SFTP](#sftp)
- [Keychain](#keychain)
- [Port Forwarding](#port-forwarding)
- [Cloud Sync](#cloud-sync)
- [Themes & Customization](#themes--customization)
- [Main Window](#main-window)
- [Vault Views](#vault-views)
- [Split Terminals](#split-terminals)
- [Supported Distros](#supported-distros)
- [Getting Started](#getting-started)
- [Build & Package](#build--package)
@@ -73,179 +71,111 @@
- **Netcatty is** an alternative to PuTTY, Termius, SecureCRT, and macOS Terminal.app for SSH connections
- **Netcatty is** a powerful SFTP client with dual-pane file browser
- **Netcatty is** a terminal workspace with split panes, tabs, and session management
- **Netcatty is not** a shell replacement — it connects to remote shells via SSH/Telnet or local terminals
- **Netcatty supports** SSH, local terminal, Telnet, Mosh, and Serial connections (when available)
- **Netcatty is not** a shell replacement — it connects to shells via SSH/Telnet/Mosh or local/serial sessions
---
<a name="why-netcatty"></a>
# Why Netcatty
If you regularly work with a fleet of servers, Netcatty is built for speed and flow:
- **Workspace-first** — split panes + tabs + session restore for “always-on” workflows
- **Vault organization** — grid/list/tree views with fast search and drag-friendly workflows
- **Serious SFTP** — built-in editor + drag & drop + smooth file operations
---
<a name="features"></a>
# Features
### 🖥 Terminal & Sessions
- **xterm.js-based terminal** with GPU-accelerated rendering
### 🗂 Vault
- **Multiple views** — grid / list / tree
- **Fast search** — locate hosts and groups quickly
### 🖥️ Terminal Workspaces
- **Split panes** — horizontal and vertical splits for multi-tasking
- **Tab management** — multiple sessions with drag-to-reorder
- **Session persistence** — restore sessions on restart
- **Broadcast mode** — type once, send to multiple terminals
- **Session management** — run multiple connections side-by-side
### 🔐 SSH Client
- **SSH2 protocol** with full authentication support
- **Password & key-based authentication**
- **SSH certificates** support
- **Jump hosts / Bastion** — chain through multiple hosts
- **Proxy support** — HTTP CONNECT and SOCKS5 proxies
- **Agent forwarding** — including OpenSSH Agent and Pageant
- **Environment variables** — set custom env vars per host
### 📁 SFTP + Built-in Editor
- **File workflows** — drag & drop uploads/downloads
- **Edit in place** — built-in editor for quick changes
### 📁 SFTP
- **Dual-pane file browser** — local ↔ remote or remote ↔ remote
- **Drag & drop** file transfers
- **Queue management** for batch transfers
- **Progress tracking** with transfer speed
### 🎨 Personalization
- **Custom themes** — tune the app appearance to your taste
- **Keyword highlighting** — customize highlight rules for terminal output
### 🔑 Keychain
- **Generate SSH keys** — RSA, ECDSA, ED25519
- **Import existing keys** — PEM, OpenSSH formats
- **SSH certificates** support
- **Identity management** — reusable username + auth combinations
- **Export public keys** to remote hosts
---
### 🔌 Port Forwarding
- **Local forwarding** — expose remote services locally
- **Remote forwarding** — expose local services remotely
- **Dynamic forwarding** — SOCKS5 proxy
- **Visual tunnel management**
<a name="demos"></a>
# Demos
### ☁️ Cloud Sync
- **End-to-end encrypted sync** — your data is encrypted before leaving your device
- **Multiple providers** — GitHub Gist, S3-compatible storage, WebDAV, Google Drive, OneDrive
- **Sync hosts, keys, snippets, and settings**
GIF previews (stored in `screenshots/gifs/`), rendered inline on GitHub:
### 🎨 Themes & Customization
- **Light & Dark mode**
- **Custom accent colors**
- **50+ terminal color schemes**
- **Font customization** — JetBrains Mono, Fira Code, and more
- **i18n support** — English, 简体中文, and more
### Vault views: grid / list / tree
Switch between different Vault views to match your workflow: overview in grid, dense scanning in list, and hierarchical navigation in tree.
![Vault views: grid/list/tree](screenshots/gifs/gird-list-tre-views.gif)
### Split terminals + session management
Work in multiple sessions at once with split panes. Keep related tasks side-by-side and reduce context switching.
![Split terminals + session management](screenshots/gifs/dual-terminal--split-manage.gif)
### SFTP: drag & drop + built-in editor
Move files with drag & drop, then edit quickly using the built-in editor without leaving the app.
![SFTP: drag & drop + built-in editor](screenshots/gifs/sftpview-with-drag-and-built-in-editor.gif)
### Drag file upload
Drop files into the app to kick off uploads without hunting through dialogs.
![Drag file upload](screenshots/gifs/drag-file-upload.gif)
### Custom themes
Make Netcatty yours: customize themes and UI appearance.
![Custom themes](screenshots/gifs/custom-themes.gif)
### Keyword highlighting
Highlight important terminal output so errors, warnings, and key events stand out at a glance.
![Keyword highlighting](screenshots/gifs/custom-highlight.gif)
---
<a name="screenshots"></a>
# Screenshots
<a name="host-management"></a>
## Host Management
<a name="main-window"></a>
## Main Window
The Vault view is your command center for managing all SSH connections. Create hierarchical groups with right-click context menus, drag hosts between groups, and use breadcrumb navigation to quickly traverse your host tree. Each host displays its connection status, OS icon, and quick-connect button. Switch between grid and list views based on your preference, and use the powerful search to filter hosts by name, hostname, tags, or group.
The main window is designed for long-running SSH workflows: quick access to sessions, navigation, and core tools in one place.
**Dark Mode**
![Main Window (Dark)](screenshots/main-window-dark.png)
![Host Management](screenshots/vault_grid_view.png)
![Main Window (Light)](screenshots/main-window-light.png)
**Nested Folders & Organization**
<a name="vault-views"></a>
## Vault Views
![Nested Folders](screenshots/nested_folder_structure.png)
Organize and navigate your hosts using the view that best fits the moment: grid for overview, list for scanning, tree for structure.
**List View**
![Vault Grid View](screenshots/vault_grid_view.png)
![List View](screenshots/vault_list_view.png)
![Vault List View](screenshots/vault_list_view.png)
<a name="terminal"></a>
## Terminal
![Vault Tree View (Dark)](screenshots/treeview-dark.png)
Powered by xterm.js with WebGL acceleration, the terminal delivers a smooth, responsive experience. Split your workspace horizontally or vertically to monitor multiple sessions simultaneously. Enable broadcast mode to send commands to all terminals at once — perfect for fleet management. The theme customization panel offers 50+ color schemes with live preview, adjustable font size, and multiple font family options including JetBrains Mono and Fira Code.
![Vault Tree View (Light)](screenshots/treeview-light.png)
**Split Windows**
<a name="split-terminals"></a>
## Split Terminals
**Broadcast Mode**
Split panes help you monitor multiple servers/services at the same time (deploy + logs + metrics) without juggling windows.
Type once, execute everywhere. Great for maintaining multiple servers simultaneously.
![Broadcast Mode](screenshots/broadcast_mode.png)
**Performance Info & Customization**
Monitor your connection health and customize every aspect of your terminal.
![Terminal Performance](screenshots/terminal_performance.png)
<a name="sftp"></a>
## SFTP
The dual-pane SFTP browser supports local-to-remote and remote-to-remote file transfers. Navigate directories with single-click, drag files between panes, and monitor transfer progress in real-time. The interface shows file permissions, sizes, and modification dates. Queue multiple transfers and watch them complete with detailed speed and progress indicators. Context menus provide quick access to rename, delete, download, and upload operations.
![SFTP Dual Pane](screenshots/sftp_dual_pane.png)
**Transfer Queue**
![Transfer Queue](screenshots/sftp_transfer_queue.png)
<a name="keychain"></a>
## Keychain
The Keychain is your secure vault for SSH credentials. Generate new keys, import existing ones, or manage SSH certificates for enterprise authentication.
| Key Type | Algorithm | Recommended Use |
|----------|-----------|----------------|
| **ED25519** | EdDSA | Modern, fast, most secure (recommended) |
| **ECDSA** | NIST P-256/384/521 | Good security, widely supported |
| **RSA** | RSA 2048/4096 | Legacy compatibility, universal support |
| **Certificate** | CA-signed | Enterprise environments, short-lived auth |
**Features:**
- 🔑 Generate keys with customizable bit lengths
- 📥 Import PEM/OpenSSH format keys
- 👤 Create reusable identities (username + auth method)
- 📤 One-click export public keys to remote hosts
![Key Manager](screenshots/key-manager.png)
**Key Generator**
![Key Generator](screenshots/key_generator_ui.png)
<a name="port-forwarding"></a>
## Port Forwarding
Set up SSH tunnels with an intuitive visual interface. Each tunnel shows real-time status with clear indicators for active, connecting, or error states. Save tunnel configurations for quick reuse across sessions.
| Type | Direction | Use Case | Example |
|------|-----------|----------|--------|
| **Local** | Remote → Local | Access remote services on your machine | Forward remote MySQL `3306` to `localhost:3306` |
| **Remote** | Local → Remote | Share local services with remote server | Expose local dev server to remote machine |
| **Dynamic** | SOCKS5 Proxy | Secure browsing through SSH tunnel | Browse internet via encrypted SSH connection |
![Port Forwarding](screenshots/port-forwadring.png)
<a name="cloud-sync"></a>
## Cloud Sync
Keep your hosts, keys, snippets, and settings synchronized across all your devices with end-to-end encryption. Your master password encrypts all data locally before upload — the cloud provider never sees plaintext.
| Provider | Best For | Setup Complexity |
|----------|----------|------------------|
| **GitHub Gist** | Quick setup, version history | ⭐ Easy |
| **Google Drive** | Personal use, large storage | ⭐ Easy |
| **OneDrive** | Microsoft ecosystem users | ⭐ Easy |
| **S3-Compatible** | AWS, MinIO, Cloudflare R2, self-hosted | ⭐⭐ Medium |
| **WebDAV** | Nextcloud, ownCloud, self-hosted | ⭐⭐ Medium |
**What syncs:**
- ✅ Hosts & connection settings
- ✅ SSH keys & certificates
- ✅ Identities & credentials
- ✅ Snippets & scripts
- ✅ Custom groups & tags
- ✅ Port forwarding rules
- ✅ Application preferences
![Cloud Sync](screenshots/cloud-sync.png)
<a name="themes--customization"></a>
## Themes & Customization
Make Netcatty truly yours with extensive customization options. Toggle between light and dark modes, or let the app follow your system preference. Pick any accent color to match your style. The application supports multiple languages including English and 简体中文, with more translations welcome via community contributions. All preferences sync across devices when cloud sync is enabled, so your personalized experience follows you everywhere.
![Themes & i18n](screenshots/app-themes-i18n.png)
![Split Windows](screenshots/split-window.png)
---
@@ -269,8 +199,6 @@ Netcatty automatically detects and displays OS icons for connected hosts:
<img src="public/distro/kali.svg" width="48" alt="Kali Linux" title="Kali Linux">
</p>
---
<a name="getting-started"></a>
# Getting Started
@@ -278,11 +206,11 @@ Netcatty automatically detects and displays OS icons for connected hosts:
Download the latest release for your platform from [GitHub Releases](https://github.com/binaricat/Netcatty/releases/latest).
| Platform | Architecture | Status |
|----------|--------------|--------|
| **macOS** | Apple Silicon (M1/M2/M3) | ✅ Supported |
| **macOS** | Intel | ✅ Supported |
| **Windows** | x64 | ✅ Supported |
| OS | Support |
| :--- | :--- |
| **macOS** | Universal (x64 / arm64) |
| **Windows** | x64 / arm64 |
| **Linux** | x64 / arm64 |
Or browse all releases at [GitHub Releases](https://github.com/binaricat/Netcatty/releases).
@@ -354,7 +282,7 @@ npm run pack:linux # Linux (AppImage + DEB + RPM)
| Category | Technology |
|----------|------------|
| Framework | Electron 39 |
| Framework | Electron 40 |
| Frontend | React 19, TypeScript |
| Build Tool | Vite 7 |
| Terminal | xterm.js 5 |
@@ -385,9 +313,7 @@ See [agents.md](agents.md) for architecture overview and coding conventions.
Thanks to all the people who contribute!
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" alt="contributors" />
</a>
See: https://github.com/binaricat/Netcatty/graphs/contributors
---

View File

@@ -11,13 +11,13 @@
<p align="center">
一个基于 Electron、React 和 xterm.js 构建的功能丰富的 SSH 工作空间。<br/>
主机管理、分屏终端、SFTP、端口转发、云同步 —— 一应俱全。
分屏终端、Vault 多视图、SFTP 工作流、自定义主题、关键词高亮 —— 一应俱全。
</p>
<p align="center">
<a href="https://github.com/binaricat/Netcatty/releases/latest"><img alt="GitHub Release" src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=Release"></a>
&nbsp;
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows-blue?style=for-the-badge&logo=electron"></a>
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows%20%7C%20Linux-blue?style=for-the-badge&logo=electron"></a>
&nbsp;
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/License-GPL--3.0-green?style=for-the-badge"></a>
</p>
@@ -40,22 +40,20 @@
---
[![Netcatty 主界面](screenshots/vault_grid_view.png)](screenshots/vault_grid_view.png)
[![Netcatty 主界面](screenshots/main-window-dark.png)](screenshots/main-window-dark.png)
---
# 目录 <!-- omit in toc -->
- [Netcatty 是什么](#netcatty-是什么)
- [为什么是 Netcatty](#为什么是-netcatty)
- [功能特性](#功能特性)
- [演示](#演示)
- [界面截图](#界面截图)
- [机管理](#主机管理)
- [终端](#终端)
- [SFTP](#sftp)
- [密钥管理](#密钥管理)
- [端口转发](#端口转发)
- [云同步](#云同步)
- [主题与定制](#主题与定制)
- [界面](#主界面)
- [Vault 视图](#vault-视图)
- [分屏终端](#分屏终端)
- [支持的发行版](#支持的发行版)
- [快速开始](#快速开始)
- [构建与打包](#构建与打包)
@@ -73,186 +71,118 @@
- **Netcatty 是** PuTTY、Termius、SecureCRT 和 macOS Terminal.app 的现代替代品
- **Netcatty 是** 一个强大的 SFTP 客户端,支持双窗格文件浏览
- **Netcatty 是** 一个终端工作空间,支持分屏、标签页和会话管理
- **Netcatty 不是** Shell 替代品 —— 它通过 SSH/Telnet 或本地终端连接到远程 Shell
- **Netcatty 支持** SSH、本地终端、Telnet、Mosh、串口Serial等连接方式视环境而定
- **Netcatty 不是** Shell 替代品 —— 它通过 SSH/Telnet/Mosh 或本地/串口会话连接到 Shell
---
<a name="为什么是-netcatty"></a>
# 为什么是 Netcatty
如果你需要同时维护多台服务器Netcatty 更像是“工作台”而不是单一终端:
- **以工作区为核心** —— 分屏 + 多会话并行,适合长期驻留的工作流
- **Vault 管理** —— 网格/列表/树形视图,配合搜索与拖拽更顺手
- **认真做的 SFTP** —— 内置编辑器 + 拖拽上传,文件操作更丝滑
---
<a name="功能特性"></a>
# 功能特性
### 🖥 终端与会话
- **基于 xterm.js 的终端**,支持 GPU 加速渲染
- **分屏功能** —— 水平和垂直分割,多任务并行
- **标签页管理** —— 多会话支持,拖拽排序
- **会话持久化** —— 重启后恢复会话
- **广播模式** —— 一次输入,发送到多个终端
### 🗂 Vault
- **多种视图** —— 网格 / 列表 / 树形
- **快速搜索** —— 迅速定位主机与分组
### 🔐 SSH 客户端
- **SSH2 协议**,完整的认证支持
- **密码和密钥认证**
- **SSH 证书**支持
- **跳板机 / 堡垒机** —— 多主机链式连接
- **代理支持** —— HTTP CONNECT 和 SOCKS5 代理
- **Agent 转发** —— 支持 OpenSSH Agent 和 Pageant
- **环境变量** —— 为每个主机设置自定义环境变量
### 🖥️ 终端工作区
- **分屏** —— 水平/垂直分割,多任务并行
- **多会话管理** —— 多连接并排处理
### 📁 SFTP
- **双窗格文件浏览器** —— 本地 ↔ 远程 或 远程 ↔ 远程
- **拖放传输** 文件
- **队列管理** 批量传输
- **进度跟踪** 显示传输速度
### 📁 SFTP + 内置编辑器
- **文件工作流** —— 拖拽上传/下载更直观
- **就地编辑** —— 内置编辑器快速修改文件
### 🔑 密钥管理
- **生成 SSH 密钥** —— RSA、ECDSA、ED25519
- **导入已有密钥** —— PEM、OpenSSH 格式
- **SSH 证书**支持
- **身份管理** —— 可复用的用户名 + 认证方式组合
- **导出公钥**到远程主机
### 🎨 个性化
- **自定义主题** —— 按喜好调整应用外观
- **关键词高亮** —— 自定义终端输出高亮规则
### 🔌 端口转发
- **本地转发** —— 将远程服务暴露到本地
- **远程转发** —— 将本地服务暴露到远程
- **动态转发** —— SOCKS5 代理
- **可视化隧道管理**
---
### ☁️ 云同步
- **端到端加密同步** —— 数据在离开设备前加密
- **多种存储后端** —— GitHub Gist、S3 兼容存储、WebDAV、Google Drive、OneDrive
- **同步主机、密钥、代码片段和设置**
<a name="演示"></a>
# 演示
### 🎨 主题与定制
- **浅色 & 深色模式**
- **自定义强调色**
- **50+ 终端配色方案**
- **字体自定义** —— JetBrains Mono、Fira Code 等
- **多语言支持** —— English、简体中文 等
GIF 预览(素材均在 `screenshots/gifs/`),在 GitHub README 中可直接观看:
### Vault 视图:网格 / 列表 / 树形
根据不同场景自由切换视图:网格适合总览,列表适合密集浏览,树形适合层级导航与整理。
![Vault 视图:网格/列表/树形](screenshots/gifs/gird-list-tre-views.gif)
### 分屏终端 + 会话管理
用分屏把多个会话并排放在同一个工作区里,降低来回切换窗口/标签页的成本。
![分屏终端 + 会话管理](screenshots/gifs/dual-terminal--split-manage.gif)
### SFTP拖拽 + 内置编辑器
通过拖拽完成文件传输,并用内置编辑器快速修改文件内容,不用来回切换工具。
![SFTP拖拽 + 内置编辑器](screenshots/gifs/sftpview-with-drag-and-built-in-editor.gif)
### 拖拽文件上传
把文件直接拖进应用即可触发上传流程,省去多层对话框与路径选择。
![拖拽文件上传](screenshots/gifs/drag-file-upload.gif)
### 自定义主题
按自己的审美与习惯定制主题与界面外观,让日常使用更顺手。
![自定义主题](screenshots/gifs/custom-themes.gif)
### 关键词高亮
让关键输出一眼可见:错误、告警或特定标记被高亮后更容易扫到与定位。
![关键词高亮](screenshots/gifs/custom-highlight.gif)
---
<a name="界面截图"></a>
# 界面截图
<a name="主机管理"></a>
## 主机管理
<a name="主界面"></a>
## 主界面
Vault 视图是管理所有 SSH 连接的控制中心。通过右键菜单创建层级分组,在分组间拖拽主机,使用面包屑导航快速遍历主机树。每个主机显示连接状态、操作系统图标和快速连接按钮。根据偏好在网格和列表视图之间切换,使用强大的搜索按名称、主机名、标签或分组过滤主机
主界面围绕长期 SSH 工作流设计:把会话、导航和常用工具集中到同一处,减少切换成本
**深色模式**
![主界面(深色)](screenshots/main-window-dark.png)
![机管理](screenshots/vault_grid_view.png)
![界面(浅色)](screenshots/main-window-light.png)
**层级文件夹与分组**
<a name="vault-视图"></a>
## Vault 视图
![层级文件夹](screenshots/nested_folder_structure.png)
用更适合当前任务的方式管理与浏览主机:网格看全局,列表做筛选,树形做整理与层级导航。
**列表视图**
![Vault 网格视图](screenshots/vault_grid_view.png)
![列表视图](screenshots/vault_list_view.png)
![Vault 列表视图](screenshots/vault_list_view.png)
<a name="终端"></a>
## 终端
![Vault 树形视图(深色)](screenshots/treeview-dark.png)
基于 xterm.js 的 WebGL 加速终端,提供流畅、响应迅速的体验。水平或垂直分割工作区,同时监控多个会话。启用广播模式可一次向所有终端发送命令 —— 非常适合批量管理。主题定制面板提供 50+ 配色方案和实时预览、可调节字号以及多种字体选择,包括 JetBrains Mono 和 Fira Code。
![Vault 树形视图(浅色)](screenshots/treeview-light.png)
**分屏窗口**
<a name="分屏终端"></a>
## 分屏终端
**广播模式**
分屏适合同时处理多个任务(例如部署 + 日志 + 排障),不用频繁切换窗口。
一次输入,多处执行。非常适合同时维护这多台服务器。
![广播模式](screenshots/broadcast_mode.png)
**性能信息与定制**
监控连接健康状况,并自定义终端的方方面面。
![终端性能](screenshots/terminal_performance.png)
<a name="sftp"></a>
## SFTP
双窗格 SFTP 浏览器支持本地到远程和远程到远程的文件传输。单击导航目录,在窗格之间拖放文件,实时监控传输进度。界面显示文件权限、大小和修改日期。批量传输队列管理,详细的速度和进度指示器。右键菜单快速访问重命名、删除、下载和上传操作。
![SFTP 双窗格](screenshots/sftp_dual_pane.png)
**传输队列**
![传输队列](screenshots/sftp_transfer_queue.png)
<a name="密钥管理"></a>
## 密钥管理
密钥库是您存储 SSH 凭证的安全保险库。生成新密钥、导入已有密钥或管理企业认证的 SSH 证书。
| 密钥类型 | 算法 | 推荐用途 |
|---------|------|---------|
| **ED25519** | EdDSA | 现代、快速、最安全(推荐) |
| **ECDSA** | NIST P-256/384/521 | 安全性好、广泛支持 |
| **RSA** | RSA 2048/4096 | 旧版兼容、通用支持 |
| **证书** | CA 签名 | 企业环境、短期认证 |
**功能:**
- 🔑 生成可自定义位长的密钥
- 📥 导入 PEM/OpenSSH 格式密钥
- 👤 创建可复用身份(用户名 + 认证方式)
- 📤 一键导出公钥到远程主机
![密钥管理器](screenshots/key-manager.png)
**密钥生成器**
![密钥生成器](screenshots/key_generator_ui.png)
<a name="端口转发"></a>
## 端口转发
通过直观的可视化界面设置 SSH 隧道。每个隧道显示实时状态,清晰指示活动、连接中或错误状态。保存隧道配置以便跨会话快速复用。
| 类型 | 方向 | 使用场景 | 示例 |
|-----|-----|---------|-----|
| **本地** | 远程 → 本地 | 在本机访问远程服务 | 将远程 MySQL `3306` 转发到 `localhost:3306` |
| **远程** | 本地 → 远程 | 与远程服务器共享本地服务 | 将本地开发服务器暴露给远程机器 |
| **动态** | SOCKS5 代理 | 通过 SSH 隧道安全浏览 | 通过加密 SSH 连接浏览互联网 |
![端口转发](screenshots/port-forwadring.png)
<a name="云同步"></a>
## 云同步
通过端到端加密在所有设备间同步主机、密钥、代码片段和设置。主密码在上传前本地加密所有数据 —— 云服务商永远看不到明文。
| 服务商 | 最适合 | 配置复杂度 |
|-------|-------|----------|
| **GitHub Gist** | 快速设置、版本历史 | ⭐ 简单 |
| **Google Drive** | 个人使用、大容量存储 | ⭐ 简单 |
| **OneDrive** | 微软生态用户 | ⭐ 简单 |
| **S3 兼容存储** | AWS、MinIO、Cloudflare R2、自托管 | ⭐⭐ 中等 |
| **WebDAV** | Nextcloud、ownCloud、自托管 | ⭐⭐ 中等 |
**同步内容:**
- ✅ 主机与连接设置
- ✅ SSH 密钥与证书
- ✅ 身份与凭证
- ✅ 代码片段与脚本
- ✅ 自定义分组与标签
- ✅ 端口转发规则
- ✅ 应用程序偏好设置
![云同步](screenshots/cloud-sync.png)
<a name="主题与定制"></a>
## 主题与定制
让 Netcatty 真正属于你。在浅色和深色模式之间切换,或让应用跟随系统偏好。选择任意强调色来匹配你的风格。应用支持多种语言,包括 English 和简体中文,欢迎社区贡献更多翻译。启用云同步后,所有偏好设置都会跨设备同步,个性化体验随处可用。
![主题与国际化](screenshots/app-themes-i18n.png)
![分屏窗口](screenshots/split-window.png)
---
<a name="支持的发行版"></a>
# 支持的发行版
Netcatty 自动检测并显示已连接主机的操作系统图标:
Netcatty 自动识别并在主机列表中展示对应的系统图标:
<p align="center">
<img src="public/distro/ubuntu.svg" width="48" alt="Ubuntu" title="Ubuntu">
@@ -269,8 +199,6 @@ Netcatty 自动检测并显示已连接主机的操作系统图标:
<img src="public/distro/kali.svg" width="48" alt="Kali Linux" title="Kali Linux">
</p>
---
<a name="快速开始"></a>
# 快速开始
@@ -278,11 +206,11 @@ Netcatty 自动检测并显示已连接主机的操作系统图标:
从 [GitHub Releases](https://github.com/binaricat/Netcatty/releases/latest) 下载适合您平台的最新版本。
| 平台 | 架构 | 状态 |
|------|------|------|
| **macOS** | Apple Silicon (M1/M2/M3) | ✅ 支持 |
| **macOS** | Intel | ✅ 支持 |
| **Windows** | x64 | ✅ 支持 |
| 操作系统 | 支持情况 |
| :--- | :--- |
| **macOS** | Universal (x64 / arm64) |
| **Windows** | x64 / arm64 |
| **Linux** | x64 / arm64 |
或在 [GitHub Releases](https://github.com/binaricat/Netcatty/releases) 浏览所有版本。
@@ -354,7 +282,7 @@ npm run pack:linux # Linux (AppImage, deb, rpm)
| 分类 | 技术 |
|-----|-----|
| 框架 | Electron 39 |
| 框架 | Electron 40 |
| 前端 | React 19, TypeScript |
| 构建工具 | Vite 7 |
| 终端 | xterm.js 5 |
@@ -385,9 +313,7 @@ npm run pack:linux # Linux (AppImage, deb, rpm)
感谢所有参与贡献的人!
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" alt="contributors" />
</a>
查看:https://github.com/binaricat/Netcatty/graphs/contributors
---

View File

@@ -14,6 +14,7 @@ const en: Messages = {
'common.import': 'Import',
'common.generate': 'Generate',
'common.delete': 'Delete',
'common.edit': 'Edit',
'common.clear': 'Clear',
'common.optional': 'Optional',
'common.selectPlaceholder': 'Select...',
@@ -39,6 +40,7 @@ const en: Messages = {
'sort.za': 'Z-a',
'sort.newest': 'Newest to oldest',
'sort.oldest': 'Oldest to newest',
'sort.group': 'By group',
'field.label': 'Label',
'field.type': 'Type',
'auth.keyType': 'Type {type}',
@@ -47,12 +49,18 @@ const en: Messages = {
// Dialogs / prompts
'confirm.deleteHost': 'Delete Host "{name}"?',
'confirm.deleteIdentity': 'Delete Identity "{name}"?',
'dialog.createWorkspace.title': 'Create Workspace',
'dialog.renameWorkspace.title': 'Rename workspace',
'dialog.renameSession.title': 'Rename session',
'field.name': 'Name',
'field.selectHosts': 'Select Hosts',
'placeholder.workspaceName': 'Workspace name',
'placeholder.sessionName': 'Session name',
'placeholder.searchHosts': 'Search hosts...',
'toast.settingsUnavailable': 'Settings window is unavailable on this platform.',
'credentials.protectionUnavailable.title': 'Credential Protection Unavailable',
'credentials.protectionUnavailable.message': 'Saved passwords and keys cannot be auto-decrypted on this device. Re-enter credentials before connecting.',
'credentials.protectionUnavailable.action': 'Open Settings',
// Settings shell
'settings.title': 'Settings',
@@ -76,6 +84,61 @@ const en: Messages = {
'settings.system.clearing': 'Clearing...',
'settings.system.clearResult': 'Deleted {deleted} file(s), {failed} failed.',
'settings.system.tempDirectoryHint': 'Temporary files are created when opening remote files with external applications. They are automatically cleaned up when SFTP sessions close.',
'settings.system.credentials.title': 'Credential Protection',
'settings.system.credentials.status': 'Status',
'settings.system.credentials.checking': 'Checking...',
'settings.system.credentials.available': 'Available (OS keychain ready)',
'settings.system.credentials.unavailable': 'Unavailable (cannot decrypt saved credentials)',
'settings.system.credentials.unknown': 'Unknown (not supported in this environment)',
'settings.system.credentials.unavailableHint': 'Credentials encrypted on another user profile or machine cannot be decrypted here. Re-enter and save credentials on this device.',
'settings.system.credentials.portabilityHint': 'Cloud Sync is portable because it uses your master key encryption. Local safeStorage encryption is device/user scoped.',
// Settings > Session Logs
'settings.sessionLogs.title': 'Session Logs',
'settings.sessionLogs.description': 'Configure session log export and auto-save settings.',
'settings.sessionLogs.autoSave': 'Auto-Save',
'settings.sessionLogs.enableAutoSave': 'Enable auto-save',
'settings.sessionLogs.enableAutoSaveDesc': 'Automatically save session logs when terminal sessions end.',
'settings.sessionLogs.directory': 'Save Directory',
'settings.sessionLogs.noDirectory': 'No directory selected',
'settings.sessionLogs.browse': 'Browse',
'settings.sessionLogs.openFolder': 'Open folder',
'settings.sessionLogs.directoryHint': 'Logs will be organized by host in subdirectories.',
'settings.sessionLogs.format': 'Log Format',
'settings.sessionLogs.formatDesc': 'Choose the format for saved log files.',
'settings.sessionLogs.formatTxt': 'Plain Text (.txt)',
'settings.sessionLogs.formatRaw': 'Raw with ANSI (.log)',
'settings.sessionLogs.formatHtml': 'HTML (.html)',
'settings.sessionLogs.hint': 'Session logs capture all terminal output for troubleshooting and auditing purposes.',
// Settings > Global Hotkey (Quake Mode)
'settings.globalHotkey.title': 'Global Hotkey',
'settings.globalHotkey.toggleWindow': 'Toggle Window',
'settings.globalHotkey.toggleWindowDesc': 'Press a key combination to set a global shortcut for showing/hiding the window.',
'settings.globalHotkey.notSet': 'Not set',
'settings.globalHotkey.reset': 'Reset to default',
'settings.globalHotkey.closeToTray': 'Close to System Tray',
'settings.globalHotkey.closeToTrayDesc': 'When enabled, closing the window will minimize to the system tray instead of quitting.',
'settings.globalHotkey.hint': 'Global hotkey works system-wide to quickly show or hide the window (Quake-style terminal).',
// Tray Panel
'tray.openMainWindow': 'Open Main Window',
'tray.sessions': 'Sessions',
'tray.portForwarding': 'Port Forwarding',
'tray.status.connected': 'Connected',
'tray.status.connecting': 'Connecting',
'tray.status.disconnected': 'Disconnected',
'tray.status.active': 'Active',
'tray.status.inactive': 'Inactive',
'tray.status.error': 'Error',
'tray.recentHosts': 'Recent Hosts',
'tray.empty.title': 'Nothing here yet',
'tray.empty.subtitle': 'Go connect to a server, they miss you 🚀',
'tray.quit': 'Quit Netcatty',
// Vault Sidebar
'vault.sidebar.collapse': 'Collapse sidebar',
'vault.sidebar.expand': 'Expand sidebar',
// Settings > Application
'settings.application.checkUpdates': 'Check for updates',
@@ -119,6 +182,8 @@ const en: Messages = {
'/* Example: */\n.terminal { background: #1a1a2e !important; }\n:root { --radius: 0.25rem; }',
'settings.appearance.language': 'Language',
'settings.appearance.language.desc': 'Choose the UI language',
'settings.appearance.uiFont': 'Interface Font',
'settings.appearance.uiFont.desc': 'Choose the font for the application interface',
// Settings > Terminal
'settings.terminal.section.theme': 'Terminal Theme',
@@ -165,6 +230,9 @@ const en: Messages = {
'settings.terminal.behavior.middleClickPaste': 'Middle-click paste',
'settings.terminal.behavior.middleClickPaste.desc':
'Paste clipboard content on middle-click',
'settings.terminal.behavior.bracketedPaste': 'Bracketed paste mode',
'settings.terminal.behavior.bracketedPaste.desc':
'Wrap pasted text with escape sequences so the shell can distinguish paste from typed input. Disable if you see ^[[200~ artifacts.',
'settings.terminal.behavior.scrollOnInput': 'Scroll on input',
'settings.terminal.behavior.scrollOnInput.desc': 'Scroll terminal to bottom when typing',
'settings.terminal.behavior.scrollOnOutput': 'Scroll on output',
@@ -201,6 +269,18 @@ const en: Messages = {
'settings.terminal.section.connection': 'Connection',
'settings.terminal.connection.keepaliveInterval': 'Keepalive Interval',
'settings.terminal.connection.keepaliveInterval.desc': 'How often (in seconds) to send SSH-level keepalive packets to server. Set to 0 to disable.',
'settings.terminal.section.serverStats': 'Server Stats (Linux)',
'settings.terminal.serverStats.show': 'Show Server Stats',
'settings.terminal.serverStats.show.desc': 'Display CPU, memory, and disk usage in the terminal statusbar (Linux servers only).',
'settings.terminal.serverStats.refreshInterval': 'Refresh Interval',
'settings.terminal.serverStats.refreshInterval.desc': 'How often to refresh server stats.',
'settings.terminal.serverStats.seconds': 'seconds',
// Settings > Terminal > Rendering
'settings.terminal.section.rendering': 'Rendering',
'settings.terminal.rendering.renderer': 'Renderer',
'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 > Shortcuts
'settings.shortcuts.section.scheme': 'Hotkey Scheme',
@@ -217,6 +297,7 @@ const en: Messages = {
'settings.shortcuts.category.terminal': 'Terminal',
'settings.shortcuts.category.navigation': 'Navigation',
'settings.shortcuts.category.app': 'App',
'settings.shortcuts.category.sftp': 'SFTP',
// Context menus / common actions
'action.newHost': 'New Host',
@@ -258,6 +339,7 @@ const en: Messages = {
'sync.autoSync.vaultLocked': 'Vault is locked. Open Settings → Sync & Cloud to unlock.',
'sync.autoSync.conflictDetected': 'Sync conflict detected. Open Settings → Sync & Cloud to resolve.',
'sync.autoSync.syncFailed': 'Sync failed',
'sync.credentialsUnavailable': 'This device cannot decrypt some saved credentials. Re-enter credentials locally before syncing.',
'time.never': 'Never',
'time.justNow': 'Just now',
'time.minutesAgo': '{minutes}m ago',
@@ -281,6 +363,11 @@ const en: Messages = {
'vault.groups.createDialog.desc': 'Create a new group for organizing hosts.',
'vault.groups.renameDialogTitle': 'Rename Group',
'vault.groups.renameDialog.desc': 'Rename an existing group.',
'vault.groups.deleteDialogTitle': 'Delete Group',
'vault.groups.deleteDialog.desc': 'This will permanently delete the group and move all hosts to the root level.',
'vault.groups.deleteDialog.managedDesc': 'This is a managed SSH config group. Deleting it will also delete all hosts and unlink from the source file.',
'vault.groups.deleteDialog.deleteHosts': 'Also delete all hosts in this group',
'vault.groups.ungrouped': 'Ungrouped',
'vault.groups.field.name': 'Group Name',
'vault.groups.placeholder.example': 'e.g. Production',
'vault.groups.parentLabel': 'Parent',
@@ -288,6 +375,9 @@ const en: Messages = {
'vault.groups.errors.required': 'Group name is required.',
'vault.groups.errors.invalidChars': "Group name cannot include '/' or '\\\\'.",
'vault.managedSource.unmanage': 'Unmanage',
'vault.managedSource.unmanageSuccess': 'Successfully unmanaged group',
'vault.hosts.header.entries': '{count} entries',
'vault.hosts.header.live': '{count} live',
@@ -296,10 +386,28 @@ const en: Messages = {
'vault.hosts.connect': 'Connect',
'vault.view.grid': 'Grid',
'vault.view.list': 'List',
'vault.view.tree': 'Tree',
'vault.tree.expandAll': 'Expand All',
'vault.tree.collapseAll': 'Collapse All',
'vault.hosts.newHost': 'New Host',
'vault.hosts.newGroup': 'New Group',
'vault.hosts.import': 'Import',
'vault.hosts.export': 'Export',
'vault.hosts.export.toast.success': 'Exported {count} hosts to CSV',
'vault.hosts.export.toast.successWithSkipped': 'Exported {count} hosts to CSV ({skipped} unsupported hosts skipped)',
'vault.hosts.export.toast.noHosts': 'No hosts to export',
'vault.hosts.allHosts': 'All hosts',
'vault.hosts.copyCredentials': 'Copy Credentials',
'vault.hosts.copyCredentials.toast.success': 'Credentials copied to clipboard',
'vault.hosts.copyCredentials.toast.noPassword': 'No password saved for this host',
'vault.hosts.multiSelect': 'Multi-select',
'vault.hosts.selected': '{count} selected',
'vault.hosts.selectAll': 'Select All',
'vault.hosts.deselectAll': 'Deselect All',
'vault.hosts.deleteSelected': 'Delete ({count})',
'vault.hosts.deleteMultiple.success': 'Deleted {count} hosts',
'vault.hosts.empty.title': 'Set up your hosts',
'vault.hosts.empty.desc': 'Save hosts to quickly connect to your servers, VMs, and containers.',
// Vault import
'vault.import.title': 'Add data to your vault',
@@ -316,6 +424,18 @@ const en: Messages = {
'vault.import.toast.summary':
'Imported {count} hosts (skipped {skipped}, duplicates {duplicates}).',
'vault.import.toast.firstIssue': 'First issue: {issue}',
'vault.import.sshConfig.chooseMode': 'Choose how to import your SSH config file.',
'vault.import.sshConfig.modeQuestion': 'How would you like to import?',
'vault.import.sshConfig.importOnly': 'Import Only',
'vault.import.sshConfig.importOnlyDesc': 'One-time import. Changes won\'t sync back to the file.',
'vault.import.sshConfig.managed': 'Managed Sync',
'vault.import.sshConfig.managedDesc': 'Keep in sync. Changes will be saved back to the file.',
'vault.import.sshConfig.managedGroup': 'ssh config',
'vault.import.sshConfig.managedSuccess': 'Imported {count} hosts. File is now managed.',
'vault.import.sshConfig.alreadyManaged': 'This file is already being managed.',
'vault.import.sshConfig.alreadyManagedDesc': 'This file is already managed under group "{group}". Remove the existing managed source first if you want to re-import.',
'vault.import.sshConfig.noFilePath': 'Cannot manage this file.',
'vault.import.sshConfig.noFilePathDesc': 'Unable to determine the file path. Managed sync requires access to the file system.',
// Known Hosts
'knownHosts.search.placeholder': 'Search known hosts...',
@@ -391,6 +511,13 @@ const en: Messages = {
'pf.view.list': 'List',
'pf.rule.summary.dynamic': 'SOCKS on {bindAddress}:{localPort}',
'pf.rule.summary.default': '{bindAddress}:{localPort} -> {remoteHost}:{remotePort}',
'pf.tooltip.relayHost': 'Relay Host',
'pf.tooltip.hostLabel': 'Host',
'pf.tooltip.hostAddress': 'Address',
'pf.tooltip.noHost': 'No relay host configured',
'pf.tooltip.localDesc': 'Local port forwarding: Access remote services through SSH tunnel',
'pf.tooltip.remoteDesc': 'Remote port forwarding: Expose local services to remote host',
'pf.tooltip.dynamicDesc': 'Dynamic SOCKS proxy: Route traffic through SSH tunnel',
'pf.deleteActive.title': 'Delete Active Port Forwarding?',
'pf.deleteActive.desc': 'This port forwarding rule "{label}" is currently active. Deleting it will stop the tunnel first.',
'pf.deleteActive.confirm': 'Stop and Delete',
@@ -402,14 +529,21 @@ const en: Messages = {
'sftp.newFile': 'New File',
'sftp.filter': 'Filter',
'sftp.filter.placeholder': 'Filter by filename...',
'sftp.bookmark.add': 'Bookmark this path',
'sftp.bookmark.remove': 'Remove bookmark',
'sftp.bookmark.empty': 'No bookmarks yet',
'sftp.columns.name': 'Name',
'sftp.columns.modified': 'Modified',
'sftp.columns.size': 'Size',
'sftp.columns.kind': 'Kind',
'sftp.columns.actions': 'Actions',
'sftp.emptyDirectory': 'Empty directory',
'sftp.nav.up': 'Go up',
'sftp.nav.home': 'Go to home',
'sftp.nav.refresh': 'Refresh',
'sftp.upload': 'Upload',
'sftp.uploadFiles': 'Upload files',
'sftp.uploadFolder': 'Upload folder',
'sftp.dragDropToUpload': 'Drag and drop files here to upload',
'sftp.retry': 'Retry',
'sftp.context.open': 'Open',
@@ -431,6 +565,10 @@ const en: Messages = {
'sftp.status.uploading': 'Uploading...',
'sftp.status.ready': 'Ready',
'sftp.goUp': 'Go up',
'sftp.encoding.label': 'Filename Encoding',
'sftp.encoding.auto': 'Auto',
'sftp.encoding.utf8': 'UTF-8',
'sftp.encoding.gb18030': 'GB18030',
'sftp.goHome': 'Go to home',
'sftp.folderName': 'Folder name',
'sftp.folderName.placeholder': 'Enter folder name',
@@ -441,6 +579,7 @@ const en: Messages = {
'sftp.rename.newName': 'New name',
'sftp.rename.placeholder': 'Enter new name',
'sftp.confirm.deleteOne': 'Delete "{name}"?',
'sftp.deleteConfirm.single': 'Delete "{name}"?',
'sftp.deleteConfirm.title': 'Delete {count} item(s)?',
'sftp.deleteConfirm.desc': 'This action cannot be undone. The following will be deleted:',
'sftp.error.loadFailed': 'Failed to load directory',
@@ -490,6 +629,12 @@ const en: Messages = {
'sftp.conflict.action.keepBoth': 'Keep Both',
'sftp.conflict.action.replace': 'Replace',
// SFTP Upload Phases
'sftp.upload.phase.compressing': 'Compressing',
'sftp.upload.phase.uploading': 'Uploading',
'sftp.upload.phase.extracting': 'Extracting',
'sftp.upload.phase.compressed': 'Compressed',
// SFTP File Opener
'sftp.context.openWith': 'Open with...',
'sftp.context.edit': 'Edit',
@@ -553,10 +698,20 @@ const en: Messages = {
// SFTP Folder Upload Progress
'sftp.upload.progress': 'Uploading {current} of {total} files...',
'sftp.upload.uploading': 'Uploading...',
'sftp.upload.compressing': 'Compressing...',
'sftp.upload.extracting': 'Extracting...',
'sftp.upload.scanning': 'Scanning files...',
'sftp.upload.completed': 'Completed',
'sftp.upload.compressed': 'Compressed Transfer',
'sftp.upload.currentFile': 'Current: {fileName}',
'sftp.upload.cancelled': 'Upload cancelled',
'sftp.upload.cancel': 'Cancel',
// SFTP Download
'sftp.download.completed': 'Downloaded',
'sftp.download.cancelled': 'Download cancelled',
// SFTP Reconnecting
'sftp.reconnecting.title': 'Reconnecting...',
'sftp.reconnecting.desc': 'Connection lost, attempting to reconnect',
@@ -572,11 +727,14 @@ const en: Messages = {
'settings.sftp.showHiddenFiles.enable': 'Show hidden files',
'settings.sftp.showHiddenFiles.enableDesc': 'Display Windows hidden files when browsing local filesystem',
// Settings > SFTP Compressed Upload
'settings.sftp.compressedUpload': 'Folder Compression Transfer',
'settings.sftp.compressedUpload.desc': 'Compress folders before uploading to significantly reduce transfer time.',
'settings.sftp.compressedUpload.enable': 'Enable folder compression',
'settings.sftp.compressedUpload.enableDesc': 'Automatically compress folders using tar before transfer. Requires tar support on the server. Falls back to regular transfer if not available.',
// Quick Switcher
'qs.search.placeholder': 'Search hosts or tabs',
'qs.recentConnections': 'Recent connections',
'qs.createWorkspace': 'Create a workspace',
'qs.restore': 'Restore',
'qs.jumpTo': 'Jump To',
'qs.localTerminal': 'Local Terminal',
@@ -614,6 +772,8 @@ const en: Messages = {
'hostDetails.sftp.sudo': 'Sudo Mode',
'hostDetails.sftp.sudo.desc': 'Automatically acquire Root privileges using stored password',
'hostDetails.sftp.sudo.passwordWarning': 'Sudo mode requires a password. Configure one above, or ensure the server allows passwordless sudo.',
'hostDetails.sftp.encoding': 'Filename Encoding',
'hostDetails.sftp.encoding.desc': 'Select the encoding used to decode and send SFTP filenames.',
'hostDetails.label.placeholder': 'Label (e.g., Production Server)',
'hostDetails.group.placeholder': 'Parent Group',
'hostDetails.section.credentials': 'Credentials',
@@ -622,6 +782,9 @@ const en: Messages = {
'hostDetails.section.mosh': 'Mosh',
'hostDetails.username.placeholder': 'Username',
'hostDetails.password.placeholder': 'Password',
'hostDetails.password.show': 'Show password',
'hostDetails.password.hide': 'Hide password',
'hostDetails.password.save': 'Save password',
'hostDetails.identity.suggestions': 'Identities',
'hostDetails.identity.missing': 'Identity not found',
'hostDetails.credential.keyCertificate': 'Key, Certificate',
@@ -637,6 +800,10 @@ const en: Messages = {
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent is not running',
'hostDetails.agentForwarding.agentNotRunningHint': 'Enable OpenSSH Authentication Agent service in Windows Services (services.msc) for agent forwarding to work.',
'hostDetails.section.agentForwarding': 'SSH Agent',
'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.',
'hostDetails.legacyAlgorithms.warning': 'These algorithms have known security weaknesses. Only enable for legacy devices that do not support modern cryptography.',
'hostDetails.jumpHosts': 'Proxy via Hosts',
'hostDetails.jumpHosts.hops': '{count} hop(s)',
'hostDetails.jumpHosts.direct': 'Direct',
@@ -726,7 +893,7 @@ const en: Messages = {
'logs.empty.title': 'No Connection Logs',
'logs.empty.desc':
'Your connection history will appear here when you connect to hosts or open local terminals.',
'logs.showing': 'Showing {limit} of {total} logs.',
'logs.loadMore': 'Load {count} more logs',
'logs.ongoing': 'ongoing',
'logs.localTerminal': 'Local Terminal',
'logs.action.save': 'Save',
@@ -737,6 +904,7 @@ const en: Messages = {
'logView.customizeAppearance': 'Customize appearance',
'logView.appearance': 'Appearance',
'logView.readOnly': 'Read-only',
'logView.export': 'Export',
// Terminal toolbar / search / context menu / auth
'terminal.toolbar.openSftp': 'Open SFTP',
@@ -751,9 +919,48 @@ const en: Messages = {
'terminal.toolbar.broadcast': 'Broadcast',
'terminal.toolbar.broadcastEnable': 'Enable Broadcast Mode',
'terminal.toolbar.broadcastDisable': 'Disable Broadcast Mode',
'terminal.toolbar.composeBar': 'Compose Bar',
'terminal.composeBar.placeholder': 'Type command here, press Enter to send...',
'terminal.composeBar.send': 'Send',
'terminal.composeBar.close': 'Close compose bar',
'terminal.composeBar.broadcasting': 'Broadcasting to all sessions',
'terminal.toolbar.focus': 'Focus',
'terminal.toolbar.focusMode': 'Focus Mode',
'terminal.toolbar.closeSession': 'Close session',
'terminal.toolbar.hostHighlight.title': 'Host Keyword Highlighting',
'terminal.toolbar.hostHighlight.noRules': 'No custom highlight rules defined for this host',
'terminal.toolbar.hostHighlight.addRule': 'Add New Rule',
'terminal.toolbar.hostHighlight.labelPlaceholder': 'Label (e.g., Error)',
'terminal.toolbar.hostHighlight.patternPlaceholder': 'Regex pattern (e.g., \\bfailed\\b)',
'terminal.toolbar.hostHighlight.invalidPattern': 'Invalid regex pattern',
'terminal.toolbar.hostHighlight.clearAll': 'Clear All',
'terminal.toolbar.hostHighlight.changeColor': 'Change highlight color for',
'terminal.toolbar.hostHighlight.selectColor': 'Select color for new rule',
'terminal.serverStats.cpu': 'CPU Usage',
'terminal.serverStats.cpuCores': 'CPU Core Usage',
'terminal.serverStats.memory': 'Memory Usage',
'terminal.serverStats.memoryDetails': 'Memory Details',
'terminal.serverStats.memUsed': 'Used',
'terminal.serverStats.memBuffers': 'Buffers',
'terminal.serverStats.memCached': 'Cache',
'terminal.serverStats.memFree': 'Free',
'terminal.serverStats.swap': 'Swap',
'terminal.serverStats.swapUsed': 'Swap Used',
'terminal.serverStats.swapFree': 'Swap Free',
'terminal.serverStats.swapTotal': 'Total',
'terminal.serverStats.topProcesses': 'Top Processes by Memory',
'terminal.serverStats.disk': 'Disk Usage (Root)',
'terminal.serverStats.diskDetails': 'Mounted Disks',
'terminal.serverStats.network': 'Network Speed',
'terminal.serverStats.networkDetails': 'Network Interfaces',
'terminal.serverStats.noData': 'No data available',
'terminal.dragDrop.localTitle': 'Drop to Insert Paths',
'terminal.dragDrop.localMessage': 'File paths will be inserted into the terminal',
'terminal.dragDrop.remoteTitle': 'Drop to Upload Files',
'terminal.dragDrop.remoteMessage': 'Files will be uploaded via SFTP',
'terminal.dragDrop.notConnected': 'Cannot drop files - terminal is not connected',
'terminal.dragDrop.errorTitle': 'Drop Error',
'terminal.dragDrop.errorMessage': 'Failed to process dropped files',
'terminal.search.placeholder': 'Search...',
'terminal.search.noResults': 'No results',
'terminal.search.prevMatch': 'Previous match (Shift+Enter)',
@@ -777,6 +984,10 @@ const en: Messages = {
'terminal.auth.selectKey': 'Select Key',
'terminal.auth.noKeysHint': 'No keys available. Add keys in Keychain.',
'terminal.auth.continueSave': 'Continue & Save',
'terminal.auth.credentialsUnavailable': 'Saved credentials cannot be decrypted on this device. Please re-enter and save them again.',
'terminal.auth.jumpCredentialsUnavailable': 'A jump host has saved credentials that cannot be decrypted on this device. Open host settings and re-enter them.',
'terminal.auth.proxyCredentialsUnavailable': 'Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.',
'terminal.auth.keyUnavailableFallbackPassword': 'Saved SSH key is unavailable on this device. Falling back to password authentication.',
'terminal.progress.timeoutIn': 'Timeout in {seconds}s',
'terminal.progress.disconnected': 'Disconnected',
'terminal.progress.cancelling': 'Cancelling...',
@@ -784,13 +995,58 @@ const en: Messages = {
'terminal.connection.chainOf': 'Chain {current} of {total}',
'terminal.connection.showLogs': 'Show logs',
'terminal.connection.hideLogs': 'Hide logs',
'terminal.connection.protocol.ssh': 'SSH',
'terminal.connection.protocol.telnet': 'Telnet',
'terminal.connection.protocol.mosh': 'Mosh',
'terminal.connection.protocol.serial': 'Serial',
'terminal.connection.protocol.local': 'Local Shell',
'terminal.themeModal.title': 'Terminal Appearance',
'terminal.themeModal.tab.theme': 'Theme',
'terminal.themeModal.tab.font': 'Font',
'terminal.themeModal.tab.custom': 'Custom',
'terminal.themeModal.fontSize': 'Font Size',
'terminal.themeModal.livePreview': 'Live Preview',
'terminal.themeModal.themeType': '{type} theme',
// Custom Themes
'terminal.customTheme.section': 'Custom Themes',
'terminal.customTheme.yourThemes': 'Your Themes',
'terminal.customTheme.new': 'New Theme',
'terminal.customTheme.newDesc': 'Clone current theme and customize',
'terminal.customTheme.newTitle': 'New Custom Theme',
'terminal.customTheme.editTitle': 'Edit Theme',
'terminal.customTheme.import': 'Import .itermcolors',
'terminal.customTheme.importDesc': 'Import from iTerm2 color scheme file',
'terminal.customTheme.importError': 'Failed to parse the selected file. Please ensure it is a valid .itermcolors XML file.',
'terminal.customTheme.delete': 'Delete Theme',
'terminal.customTheme.confirmDelete': 'Confirm Delete',
'terminal.customTheme.name': 'Name',
'terminal.customTheme.namePlaceholder': 'My Custom Theme',
'terminal.customTheme.type': 'Type',
'terminal.customTheme.group.general': 'General',
'terminal.customTheme.group.normal': 'Normal Colors',
'terminal.customTheme.group.bright': 'Bright Colors',
'terminal.customTheme.color.background': 'Background',
'terminal.customTheme.color.foreground': 'Foreground',
'terminal.customTheme.color.cursor': 'Cursor',
'terminal.customTheme.color.selection': 'Selection',
'terminal.customTheme.color.black': 'Black',
'terminal.customTheme.color.red': 'Red',
'terminal.customTheme.color.green': 'Green',
'terminal.customTheme.color.yellow': 'Yellow',
'terminal.customTheme.color.blue': 'Blue',
'terminal.customTheme.color.magenta': 'Magenta',
'terminal.customTheme.color.cyan': 'Cyan',
'terminal.customTheme.color.white': 'White',
'terminal.customTheme.color.brightBlack': 'Bright Black',
'terminal.customTheme.color.brightRed': 'Bright Red',
'terminal.customTheme.color.brightGreen': 'Bright Green',
'terminal.customTheme.color.brightYellow': 'Bright Yellow',
'terminal.customTheme.color.brightBlue': 'Bright Blue',
'terminal.customTheme.color.brightMagenta': 'Bright Magenta',
'terminal.customTheme.color.brightCyan': 'Bright Cyan',
'terminal.customTheme.color.brightWhite': 'Bright White',
// Cloud Sync Settings
'cloudSync.gate.title': 'End-to-End Encrypted Sync',
'cloudSync.gate.desc':
@@ -1045,6 +1301,7 @@ const en: Messages = {
'tabs.closeLogViewAria': 'Close log view',
'tabs.logPrefix': 'Log:',
'tabs.logLocal': 'Local',
'tabs.copyTab': 'Copy Tab',
'keychain.edit.labelRequired': 'Label *',
'keychain.edit.keyLabelPlaceholder': 'Key label',
'keychain.edit.privateKeyRequired': 'Private key *',
@@ -1096,6 +1353,23 @@ const en: Messages = {
'snippets.packageDialog.placeholder': 'e.g. ops/maintenance',
'snippets.packageDialog.hint': 'Use "/" to create nested packages.',
// Snippets Rename Dialog
'snippets.renameDialog.title': 'Rename Package',
'snippets.renameDialog.currentPath': 'Current path: {path}',
'snippets.renameDialog.placeholder': 'Enter new name',
'snippets.renameDialog.error.empty': 'Package name cannot be empty',
'snippets.renameDialog.error.duplicate': 'A package with this name already exists',
'snippets.renameDialog.error.invalidChars': 'Package name can only contain letters, numbers, hyphens, and underscores',
// Snippet Shortkey
'snippets.field.shortkey': 'Keyboard Shortcut',
'snippets.shortkey.placeholder': 'Click to set shortcut',
'snippets.shortkey.recording': 'Press a key combination...',
'snippets.shortkey.hint': 'Press this shortcut in terminal to quickly send the command.',
'snippets.shortkey.clear': 'Clear shortcut',
'snippets.shortkey.error.systemConflict': 'This shortcut conflicts with a system shortcut',
'snippets.shortkey.error.snippetConflict': 'This shortcut is already used by snippet: {name}',
// Serial Port
'serial.button': 'Serial',
'serial.modal.title': 'Connect to Serial Port',
@@ -1150,6 +1424,19 @@ const en: Messages = {
'keyboard.interactive.fillSaved': 'Fill with saved password',
'keyboard.interactive.useSaved': 'Use saved',
'keyboard.interactive.useSavedPassword': 'Use saved password',
// Passphrase Modal for encrypted SSH keys
'passphrase.title': 'SSH Key Passphrase',
'passphrase.desc': 'Enter the passphrase for {keyName}',
'passphrase.descWithHost': 'Enter the passphrase for {keyName} to connect to {hostname}',
'passphrase.label': 'Passphrase',
'passphrase.keyPath': 'Key',
'passphrase.unlock': 'Unlock',
'passphrase.unlocking': 'Unlocking...',
'passphrase.skip': 'Skip',
// Text Editor
'sftp.editor.wordWrap': 'Word Wrap',
};
export default en;

View File

@@ -27,6 +27,7 @@ const zhCN: Messages = {
'sort.za': 'Z-a',
'sort.newest': '从新到旧',
'sort.oldest': '从旧到新',
'sort.group': '按分组',
'field.label': 'Label',
'field.type': '类型',
'auth.keyType': '类型 {type}',
@@ -41,6 +42,9 @@ const zhCN: Messages = {
'placeholder.workspaceName': '工作区名称',
'placeholder.sessionName': '会话名称',
'toast.settingsUnavailable': '当前平台无法打开设置窗口。',
'credentials.protectionUnavailable.title': '凭据保护不可用',
'credentials.protectionUnavailable.message': '当前设备无法自动解密已保存的密码和密钥。连接前请重新输入凭据。',
'credentials.protectionUnavailable.action': '打开设置',
// Settings shell
'settings.title': '设置',
@@ -64,6 +68,61 @@ const zhCN: Messages = {
'settings.system.clearing': '清理中...',
'settings.system.clearResult': '已删除 {deleted} 个文件,{failed} 个失败。',
'settings.system.tempDirectoryHint': '临时文件在使用外部应用打开远程文件时创建。SFTP 会话关闭时会自动清理。',
'settings.system.credentials.title': '凭据保护',
'settings.system.credentials.status': '状态',
'settings.system.credentials.checking': '检查中...',
'settings.system.credentials.available': '可用(系统钥匙串正常)',
'settings.system.credentials.unavailable': '不可用(无法解密已保存凭据)',
'settings.system.credentials.unknown': '未知(当前环境不支持)',
'settings.system.credentials.unavailableHint': '在其他用户或机器上加密的凭据无法在此处解密。请在当前设备重新输入并保存凭据。',
'settings.system.credentials.portabilityHint': '云同步可跨设备,因为使用主密钥加密;本地 safeStorage 加密仅绑定当前系统用户/设备。',
// Settings > Session Logs
'settings.sessionLogs.title': '会话日志',
'settings.sessionLogs.description': '配置会话日志导出和自动保存设置。',
'settings.sessionLogs.autoSave': '自动保存',
'settings.sessionLogs.enableAutoSave': '启用自动保存',
'settings.sessionLogs.enableAutoSaveDesc': '在终端会话结束时自动保存会话日志。',
'settings.sessionLogs.directory': '保存目录',
'settings.sessionLogs.noDirectory': '未选择目录',
'settings.sessionLogs.browse': '浏览',
'settings.sessionLogs.openFolder': '打开文件夹',
'settings.sessionLogs.directoryHint': '日志将按主机名组织在子目录中。',
'settings.sessionLogs.format': '日志格式',
'settings.sessionLogs.formatDesc': '选择保存日志文件的格式。',
'settings.sessionLogs.formatTxt': '纯文本 (.txt)',
'settings.sessionLogs.formatRaw': '原始格式 (.log)',
'settings.sessionLogs.formatHtml': 'HTML (.html)',
'settings.sessionLogs.hint': '会话日志用于记录终端输出,便于故障排查和审计。',
// Settings > Global Hotkey (Quake Mode)
'settings.globalHotkey.title': '全局快捷键',
'settings.globalHotkey.toggleWindow': '切换窗口',
'settings.globalHotkey.toggleWindowDesc': '按下组合键以设置显示/隐藏窗口的全局快捷键。',
'settings.globalHotkey.notSet': '未设置',
'settings.globalHotkey.reset': '恢复默认',
'settings.globalHotkey.closeToTray': '关闭时最小化到托盘',
'settings.globalHotkey.closeToTrayDesc': '启用后,关闭窗口将最小化到系统托盘而不是退出程序。',
'settings.globalHotkey.hint': '全局快捷键在系统范围内工作,可快速显示或隐藏窗口(下拉式终端风格)。',
// Tray Panel
'tray.openMainWindow': '打开主窗口',
'tray.sessions': '会话',
'tray.portForwarding': '端口转发',
'tray.status.connected': '已连接',
'tray.status.connecting': '连接中',
'tray.status.disconnected': '已断开',
'tray.status.active': '已启用',
'tray.status.inactive': '未启用',
'tray.status.error': '错误',
'tray.recentHosts': '最近连接的主机',
'tray.empty.title': '一切都很安静',
'tray.empty.subtitle': '去连接个服务器吧,它们想念你了 🚀',
'tray.quit': '退出 Netcatty',
// Vault Sidebar
'vault.sidebar.collapse': '收起侧边栏',
'vault.sidebar.expand': '展开侧边栏',
// Settings > Application
'settings.application.checkUpdates': '检查更新',
@@ -106,6 +165,8 @@ const zhCN: Messages = {
'/* 示例:*/\n.terminal { background: #1a1a2e !important; }\n:root { --radius: 0.25rem; }',
'settings.appearance.language': '语言',
'settings.appearance.language.desc': '选择界面语言',
'settings.appearance.uiFont': '界面字体',
'settings.appearance.uiFont.desc': '选择软件界面使用的字体',
// Context menus / common actions
'action.newHost': '新建主机',
@@ -141,6 +202,7 @@ const zhCN: Messages = {
'sync.autoSync.vaultLocked': 'Vault 处于锁定状态。请打开 设置 → Sync & Cloud 解锁。',
'sync.autoSync.conflictDetected': '检测到同步冲突。请打开 设置 → Sync & Cloud 处理。',
'sync.autoSync.syncFailed': '同步失败',
'sync.credentialsUnavailable': '当前设备无法解密部分已保存凭据。请先在本地重新输入凭据后再同步。',
'time.never': '从未',
'time.justNow': '刚刚',
'time.minutesAgo': '{minutes} 分钟前',
@@ -164,6 +226,11 @@ const zhCN: Messages = {
'vault.groups.createDialog.desc': '创建新的分组用于组织主机。',
'vault.groups.renameDialogTitle': '重命名分组',
'vault.groups.renameDialog.desc': '重命名已有分组。',
'vault.groups.deleteDialogTitle': '删除分组',
'vault.groups.deleteDialog.desc': '这将永久删除该分组并将所有主机移动到根级别。',
'vault.groups.deleteDialog.managedDesc': '这是一个托管的 SSH config 分组。删除后将同时删除所有主机并断开与源文件的连接。',
'vault.groups.deleteDialog.deleteHosts': '同时删除该分组下的所有主机',
'vault.groups.ungrouped': '未分组',
'vault.groups.field.name': '分组名称',
'vault.groups.placeholder.example': '例如Production',
'vault.groups.parentLabel': '父级',
@@ -171,6 +238,9 @@ const zhCN: Messages = {
'vault.groups.errors.required': '分组名称不能为空。',
'vault.groups.errors.invalidChars': "分组名称不能包含 '/' 或 '\\\\'.",
'vault.managedSource.unmanage': '取消托管',
'vault.managedSource.unmanageSuccess': '已取消托管分组',
'vault.hosts.header.entries': '{count} 条',
'vault.hosts.header.live': '{count} 个在线',
@@ -179,10 +249,28 @@ const zhCN: Messages = {
'vault.hosts.connect': '连接',
'vault.view.grid': '网格',
'vault.view.list': '列表',
'vault.view.tree': '树形',
'vault.tree.expandAll': '展开全部',
'vault.tree.collapseAll': '折叠全部',
'vault.hosts.newHost': '新建主机',
'vault.hosts.newGroup': '新建分组',
'vault.hosts.import': '导入',
'vault.hosts.export': '导出',
'vault.hosts.export.toast.success': '已导出 {count} 个主机到 CSV',
'vault.hosts.export.toast.successWithSkipped': '已导出 {count} 个主机到 CSV跳过 {skipped} 个不支持的主机)',
'vault.hosts.export.toast.noHosts': '没有主机可导出',
'vault.hosts.allHosts': '全部主机',
'vault.hosts.copyCredentials': '复制账密信息',
'vault.hosts.copyCredentials.toast.success': '账密信息已复制到剪贴板',
'vault.hosts.copyCredentials.toast.noPassword': '该主机未保存密码',
'vault.hosts.multiSelect': '多选',
'vault.hosts.selected': '已选择 {count} 项',
'vault.hosts.selectAll': '全选',
'vault.hosts.deselectAll': '取消全选',
'vault.hosts.deleteSelected': '删除 ({count})',
'vault.hosts.deleteMultiple.success': '已删除 {count} 个主机',
'vault.hosts.empty.title': '设置你的主机',
'vault.hosts.empty.desc': '保存主机以快速连接到你的服务器、虚拟机和容器。',
// Vault import
'vault.import.title': '添加数据到你的 Vault',
@@ -197,6 +285,18 @@ const zhCN: Messages = {
'vault.import.toast.noNewHosts': '从 {format} 没有导入到新的主机。',
'vault.import.toast.summary': '已导入 {count} 个主机(跳过 {skipped},重复 {duplicates})。',
'vault.import.toast.firstIssue': '首个问题:{issue}',
'vault.import.sshConfig.chooseMode': '选择如何导入你的 SSH config 文件。',
'vault.import.sshConfig.modeQuestion': '你希望如何导入?',
'vault.import.sshConfig.importOnly': '仅导入',
'vault.import.sshConfig.importOnlyDesc': '一次性导入,修改不会同步回文件。',
'vault.import.sshConfig.managed': '托管同步',
'vault.import.sshConfig.managedDesc': '保持同步,修改会自动保存回文件。',
'vault.import.sshConfig.managedGroup': 'ssh config',
'vault.import.sshConfig.managedSuccess': '已导入 {count} 个主机,文件已托管。',
'vault.import.sshConfig.alreadyManaged': '该文件已被托管。',
'vault.import.sshConfig.alreadyManagedDesc': '该文件已在分组 "{group}" 下托管。如需重新导入,请先移除现有的托管源。',
'vault.import.sshConfig.noFilePath': '无法托管此文件。',
'vault.import.sshConfig.noFilePathDesc': '无法确定文件路径。托管同步需要访问文件系统。',
// Known Hosts
'knownHosts.search.placeholder': '搜索已知主机...',
@@ -267,14 +367,21 @@ const zhCN: Messages = {
'sftp.newFile': '新建文件',
'sftp.filter': '筛选',
'sftp.filter.placeholder': '按文件名筛选...',
'sftp.bookmark.add': '收藏此路径',
'sftp.bookmark.remove': '取消收藏',
'sftp.bookmark.empty': '暂无收藏路径',
'sftp.columns.name': '名称',
'sftp.columns.modified': '修改时间',
'sftp.columns.size': '大小',
'sftp.columns.kind': '类型',
'sftp.columns.actions': '操作',
'sftp.emptyDirectory': '空目录',
'sftp.nav.up': '返回上层',
'sftp.nav.home': '返回主目录',
'sftp.nav.refresh': '刷新',
'sftp.upload': '上传',
'sftp.uploadFiles': '上传文件',
'sftp.uploadFolder': '上传文件夹',
'sftp.dragDropToUpload': '拖拽文件到这里上传',
'sftp.retry': '重试',
'sftp.context.open': '打开',
@@ -296,6 +403,10 @@ const zhCN: Messages = {
'sftp.status.uploading': '上传中...',
'sftp.status.ready': '就绪',
'sftp.goUp': '上一级',
'sftp.encoding.label': '文件名编码',
'sftp.encoding.auto': '自动',
'sftp.encoding.utf8': 'UTF-8',
'sftp.encoding.gb18030': 'GB18030',
'sftp.goHome': '返回主目录',
'sftp.folderName': '文件夹名称',
'sftp.folderName.placeholder': '输入文件夹名称',
@@ -306,6 +417,7 @@ const zhCN: Messages = {
'sftp.rename.newName': '新名称',
'sftp.rename.placeholder': '输入新名称',
'sftp.confirm.deleteOne': '删除 "{name}"',
'sftp.deleteConfirm.single': '删除 "{name}"',
'sftp.deleteConfirm.title': '删除 {count} 个项目?',
'sftp.deleteConfirm.desc': '此操作不可撤销,将删除以下内容:',
'sftp.error.loadFailed': '加载目录失败',
@@ -338,9 +450,6 @@ const zhCN: Messages = {
// Quick Switcher
'qs.search.placeholder': '搜索主机或标签页',
'qs.recentConnections': '最近连接',
'qs.createWorkspace': '创建工作区',
'qs.restore': '恢复',
'qs.jumpTo': '跳转到',
'qs.localTerminal': '本地终端',
@@ -374,6 +483,8 @@ const zhCN: Messages = {
'hostDetails.sftp.sudo': 'Sudo 提权模式',
'hostDetails.sftp.sudo.desc': '使用保存的密码自动获取 Root 权限',
'hostDetails.sftp.sudo.passwordWarning': 'Sudo 模式需要密码。请在上方配置密码,或确保服务器允许免密 sudo。',
'hostDetails.sftp.encoding': '文件名编码',
'hostDetails.sftp.encoding.desc': '选择用于解码和发送 SFTP 文件名的编码。',
'hostDetails.label.placeholder': '名称例如Production Server',
'hostDetails.group.placeholder': '父级 Group',
'hostDetails.section.credentials': '凭据',
@@ -382,6 +493,9 @@ const zhCN: Messages = {
'hostDetails.section.mosh': 'Mosh',
'hostDetails.username.placeholder': '用户名',
'hostDetails.password.placeholder': '密码',
'hostDetails.password.show': '显示密码',
'hostDetails.password.hide': '隐藏密码',
'hostDetails.password.save': '保存密码',
'hostDetails.identity.suggestions': '身份',
'hostDetails.identity.missing': '身份不存在',
'hostDetails.credential.keyCertificate': '密钥 / 证书',
@@ -397,6 +511,10 @@ const zhCN: Messages = {
'hostDetails.agentForwarding.agentNotRunning': 'SSH Agent 未运行',
'hostDetails.agentForwarding.agentNotRunningHint': '请在 Windows 服务管理器 (services.msc) 中启用 OpenSSH Authentication Agent 服务。',
'hostDetails.section.agentForwarding': 'SSH 代理',
'hostDetails.section.legacyAlgorithms': '旧版算法',
'hostDetails.legacyAlgorithms': '允许旧版算法',
'hostDetails.legacyAlgorithms.desc': '启用已弃用的 SSH 算法diffie-hellman-group1、ssh-dss、3des-cbc 等)以连接老旧网络设备。',
'hostDetails.legacyAlgorithms.warning': '这些算法存在已知安全漏洞,仅建议在老旧设备不支持现代加密时启用。',
'hostDetails.jumpHosts': '通过主机代理',
'hostDetails.jumpHosts.hops': '{count} 跳',
'hostDetails.jumpHosts.direct': '直连',
@@ -457,7 +575,7 @@ const zhCN: Messages = {
'logs.table.saved': '收藏',
'logs.empty.title': '暂无连接日志',
'logs.empty.desc': '当你连接主机或打开本地终端后,这里会显示连接历史。',
'logs.showing': '显示 {limit}/{total} 条日志。',
'logs.loadMore': '加载更多 ({count} 条)',
'logs.ongoing': '进行中',
'logs.localTerminal': '本地终端',
'logs.action.save': '收藏',
@@ -468,6 +586,7 @@ const zhCN: Messages = {
'logView.customizeAppearance': '自定义外观',
'logView.appearance': '外观',
'logView.readOnly': '只读',
'logView.export': '导出',
// Terminal toolbar / search / context menu / auth
'terminal.toolbar.openSftp': '打开 SFTP',
@@ -482,9 +601,48 @@ const zhCN: Messages = {
'terminal.toolbar.broadcast': '广播',
'terminal.toolbar.broadcastEnable': '启用广播模式',
'terminal.toolbar.broadcastDisable': '关闭广播模式',
'terminal.toolbar.composeBar': '撰写栏',
'terminal.composeBar.placeholder': '在此输入命令,按回车发送...',
'terminal.composeBar.send': '发送',
'terminal.composeBar.close': '关闭撰写栏',
'terminal.composeBar.broadcasting': '正在广播到所有会话',
'terminal.toolbar.focus': '聚焦',
'terminal.toolbar.focusMode': '聚焦模式',
'terminal.toolbar.closeSession': '关闭会话',
'terminal.toolbar.hostHighlight.title': '主机关键字高亮',
'terminal.toolbar.hostHighlight.noRules': '此主机未定义自定义高亮规则',
'terminal.toolbar.hostHighlight.addRule': '添加新规则',
'terminal.toolbar.hostHighlight.labelPlaceholder': '标签(例如:错误)',
'terminal.toolbar.hostHighlight.patternPlaceholder': '正则表达式(例如:\\bfailed\\b',
'terminal.toolbar.hostHighlight.invalidPattern': '无效的正则表达式',
'terminal.toolbar.hostHighlight.clearAll': '清除全部',
'terminal.toolbar.hostHighlight.changeColor': '更改高亮颜色',
'terminal.toolbar.hostHighlight.selectColor': '选择新规则的颜色',
'terminal.serverStats.cpu': 'CPU 使用率',
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
'terminal.serverStats.memory': '内存使用',
'terminal.serverStats.memoryDetails': '内存详情',
'terminal.serverStats.memUsed': '已用',
'terminal.serverStats.memBuffers': '缓冲区',
'terminal.serverStats.memCached': '缓存',
'terminal.serverStats.memFree': '空闲',
'terminal.serverStats.swap': '交换空间',
'terminal.serverStats.swapUsed': '已用交换',
'terminal.serverStats.swapFree': '空闲交换',
'terminal.serverStats.swapTotal': '总计',
'terminal.serverStats.topProcesses': '内存占用前十进程',
'terminal.serverStats.disk': '磁盘使用(根分区)',
'terminal.serverStats.diskDetails': '已挂载磁盘',
'terminal.serverStats.network': '网络速度',
'terminal.serverStats.networkDetails': '网络接口',
'terminal.serverStats.noData': '暂无数据',
'terminal.dragDrop.localTitle': '拖放以插入路径',
'terminal.dragDrop.localMessage': '文件路径将被插入到终端',
'terminal.dragDrop.remoteTitle': '拖放以上传文件',
'terminal.dragDrop.remoteMessage': '文件将通过 SFTP 上传',
'terminal.dragDrop.notConnected': '无法拖放文件 - 终端未连接',
'terminal.dragDrop.errorTitle': '拖放错误',
'terminal.dragDrop.errorMessage': '处理拖放文件失败',
'terminal.search.placeholder': '搜索…',
'terminal.search.noResults': '无结果',
'terminal.search.prevMatch': '上一个匹配 (Shift+Enter)',
@@ -508,6 +666,10 @@ const zhCN: Messages = {
'terminal.auth.selectKey': '选择密钥',
'terminal.auth.noKeysHint': '暂无密钥,请先在钥匙串中添加。',
'terminal.auth.continueSave': '继续并保存',
'terminal.auth.credentialsUnavailable': '当前设备无法解密已保存凭据,请重新输入并再次保存。',
'terminal.auth.jumpCredentialsUnavailable': '某个跳板机的已保存凭据无法在当前设备解密,请到主机设置中重新填写。',
'terminal.auth.proxyCredentialsUnavailable': '代理凭据无法在当前设备解密,请到主机设置中重新填写代理密码。',
'terminal.auth.keyUnavailableFallbackPassword': '已保存 SSH 密钥在当前设备不可用,改用密码认证。',
'terminal.connectionErrorTitle': '连接错误',
'terminal.progress.timeoutIn': '将在 {seconds}s 后超时',
'terminal.progress.disconnected': '已断开',
@@ -516,13 +678,58 @@ const zhCN: Messages = {
'terminal.connection.chainOf': 'Chain {current} / {total}',
'terminal.connection.showLogs': '显示日志',
'terminal.connection.hideLogs': '隐藏日志',
'terminal.connection.protocol.ssh': 'SSH',
'terminal.connection.protocol.telnet': 'Telnet',
'terminal.connection.protocol.mosh': 'Mosh',
'terminal.connection.protocol.serial': '串口',
'terminal.connection.protocol.local': '本地终端',
'terminal.themeModal.title': 'Terminal 外观',
'terminal.themeModal.tab.theme': '主题',
'terminal.themeModal.tab.font': '字体',
'terminal.themeModal.tab.custom': '自定义',
'terminal.themeModal.fontSize': '字体大小',
'terminal.themeModal.livePreview': '实时预览',
'terminal.themeModal.themeType': '{type} 主题',
// Cloud Sync Settings
// Custom Themes
'terminal.customTheme.section': '自定义主题',
'terminal.customTheme.yourThemes': '我的主题',
'terminal.customTheme.new': '新建主题',
'terminal.customTheme.newDesc': '克隆当前主题并自定义',
'terminal.customTheme.newTitle': '新建自定义主题',
'terminal.customTheme.editTitle': '编辑主题',
'terminal.customTheme.import': '导入 .itermcolors',
'terminal.customTheme.importDesc': '从 iTerm2 配色方案文件导入',
'terminal.customTheme.importError': '无法解析所选文件,请确保它是有效的 .itermcolors XML 文件。',
'terminal.customTheme.delete': '删除主题',
'terminal.customTheme.confirmDelete': '确认删除',
'terminal.customTheme.name': '名称',
'terminal.customTheme.namePlaceholder': '我的自定义主题',
'terminal.customTheme.type': '类型',
'terminal.customTheme.group.general': '通用',
'terminal.customTheme.group.normal': '标准色',
'terminal.customTheme.group.bright': '高亮色',
'terminal.customTheme.color.background': '背景',
'terminal.customTheme.color.foreground': '前景',
'terminal.customTheme.color.cursor': '光标',
'terminal.customTheme.color.selection': '选区',
'terminal.customTheme.color.black': '黑色',
'terminal.customTheme.color.red': '红色',
'terminal.customTheme.color.green': '绿色',
'terminal.customTheme.color.yellow': '黄色',
'terminal.customTheme.color.blue': '蓝色',
'terminal.customTheme.color.magenta': '品红',
'terminal.customTheme.color.cyan': '青色',
'terminal.customTheme.color.white': '白色',
'terminal.customTheme.color.brightBlack': '亮黑',
'terminal.customTheme.color.brightRed': '亮红',
'terminal.customTheme.color.brightGreen': '亮绿',
'terminal.customTheme.color.brightYellow': '亮黄',
'terminal.customTheme.color.brightBlue': '亮蓝',
'terminal.customTheme.color.brightMagenta': '亮品红',
'terminal.customTheme.color.brightCyan': '亮青色',
'terminal.customTheme.color.brightWhite': '亮白',
'cloudSync.gate.title': '端到端加密同步',
'cloudSync.gate.desc':
'数据会在本地加密后再同步,云端不会看到明文。设置主密钥以启用安全同步。',
@@ -681,6 +888,7 @@ const zhCN: Messages = {
'common.import': '导入',
'common.generate': '生成',
'common.delete': '删除',
'common.edit': '编辑',
'common.clear': '清除',
'common.optional': '可选',
'common.selectPlaceholder': '请选择...',
@@ -709,6 +917,13 @@ const zhCN: Messages = {
'pf.view.list': '列表',
'pf.rule.summary.dynamic': 'SOCKS 监听于 {bindAddress}:{localPort}',
'pf.rule.summary.default': '{bindAddress}:{localPort} -> {remoteHost}:{remotePort}',
'pf.tooltip.relayHost': '中转主机',
'pf.tooltip.hostLabel': '主机',
'pf.tooltip.hostAddress': '地址',
'pf.tooltip.noHost': '未配置中转主机',
'pf.tooltip.localDesc': '本地端口转发:通过 SSH 隧道访问远程服务',
'pf.tooltip.remoteDesc': '远程端口转发:将本地服务暴露给远程主机',
'pf.tooltip.dynamicDesc': '动态 SOCKS 代理:通过 SSH 隧道转发流量',
'pf.deleteActive.title': '删除正在运行的端口转发?',
'pf.deleteActive.desc': '端口转发规则 "{label}" 当前正在运行。删除前将先关闭转发连接。',
'pf.deleteActive.confirm': '关闭并删除',
@@ -736,6 +951,12 @@ const zhCN: Messages = {
'sftp.conflict.action.keepBoth': '保留两者',
'sftp.conflict.action.replace': '替换',
// SFTP Upload Phases
'sftp.upload.phase.compressing': '正在压缩',
'sftp.upload.phase.uploading': '正在上传',
'sftp.upload.phase.extracting': '正在解压',
'sftp.upload.phase.compressed': '压缩传输',
// SFTP File Opener
'sftp.context.openWith': '打开方式...',
'sftp.context.edit': '编辑',
@@ -799,10 +1020,20 @@ const zhCN: Messages = {
// SFTP Folder Upload Progress
'sftp.upload.progress': '正在上传 {current}/{total} 个文件...',
'sftp.upload.uploading': '正在上传...',
'sftp.upload.compressing': '正在压缩...',
'sftp.upload.extracting': '正在解压...',
'sftp.upload.scanning': '正在扫描文件...',
'sftp.upload.completed': '已完成',
'sftp.upload.compressed': '压缩传输',
'sftp.upload.currentFile': '当前: {fileName}',
'sftp.upload.cancelled': '上传已取消',
'sftp.upload.cancel': '取消',
// SFTP Download
'sftp.download.completed': '已下载',
'sftp.download.cancelled': '下载已取消',
// SFTP Reconnecting
'sftp.reconnecting.title': '正在重连...',
'sftp.reconnecting.desc': '连接已断开,正在尝试重新连接',
@@ -818,6 +1049,12 @@ const zhCN: Messages = {
'settings.sftp.showHiddenFiles.enable': '显示隐藏文件',
'settings.sftp.showHiddenFiles.enableDesc': '浏览本地文件系统时显示 Windows 隐藏文件',
// Settings > SFTP Compressed Upload
'settings.sftp.compressedUpload': '文件夹压缩传输',
'settings.sftp.compressedUpload.desc': '上传前压缩文件夹,可大幅减少传输时间。',
'settings.sftp.compressedUpload.enable': '启用文件夹压缩',
'settings.sftp.compressedUpload.enableDesc': '自动使用 tar 压缩文件夹后再传输。需要服务器支持 tar 命令,不支持时自动回退到普通传输。',
// Settings > Terminal
'settings.terminal.section.theme': '终端主题',
'settings.terminal.themeModal.title': '选择主题',
@@ -860,6 +1097,9 @@ const zhCN: Messages = {
'settings.terminal.behavior.copyOnSelect.desc': '自动复制选中的文本',
'settings.terminal.behavior.middleClickPaste': '中键粘贴',
'settings.terminal.behavior.middleClickPaste.desc': '中键点击时粘贴剪贴板内容',
'settings.terminal.behavior.bracketedPaste': '括号粘贴模式',
'settings.terminal.behavior.bracketedPaste.desc':
'粘贴文本时使用转义序列包裹,以便终端区分粘贴和键入。如果出现 ^[[200~ 字样请关闭此选项。',
'settings.terminal.behavior.scrollOnInput': '输入时自动滚动',
'settings.terminal.behavior.scrollOnInput.desc': '输入时将终端滚动到底部',
'settings.terminal.behavior.scrollOnOutput': '输出时自动滚动',
@@ -893,6 +1133,18 @@ const zhCN: Messages = {
'settings.terminal.section.connection': '连接',
'settings.terminal.connection.keepaliveInterval': '会话保持间隔',
'settings.terminal.connection.keepaliveInterval.desc': '向服务器发送 SSH 级别保活数据包的频率(秒)。设为 0 表示禁用。',
'settings.terminal.section.serverStats': '服务器状态Linux',
'settings.terminal.serverStats.show': '显示服务器状态',
'settings.terminal.serverStats.show.desc': '在终端状态栏显示 CPU、内存和磁盘使用情况仅限 Linux 服务器)。',
'settings.terminal.serverStats.refreshInterval': '刷新间隔',
'settings.terminal.serverStats.refreshInterval.desc': '服务器状态刷新的频率。',
'settings.terminal.serverStats.seconds': '秒',
// Settings > Terminal > Rendering
'settings.terminal.section.rendering': '渲染',
'settings.terminal.rendering.renderer': '渲染器',
'settings.terminal.rendering.renderer.desc': '选择终端渲染技术。自动模式会在低内存设备上使用 Canvas。更改将在新终端会话中生效。',
'settings.terminal.rendering.auto': '自动',
// Settings > Shortcuts
'settings.shortcuts.section.scheme': '快捷键方案',
@@ -909,6 +1161,36 @@ const zhCN: Messages = {
'settings.shortcuts.category.terminal': '终端',
'settings.shortcuts.category.navigation': '导航',
'settings.shortcuts.category.app': '应用',
'settings.shortcuts.category.sftp': 'SFTP',
'settings.shortcuts.binding.switch-tab-1-9': '切换到标签页 [1...9]',
'settings.shortcuts.binding.next-tab': '下一个标签页',
'settings.shortcuts.binding.prev-tab': '上一个标签页',
'settings.shortcuts.binding.close-tab': '关闭标签页',
'settings.shortcuts.binding.new-tab': '新建本地标签页',
'settings.shortcuts.binding.copy': '从终端复制',
'settings.shortcuts.binding.paste': '粘贴到终端',
'settings.shortcuts.binding.select-all': '全选终端内容',
'settings.shortcuts.binding.clear-buffer': '清空终端缓冲区',
'settings.shortcuts.binding.search-terminal': '打开终端搜索',
'settings.shortcuts.binding.move-focus': '在分屏间移动焦点',
'settings.shortcuts.binding.split-horizontal': '水平分屏',
'settings.shortcuts.binding.split-vertical': '垂直分屏',
'settings.shortcuts.binding.open-hosts': '打开主机列表',
'settings.shortcuts.binding.open-local': '打开本地终端',
'settings.shortcuts.binding.open-sftp': '打开 SFTP',
'settings.shortcuts.binding.port-forwarding': '打开端口转发',
'settings.shortcuts.binding.command-palette': '打开命令面板',
'settings.shortcuts.binding.quick-switch': '快速切换',
'settings.shortcuts.binding.snippets': '打开代码片段',
'settings.shortcuts.binding.broadcast': '切换广播模式',
'settings.shortcuts.binding.sftp-copy': '复制文件',
'settings.shortcuts.binding.sftp-cut': '剪切文件',
'settings.shortcuts.binding.sftp-paste': '粘贴文件',
'settings.shortcuts.binding.sftp-select-all': '全选文件',
'settings.shortcuts.binding.sftp-rename': '重命名文件',
'settings.shortcuts.binding.sftp-delete': '删除文件',
'settings.shortcuts.binding.sftp-refresh': '刷新',
'settings.shortcuts.binding.sftp-new-folder': '新建文件夹',
// Host Details (sub-panels)
'hostDetails.proxyPanel.title': 'Proxy',
@@ -1034,6 +1316,7 @@ const zhCN: Messages = {
'tabs.closeLogViewAria': '关闭日志视图',
'tabs.logPrefix': '日志:',
'tabs.logLocal': '本地',
'tabs.copyTab': '复制标签页',
'keychain.edit.labelRequired': 'Label *',
'keychain.edit.keyLabelPlaceholder': '密钥 Label',
'keychain.edit.privateKeyRequired': '私钥 *',
@@ -1085,6 +1368,23 @@ const zhCN: Messages = {
'snippets.packageDialog.placeholder': '例如ops/maintenance',
'snippets.packageDialog.hint': '使用 "/" 创建嵌套代码包。',
// Snippets Rename Dialog
'snippets.renameDialog.title': '重命名代码包',
'snippets.renameDialog.currentPath': '当前路径:{path}',
'snippets.renameDialog.placeholder': '输入新名称',
'snippets.renameDialog.error.empty': '代码包名称不能为空',
'snippets.renameDialog.error.duplicate': '已存在同名的代码包',
'snippets.renameDialog.error.invalidChars': '代码包名称只能包含字母、数字、连字符和下划线',
// Snippet Shortkey
'snippets.field.shortkey': '快捷键',
'snippets.shortkey.placeholder': '点击设置快捷键',
'snippets.shortkey.recording': '请按下快捷键组合...',
'snippets.shortkey.hint': '在终端中按下此快捷键可快速发送命令。',
'snippets.shortkey.clear': '清除快捷键',
'snippets.shortkey.error.systemConflict': '此快捷键与系统快捷键冲突',
'snippets.shortkey.error.snippetConflict': '此快捷键已被代码片段使用:{name}',
// Serial Port
'serial.button': '串口',
'serial.modal.title': '连接串口',
@@ -1139,6 +1439,19 @@ const zhCN: Messages = {
'keyboard.interactive.fillSaved': '填入已保存的密码',
'keyboard.interactive.useSaved': '使用已保存',
'keyboard.interactive.useSavedPassword': '使用已保存的密码',
// Passphrase Modal for encrypted SSH keys
'passphrase.title': 'SSH 密钥密码',
'passphrase.desc': '请输入 {keyName} 的密码',
'passphrase.descWithHost': '请输入 {keyName} 的密码以连接到 {hostname}',
'passphrase.label': '密码',
'passphrase.keyPath': '密钥',
'passphrase.unlock': '解锁',
'passphrase.unlocking': '解锁中...',
'passphrase.skip': '跳过',
// Text Editor
'sftp.editor.wordWrap': '自动换行',
};
export default zhCN;

View File

@@ -0,0 +1,177 @@
import { useSyncExternalStore, useCallback } from 'react';
import { TerminalTheme } from '../../domain/models';
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
import { STORAGE_KEY_CUSTOM_THEMES } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
// Access the Electron bridge for cross-window IPC
type NetcattyBridge = {
notifySettingsChanged?(payload: { key: string; value: unknown }): void;
onSettingsChanged?(cb: (payload: { key: string; value: unknown }) => void): () => void;
};
const getBridge = (): NetcattyBridge | undefined =>
(window as unknown as { netcatty?: NetcattyBridge }).netcatty;
/**
* Custom Theme Store - manages user-created terminal themes
* Uses useSyncExternalStore pattern (same as fontStore)
* Persists to localStorage + cross-window IPC sync
*/
type Listener = () => void;
class CustomThemeStore {
private themes: TerminalTheme[] = [];
private listeners = new Set<Listener>();
private loaded = false;
/** Cached merged array for stable useSyncExternalStore snapshots */
private cachedAllThemes: TerminalTheme[] | null = null;
constructor() {
this.loadFromStorage();
this.setupCrossWindowSync();
}
private loadFromStorage = () => {
try {
const parsed = localStorageAdapter.read<TerminalTheme[]>(STORAGE_KEY_CUSTOM_THEMES);
if (Array.isArray(parsed)) {
this.themes = parsed.map((t: TerminalTheme) => ({ ...t, isCustom: true }));
}
} catch {
// ignore corrupt data
}
this.loaded = true;
this.cachedAllThemes = null; // invalidate cache
};
private saveToStorage = () => {
try {
localStorageAdapter.write(STORAGE_KEY_CUSTOM_THEMES, this.themes);
} catch {
// storage full or unavailable
}
};
private notify = () => {
this.cachedAllThemes = null; // invalidate cache on any mutation
this.listeners.forEach(listener => listener());
};
/** Broadcast change to other Electron windows via IPC */
private broadcastChange = () => {
try {
getBridge()?.notifySettingsChanged?.({
key: STORAGE_KEY_CUSTOM_THEMES,
value: this.themes,
});
} catch {
// not in Electron or bridge unavailable
}
};
/** Listen for changes from other windows and reload */
private setupCrossWindowSync = () => {
try {
getBridge()?.onSettingsChanged?.((payload) => {
if (payload.key === STORAGE_KEY_CUSTOM_THEMES) {
// Another window changed custom themes — reload from localStorage
this.loadFromStorage();
this.notify();
}
});
} catch {
// not in Electron or bridge unavailable
}
};
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
// ---- Getters (stable references for useSyncExternalStore) ----
getCustomThemes = (): TerminalTheme[] => this.themes;
/** Returns all themes: built-in + custom (cached for snapshot stability) */
getAllThemes = (): TerminalTheme[] => {
if (!this.cachedAllThemes) {
this.cachedAllThemes = [...TERMINAL_THEMES, ...this.themes];
}
return this.cachedAllThemes;
};
/** Find a theme by ID across both built-in and custom */
getThemeById = (id: string): TerminalTheme | undefined => {
return TERMINAL_THEMES.find(t => t.id === id) || this.themes.find(t => t.id === id);
};
// ---- Mutations ----
addTheme = (theme: TerminalTheme) => {
this.themes = [...this.themes, { ...theme, isCustom: true }];
this.saveToStorage();
this.notify();
this.broadcastChange();
};
updateTheme = (id: string, updates: Partial<TerminalTheme>) => {
this.themes = this.themes.map(t =>
t.id === id ? { ...t, ...updates, isCustom: true } : t
);
this.saveToStorage();
this.notify();
this.broadcastChange();
};
deleteTheme = (id: string) => {
this.themes = this.themes.filter(t => t.id !== id);
this.saveToStorage();
this.notify();
this.broadcastChange();
};
}
// Singleton
export const customThemeStore = new CustomThemeStore();
// ============== Hooks ==============
/** Get all themes (built-in + custom) */
export const useAllThemes = (): TerminalTheme[] => {
return useSyncExternalStore(
customThemeStore.subscribe,
customThemeStore.getAllThemes
);
};
/** Get custom themes only */
export const useCustomThemes = (): TerminalTheme[] => {
return useSyncExternalStore(
customThemeStore.subscribe,
customThemeStore.getCustomThemes
);
};
/** Get theme by ID (built-in or custom) with fallback */
export const useThemeById = (id: string): TerminalTheme => {
const allThemes = useAllThemes();
return allThemes.find(t => t.id === id) || TERMINAL_THEMES[0];
};
/** Theme mutation actions */
export const useCustomThemeActions = () => {
const addTheme = useCallback((theme: TerminalTheme) => {
customThemeStore.addTheme(theme);
}, []);
const updateTheme = useCallback((id: string, updates: Partial<TerminalTheme>) => {
customThemeStore.updateTheme(id, updates);
}, []);
const deleteTheme = useCallback((id: string) => {
customThemeStore.deleteTheme(id);
}, []);
return { addTheme, updateTheme, deleteTheme };
};

View File

@@ -0,0 +1,34 @@
export const isSessionError = (err: unknown): boolean => {
if (!(err instanceof Error)) return false;
const msg = err.message.toLowerCase();
return (
msg.includes("session not found") ||
msg.includes("sftp session") ||
msg.includes("not found") ||
msg.includes("closed") ||
msg.includes("connection reset")
);
};
/**
* Check if an error message indicates a fatal error that should stop the entire upload.
* This includes session errors AND target directory deletion errors.
*/
export const isFatalUploadError = (errorMessage: string): boolean => {
const msg = errorMessage.toLowerCase();
return (
// Session-related errors
msg.includes("session not found") ||
msg.includes("sftp session") ||
msg.includes("connection") ||
msg.includes("disconnected") ||
// Target directory was deleted during upload
msg.includes("no such file") ||
msg.includes("enoent") ||
msg.includes("does not exist") ||
msg.includes("write stream error") ||
// Directory was removed
msg.includes("directory not found") ||
msg.includes("not a directory")
);
};

View File

@@ -0,0 +1,454 @@
import { SftpFileEntry } from "../../../domain/models";
import { formatDate } from "./utils";
// Mock local file data for development (when backend is not available)
export function buildMockLocalFiles(path: string): SftpFileEntry[] {
// Normalize path for matching (handle both Windows and Unix paths)
const normPath = path.replace(/\\/g, "/").replace(/\/$/, "") || "/";
const mockData: Record<string, SftpFileEntry[]> = {
// Unix-style paths
"/": [
{
name: "Users",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
{
name: "Applications",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 172800000,
lastModifiedFormatted: formatDate(Date.now() - 172800000),
},
{
name: "System",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 259200000,
lastModifiedFormatted: formatDate(Date.now() - 259200000),
},
],
"/Users": [
{
name: "damao",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 3600000,
lastModifiedFormatted: formatDate(Date.now() - 3600000),
},
{
name: "Shared",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
],
"/Users/damao": [
{
name: "Desktop",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 1800000,
lastModifiedFormatted: formatDate(Date.now() - 1800000),
},
{
name: "Documents",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 7200000,
lastModifiedFormatted: formatDate(Date.now() - 7200000),
},
{
name: "Downloads",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 3600000,
lastModifiedFormatted: formatDate(Date.now() - 3600000),
},
{
name: "Pictures",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 172800000,
lastModifiedFormatted: formatDate(Date.now() - 172800000),
},
{
name: "Projects",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 900000,
lastModifiedFormatted: formatDate(Date.now() - 900000),
},
],
// Windows-style paths (normalized to forward slashes for matching)
"C:": [
{
name: "Users",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
{
name: "Program Files",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 172800000,
lastModifiedFormatted: formatDate(Date.now() - 172800000),
},
{
name: "Windows",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 259200000,
lastModifiedFormatted: formatDate(Date.now() - 259200000),
},
],
"C:/Users": [
{
name: "damao",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 3600000,
lastModifiedFormatted: formatDate(Date.now() - 3600000),
},
{
name: "Public",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
{
name: "Default",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 172800000,
lastModifiedFormatted: formatDate(Date.now() - 172800000),
},
],
"C:/Users/damao": [
{
name: "Desktop",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 1800000,
lastModifiedFormatted: formatDate(Date.now() - 1800000),
},
{
name: "Documents",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 7200000,
lastModifiedFormatted: formatDate(Date.now() - 7200000),
},
{
name: "Downloads",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 3600000,
lastModifiedFormatted: formatDate(Date.now() - 3600000),
},
{
name: "Pictures",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 172800000,
lastModifiedFormatted: formatDate(Date.now() - 172800000),
},
{
name: "Projects",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 900000,
lastModifiedFormatted: formatDate(Date.now() - 900000),
},
],
"C:/Users/damao/Desktop": [
{
name: "Netcatty",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 300000,
lastModifiedFormatted: formatDate(Date.now() - 300000),
},
{
name: "notes.txt",
type: "file",
size: 2048,
sizeFormatted: "2 KB",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
{
name: "screenshot.png",
type: "file",
size: 1048576,
sizeFormatted: "1 MB",
lastModified: Date.now() - 43200000,
lastModifiedFormatted: formatDate(Date.now() - 43200000),
},
],
"C:/Users/damao/Desktop/Netcatty": [
{
name: "src",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 600000,
lastModifiedFormatted: formatDate(Date.now() - 600000),
},
{
name: "package.json",
type: "file",
size: 1536,
sizeFormatted: "1.5 KB",
lastModified: Date.now() - 3600000,
lastModifiedFormatted: formatDate(Date.now() - 3600000),
},
{
name: "README.md",
type: "file",
size: 4096,
sizeFormatted: "4 KB",
lastModified: Date.now() - 7200000,
lastModifiedFormatted: formatDate(Date.now() - 7200000),
},
{
name: "tsconfig.json",
type: "file",
size: 512,
sizeFormatted: "512 Bytes",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
],
"C:/Users/damao/Documents": [
{
name: "Work",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
{
name: "Personal",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 172800000,
lastModifiedFormatted: formatDate(Date.now() - 172800000),
},
{
name: "report.pdf",
type: "file",
size: 2097152,
sizeFormatted: "2 MB",
lastModified: Date.now() - 259200000,
lastModifiedFormatted: formatDate(Date.now() - 259200000),
},
],
"C:/Users/damao/Downloads": [
{
name: "installer.exe",
type: "file",
size: 52428800,
sizeFormatted: "50 MB",
lastModified: Date.now() - 3600000,
lastModifiedFormatted: formatDate(Date.now() - 3600000),
},
{
name: "archive.zip",
type: "file",
size: 10485760,
sizeFormatted: "10 MB",
lastModified: Date.now() - 7200000,
lastModifiedFormatted: formatDate(Date.now() - 7200000),
},
{
name: "document.pdf",
type: "file",
size: 524288,
sizeFormatted: "512 KB",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
],
"C:/Users/damao/Projects": [
{
name: "webapp",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 1800000,
lastModifiedFormatted: formatDate(Date.now() - 1800000),
},
{
name: "scripts",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 43200000,
lastModifiedFormatted: formatDate(Date.now() - 43200000),
},
],
"/Users/damao/Desktop": [
{
name: "Netcatty",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 300000,
lastModifiedFormatted: formatDate(Date.now() - 300000),
},
{
name: "notes.txt",
type: "file",
size: 2048,
sizeFormatted: "2 KB",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
{
name: "screenshot.png",
type: "file",
size: 1048576,
sizeFormatted: "1 MB",
lastModified: Date.now() - 43200000,
lastModifiedFormatted: formatDate(Date.now() - 43200000),
},
],
"/Users/damao/Desktop/Netcatty": [
{
name: "src",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 600000,
lastModifiedFormatted: formatDate(Date.now() - 600000),
},
{
name: "package.json",
type: "file",
size: 1536,
sizeFormatted: "1.5 KB",
lastModified: Date.now() - 3600000,
lastModifiedFormatted: formatDate(Date.now() - 3600000),
},
{
name: "README.md",
type: "file",
size: 4096,
sizeFormatted: "4 KB",
lastModified: Date.now() - 7200000,
lastModifiedFormatted: formatDate(Date.now() - 7200000),
},
{
name: "tsconfig.json",
type: "file",
size: 512,
sizeFormatted: "512 Bytes",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
],
"/Users/damao/Documents": [
{
name: "Work",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
{
name: "Personal",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 172800000,
lastModifiedFormatted: formatDate(Date.now() - 172800000),
},
{
name: "report.pdf",
type: "file",
size: 2097152,
sizeFormatted: "2 MB",
lastModified: Date.now() - 259200000,
lastModifiedFormatted: formatDate(Date.now() - 259200000),
},
],
"/Users/damao/Downloads": [
{
name: "installer.exe",
type: "file",
size: 52428800,
sizeFormatted: "50 MB",
lastModified: Date.now() - 3600000,
lastModifiedFormatted: formatDate(Date.now() - 3600000),
},
{
name: "archive.zip",
type: "file",
size: 10485760,
sizeFormatted: "10 MB",
lastModified: Date.now() - 7200000,
lastModifiedFormatted: formatDate(Date.now() - 7200000),
},
{
name: "document.pdf",
type: "file",
size: 524288,
sizeFormatted: "512 KB",
lastModified: Date.now() - 86400000,
lastModifiedFormatted: formatDate(Date.now() - 86400000),
},
],
"/Users/damao/Projects": [
{
name: "webapp",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 1800000,
lastModifiedFormatted: formatDate(Date.now() - 1800000),
},
{
name: "scripts",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: Date.now() - 43200000,
lastModifiedFormatted: formatDate(Date.now() - 43200000),
},
],
};
return mockData[normPath] || [];
}

View File

@@ -0,0 +1,55 @@
import { SftpConnection, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
export interface SftpPane {
id: string;
connection: SftpConnection | null;
files: SftpFileEntry[];
loading: boolean;
reconnecting: boolean;
error: string | null;
selectedFiles: Set<string>;
filter: string;
filenameEncoding: SftpFilenameEncoding;
}
// Multi-tab state for left and right sides
export interface SftpSideTabs {
tabs: SftpPane[];
activeTabId: string | null;
}
// Constants for empty placeholder pane IDs
export const EMPTY_LEFT_PANE_ID = "__empty_left__";
export const EMPTY_RIGHT_PANE_ID = "__empty_right__";
export const createEmptyPane = (id?: string): SftpPane => ({
id: id || crypto.randomUUID(),
connection: null,
files: [],
loading: false,
reconnecting: false,
error: null,
selectedFiles: new Set(),
filter: "",
filenameEncoding: "auto",
});
// File watch event types
export interface FileWatchSyncedEvent {
watchId: string;
localPath: string;
remotePath: string;
bytesWritten: number;
}
export interface FileWatchErrorEvent {
watchId: string;
localPath: string;
remotePath: string;
error: string;
}
export interface SftpStateOptions {
onFileWatchSynced?: (event: FileWatchSyncedEvent) => void;
onFileWatchError?: (event: FileWatchErrorEvent) => void;
}

View File

@@ -0,0 +1,427 @@
import { useCallback, useEffect, useRef } from "react";
import type { MutableRefObject } from "react";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import type { Host, Identity, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";
import type { SftpPane } from "./types";
import { useSftpDirectoryListing } from "./useSftpDirectoryListing";
import { useSftpHostCredentials } from "./useSftpHostCredentials";
interface UseSftpConnectionsParams {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
leftTabsRef: MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
rightTabsRef: MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
leftTabs: { tabs: SftpPane[] };
rightTabs: { tabs: SftpPane[] };
leftPane: SftpPane;
rightPane: SftpPane;
setLeftTabs: React.Dispatch<React.SetStateAction<{ tabs: SftpPane[]; activeTabId: string | null }>>;
setRightTabs: React.Dispatch<React.SetStateAction<{ tabs: SftpPane[]; activeTabId: string | null }>>;
getActivePane: (side: "left" | "right") => SftpPane | null;
updateTab: (side: "left" | "right", tabId: string, updater: (prev: SftpPane) => SftpPane) => void;
navSeqRef: MutableRefObject<{ left: number; right: number }>;
dirCacheRef: MutableRefObject<Map<string, { files: SftpFileEntry[]; timestamp: number }>>;
sftpSessionsRef: MutableRefObject<Map<string, string>>;
lastConnectedHostRef: MutableRefObject<{ left: Host | "local" | null; right: Host | "local" | null }>;
reconnectingRef: MutableRefObject<{ left: boolean; right: boolean }>;
makeCacheKey: (connectionId: string, path: string, encoding?: SftpFilenameEncoding) => string;
clearCacheForConnection: (connectionId: string) => void;
createEmptyPane: (id?: string) => SftpPane;
}
interface UseSftpConnectionsResult {
connect: (side: "left" | "right", host: Host | "local") => Promise<void>;
disconnect: (side: "left" | "right") => Promise<void>;
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
}
export const useSftpConnections = ({
hosts,
keys,
identities,
leftTabsRef,
rightTabsRef,
leftTabs,
rightTabs: _rightTabs,
leftPane,
rightPane,
setLeftTabs,
setRightTabs,
getActivePane,
updateTab,
navSeqRef,
dirCacheRef,
sftpSessionsRef,
lastConnectedHostRef,
reconnectingRef,
makeCacheKey,
clearCacheForConnection,
createEmptyPane,
}: UseSftpConnectionsParams): UseSftpConnectionsResult => {
const getHostCredentials = useSftpHostCredentials({ hosts, keys, identities });
const { listLocalFiles, listRemoteFiles } = useSftpDirectoryListing();
const connect = useCallback(
async (side: "left" | "right", host: Host | "local") => {
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
let activeTabId: string | null = null;
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
if (!sideTabs.activeTabId) {
const newPane = createEmptyPane();
activeTabId = newPane.id;
setTabs((prev) => ({
tabs: [...prev.tabs, newPane],
activeTabId: newPane.id,
}));
} else {
activeTabId = sideTabs.activeTabId;
}
if (!activeTabId) return;
const connectionId = `${side}-${Date.now()}`;
navSeqRef.current[side] += 1;
const connectRequestId = navSeqRef.current[side];
lastConnectedHostRef.current[side] = host;
const currentPane = getActivePane(side);
// Reset encoding to host's configured encoding or "auto" when connecting to a new host
// This ensures proper auto-detection works and respects host-level encoding settings
const filenameEncoding: SftpFilenameEncoding =
host === "local" ? "auto" : (host.sftpEncoding ?? "auto");
if (currentPane?.connection) {
clearCacheForConnection(currentPane.connection.id);
}
if (currentPane?.connection && !currentPane.connection.isLocal) {
const oldSftpId = sftpSessionsRef.current.get(currentPane.connection.id);
if (oldSftpId) {
try {
await netcattyBridge.get()?.closeSftp(oldSftpId);
} catch {
// Ignore errors when closing stale SFTP sessions
}
sftpSessionsRef.current.delete(currentPane.connection.id);
}
}
if (host === "local") {
let homeDir = await netcattyBridge.get()?.getHomeDir?.();
if (!homeDir) {
const isWindows = navigator.platform.toLowerCase().includes("win");
homeDir = isWindows ? "C:\\Users\\damao" : "/Users/damao";
}
const connection: SftpConnection = {
id: connectionId,
hostId: "local",
hostLabel: "Local",
isLocal: true,
status: "connected",
currentPath: homeDir,
homeDir,
};
updateTab(side, activeTabId, (prev) => ({
...prev,
connection,
loading: true,
reconnecting: false,
error: null,
filenameEncoding, // Reset encoding for new connection
}));
try {
const files = await listLocalFiles(homeDir);
if (navSeqRef.current[side] !== connectRequestId) return;
dirCacheRef.current.set(makeCacheKey(connectionId, homeDir, filenameEncoding), {
files,
timestamp: Date.now(),
});
reconnectingRef.current[side] = false;
updateTab(side, activeTabId, (prev) => ({
...prev,
files,
loading: false,
reconnecting: false,
}));
} catch (err) {
if (navSeqRef.current[side] !== connectRequestId) return;
reconnectingRef.current[side] = false;
updateTab(side, activeTabId, (prev) => ({
...prev,
error: err instanceof Error ? err.message : "Failed to list directory",
loading: false,
reconnecting: false,
}));
}
} else {
const connection: SftpConnection = {
id: connectionId,
hostId: host.id,
hostLabel: host.label,
isLocal: false,
status: "connecting",
currentPath: "/",
};
updateTab(side, activeTabId, (prev) => ({
...prev,
connection,
loading: true,
reconnecting: prev.reconnecting,
error: null,
files: prev.reconnecting ? prev.files : [],
filenameEncoding, // Reset encoding for new connection
}));
try {
const credentials = getHostCredentials(host);
const bridge = netcattyBridge.get();
const openSftp = bridge?.openSftp;
if (!openSftp) throw new Error("SFTP bridge unavailable");
const isAuthError = (err: unknown): boolean => {
if (!(err instanceof Error)) return false;
const msg = err.message.toLowerCase();
return (
msg.includes("authentication") ||
msg.includes("auth") ||
msg.includes("password") ||
msg.includes("permission denied")
);
};
const hasKey = !!credentials.privateKey;
const hasPassword = !!credentials.password;
let sftpId: string | undefined;
if (hasKey) {
try {
const keyFirstCredentials = {
sessionId: `sftp-${connectionId}`,
...credentials,
};
if (!credentials.sudo) {
keyFirstCredentials.password = undefined;
}
sftpId = await openSftp(keyFirstCredentials);
} catch (err) {
if (hasPassword && isAuthError(err)) {
sftpId = await openSftp({
sessionId: `sftp-${connectionId}`,
...credentials,
privateKey: undefined,
certificate: undefined,
publicKey: undefined,
keyId: undefined,
keySource: undefined,
});
} else {
throw err;
}
}
} else {
sftpId = await openSftp({
sessionId: `sftp-${connectionId}`,
...credentials,
});
}
if (!sftpId) throw new Error("Failed to open SFTP session");
sftpSessionsRef.current.set(connectionId, sftpId);
let startPath = "/";
const statSftp = netcattyBridge.get()?.statSftp;
if (statSftp) {
const candidates: string[] = [];
if (credentials.username === "root") {
candidates.push("/root");
} else if (credentials.username) {
candidates.push(`/home/${credentials.username}`);
candidates.push("/root");
} else {
candidates.push("/root");
}
for (const candidate of candidates) {
try {
const stat = await statSftp(sftpId, candidate, filenameEncoding);
if (stat?.type === "directory") {
startPath = candidate;
break;
}
} catch {
// Ignore missing/permission errors
}
}
} else {
if (credentials.username === "root") {
try {
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
if (rootFiles) startPath = "/root";
} catch {
// Fallback path not available
}
} else if (credentials.username) {
try {
const homeFiles = await netcattyBridge.get()?.listSftp(
sftpId,
`/home/${credentials.username}`,
filenameEncoding,
);
if (homeFiles) startPath = `/home/${credentials.username}`;
} catch {
// Fall through to /root check
}
if (startPath === "/") {
try {
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
if (rootFiles) startPath = "/root";
} catch {
// Fallback path not available
}
}
} else {
try {
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
if (rootFiles) startPath = "/root";
} catch {
// Fallback path not available
}
}
}
const files = await listRemoteFiles(sftpId, startPath, filenameEncoding);
if (navSeqRef.current[side] !== connectRequestId) return;
dirCacheRef.current.set(makeCacheKey(connectionId, startPath, filenameEncoding), {
files,
timestamp: Date.now(),
});
reconnectingRef.current[side] = false;
updateTab(side, activeTabId, (prev) => ({
...prev,
connection: prev.connection
? {
...prev.connection,
status: "connected",
currentPath: startPath,
homeDir: startPath,
}
: null,
files,
loading: false,
reconnecting: false,
}));
} catch (err) {
if (navSeqRef.current[side] !== connectRequestId) return;
reconnectingRef.current[side] = false;
updateTab(side, activeTabId, (prev) => ({
...prev,
connection: prev.connection
? {
...prev.connection,
status: "error",
error: err instanceof Error ? err.message : "Connection failed",
}
: null,
error: err instanceof Error ? err.message : "Connection failed",
loading: false,
reconnecting: false,
}));
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
getHostCredentials,
getActivePane,
updateTab,
clearCacheForConnection,
makeCacheKey,
listLocalFiles,
listRemoteFiles,
],
);
const initialConnectDoneRef = useRef(false);
useEffect(() => {
if (!initialConnectDoneRef.current && leftTabs.tabs.length === 0) {
initialConnectDoneRef.current = true;
setTimeout(() => {
connect("left", "local");
}, 0);
}
}, [connect, leftTabs.tabs.length]);
useEffect(() => {
const attemptReconnect = async (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 (leftPane.reconnecting && reconnectingRef.current.left) {
attemptReconnect("left");
}
if (rightPane.reconnecting && reconnectingRef.current.right) {
attemptReconnect("right");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [leftPane.reconnecting, rightPane.reconnecting, connect]);
const disconnect = useCallback(
async (side: "left" | "right") => {
const pane = getActivePane(side);
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
const activeTabId = sideTabs.activeTabId;
if (!pane || !activeTabId) return;
navSeqRef.current[side] += 1;
if (pane.connection) {
clearCacheForConnection(pane.connection.id);
}
reconnectingRef.current[side] = false;
lastConnectedHostRef.current[side] = null;
if (pane.connection && !pane.connection.isLocal) {
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (sftpId) {
try {
await netcattyBridge.get()?.closeSftp(sftpId);
} catch {
// Ignore errors when closing SFTP session during disconnect
}
sftpSessionsRef.current.delete(pane.connection.id);
}
}
updateTab(side, activeTabId, () => createEmptyPane(activeTabId));
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[getActivePane, clearCacheForConnection, updateTab],
);
return {
connect,
disconnect,
listLocalFiles,
listRemoteFiles,
};
};

View File

@@ -0,0 +1,63 @@
import { useCallback } from "react";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import type { SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
import { buildMockLocalFiles } from "./mockLocalFiles";
import { formatFileSize, formatDate } from "./utils";
export const useSftpDirectoryListing = () => {
const getMockLocalFiles = useCallback((path: string): SftpFileEntry[] => {
return buildMockLocalFiles(path);
}, []);
const listLocalFiles = useCallback(
async (path: string): Promise<SftpFileEntry[]> => {
const rawFiles = await netcattyBridge.get()?.listLocalDir?.(path);
if (!rawFiles) {
return getMockLocalFiles(path);
}
return rawFiles.map((f) => {
const size = parseInt(f.size) || 0;
const lastModified = new Date(f.lastModified).getTime();
return {
name: f.name,
type: f.type as "file" | "directory" | "symlink",
size,
sizeFormatted: formatFileSize(size),
lastModified,
lastModifiedFormatted: formatDate(lastModified),
linkTarget: f.linkTarget as "file" | "directory" | null | undefined,
hidden: f.hidden,
};
});
},
[getMockLocalFiles],
);
const listRemoteFiles = useCallback(
async (sftpId: string, path: string, encoding?: SftpFilenameEncoding): Promise<SftpFileEntry[]> => {
const rawFiles = await netcattyBridge.get()?.listSftp(sftpId, path, encoding);
if (!rawFiles) return [];
return rawFiles.map((f) => {
const size = parseInt(f.size) || 0;
const lastModified = new Date(f.lastModified).getTime();
return {
name: f.name,
type: f.type as "file" | "directory" | "symlink",
size,
sizeFormatted: formatFileSize(size),
lastModified,
lastModifiedFormatted: formatDate(lastModified),
linkTarget: f.linkTarget as "file" | "directory" | null | undefined,
};
});
},
[],
);
return {
listLocalFiles,
listRemoteFiles,
};
};

View File

@@ -0,0 +1,449 @@
import React, { useCallback, useRef, useMemo } from "react";
import { TransferTask, TransferStatus } from "../../../domain/models";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { logger } from "../../../lib/logger";
import { SftpPane } from "./types";
import { joinPath } from "./utils";
import {
UploadController,
uploadFromDataTransfer,
UploadBridge,
UploadCallbacks,
UploadResult,
UploadTaskInfo,
} from "../../../lib/uploadService";
// Re-export UploadResult for external usage
export type { UploadResult };
interface UseSftpExternalOperationsParams {
getActivePane: (side: "left" | "right") => SftpPane | null;
refresh: (side: "left" | "right") => Promise<void>;
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
addExternalUpload?: (task: TransferTask) => void;
updateExternalUpload?: (taskId: string, updates: Partial<TransferTask>) => void;
dismissExternalUpload?: (taskId: string) => void;
}
interface SftpExternalOperationsResult {
readTextFile: (side: "left" | "right", filePath: string) => Promise<string>;
readBinaryFile: (side: "left" | "right", filePath: string) => Promise<ArrayBuffer>;
writeTextFile: (side: "left" | "right", filePath: string, content: string) => Promise<void>;
downloadToTempAndOpen: (
side: "left" | "right",
remotePath: string,
fileName: string,
appPath: string,
options?: { enableWatch?: boolean }
) => Promise<{ localTempPath: string; watchId?: string }>;
uploadExternalFiles: (
side: "left" | "right",
dataTransfer: DataTransfer
) => Promise<UploadResult[]>;
cancelExternalUpload: () => Promise<void>;
selectApplication: () => Promise<{ path: string; name: string } | null>;
}
export const useSftpExternalOperations = (
params: UseSftpExternalOperationsParams
): SftpExternalOperationsResult => {
const { getActivePane, refresh, sftpSessionsRef, addExternalUpload, updateExternalUpload, dismissExternalUpload } = params;
// Upload controller for cancellation support
const uploadControllerRef = useRef<UploadController | null>(null);
const readTextFile = useCallback(
async (side: "left" | "right", filePath: string): Promise<string> => {
const pane = getActivePane(side);
if (!pane?.connection) {
throw new Error("No connection available");
}
if (pane.connection.isLocal) {
const bridge = netcattyBridge.get();
if (bridge?.readLocalFile) {
const buffer = await bridge.readLocalFile(filePath);
return new TextDecoder().decode(buffer);
}
throw new Error("Local file reading not supported");
}
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
throw new Error("SFTP session not found");
}
const bridge = netcattyBridge.get();
if (!bridge) {
throw new Error("Bridge not available");
}
return await bridge.readSftp(sftpId, filePath, pane.filenameEncoding);
},
[getActivePane, sftpSessionsRef],
);
const readBinaryFile = useCallback(
async (side: "left" | "right", filePath: string): Promise<ArrayBuffer> => {
const pane = getActivePane(side);
if (!pane?.connection) {
throw new Error("No connection available");
}
if (pane.connection.isLocal) {
const bridge = netcattyBridge.get();
if (bridge?.readLocalFile) {
return await bridge.readLocalFile(filePath);
}
throw new Error("Local file reading not supported");
}
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
throw new Error("SFTP session not found");
}
const bridge = netcattyBridge.get();
if (!bridge?.readSftpBinary) {
throw new Error("Binary file reading not supported");
}
return await bridge.readSftpBinary(sftpId, filePath, pane.filenameEncoding);
},
[getActivePane, sftpSessionsRef],
);
const writeTextFile = useCallback(
async (side: "left" | "right", filePath: string, content: string): Promise<void> => {
const pane = getActivePane(side);
if (!pane?.connection) {
throw new Error("No connection available");
}
if (pane.connection.isLocal) {
const bridge = netcattyBridge.get();
if (bridge?.writeLocalFile) {
const data = new TextEncoder().encode(content);
await bridge.writeLocalFile(filePath, data.buffer);
return;
}
throw new Error("Local file writing not supported");
}
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
throw new Error("SFTP session not found");
}
const bridge = netcattyBridge.get();
if (!bridge) {
throw new Error("Bridge not available");
}
await bridge.writeSftp(sftpId, filePath, content, pane.filenameEncoding);
},
[getActivePane, sftpSessionsRef],
);
const downloadToTempAndOpen = useCallback(
async (
side: "left" | "right",
remotePath: string,
fileName: string,
appPath: string,
options?: { enableWatch?: boolean }
): Promise<{ localTempPath: string; watchId?: string }> => {
const pane = getActivePane(side);
if (!pane?.connection) {
throw new Error("No connection available");
}
const bridge = netcattyBridge.get();
if (!bridge?.downloadSftpToTemp || !bridge?.openWithApplication) {
throw new Error("System app opening not supported");
}
if (pane.connection.isLocal) {
await bridge.openWithApplication(remotePath, appPath);
return { localTempPath: remotePath };
}
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
throw new Error("SFTP session not found");
}
console.log("[SFTP] Downloading file to temp", { sftpId, remotePath, fileName });
const localTempPath = await bridge.downloadSftpToTemp(
sftpId,
remotePath,
fileName,
pane.filenameEncoding,
);
console.log("[SFTP] File downloaded to temp", { localTempPath });
if (bridge.registerTempFile) {
try {
await bridge.registerTempFile(sftpId, localTempPath);
} catch (err) {
console.warn("[SFTP] Failed to register temp file for cleanup:", err);
}
}
console.log("[SFTP] Opening with application", { localTempPath, appPath });
await bridge.openWithApplication(localTempPath, appPath);
console.log("[SFTP] Application launched");
let watchId: string | undefined;
console.log("[SFTP] Auto-sync enabled check", { enableWatch: options?.enableWatch, hasStartFileWatch: !!bridge.startFileWatch });
if (options?.enableWatch && bridge.startFileWatch) {
try {
console.log("[SFTP] Starting file watch", { localTempPath, remotePath, sftpId });
const result = await bridge.startFileWatch(
localTempPath,
remotePath,
sftpId,
pane.filenameEncoding,
);
watchId = result.watchId;
console.log("[SFTP] File watch started successfully", { watchId, localTempPath, remotePath });
} catch (err) {
console.warn("[SFTP] Failed to start file watch:", err);
}
} else {
console.log("[SFTP] File watching not enabled or not available");
}
return { localTempPath, watchId };
},
[getActivePane, sftpSessionsRef],
);
// Create upload callbacks that translate to TransferTask updates
const createUploadCallbacks = useCallback((
connectionId: string,
targetPath: string
): UploadCallbacks => {
return {
onScanningStart: (taskId: string) => {
if (addExternalUpload) {
const scanningTask: TransferTask = {
id: taskId,
fileName: "Scanning files...",
sourcePath: "local",
targetPath,
sourceConnectionId: "external",
targetConnectionId: connectionId,
direction: "upload",
status: "pending" as TransferStatus,
totalBytes: 0,
transferredBytes: 0,
speed: 0,
startTime: Date.now(),
isDirectory: true,
};
addExternalUpload(scanningTask);
}
},
onScanningEnd: (taskId: string) => {
if (dismissExternalUpload) {
dismissExternalUpload(taskId);
}
},
onTaskCreated: (task: UploadTaskInfo) => {
if (addExternalUpload) {
const transferTask: TransferTask = {
id: task.id,
fileName: task.displayName,
sourcePath: "local",
targetPath: joinPath(targetPath, task.fileName),
sourceConnectionId: "external",
targetConnectionId: connectionId,
direction: "upload",
status: "transferring" as TransferStatus,
totalBytes: task.totalBytes,
transferredBytes: 0,
speed: 0,
startTime: Date.now(),
isDirectory: task.isDirectory,
};
addExternalUpload(transferTask);
}
},
onTaskProgress: (taskId: string, progress) => {
if (updateExternalUpload) {
updateExternalUpload(taskId, {
transferredBytes: progress.transferred,
speed: progress.speed,
});
}
},
onTaskCompleted: (taskId: string, totalBytes: number) => {
if (updateExternalUpload) {
updateExternalUpload(taskId, {
status: "completed" as TransferStatus,
endTime: Date.now(),
transferredBytes: totalBytes,
speed: 0,
});
}
},
onTaskFailed: (taskId: string, error: string) => {
if (updateExternalUpload) {
updateExternalUpload(taskId, {
status: "failed" as TransferStatus,
endTime: Date.now(),
error,
speed: 0,
});
}
},
onTaskCancelled: (taskId: string) => {
if (updateExternalUpload) {
updateExternalUpload(taskId, {
status: "cancelled" as TransferStatus,
endTime: Date.now(),
speed: 0,
});
}
},
};
}, [addExternalUpload, updateExternalUpload, dismissExternalUpload]);
// Create upload bridge that wraps netcattyBridge
const createUploadBridge = useMemo((): UploadBridge => {
const bridge = netcattyBridge.get();
return {
writeLocalFile: bridge?.writeLocalFile,
mkdirLocal: bridge?.mkdirLocal,
mkdirSftp: async (sftpId: string, path: string) => {
const b = netcattyBridge.get();
if (b?.mkdirSftp) {
await b.mkdirSftp(sftpId, path);
}
},
writeSftpBinary: bridge?.writeSftpBinary,
// Wrap writeSftpBinaryWithProgress to adapt UploadBridge interface to NetcattyBridge interface
// UploadBridge: (sftpId, path, data, taskId, onProgress, onComplete, onError)
// NetcattyBridge: (sftpId, path, content, transferId, encoding, onProgress, onComplete, onError)
writeSftpBinaryWithProgress: bridge?.writeSftpBinaryWithProgress
? async (sftpId, path, data, taskId, onProgress, onComplete, onError) => {
const b = netcattyBridge.get();
if (!b?.writeSftpBinaryWithProgress) return undefined;
// Pass undefined for encoding to use session default, and forward callbacks
return b.writeSftpBinaryWithProgress(
sftpId,
path,
data,
taskId,
undefined, // encoding - use session default
onProgress,
onComplete,
onError
);
}
: undefined,
cancelSftpUpload: bridge?.cancelSftpUpload,
// Stream transfer for large files (avoids loading into memory)
startStreamTransfer: bridge?.startStreamTransfer
? async (options, onProgress, onComplete, onError) => {
const b = netcattyBridge.get();
if (!b?.startStreamTransfer) {
return { transferId: options.transferId, error: 'Stream transfer not available' };
}
try {
const result = await b.startStreamTransfer(options, onProgress, onComplete, onError);
return result;
} catch (error) {
return {
transferId: options.transferId,
error: error instanceof Error ? error.message : String(error),
};
}
}
: undefined,
cancelTransfer: bridge?.cancelTransfer,
};
}, []);
const uploadExternalFiles = useCallback(
async (side: "left" | "right", dataTransfer: DataTransfer): Promise<UploadResult[]> => {
const pane = getActivePane(side);
if (!pane?.connection) {
throw new Error("No active connection");
}
const bridge = netcattyBridge.get();
if (!bridge) {
throw new Error("Bridge not available");
}
const sftpId = pane.connection.isLocal
? null
: sftpSessionsRef.current.get(pane.connection.id) || null;
if (!pane.connection.isLocal && !sftpId) {
throw new Error("SFTP session not found");
}
// Create a new upload controller for this upload
const controller = new UploadController();
uploadControllerRef.current = controller;
const callbacks = createUploadCallbacks(pane.connection.id, pane.connection.currentPath);
try {
const results = await uploadFromDataTransfer(
dataTransfer,
{
targetPath: pane.connection.currentPath,
sftpId,
isLocal: pane.connection.isLocal,
bridge: createUploadBridge,
joinPath,
callbacks,
},
controller
);
await refresh(side);
return results;
} catch (error) {
logger.error("[SFTP] Upload failed:", error);
throw error;
} finally {
uploadControllerRef.current = null;
}
},
[getActivePane, refresh, sftpSessionsRef, createUploadCallbacks, createUploadBridge],
);
const cancelExternalUpload = useCallback(async () => {
const controller = uploadControllerRef.current;
if (controller) {
logger.info("[SFTP] Cancelling external upload");
await controller.cancel();
}
}, []);
const selectApplication = useCallback(
async (): Promise<{ path: string; name: string } | null> => {
const bridge = netcattyBridge.get();
if (!bridge?.selectApplication) {
return null;
}
return await bridge.selectApplication();
},
[],
);
return {
readTextFile,
readBinaryFile,
writeTextFile,
downloadToTempAndOpen,
uploadExternalFiles,
cancelExternalUpload,
selectApplication,
};
};

View File

@@ -0,0 +1,27 @@
import { useEffect } from "react";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import type { FileWatchErrorEvent, FileWatchSyncedEvent, SftpStateOptions } from "./types";
export const useSftpFileWatch = (options?: SftpStateOptions) => {
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onFileWatchSynced || !bridge?.onFileWatchError) return;
const unsubscribeSynced = bridge.onFileWatchSynced((payload: FileWatchSyncedEvent) => {
options?.onFileWatchSynced?.(payload);
});
const unsubscribeError = bridge.onFileWatchError((payload: FileWatchErrorEvent) => {
options?.onFileWatchError?.(payload);
});
return () => {
try {
unsubscribeSynced?.();
unsubscribeError?.();
} catch {
// ignore cleanup errors
}
};
}, [options]);
};

View File

@@ -0,0 +1,76 @@
import { useCallback } from "react";
import type { Host, Identity, SSHKey } from "../../../domain/models";
import { resolveHostAuth } from "../../../domain/sshAuth";
interface UseSftpHostCredentialsParams {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
}
export const useSftpHostCredentials = ({
hosts,
keys,
identities,
}: UseSftpHostCredentialsParams) =>
useCallback(
(host: Host): NetcattySSHOptions => {
const resolved = resolveHostAuth({ host, keys, identities });
const key = resolved.key || null;
const proxyConfig = host.proxyConfig
? {
type: host.proxyConfig.type,
host: host.proxyConfig.host,
port: host.proxyConfig.port,
username: host.proxyConfig.username,
password: host.proxyConfig.password,
}
: undefined;
let jumpHosts: NetcattyJumpHost[] | undefined;
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
jumpHosts = host.hostChain.hostIds
.map((hostId) => hosts.find((h) => h.id === hostId))
.filter((h): h is Host => !!h)
.map((jumpHost) => {
const jumpAuth = resolveHostAuth({
host: jumpHost,
keys,
identities,
});
const jumpKey = jumpAuth.key;
return {
hostname: jumpHost.hostname,
port: jumpHost.port || 22,
username: jumpAuth.username || "root",
password: jumpAuth.password,
privateKey: jumpKey?.privateKey,
certificate: jumpKey?.certificate,
passphrase: jumpAuth.passphrase || jumpKey?.passphrase,
publicKey: jumpKey?.publicKey,
keyId: jumpAuth.keyId,
keySource: jumpKey?.source,
label: jumpHost.label,
};
});
}
return {
hostname: host.hostname,
username: resolved.username,
port: host.port || 22,
password: resolved.password,
privateKey: key?.privateKey,
certificate: key?.certificate,
passphrase: resolved.passphrase || key?.passphrase,
publicKey: key?.publicKey,
keyId: resolved.keyId,
keySource: key?.source,
proxy: proxyConfig,
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
sudo: host.sftpSudo,
};
},
[hosts, identities, keys],
);

View File

@@ -0,0 +1,624 @@
import { useCallback } from "react";
import type { Host, SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { logger } from "../../../lib/logger";
import { SftpPane } from "./types";
import { getParentPath, isNavigableDirectory, isWindowsRoot, joinPath } from "./utils";
interface UseSftpPaneActionsParams {
getActivePane: (side: "left" | "right") => SftpPane | null;
updateTab: (side: "left" | "right", tabId: string, updater: (pane: SftpPane) => SftpPane) => void;
updateActiveTab: (side: "left" | "right", updater: (pane: SftpPane) => SftpPane) => void;
leftTabsRef: React.MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
rightTabsRef: React.MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
navSeqRef: React.MutableRefObject<{ left: number; right: number }>;
dirCacheRef: React.MutableRefObject<Map<string, { files: SftpFileEntry[]; timestamp: number }>>;
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
lastConnectedHostRef: React.MutableRefObject<{ left: Host | "local" | null; right: Host | "local" | null }>;
reconnectingRef: React.MutableRefObject<{ left: boolean; right: boolean }>;
makeCacheKey: (connectionId: string, path: string, encoding?: SftpFilenameEncoding) => string;
clearCacheForConnection: (connectionId: string) => void;
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
handleSessionError: (side: "left" | "right", error: Error) => void;
isSessionError: (err: unknown) => boolean;
dirCacheTtlMs: number;
}
interface UseSftpPaneActionsResult {
navigateTo: (side: "left" | "right", path: string, options?: { force?: boolean }) => Promise<void>;
refresh: (side: "left" | "right") => Promise<void>;
navigateUp: (side: "left" | "right") => Promise<void>;
openEntry: (side: "left" | "right", entry: SftpFileEntry) => Promise<void>;
toggleSelection: (side: "left" | "right", fileName: string, multiSelect: boolean) => void;
rangeSelect: (side: "left" | "right", fileNames: string[]) => void;
clearSelection: (side: "left" | "right") => void;
selectAll: (side: "left" | "right") => void;
setFilter: (side: "left" | "right", filter: string) => void;
getFilteredFiles: (pane: SftpPane) => SftpFileEntry[];
createDirectory: (side: "left" | "right", name: string) => Promise<void>;
createFile: (side: "left" | "right", name: string) => Promise<void>;
deleteFiles: (side: "left" | "right", fileNames: string[]) => Promise<void>;
deleteFilesAtPath: (
side: "left" | "right",
connectionId: string,
path: string,
fileNames: string[],
) => Promise<void>;
renameFile: (side: "left" | "right", oldName: string, newName: string) => Promise<void>;
changePermissions: (side: "left" | "right", filePath: string, mode: string) => Promise<void>;
}
export const useSftpPaneActions = ({
getActivePane,
updateTab,
updateActiveTab,
leftTabsRef,
rightTabsRef,
navSeqRef,
dirCacheRef,
sftpSessionsRef,
lastConnectedHostRef,
reconnectingRef,
makeCacheKey,
clearCacheForConnection,
listLocalFiles,
listRemoteFiles,
handleSessionError,
isSessionError,
dirCacheTtlMs,
}: UseSftpPaneActionsParams): UseSftpPaneActionsResult => {
const navigateTo = useCallback(
async (
side: "left" | "right",
path: string,
options?: { force?: boolean },
) => {
console.log("[SFTP navigateTo] called", { side, path, force: options?.force });
const pane = getActivePane(side);
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
const activeTabId = sideTabs.activeTabId;
console.log("[SFTP navigateTo] state check", {
paneId: pane?.id,
hasConnection: !!pane?.connection,
activeTabId,
currentPath: pane?.connection?.currentPath,
});
if (!pane?.connection || !activeTabId) {
console.log("[SFTP navigateTo] No pane/connection/activeTabId, returning early");
return;
}
const requestId = ++navSeqRef.current[side];
const cacheKey = makeCacheKey(pane.connection.id, path, pane.filenameEncoding);
const cached = options?.force
? undefined
: dirCacheRef.current.get(cacheKey);
if (
cached &&
Date.now() - cached.timestamp < dirCacheTtlMs &&
cached.files
) {
console.log("[SFTP navigateTo] Using cached files for path", { path, cacheKey });
updateTab(side, activeTabId, (prev) => ({
...prev,
connection: prev.connection
? { ...prev.connection, currentPath: path }
: null,
files: cached.files,
loading: false,
error: null,
selectedFiles: new Set(),
}));
return;
}
console.log("[SFTP navigateTo] Fetching files from server for path", { path });
updateTab(side, activeTabId, (prev) => ({ ...prev, loading: true, error: null }));
try {
let files: SftpFileEntry[];
if (pane.connection.isLocal) {
files = await listLocalFiles(path);
} else {
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
clearCacheForConnection(pane.connection.id);
updateTab(side, activeTabId, (prev) => ({
...prev,
connection: null,
files: [],
loading: false,
reconnecting: false,
error: "SFTP session lost. Please reconnect.",
selectedFiles: new Set(),
filter: "",
}));
return;
}
try {
files = await listRemoteFiles(sftpId, path, pane.filenameEncoding);
} catch (err) {
if (isSessionError(err)) {
sftpSessionsRef.current.delete(pane.connection.id);
clearCacheForConnection(pane.connection.id);
updateTab(side, activeTabId, (prev) => ({
...prev,
connection: null,
files: [],
loading: false,
reconnecting: false,
error: "SFTP session expired. Please reconnect.",
selectedFiles: new Set(),
filter: "",
}));
return;
}
throw err as Error;
}
}
if (navSeqRef.current[side] !== requestId) return;
dirCacheRef.current.set(cacheKey, {
files,
timestamp: Date.now(),
});
updateTab(side, activeTabId, (prev) => ({
...prev,
connection: prev.connection
? { ...prev.connection, currentPath: path }
: null,
files,
loading: false,
selectedFiles: new Set(),
}));
} catch (err) {
if (navSeqRef.current[side] !== requestId) return;
updateTab(side, activeTabId, (prev) => ({
...prev,
error:
err instanceof Error ? err.message : "Failed to list directory",
loading: false,
}));
}
},
[
getActivePane,
updateTab,
leftTabsRef,
rightTabsRef,
navSeqRef,
dirCacheRef,
makeCacheKey,
dirCacheTtlMs,
listLocalFiles,
listRemoteFiles,
sftpSessionsRef,
clearCacheForConnection,
isSessionError,
],
);
const refresh = useCallback(
async (side: "left" | "right") => {
const pane = getActivePane(side);
if (pane?.connection) {
await navigateTo(side, pane.connection.currentPath, { force: true });
} else if (!pane?.connection && pane?.error) {
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",
}));
}
}
},
[getActivePane, navigateTo, updateActiveTab, lastConnectedHostRef, reconnectingRef],
);
const navigateUp = useCallback(
async (side: "left" | "right") => {
const pane = getActivePane(side);
if (!pane?.connection) return;
const currentPath = pane.connection.currentPath;
const isAtRoot = currentPath === "/" || isWindowsRoot(currentPath);
if (!isAtRoot) {
const parentPath = getParentPath(currentPath);
await navigateTo(side, parentPath);
}
},
[getActivePane, navigateTo],
);
const openEntry = useCallback(
async (side: "left" | "right", entry: SftpFileEntry) => {
console.log("[SFTP openEntry] called", { side, entryName: entry.name, entryType: entry.type });
const pane = getActivePane(side);
console.log("[SFTP openEntry] getActivePane result", {
paneId: pane?.id,
hasConnection: !!pane?.connection,
currentPath: pane?.connection?.currentPath,
});
if (!pane?.connection) {
console.log("[SFTP openEntry] No pane or connection, returning early");
return;
}
if (entry.name === "..") {
const currentPath = pane.connection.currentPath;
const isAtRoot = currentPath === "/" || isWindowsRoot(currentPath);
console.log("[SFTP openEntry] Navigating up from '..'", {
currentPath,
isAtRoot,
isWindowsRoot: isWindowsRoot(currentPath),
});
if (!isAtRoot) {
const parentPath = getParentPath(currentPath);
console.log("[SFTP openEntry] Calculated parent path", { currentPath, parentPath });
await navigateTo(side, parentPath);
} else {
console.log("[SFTP openEntry] Already at root, not navigating");
}
return;
}
if (isNavigableDirectory(entry)) {
const newPath = joinPath(pane.connection.currentPath, entry.name);
console.log("[SFTP openEntry] Navigating into directory", { currentPath: pane.connection.currentPath, entryName: entry.name, newPath });
await navigateTo(side, newPath);
}
},
[getActivePane, navigateTo],
);
const toggleSelection = useCallback(
(side: "left" | "right", fileName: string, multiSelect: boolean) => {
updateActiveTab(side, (prev) => {
const newSelection = new Set(multiSelect ? prev.selectedFiles : []);
if (newSelection.has(fileName)) {
newSelection.delete(fileName);
} else {
newSelection.add(fileName);
}
return { ...prev, selectedFiles: newSelection };
});
},
[updateActiveTab],
);
const rangeSelect = useCallback(
(side: "left" | "right", fileNames: string[]) => {
const newSelection = new Set<string>();
for (const name of fileNames) {
if (name && name !== "..") {
newSelection.add(name);
}
}
updateActiveTab(side, (prev) => ({ ...prev, selectedFiles: newSelection }));
},
[updateActiveTab],
);
const clearSelection = useCallback((side: "left" | "right") => {
updateActiveTab(side, (prev) => ({ ...prev, selectedFiles: new Set() }));
}, [updateActiveTab]);
const selectAll = useCallback(
(side: "left" | "right") => {
const pane = getActivePane(side);
if (!pane) return;
updateActiveTab(side, (prev) => ({
...prev,
selectedFiles: new Set(
pane.files.filter((f) => f.name !== "..").map((f) => f.name),
),
}));
},
[getActivePane, updateActiveTab],
);
const setFilter = useCallback((side: "left" | "right", filter: string) => {
updateActiveTab(side, (prev) => ({ ...prev, filter }));
}, [updateActiveTab]);
const getFilteredFiles = useCallback((pane: SftpPane): SftpFileEntry[] => {
const term = pane.filter.trim().toLowerCase();
if (!term) return pane.files;
return pane.files.filter(
(f) => f.name === ".." || f.name.toLowerCase().includes(term),
);
}, []);
const createDirectory = useCallback(
async (side: "left" | "right", name: string) => {
const pane = getActivePane(side);
if (!pane?.connection) return;
const fullPath = joinPath(pane.connection.currentPath, name);
try {
if (pane.connection.isLocal) {
await netcattyBridge.get()?.mkdirLocal?.(fullPath);
} else {
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
handleSessionError(side, new Error("SFTP session not found"));
return;
}
await netcattyBridge.get()?.mkdirSftp(sftpId, fullPath, pane.filenameEncoding);
}
await refresh(side);
} catch (err) {
if (isSessionError(err)) {
handleSessionError(side, err as Error);
return;
}
throw err;
}
},
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
);
const createFile = useCallback(
async (side: "left" | "right", name: string) => {
const pane = getActivePane(side);
if (!pane?.connection) return;
const fullPath = joinPath(pane.connection.currentPath, name);
try {
if (pane.connection.isLocal) {
const bridge = netcattyBridge.get();
if (bridge?.writeLocalFile) {
const emptyBuffer = new ArrayBuffer(0);
await bridge.writeLocalFile(fullPath, emptyBuffer);
} else {
throw new Error("Local file writing not supported");
}
} else {
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
handleSessionError(side, new Error("SFTP session not found"));
return;
}
const bridge = netcattyBridge.get();
if (bridge?.writeSftpBinary) {
const emptyBuffer = new ArrayBuffer(0);
await bridge.writeSftpBinary(sftpId, fullPath, emptyBuffer, pane.filenameEncoding);
} else if (bridge?.writeSftp) {
await bridge.writeSftp(sftpId, fullPath, "", pane.filenameEncoding);
} else {
throw new Error("No write method available");
}
}
await refresh(side);
} catch (err) {
if (isSessionError(err)) {
handleSessionError(side, err as Error);
return;
}
throw err;
}
},
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
);
const deleteFiles = useCallback(
async (side: "left" | "right", fileNames: string[]) => {
const pane = getActivePane(side);
if (!pane?.connection) return;
try {
for (const name of fileNames) {
const fullPath = joinPath(pane.connection.currentPath, name);
if (pane.connection.isLocal) {
await netcattyBridge.get()?.deleteLocalFile?.(fullPath);
} else {
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
handleSessionError(side, new Error("SFTP session not found"));
return;
}
await netcattyBridge.get()?.deleteSftp?.(sftpId, fullPath, pane.filenameEncoding);
}
}
await refresh(side);
} catch (err) {
if (isSessionError(err)) {
handleSessionError(side, err as Error);
return;
}
throw err;
}
},
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
);
const deleteFilesAtPath = useCallback(
async (
side: "left" | "right",
connectionId: string,
path: string,
fileNames: string[],
) => {
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
const pane = sideTabs.tabs.find((tab) => tab.connection?.id === connectionId);
if (!pane?.connection) {
throw new Error("Source pane is no longer available");
}
const bridge = netcattyBridge.get();
if (!bridge) {
throw new Error("Netcatty bridge not available");
}
try {
for (const name of fileNames) {
const fullPath = joinPath(path, name);
if (pane.connection.isLocal) {
if (!bridge.deleteLocalFile) {
throw new Error("Local delete unavailable");
}
await bridge.deleteLocalFile(fullPath);
} else {
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId) {
const error = new Error("SFTP session not found");
handleSessionError(side, error);
throw error;
}
if (!bridge.deleteSftp) {
throw new Error("SFTP delete unavailable");
}
await bridge.deleteSftp(sftpId, fullPath, pane.filenameEncoding);
}
}
clearCacheForConnection(pane.connection.id);
if (sideTabs.activeTabId === pane.id && pane.connection.currentPath === path) {
await refresh(side);
} else {
updateTab(side, pane.id, (prev) => {
if (!prev.connection || prev.connection.id !== connectionId) return prev;
if (prev.connection.currentPath !== path) return prev;
const removeSet = new Set(fileNames);
const filteredFiles = prev.files.filter((file) => !removeSet.has(file.name));
const nextSelection = new Set(prev.selectedFiles);
for (const name of fileNames) {
nextSelection.delete(name);
}
return {
...prev,
files: filteredFiles,
selectedFiles: nextSelection,
};
});
}
} catch (err) {
if (isSessionError(err)) {
handleSessionError(side, err as Error);
throw err;
}
throw err;
}
},
[
clearCacheForConnection,
handleSessionError,
isSessionError,
leftTabsRef,
refresh,
rightTabsRef,
sftpSessionsRef,
updateTab,
],
);
const renameFile = useCallback(
async (side: "left" | "right", oldName: string, newName: string) => {
const pane = getActivePane(side);
if (!pane?.connection) return;
const oldPath = joinPath(pane.connection.currentPath, oldName);
const newPath = joinPath(pane.connection.currentPath, 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);
}
await refresh(side);
} catch (err) {
if (isSessionError(err)) {
handleSessionError(side, err as Error);
return;
}
throw err;
}
},
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
);
const changePermissions = useCallback(
async (
side: "left" | "right",
filePath: string,
mode: string,
) => {
const pane = getActivePane(side);
if (!pane?.connection || pane.connection.isLocal) {
logger.warn("Cannot change permissions on local files");
return;
}
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
if (!sftpId || !netcattyBridge.get()?.chmodSftp) {
handleSessionError(side, new Error("SFTP session not found"));
return;
}
try {
await netcattyBridge.get()!.chmodSftp!(sftpId, filePath, mode, pane.filenameEncoding);
await refresh(side);
} catch (err) {
if (isSessionError(err)) {
handleSessionError(side, err as Error);
return;
}
logger.error("Failed to change permissions:", err);
}
},
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
);
return {
navigateTo,
refresh,
navigateUp,
openEntry,
toggleSelection,
rangeSelect,
clearSelection,
selectAll,
setFilter,
getFilteredFiles,
createDirectory,
createFile,
deleteFiles,
deleteFilesAtPath,
renameFile,
changePermissions,
};
};

View File

@@ -0,0 +1,19 @@
import { useEffect } from "react";
import type { MutableRefObject } from "react";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
export const useSftpSessionCleanup = (sftpSessionsRef: MutableRefObject<Map<string, string>>) => {
useEffect(() => {
const sessionsRef = sftpSessionsRef.current;
return () => {
sessionsRef.forEach(async (sftpId) => {
try {
await netcattyBridge.get()?.closeSftp(sftpId);
} catch {
// Ignore errors when closing SFTP sessions during cleanup
}
});
};
}, [sftpSessionsRef]);
};

View File

@@ -0,0 +1,78 @@
import { useCallback } from "react";
import type { MutableRefObject } from "react";
import type { Host } from "../../../domain/models";
import type { SftpPane } from "./types";
interface UseSftpSessionErrorsParams {
getActivePane: (side: "left" | "right") => SftpPane | null;
leftTabsRef: MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
rightTabsRef: MutableRefObject<{ tabs: SftpPane[]; activeTabId: string | null }>;
updateActiveTab: (
side: "left" | "right",
updater: (prev: SftpPane) => SftpPane,
) => void;
sftpSessionsRef: MutableRefObject<Map<string, string>>;
clearCacheForConnection: (connectionId: string) => void;
navSeqRef: MutableRefObject<{ left: number; right: number }>;
lastConnectedHostRef: MutableRefObject<{ left: Host | "local" | null; right: Host | "local" | null }>;
reconnectingRef: MutableRefObject<{ left: boolean; right: boolean }>;
}
export const useSftpSessionErrors = ({
getActivePane,
leftTabsRef,
rightTabsRef,
updateActiveTab,
sftpSessionsRef,
clearCacheForConnection,
navSeqRef,
lastConnectedHostRef,
reconnectingRef,
}: UseSftpSessionErrorsParams) =>
useCallback(
(side: "left" | "right", _error: Error) => {
const pane = getActivePane(side);
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
if (!pane || !sideTabs.activeTabId) return;
if (pane.connection) {
sftpSessionsRef.current.delete(pane.connection.id);
clearCacheForConnection(pane.connection.id);
}
navSeqRef.current[side] += 1;
const lastHost = lastConnectedHostRef.current[side];
if (lastHost && pane.files.length > 0 && !reconnectingRef.current[side]) {
reconnectingRef.current[side] = true;
updateActiveTab(side, (prev) => ({
...prev,
reconnecting: true,
error: "sftp.error.connectionLostReconnecting",
}));
} else {
updateActiveTab(side, (prev) => ({
...prev,
connection: null,
files: [],
loading: false,
reconnecting: false,
error: "sftp.error.sessionLost",
selectedFiles: new Set(),
filter: "",
}));
}
},
[
getActivePane,
leftTabsRef,
rightTabsRef,
updateActiveTab,
sftpSessionsRef,
clearCacheForConnection,
navSeqRef,
lastConnectedHostRef,
reconnectingRef,
],
);

View File

@@ -0,0 +1,247 @@
import React, { useCallback, useMemo, useRef, useState } from "react";
import { createEmptyPane, EMPTY_LEFT_PANE_ID, EMPTY_RIGHT_PANE_ID, SftpPane, SftpSideTabs } from "./types";
import { logger } from "../../../lib/logger";
export interface SftpTabsState {
leftTabs: SftpSideTabs;
rightTabs: SftpSideTabs;
leftTabsRef: React.MutableRefObject<SftpSideTabs>;
rightTabsRef: React.MutableRefObject<SftpSideTabs>;
setLeftTabs: React.Dispatch<React.SetStateAction<SftpSideTabs>>;
setRightTabs: React.Dispatch<React.SetStateAction<SftpSideTabs>>;
leftPane: SftpPane;
rightPane: SftpPane;
getActivePane: (side: "left" | "right") => SftpPane | null;
updateTab: (side: "left" | "right", tabId: string, updater: (pane: SftpPane) => SftpPane) => void;
updateActiveTab: (side: "left" | "right", updater: (pane: SftpPane) => SftpPane) => void;
addTab: (side: "left" | "right") => string;
closeTab: (side: "left" | "right", tabId: string) => void;
selectTab: (side: "left" | "right", tabId: string) => void;
reorderTabs: (
side: "left" | "right",
draggedId: string,
targetId: string,
position: "before" | "after",
) => void;
moveTabToOtherSide: (fromSide: "left" | "right", tabId: string) => void;
getTabsInfo: (side: "left" | "right") => Array<{
id: string;
label: string;
isLocal: boolean;
hostId: string | null;
}>;
getActiveTabId: (side: "left" | "right") => string | null;
}
export const useSftpTabsState = (): SftpTabsState => {
const [leftTabs, setLeftTabs] = useState<SftpSideTabs>({
tabs: [],
activeTabId: null,
});
const [rightTabs, setRightTabs] = useState<SftpSideTabs>({
tabs: [],
activeTabId: null,
});
const leftTabsRef = useRef(leftTabs);
const rightTabsRef = useRef(rightTabs);
leftTabsRef.current = leftTabs;
rightTabsRef.current = rightTabs;
const getActivePane = useCallback((side: "left" | "right"): SftpPane | null => {
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
if (!sideTabs.activeTabId) return null;
return sideTabs.tabs.find((t) => t.id === sideTabs.activeTabId) || null;
}, []);
const leftPane = useMemo(() => {
const pane = leftTabs.activeTabId
? leftTabs.tabs.find((t) => t.id === leftTabs.activeTabId)
: null;
return pane || createEmptyPane(EMPTY_LEFT_PANE_ID);
}, [leftTabs]);
const rightPane = useMemo(() => {
const pane = rightTabs.activeTabId
? rightTabs.tabs.find((t) => t.id === rightTabs.activeTabId)
: null;
return pane || createEmptyPane(EMPTY_RIGHT_PANE_ID);
}, [rightTabs]);
const updateTab = useCallback(
(side: "left" | "right", tabId: string, updater: (pane: SftpPane) => SftpPane) => {
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
setTabs((prev) => ({
...prev,
tabs: prev.tabs.map((t) => (t.id === tabId ? updater(t) : t)),
}));
},
[],
);
const updateActiveTab = useCallback(
(side: "left" | "right", updater: (pane: SftpPane) => SftpPane) => {
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
if (!sideTabs.activeTabId) return;
updateTab(side, sideTabs.activeTabId, updater);
},
[updateTab],
);
const addTab = useCallback(
(side: "left" | "right") => {
const newPane = createEmptyPane();
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
setTabs((prev) => ({
tabs: [...prev.tabs, newPane],
activeTabId: newPane.id,
}));
return newPane.id;
},
[],
);
const closeTab = useCallback(
(side: "left" | "right", tabId: string) => {
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
setTabs((prev) => {
const tabIndex = prev.tabs.findIndex((t) => t.id === tabId);
if (tabIndex === -1) return prev;
let newActiveTabId: string | null = null;
if (prev.tabs.length > 1) {
if (prev.activeTabId === tabId) {
const nextIndex = tabIndex < prev.tabs.length - 1 ? tabIndex + 1 : tabIndex - 1;
newActiveTabId = prev.tabs[nextIndex]?.id || null;
} else {
newActiveTabId = prev.activeTabId;
}
}
return {
tabs: prev.tabs.filter((t) => t.id !== tabId),
activeTabId: newActiveTabId,
};
});
},
[],
);
const selectTab = useCallback(
(side: "left" | "right", tabId: string) => {
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
setTabs((prev) => ({
...prev,
activeTabId: tabId,
}));
},
[],
);
const reorderTabs = useCallback(
(
side: "left" | "right",
draggedId: string,
targetId: string,
position: "before" | "after",
) => {
const setTabs = side === "left" ? setLeftTabs : setRightTabs;
setTabs((prev) => {
const tabs = [...prev.tabs];
const draggedIndex = tabs.findIndex((t) => t.id === draggedId);
const targetIndex = tabs.findIndex((t) => t.id === targetId);
if (draggedIndex === -1 || targetIndex === -1) return prev;
const [draggedTab] = tabs.splice(draggedIndex, 1);
const insertIndex = position === "before" ? targetIndex : targetIndex + 1;
const adjustedIndex = draggedIndex < targetIndex ? insertIndex - 1 : insertIndex;
tabs.splice(adjustedIndex, 0, draggedTab);
return { ...prev, tabs };
});
},
[],
);
const moveTabToOtherSide = useCallback(
(fromSide: "left" | "right", tabId: string) => {
const sourceTabs = fromSide === "left" ? leftTabsRef.current : rightTabsRef.current;
const setSourceTabs = fromSide === "left" ? setLeftTabs : setRightTabs;
const setTargetTabs = fromSide === "left" ? setRightTabs : setLeftTabs;
const tabToMove = sourceTabs.tabs.find((t) => t.id === tabId);
if (!tabToMove) return;
logger.info("[SFTP] Moving tab to other side", {
fromSide,
toSide: fromSide === "left" ? "right" : "left",
tabId,
hostLabel: tabToMove.connection?.hostLabel,
});
setSourceTabs((prev) => {
const newTabs = prev.tabs.filter((t) => t.id !== tabId);
let newActiveTabId: string | null = null;
if (newTabs.length > 0) {
if (prev.activeTabId === tabId) {
newActiveTabId = newTabs[0].id;
} else {
newActiveTabId = prev.activeTabId;
}
}
return { tabs: newTabs, activeTabId: newActiveTabId };
});
setTargetTabs((prev) => ({
tabs: [...prev.tabs, tabToMove],
activeTabId: tabToMove.id,
}));
},
[],
);
const DEFAULT_TAB_LABEL = "New Tab";
const getTabsInfo = useCallback(
(side: "left" | "right") => {
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
return sideTabs.tabs.map((pane) => ({
id: pane.id,
label: pane.connection?.hostLabel || DEFAULT_TAB_LABEL,
isLocal: pane.connection?.isLocal || false,
hostId: pane.connection?.hostId || null,
}));
},
[],
);
const getActiveTabId = useCallback(
(side: "left" | "right") => {
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
return sideTabs.activeTabId;
},
[],
);
return {
leftTabs,
rightTabs,
leftTabsRef,
rightTabsRef,
setLeftTabs,
setRightTabs,
leftPane,
rightPane,
getActivePane,
updateTab,
updateActiveTab,
addTab,
closeTab,
selectTab,
reorderTabs,
moveTabToOtherSide,
getTabsInfo,
getActiveTabId,
};
};

View File

@@ -0,0 +1,948 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
FileConflict,
SftpFileEntry,
SftpFilenameEncoding,
TransferDirection,
TransferStatus,
TransferTask,
} from "../../../domain/models";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { logger } from "../../../lib/logger";
import { SftpPane } from "./types";
import { joinPath } from "./utils";
interface UseSftpTransfersParams {
getActivePane: (side: "left" | "right") => SftpPane | null;
refresh: (side: "left" | "right") => Promise<void>;
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
listLocalFiles: (path: string) => Promise<SftpFileEntry[]>;
listRemoteFiles: (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => Promise<SftpFileEntry[]>;
handleSessionError: (side: "left" | "right", error: Error) => void;
}
interface UseSftpTransfersResult {
transfers: TransferTask[];
conflicts: FileConflict[];
activeTransfersCount: number;
startTransfer: (
sourceFiles: { name: string; isDirectory: boolean }[],
sourceSide: "left" | "right",
targetSide: "left" | "right",
options?: {
sourcePane?: SftpPane;
sourcePath?: string;
sourceConnectionId?: string;
onTransferComplete?: (result: TransferResult) => void | Promise<void>;
},
) => Promise<TransferResult[]>;
addExternalUpload: (task: TransferTask) => void;
updateExternalUpload: (taskId: string, updates: Partial<TransferTask>) => void;
cancelTransfer: (transferId: string) => Promise<void>;
retryTransfer: (transferId: string) => Promise<void>;
clearCompletedTransfers: () => void;
dismissTransfer: (transferId: string) => void;
resolveConflict: (conflictId: string, action: "replace" | "skip" | "duplicate") => Promise<void>;
}
interface TransferResult {
id: string;
fileName: string;
originalFileName?: string;
status: TransferStatus;
}
export const useSftpTransfers = ({
getActivePane,
refresh,
sftpSessionsRef,
listLocalFiles,
listRemoteFiles,
handleSessionError,
}: UseSftpTransfersParams): UseSftpTransfersResult => {
const [transfers, setTransfers] = useState<TransferTask[]>([]);
const [conflicts, setConflicts] = useState<FileConflict[]>([]);
const progressIntervalsRef = useRef<Map<string, NodeJS.Timeout>>(new Map());
// Track cancelled task IDs for checking during async operations
const cancelledTasksRef = useRef<Set<string>>(new Set());
const completionHandlersRef = useRef<Map<string, (result: TransferResult) => void | Promise<void>>>(new Map());
useEffect(() => {
const intervalsRef = progressIntervalsRef.current;
return () => {
intervalsRef.forEach((interval) => {
clearInterval(interval);
});
intervalsRef.clear();
};
}, []);
const startProgressSimulation = useCallback(
(taskId: string, estimatedBytes: number) => {
const existing = progressIntervalsRef.current.get(taskId);
if (existing) clearInterval(existing);
const baseSpeed = Math.max(50000, Math.min(500000, estimatedBytes / 10));
const variability = 0.3;
let transferred = 0;
const interval = setInterval(() => {
const speedFactor = 1 + (Math.random() - 0.5) * variability;
const chunkSize = Math.floor(baseSpeed * speedFactor * 0.1);
transferred = Math.min(transferred + chunkSize, estimatedBytes);
setTransfers((prev) =>
prev.map((t) => {
if (t.id !== taskId || t.status !== "transferring") return t;
return {
...t,
transferredBytes: transferred,
totalBytes: estimatedBytes,
speed: chunkSize * 10,
};
}),
);
if (transferred >= estimatedBytes * 0.95) {
clearInterval(interval);
progressIntervalsRef.current.delete(taskId);
}
}, 100);
progressIntervalsRef.current.set(taskId, interval);
},
[],
);
const stopProgressSimulation = useCallback((taskId: string) => {
const interval = progressIntervalsRef.current.get(taskId);
if (interval) {
clearInterval(interval);
progressIntervalsRef.current.delete(taskId);
}
}, []);
const transferFile = async (
task: TransferTask,
sourceSftpId: string | null,
targetSftpId: string | null,
sourceIsLocal: boolean,
targetIsLocal: boolean,
sourceEncoding: SftpFilenameEncoding,
targetEncoding: SftpFilenameEncoding,
rootTaskId: string, // The original top-level task ID for cancellation checking
onStreamProgress?: (transferred: number, total: number, speed: number) => void,
): Promise<void> => {
// Check if task or root task was cancelled before starting
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
throw new Error("Transfer cancelled");
}
if (netcattyBridge.get()?.startStreamTransfer) {
return new Promise((resolve, reject) => {
const options = {
transferId: task.id,
sourcePath: task.sourcePath,
targetPath: task.targetPath,
sourceType: sourceIsLocal ? ("local" as const) : ("sftp" as const),
targetType: targetIsLocal ? ("local" as const) : ("sftp" as const),
sourceSftpId: sourceSftpId || undefined,
targetSftpId: targetSftpId || undefined,
totalBytes: task.totalBytes || undefined,
sourceEncoding: sourceIsLocal ? undefined : sourceEncoding,
targetEncoding: targetIsLocal ? undefined : targetEncoding,
};
const onProgress = (
transferred: number,
total: number,
speed: number,
) => {
// Bubble up streaming progress to parent (for directory transfers)
onStreamProgress?.(transferred, total, speed);
setTransfers((prev) =>
prev.map((t) => {
if (t.id !== task.id) return t;
if (t.status === "cancelled") return t;
const normalizedTotal = total > 0 ? total : t.totalBytes;
const normalizedTransferred = Math.max(
t.transferredBytes,
Math.min(transferred, normalizedTotal > 0 ? normalizedTotal : transferred),
);
return {
...t,
transferredBytes: normalizedTransferred,
totalBytes: normalizedTotal,
speed: Number.isFinite(speed) && speed > 0 ? speed : 0,
};
}),
);
};
const onComplete = () => {
resolve();
};
const onError = (error: string) => {
reject(new Error(error));
};
netcattyBridge.require().startStreamTransfer!(
options,
onProgress,
onComplete,
onError,
).catch(reject);
});
}
let content: ArrayBuffer | string;
if (sourceIsLocal) {
content =
(await netcattyBridge.get()?.readLocalFile?.(task.sourcePath)) ||
new ArrayBuffer(0);
} else if (sourceSftpId) {
if (netcattyBridge.get()?.readSftpBinary) {
content = await netcattyBridge.get()!.readSftpBinary!(
sourceSftpId,
task.sourcePath,
sourceEncoding,
);
} else {
content =
(await netcattyBridge.get()?.readSftp(sourceSftpId, task.sourcePath, sourceEncoding)) || "";
}
} else {
throw new Error("No source connection");
}
if (targetIsLocal) {
if (content instanceof ArrayBuffer) {
await netcattyBridge.get()?.writeLocalFile?.(task.targetPath, content);
} else {
const encoder = new TextEncoder();
await netcattyBridge.get()?.writeLocalFile?.(
task.targetPath,
encoder.encode(content).buffer,
);
}
} else if (targetSftpId) {
if (content instanceof ArrayBuffer && netcattyBridge.get()?.writeSftpBinary) {
await netcattyBridge.get()!.writeSftpBinary!(
targetSftpId,
task.targetPath,
content,
targetEncoding,
);
} else {
const text =
content instanceof ArrayBuffer
? new TextDecoder().decode(content)
: content;
await netcattyBridge.get()?.writeSftp(targetSftpId, task.targetPath, text, targetEncoding);
}
} else {
throw new Error("No target connection");
}
};
const transferDirectory = async (
task: TransferTask,
sourceSftpId: string | null,
targetSftpId: string | null,
sourceIsLocal: boolean,
targetIsLocal: boolean,
sourceEncoding: SftpFilenameEncoding,
targetEncoding: SftpFilenameEncoding,
rootTaskId: string, // The original top-level task ID for cancellation checking
onChildProgress?: (completedBytes: number, currentFileTransferred: number, currentFileTotal: number, speed: number) => void,
) => {
// Check if task or root task was cancelled before starting
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
throw new Error("Transfer cancelled");
}
if (targetIsLocal) {
await netcattyBridge.get()?.mkdirLocal?.(task.targetPath);
} else if (targetSftpId) {
await netcattyBridge.get()?.mkdirSftp(targetSftpId, task.targetPath, targetEncoding);
}
let files: SftpFileEntry[];
if (sourceIsLocal) {
files = await listLocalFiles(task.sourcePath);
} else if (sourceSftpId) {
files = await listRemoteFiles(sourceSftpId, task.sourcePath, sourceEncoding);
} else {
throw new Error("No source connection");
}
// Track bytes completed so far in this directory (including subdirectories)
let completedBytesInDir = 0;
for (const file of files) {
if (file.name === "..") continue;
// Check if root task was cancelled during iteration
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
throw new Error("Transfer cancelled");
}
const childTask: TransferTask = {
...task,
id: crypto.randomUUID(),
fileName: file.name,
originalFileName: file.name,
sourcePath: joinPath(task.sourcePath, file.name),
targetPath: joinPath(task.targetPath, file.name),
isDirectory: file.type === "directory",
parentTaskId: task.id,
};
if (file.type === "directory") {
// For subdirectories, create a nested progress tracker
let subDirCompletedBytes = 0;
const onSubDirChildProgress = (subCompleted: number, currentTransferred: number, currentTotal: number, speed: number) => {
subDirCompletedBytes = subCompleted;
// Report to parent: our completed + subdirectory's (completed + in-progress)
onChildProgress?.(completedBytesInDir + subCompleted, currentTransferred, currentTotal, speed);
};
await transferDirectory(
childTask,
sourceSftpId,
targetSftpId,
sourceIsLocal,
targetIsLocal,
sourceEncoding,
targetEncoding,
rootTaskId,
onSubDirChildProgress,
);
completedBytesInDir += subDirCompletedBytes;
} else {
// For files, report streaming progress
const onFileStreamProgress = (transferred: number, total: number, speed: number) => {
onChildProgress?.(completedBytesInDir, transferred, total, speed);
};
await transferFile(
childTask,
sourceSftpId,
targetSftpId,
sourceIsLocal,
targetIsLocal,
sourceEncoding,
targetEncoding,
rootTaskId,
onFileStreamProgress,
);
// After file completes, add its bytes to completed total
const childSize = typeof file.size === 'string' ? parseInt(file.size, 10) || 0 : (file.size || 0);
completedBytesInDir += childSize;
onChildProgress?.(completedBytesInDir, 0, 0, 0);
}
}
};
const processTransfer = async (
task: TransferTask,
sourcePane: SftpPane,
targetPane: SftpPane,
targetSide: "left" | "right",
): Promise<TransferStatus> => {
const updateTask = (updates: Partial<TransferTask>) => {
setTransfers((prev) =>
prev.map((t) => (t.id === task.id ? { ...t, ...updates } : t)),
);
};
// Initialize encoding early to avoid temporal dead zone issues
const sourceEncoding: SftpFilenameEncoding = sourcePane.connection?.isLocal
? "auto"
: sourcePane.filenameEncoding || "auto";
const targetEncoding: SftpFilenameEncoding = targetPane.connection?.isLocal
? "auto"
: targetPane.filenameEncoding || "auto";
let actualFileSize = task.totalBytes;
if (!task.isDirectory && actualFileSize === 0) {
try {
const sourceSftpId = sourcePane.connection?.isLocal
? null
: sftpSessionsRef.current.get(sourcePane.connection!.id);
if (sourcePane.connection?.isLocal) {
const stat = await netcattyBridge.get()?.statLocal?.(task.sourcePath);
if (stat) actualFileSize = stat.size;
} else if (sourceSftpId) {
const stat = await netcattyBridge.get()?.statSftp?.(
sourceSftpId,
task.sourcePath,
sourceEncoding,
);
if (stat) actualFileSize = stat.size;
}
} catch {
// Ignore stat errors
}
}
const estimatedSize =
actualFileSize > 0
? actualFileSize
: task.isDirectory
? 1024 * 1024
: 256 * 1024;
const hasStreamingTransfer = !!netcattyBridge.get()?.startStreamTransfer;
updateTask({
status: "transferring",
totalBytes: estimatedSize,
transferredBytes: 0,
startTime: Date.now(),
});
const sourceSftpId = sourcePane.connection?.isLocal
? null
: sftpSessionsRef.current.get(sourcePane.connection!.id);
const targetSftpId = targetPane.connection?.isLocal
? null
: sftpSessionsRef.current.get(targetPane.connection!.id);
if (!sourcePane.connection?.isLocal && !sourceSftpId) {
const sourceSide = targetSide === "left" ? "right" : "left";
handleSessionError(sourceSide, new Error("Source SFTP session lost"));
throw new Error("Source SFTP session not found");
}
if (!targetPane.connection?.isLocal && !targetSftpId) {
handleSessionError(targetSide, new Error("Target SFTP session lost"));
throw new Error("Target SFTP session not found");
}
let useSimulatedProgress = false;
if (!hasStreamingTransfer && !task.isDirectory) {
useSimulatedProgress = true;
startProgressSimulation(task.id, estimatedSize);
}
try {
if (!task.skipConflictCheck && !task.isDirectory && targetPane.connection) {
let targetExists = false;
let existingStat: { size: number; mtime: number } | null = null;
let sourceStat: { size: number; mtime: number } | null = null;
try {
if (sourcePane.connection.isLocal) {
const stat = await netcattyBridge.get()?.statLocal?.(task.sourcePath);
if (stat) {
sourceStat = {
size: stat.size,
mtime: stat.lastModified || Date.now(),
};
}
} else if (sourceSftpId) {
const stat = await netcattyBridge.get()?.statSftp?.(
sourceSftpId,
task.sourcePath,
sourceEncoding,
);
if (stat) {
sourceStat = {
size: stat.size,
mtime: stat.lastModified || Date.now(),
};
}
}
} catch {
// ignore
}
try {
if (targetPane.connection.isLocal) {
const stat = await netcattyBridge.get()?.statLocal?.(task.targetPath);
if (stat) {
targetExists = true;
existingStat = {
size: stat.size,
mtime: stat.lastModified || Date.now(),
};
}
} else if (targetSftpId) {
const stat = await netcattyBridge.get()?.statSftp?.(
targetSftpId,
task.targetPath,
targetEncoding,
);
if (stat) {
targetExists = true;
existingStat = {
size: stat.size,
mtime: stat.lastModified || Date.now(),
};
}
}
} catch {
// ignore
}
if (targetExists && existingStat) {
stopProgressSimulation(task.id);
const newConflict: FileConflict = {
transferId: task.id,
fileName: task.fileName,
sourcePath: task.sourcePath,
targetPath: task.targetPath,
existingSize: existingStat.size,
newSize: sourceStat?.size || estimatedSize,
existingModified: existingStat.mtime,
newModified: sourceStat?.mtime || Date.now(),
};
setConflicts((prev) => [...prev, newConflict]);
updateTask({
status: "pending",
totalBytes: sourceStat?.size || estimatedSize,
});
return "pending";
}
}
if (task.isDirectory) {
// Track real progress for directory transfers:
// completedBytes = sum of all finished child files
// + currentFileTransferred = in-progress bytes of the currently transferring file
const onChildProgress = (completedBytes: number, currentFileTransferred: number, currentFileTotal: number, speed: number) => {
const totalProgress = completedBytes + currentFileTransferred;
setTransfers((prev) =>
prev.map((t) => {
if (t.id !== task.id || t.status === "cancelled") return t;
const newTotal = Math.max(t.totalBytes, totalProgress, completedBytes + currentFileTotal);
return {
...t,
transferredBytes: Math.max(t.transferredBytes, totalProgress),
totalBytes: newTotal,
speed: Number.isFinite(speed) && speed > 0 ? speed : t.speed,
};
}),
);
};
await transferDirectory(
task,
sourceSftpId,
targetSftpId,
sourcePane.connection!.isLocal,
targetPane.connection!.isLocal,
sourceEncoding,
targetEncoding,
task.id, // rootTaskId - this is the top-level task
onChildProgress,
);
} else {
await transferFile(
task,
sourceSftpId,
targetSftpId,
sourcePane.connection!.isLocal,
targetPane.connection!.isLocal,
sourceEncoding,
targetEncoding,
task.id, // rootTaskId - this is the top-level task
);
}
if (useSimulatedProgress) {
stopProgressSimulation(task.id);
}
setTransfers((prev) =>
prev.map((t) => {
if (t.id !== task.id) return t;
return {
...t,
status: "completed" as TransferStatus,
endTime: Date.now(),
transferredBytes: t.totalBytes,
speed: 0,
};
}),
);
await refresh(targetSide);
const completionHandler = completionHandlersRef.current.get(task.id);
if (completionHandler) {
try {
await completionHandler({
id: task.id,
fileName: task.fileName,
originalFileName: task.originalFileName ?? task.fileName,
status: "completed",
});
} finally {
completionHandlersRef.current.delete(task.id);
}
}
return "completed";
} catch (err) {
if (useSimulatedProgress) {
stopProgressSimulation(task.id);
}
// Check if this was a cancellation
const isCancelled = cancelledTasksRef.current.has(task.id) ||
(err instanceof Error && err.message === "Transfer cancelled");
if (isCancelled) {
// Don't update status - cancelTransfer already set it to cancelled
const completionHandler = completionHandlersRef.current.get(task.id);
if (completionHandler) {
try {
await completionHandler({
id: task.id,
fileName: task.fileName,
originalFileName: task.originalFileName ?? task.fileName,
status: "cancelled",
});
} finally {
completionHandlersRef.current.delete(task.id);
}
}
return "cancelled";
}
updateTask({
status: "failed",
error: err instanceof Error ? err.message : "Transfer failed",
endTime: Date.now(),
speed: 0,
});
const completionHandler = completionHandlersRef.current.get(task.id);
if (completionHandler) {
try {
await completionHandler({
id: task.id,
fileName: task.fileName,
originalFileName: task.originalFileName ?? task.fileName,
status: "failed",
});
} finally {
completionHandlersRef.current.delete(task.id);
}
}
return "failed";
}
};
const startTransfer = useCallback(
async (
sourceFiles: { name: string; isDirectory: boolean }[],
sourceSide: "left" | "right",
targetSide: "left" | "right",
options?: {
sourcePane?: SftpPane;
sourcePath?: string;
sourceConnectionId?: string;
onTransferComplete?: (result: TransferResult) => void | Promise<void>;
},
) => {
const sourcePane = options?.sourcePane ?? getActivePane(sourceSide);
const targetPane = getActivePane(targetSide);
if (!sourcePane?.connection || !targetPane?.connection) return [];
const sourceEncoding: SftpFilenameEncoding = sourcePane.connection.isLocal
? "auto"
: sourcePane.filenameEncoding || "auto";
const sourcePath = options?.sourcePath ?? sourcePane.connection.currentPath;
const targetPath = targetPane.connection.currentPath;
const sourceConnectionId = options?.sourceConnectionId ?? sourcePane.connection.id;
const sourceSftpId = sourcePane.connection.isLocal
? null
: sftpSessionsRef.current.get(sourceConnectionId);
const newTasks: TransferTask[] = [];
for (const file of sourceFiles) {
const direction: TransferDirection =
sourcePane.connection!.isLocal && !targetPane.connection!.isLocal
? "upload"
: !sourcePane.connection!.isLocal && targetPane.connection!.isLocal
? "download"
: "remote-to-remote";
let fileSize = 0;
if (!file.isDirectory) {
try {
const fullPath = joinPath(sourcePath, file.name);
if (sourcePane.connection!.isLocal) {
const stat = await netcattyBridge.get()?.statLocal?.(fullPath);
if (stat) fileSize = stat.size;
} else if (sourceSftpId) {
const stat = await netcattyBridge.get()?.statSftp?.(
sourceSftpId,
fullPath,
sourceEncoding,
);
if (stat) fileSize = stat.size;
}
} catch {
// ignore
}
}
newTasks.push({
id: crypto.randomUUID(),
fileName: file.name,
originalFileName: file.name,
sourcePath: joinPath(sourcePath, file.name),
targetPath: joinPath(targetPath, file.name),
sourceConnectionId,
targetConnectionId: targetPane.connection!.id,
direction,
status: "pending" as TransferStatus,
totalBytes: fileSize,
transferredBytes: 0,
speed: 0,
startTime: Date.now(),
isDirectory: file.isDirectory,
});
}
setTransfers((prev) => [...prev, ...newTasks]);
if (options?.onTransferComplete) {
for (const task of newTasks) {
completionHandlersRef.current.set(task.id, options.onTransferComplete);
}
}
const results: TransferResult[] = [];
for (const task of newTasks) {
const status = await processTransfer(task, sourcePane, targetPane, targetSide);
results.push({
id: task.id,
fileName: task.fileName,
originalFileName: task.originalFileName ?? task.fileName,
status,
});
}
return results;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[getActivePane, sftpSessionsRef],
);
const cancelTransfer = useCallback(
async (transferId: string) => {
// Add to cancelled set so async operations can check
cancelledTasksRef.current.add(transferId);
stopProgressSimulation(transferId);
setTransfers((prev) =>
prev.map((t) =>
t.id === transferId
? {
...t,
status: "cancelled" as TransferStatus,
endTime: Date.now(),
}
: t,
),
);
setConflicts((prev) => prev.filter((c) => c.transferId !== transferId));
if (netcattyBridge.get()?.cancelTransfer) {
try {
await netcattyBridge.get()!.cancelTransfer!(transferId);
} catch (err) {
logger.warn("Failed to cancel transfer at backend:", err);
}
}
// Clean up cancelled task ID after a delay to ensure all async ops see it
setTimeout(() => {
cancelledTasksRef.current.delete(transferId);
}, 5000);
},
[stopProgressSimulation],
);
const retryTransfer = useCallback(
async (transferId: string) => {
const task = transfers.find((t) => t.id === transferId);
if (!task) return;
const sourceSide = task.sourceConnectionId.startsWith("left") ? "left" : "right";
const targetSide = task.targetConnectionId.startsWith("left") ? "left" : "right";
const sourcePane = getActivePane(sourceSide as "left" | "right");
const targetPane = getActivePane(targetSide as "left" | "right");
if (sourcePane?.connection && targetPane?.connection) {
setTransfers((prev) =>
prev.map((t) =>
t.id === transferId
? { ...t, status: "pending" as TransferStatus, error: undefined }
: t,
),
);
await processTransfer(task, sourcePane, targetPane, targetSide);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- processTransfer is defined inline
[transfers, getActivePane],
);
const clearCompletedTransfers = useCallback(() => {
setTransfers((prev) =>
prev.filter((t) => t.status !== "completed" && t.status !== "cancelled"),
);
}, []);
const dismissTransfer = useCallback((transferId: string) => {
setTransfers((prev) => prev.filter((t) => t.id !== transferId));
}, []);
const addExternalUpload = useCallback((task: TransferTask) => {
// Filter out any pending scanning tasks before adding the new task.
// This ensures that even if dismissExternalUpload's state update hasn't been applied yet
// (due to React state batching), the scanning placeholder will still be removed.
setTransfers((prev) => [
...prev.filter(t => !(t.status === "pending" && t.fileName === "Scanning files...")),
task
]);
}, []);
const updateExternalUpload = useCallback((taskId: string, updates: Partial<TransferTask>) => {
setTransfers((prev) =>
prev.map((t) => {
if (t.id !== taskId) return t;
const merged: TransferTask = { ...t, ...updates };
// Keep progress monotonic and bounded for stable progress UI.
if (typeof merged.totalBytes === "number" && merged.totalBytes > 0) {
merged.transferredBytes = Math.max(
t.transferredBytes,
Math.min(merged.transferredBytes, merged.totalBytes),
);
} else {
merged.transferredBytes = Math.max(t.transferredBytes, merged.transferredBytes);
}
if (!Number.isFinite(merged.speed) || merged.speed < 0) {
merged.speed = 0;
}
return merged;
}),
);
}, []);
const resolveConflict = useCallback(
async (conflictId: string, action: "replace" | "skip" | "duplicate") => {
const conflict = conflicts.find((c) => c.transferId === conflictId);
if (!conflict) return;
setConflicts((prev) => prev.filter((c) => c.transferId !== conflictId));
const task = transfers.find((t) => t.id === conflictId);
if (!task) return;
if (action === "skip") {
setTransfers((prev) =>
prev.map((t) =>
t.id === conflictId
? { ...t, status: "cancelled" as TransferStatus }
: t,
),
);
const completionHandler = completionHandlersRef.current.get(conflictId);
if (completionHandler) {
try {
await completionHandler({
id: task.id,
fileName: task.fileName,
originalFileName: task.originalFileName ?? task.fileName,
status: "cancelled",
});
} finally {
completionHandlersRef.current.delete(conflictId);
}
}
return;
}
let updatedTask = { ...task };
if (action === "duplicate") {
const ext = task.fileName.includes(".")
? "." + task.fileName.split(".").pop()
: "";
const baseName = task.fileName.includes(".")
? task.fileName.slice(0, task.fileName.lastIndexOf("."))
: task.fileName;
const newName = `${baseName} (copy)${ext}`;
const newTargetPath = task.targetPath.replace(task.fileName, newName);
updatedTask = {
...task,
fileName: newName,
targetPath: newTargetPath,
skipConflictCheck: true,
};
} else if (action === "replace") {
updatedTask = {
...task,
skipConflictCheck: true,
};
}
setTransfers((prev) =>
prev.map((t) =>
t.id === conflictId
? { ...updatedTask, status: "pending" as TransferStatus }
: t,
),
);
const sourceSide = updatedTask.sourceConnectionId.startsWith("left") ? "left" : "right";
const targetSide = updatedTask.targetConnectionId.startsWith("left") ? "left" : "right";
const sourcePane = getActivePane(sourceSide as "left" | "right");
const targetPane = getActivePane(targetSide as "left" | "right");
if (sourcePane?.connection && targetPane?.connection) {
setTimeout(async () => {
await processTransfer(updatedTask, sourcePane, targetPane, targetSide);
}, 100);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- processTransfer is defined inline
[conflicts, transfers, getActivePane],
);
const activeTransfersCount = useMemo(() => transfers.filter(
(t) => t.status === "pending" || t.status === "transferring",
).length, [transfers]);
return {
transfers,
conflicts,
activeTransfersCount,
startTransfer,
addExternalUpload,
updateExternalUpload,
cancelTransfer,
retryTransfer,
clearCompletedTransfers,
dismissTransfer,
resolveConflict,
};
};

View File

@@ -0,0 +1,90 @@
import { SftpFileEntry } from "../../../domain/models";
export const formatFileSize = (bytes: number): string => {
if (bytes === 0) return "--";
const units = ["Bytes", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
const size = bytes / Math.pow(1024, i);
return `${size.toFixed(i === 0 ? 0 : 2)} ${units[i]}`;
};
export const formatDate = (timestamp: number): string => {
if (!timestamp) return "--";
const date = new Date(timestamp);
if (isNaN(date.getTime())) return "--";
const pad = (n: number) => n.toString().padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
};
export const getFileExtension = (name: string): string => {
if (name === "..") return "folder";
const ext = name.split(".").pop()?.toLowerCase();
return ext || "file";
};
// Check if an entry is navigable like a directory (directories or symlinks pointing to directories)
export const isNavigableDirectory = (entry: SftpFileEntry): boolean => {
return entry.type === "directory" || (entry.type === "symlink" && entry.linkTarget === "directory");
};
// Check if path is Windows-style
export const isWindowsPath = (path: string): boolean => /^[A-Za-z]:/.test(path);
const normalizeWindowsRoot = (path: string): string => {
const normalized = path.replace(/\//g, "\\");
if (/^[A-Za-z]:\\$/.test(normalized)) return normalized;
if (/^[A-Za-z]:$/.test(normalized)) return `${normalized}\\`;
return normalized;
};
export const isWindowsRoot = (path: string): boolean => {
if (!isWindowsPath(path)) return false;
return /^[A-Za-z]:\\?$/.test(path.replace(/\//g, "\\"));
};
export const joinPath = (base: string, name: string): string => {
if (isWindowsPath(base)) {
const normalizedBase = normalizeWindowsRoot(base).replace(/[\\/]+$/, "");
return `${normalizedBase}\\${name}`;
}
if (base === "/") return `/${name}`;
return `${base}/${name}`;
};
export const getParentPath = (path: string): string => {
console.log("[SFTP getParentPath] input", { path, isWindows: isWindowsPath(path) });
if (isWindowsPath(path)) {
const normalized = normalizeWindowsRoot(path).replace(/[\\]+$/, "");
const drive = normalized.slice(0, 2);
if (/^[A-Za-z]:$/.test(normalized) || /^[A-Za-z]:\\$/.test(normalized)) {
console.log("[SFTP getParentPath] Windows root, returning", { result: `${drive}\\` });
return `${drive}\\`;
}
const rest = normalized.slice(2).replace(/^[\\]+/, "");
const parts = rest ? rest.split(/[\\]+/).filter(Boolean) : [];
if (parts.length <= 1) {
console.log("[SFTP getParentPath] Windows near root, returning", { result: `${drive}\\` });
return `${drive}\\`;
}
parts.pop();
const result = `${drive}\\${parts.join("\\")}`;
console.log("[SFTP getParentPath] Windows result", { result });
return result;
}
if (path === "/") {
console.log("[SFTP getParentPath] Unix root, returning /");
return "/";
}
const parts = path.split("/").filter(Boolean);
console.log("[SFTP getParentPath] Unix parts before pop", { parts: [...parts] });
parts.pop();
const result = parts.length ? `/${parts.join("/")}` : "/";
console.log("[SFTP getParentPath] Unix result", { result, partsAfterPop: parts });
return result;
};
export const getFileName = (path: string): string => {
const parts = path.split(/[\\/]/).filter(Boolean);
return parts[parts.length - 1] || "";
};

View File

@@ -0,0 +1,207 @@
import { useSyncExternalStore } from 'react';
import { UI_FONTS, withUiCjkFallback, type UIFont } from '../../infrastructure/config/uiFonts';
/**
* UI Font Store - singleton pattern using useSyncExternalStore
* Fetches system fonts and combines with bundled fonts
*/
type Listener = () => void;
interface UIFontStoreState {
availableFonts: UIFont[];
isLoading: boolean;
isLoaded: boolean;
error: string | null;
}
/**
* Type definition for Local Font Access API
*/
interface LocalFontData {
family: string;
}
class UIFontStore {
private state: UIFontStoreState = {
availableFonts: UI_FONTS,
isLoading: false,
isLoaded: false,
error: null,
};
private listeners = new Set<Listener>();
getAvailableFonts = (): UIFont[] => this.state.availableFonts;
getIsLoading = (): boolean => this.state.isLoading;
getIsLoaded = (): boolean => this.state.isLoaded;
private notify = () => {
Promise.resolve().then(() => {
this.listeners.forEach(listener => listener());
});
};
private setState = (partial: Partial<UIFontStoreState>) => {
this.state = { ...this.state, ...partial };
this.notify();
};
subscribe = (listener: Listener): (() => void) => {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
};
initialize = async (): Promise<void> => {
if (this.state.isLoaded || this.state.isLoading) {
return;
}
this.setState({ isLoading: true, error: null });
try {
const localFonts = await this.getLocalFonts();
// Use a Map to deduplicate by normalized font name
const fontMap = new Map<string, UIFont>();
// Add bundled fonts first (they have priority)
UI_FONTS.forEach(font => fontMap.set(font.id, font));
// Add local fonts with a distinct ID namespace
localFonts.forEach(font => {
const localId = `local-${font.id}`;
// Skip if a bundled font with similar name exists
if (!fontMap.has(font.id)) {
fontMap.set(localId, { ...font, id: localId });
}
});
this.setState({
availableFonts: Array.from(fontMap.values()),
isLoading: false,
isLoaded: true,
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to load local fonts';
console.warn('Failed to fetch local UI fonts, using defaults:', error);
this.setState({
availableFonts: UI_FONTS,
isLoading: false,
isLoaded: true,
error: errorMessage,
});
}
};
private async getLocalFonts(): Promise<UIFont[]> {
if (typeof window === 'undefined' || !('queryLocalFonts' in window)) {
return [];
}
try {
const queryLocalFonts = (window as unknown as { queryLocalFonts: () => Promise<LocalFontData[]> }).queryLocalFonts;
const fonts = await queryLocalFonts();
// Deduplicate by family name
const uniqueFamilies = new Set<string>();
const dedupedFonts = fonts.filter(f => {
if (uniqueFamilies.has(f.family)) return false;
uniqueFamilies.add(f.family);
return true;
});
// Map to UIFont structure
return dedupedFonts.map(f => ({
id: f.family.toLowerCase().replace(/\s+/g, '-'),
name: f.family,
family: withUiCjkFallback(`"${f.family}", system-ui`),
}));
} catch (error) {
console.warn('Failed to query local fonts:', error);
return [];
}
}
getFontById = (fontId: string): UIFont => {
const fonts = this.state.availableFonts;
const found = fonts.find(f => f.id === fontId);
if (found) return found;
// For local fonts that haven't been loaded yet, construct a fallback
// This handles the case when main window receives a local font ID before fonts are loaded
if (fontId.startsWith('local-')) {
const fontName = fontId
.replace(/^local-/, '')
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
return {
id: fontId,
name: fontName,
family: withUiCjkFallback(`"${fontName}", system-ui`),
};
}
return fonts[0] || UI_FONTS[0];
};
}
// Singleton instance
export const uiFontStore = new UIFontStore();
/**
* Get available UI fonts - triggers initialization on first use
*/
export const useAvailableUIFonts = (): UIFont[] => {
if (!uiFontStore.getIsLoaded() && !uiFontStore.getIsLoading()) {
uiFontStore.initialize();
}
return useSyncExternalStore(
uiFontStore.subscribe,
uiFontStore.getAvailableFonts
);
};
/**
* Get UI font loading state
*/
export const useUIFontsLoading = (): boolean => {
return useSyncExternalStore(
uiFontStore.subscribe,
uiFontStore.getIsLoading
);
};
/**
* Get UI font loaded state
*/
export const useUIFontsLoaded = (): boolean => {
return useSyncExternalStore(
uiFontStore.subscribe,
uiFontStore.getIsLoaded
);
};
/**
* Get UI font by ID with fallback
*/
export const useUIFontById = (fontId: string): UIFont => {
const fonts = useAvailableUIFonts();
return fonts.find(f => f.id === fontId) || fonts[0] || UI_FONTS[0];
};
/**
* Check if a font ID is valid
*/
export const isValidUiFontId = (fontId: string): boolean => {
// Local fonts are always considered valid (they start with 'local-')
if (fontId.startsWith('local-')) return true;
return uiFontStore.getAvailableFonts().some(f => f.id === fontId);
};
/**
* Initialize UI fonts eagerly
*/
export const initializeUIFonts = (): void => {
uiFontStore.initialize();
};

View File

@@ -12,6 +12,9 @@ import { useCloudSync } from './useCloudSync';
import { useI18n } from '../i18n/I18nProvider';
import { getCloudSyncManager } from '../../infrastructure/services/CloudSyncManager';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import {
findSyncPayloadEncryptedCredentialPaths,
} from '../../domain/credentials';
import type { SyncPayload } from '../../domain/sync';
import { toast } from '../../components/ui/toast';
@@ -109,6 +112,12 @@ export const useAutoSync = (config: AutoSyncConfig) => {
}
const payload = buildPayload();
const encryptedCredentialPaths = findSyncPayloadEncryptedCredentialPaths(payload);
if (encryptedCredentialPaths.length > 0) {
console.warn('[AutoSync] Blocked: encrypted credential placeholders found at:', encryptedCredentialPaths.join(', '));
throw new Error(t('sync.credentialsUnavailable'));
}
const results = await sync.syncNow(payload);
for (const result of results.values()) {

View File

@@ -0,0 +1,14 @@
import { useCallback } from "react";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
export const useClipboardBackend = () => {
const readClipboardText = useCallback(async (): Promise<string> => {
const bridge = netcattyBridge.get();
if (!bridge?.readClipboardText) throw new Error("clipboard bridge unavailable");
const text = await bridge.readClipboardText();
return typeof text === "string" ? text : "";
}, []);
return { readClipboardText };
};

View File

@@ -286,7 +286,7 @@ export const useCloudSync = (): CloudSyncHook => {
// Open browser after starting server
setTimeout(() => {
window.open(data.url, '_blank', 'width=600,height=700');
window.open(data.url, "_blank", "width=600,height=700,noopener,noreferrer");
}, 100);
// Wait for callback
@@ -319,7 +319,7 @@ export const useCloudSync = (): CloudSyncHook => {
// Open browser after starting server
setTimeout(() => {
window.open(data.url, '_blank', 'width=600,height=700');
window.open(data.url, "_blank", "width=600,height=700,noopener,noreferrer");
}, 100);
// Wait for callback
@@ -358,8 +358,8 @@ export const useCloudSync = (): CloudSyncHook => {
manager.setAutoSync(enabled, intervalMinutes);
}, []);
const setDeviceName = useCallback((_name: string) => {
// TODO: Add setDeviceName to CloudSyncManager if needed
const setDeviceName = useCallback((name: string) => {
manager.setDeviceName(name);
}, []);
// ========== Utilities ==========

View File

@@ -15,6 +15,8 @@ export const useKeychainBackend = () => {
privateKey?: string;
command: string;
timeout?: number;
enableKeyboardInteractive?: boolean;
sessionId?: string;
}) => {
const bridge = netcattyBridge.get();
if (!bridge?.execCommand) throw new Error("execCommand unavailable");

View File

@@ -0,0 +1,383 @@
import { useCallback, useEffect, useRef } from "react";
import { Host, ManagedSource } from "../../domain/models";
import {
serializeHostsToSshConfig,
mergeWithExistingSshConfig,
} from "../../domain/sshConfigSerializer";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
const MANAGED_BLOCK_BEGIN = "# BEGIN NETCATTY MANAGED - DO NOT EDIT THIS BLOCK";
const MANAGED_BLOCK_END = "# END NETCATTY MANAGED";
export interface UseManagedSourceSyncOptions {
hosts: Host[];
managedSources: ManagedSource[];
onUpdateManagedSources: (sources: ManagedSource[]) => void;
}
export const useManagedSourceSync = ({
hosts,
managedSources,
onUpdateManagedSources,
}: UseManagedSourceSyncOptions) => {
const previousHostsRef = useRef<Host[]>([]);
const syncInProgressRef = useRef(false);
// Keep a ref to the latest managedSources to avoid stale closure issues
const managedSourcesRef = useRef(managedSources);
managedSourcesRef.current = managedSources;
const getManagedHostsForSource = useCallback(
(sourceId: string) => {
return hosts.filter((h) => h.managedSourceId === sourceId);
},
[hosts],
);
const readExistingFileContent = useCallback(
async (filePath: string): Promise<string | null> => {
const bridge = netcattyBridge.get();
if (!bridge?.readLocalFile) {
return null;
}
try {
const buffer = await bridge.readLocalFile(filePath);
const decoder = new TextDecoder();
return decoder.decode(buffer);
} catch {
// File might not exist yet
return null;
}
},
[],
);
const mergeWithExistingContent = useCallback(
(
existingContent: string | null,
managedHosts: Host[],
allHosts: Host[],
): string => {
// Serialize the managed hosts
const managedContent = serializeHostsToSshConfig(managedHosts, allHosts);
if (!existingContent) {
// No existing file, just wrap the managed content
return `${MANAGED_BLOCK_BEGIN}\n${managedContent}${MANAGED_BLOCK_END}\n`;
}
const beginIndex = existingContent.indexOf(MANAGED_BLOCK_BEGIN);
const endIndex = existingContent.indexOf(MANAGED_BLOCK_END);
if (beginIndex === -1 || endIndex === -1 || endIndex < beginIndex) {
// No existing managed block - need to remove duplicate Host entries
// Build a set of hostnames/aliases that will be managed
const managedHostnameSet = new Set<string>();
for (const host of managedHosts) {
if (!host.protocol || host.protocol === "ssh") {
// Add both hostname and sanitized label (alias) for matching
managedHostnameSet.add(host.hostname.toLowerCase());
if (host.label) {
managedHostnameSet.add(host.label.replace(/\s/g, "").toLowerCase());
}
}
}
// Use mergeWithExistingSshConfig to filter out existing Host blocks
// that match our managed hosts, keeping preserved content outside markers
const mergedContent = mergeWithExistingSshConfig(
existingContent,
managedHosts,
managedHostnameSet,
allHosts,
);
return mergedContent;
}
// Replace the existing managed block
const before = existingContent.substring(0, beginIndex);
const after = existingContent.substring(endIndex + MANAGED_BLOCK_END.length);
return `${before}${MANAGED_BLOCK_BEGIN}\n${managedContent}${MANAGED_BLOCK_END}${after}`;
},
[],
);
const writeSshConfigToFile = useCallback(
async (source: ManagedSource, managedHosts: Host[]) => {
console.log(`[ManagedSourceSync] writeSshConfigToFile called for ${source.groupName}, hosts:`, managedHosts.length);
const bridge = netcattyBridge.get();
if (!bridge?.writeLocalFile) {
console.warn("[ManagedSourceSync] writeLocalFile not available");
return false;
}
try {
// Read existing file content to preserve non-managed parts
const existingContent = await readExistingFileContent(source.filePath);
// Merge with existing content, preserving non-managed parts and removing duplicates
const finalContent = mergeWithExistingContent(
existingContent,
managedHosts,
hosts,
);
console.log(`[ManagedSourceSync] Final content (${finalContent.length} chars)`);
const encoder = new TextEncoder();
const buffer = encoder.encode(finalContent);
console.log(`[ManagedSourceSync] Writing to ${source.filePath}`);
await bridge.writeLocalFile(source.filePath, buffer.buffer as ArrayBuffer);
console.log(`[ManagedSourceSync] Write successful`);
return true;
} catch (err) {
console.error("[ManagedSourceSync] Failed to write SSH config:", err);
return false;
}
},
[readExistingFileContent, mergeWithExistingContent, hosts],
);
const syncManagedSource = useCallback(
async (source: ManagedSource): Promise<{ sourceId: string; success: boolean }> => {
const managedHosts = getManagedHostsForSource(source.id);
const success = await writeSshConfigToFile(source, managedHosts);
return { sourceId: source.id, success };
},
[getManagedHostsForSource, writeSshConfigToFile],
);
const unmanageSource = useCallback(
(sourceId: string) => {
const updatedSources = managedSourcesRef.current.filter((s) => s.id !== sourceId);
onUpdateManagedSources(updatedSources);
},
[onUpdateManagedSources],
);
// Clear the managed block in the SSH config file and then remove the source
// This should be called before deleting a managed group to avoid stale entries
const clearAndRemoveSource = useCallback(
async (source: ManagedSource) => {
console.log(`[ManagedSourceSync] Clearing managed block for ${source.groupName}`);
// Write empty hosts list to clear the managed block
const success = await writeSshConfigToFile(source, []);
if (success) {
console.log(`[ManagedSourceSync] Managed block cleared, removing source`);
}
// Remove the source regardless of write success
const updatedSources = managedSourcesRef.current.filter((s) => s.id !== source.id);
onUpdateManagedSources(updatedSources);
return success;
},
[onUpdateManagedSources, writeSshConfigToFile],
);
// Clear and remove multiple sources atomically to avoid race conditions
// when multiple sources are removed concurrently
const clearAndRemoveSources = useCallback(
async (sources: ManagedSource[]) => {
if (sources.length === 0) return;
console.log(`[ManagedSourceSync] Clearing ${sources.length} managed blocks`);
// Clear all files in parallel
const results = await Promise.all(
sources.map(async (source) => {
const success = await writeSshConfigToFile(source, []);
return { sourceId: source.id, success };
})
);
const successCount = results.filter(r => r.success).length;
console.log(`[ManagedSourceSync] Cleared ${successCount}/${sources.length} managed blocks`);
// Remove all sources atomically in a single update
const sourceIdsToRemove = new Set(sources.map(s => s.id));
const updatedSources = managedSourcesRef.current.filter(
(s) => !sourceIdsToRemove.has(s.id)
);
onUpdateManagedSources(updatedSources);
},
[onUpdateManagedSources, writeSshConfigToFile],
);
const pendingSyncRef = useRef(false);
const checkAndSyncRef = useRef<() => void>(() => {});
const checkAndSync = useCallback(() => {
if (managedSources.length === 0) {
// Still update previousHostsRef so we have a baseline when sources are added
previousHostsRef.current = hosts;
return;
}
const prevHosts = previousHostsRef.current;
previousHostsRef.current = hosts;
// On initial sync (prevHosts empty), sync all sources that have managed hosts
const isInitialSync = prevHosts.length === 0;
const changedSourceIds = new Set<string>();
if (isInitialSync) {
// Initial sync: sync all sources that have hosts
for (const source of managedSources) {
const currManaged = hosts.filter((h) => h.managedSourceId === source.id);
if (currManaged.length > 0) {
changedSourceIds.add(source.id);
}
}
} else {
// Build maps for all hosts (for jump host lookup)
const prevHostMap = new Map<string, Host>(prevHosts.map((h) => [h.id, h]));
const currHostMap = new Map<string, Host>(hosts.map((h) => [h.id, h]));
// Index hosts by managedSourceId to avoid O(N*M) lookups
const prevHostsBySource = new Map<string, Host[]>();
for (const h of prevHosts) {
if (h.managedSourceId) {
let list = prevHostsBySource.get(h.managedSourceId);
if (!list) {
list = [];
prevHostsBySource.set(h.managedSourceId, list);
}
list.push(h);
}
}
const currHostsBySource = new Map<string, Host[]>();
for (const h of hosts) {
if (h.managedSourceId) {
let list = currHostsBySource.get(h.managedSourceId);
if (!list) {
list = [];
currHostsBySource.set(h.managedSourceId, list);
}
list.push(h);
}
}
// Helper to check if a host's SSH-relevant fields changed
const hostChanged = (prevHost: Host | undefined, currHost: Host | undefined): boolean => {
if (!prevHost || !currHost) return prevHost !== currHost;
return (
prevHost.hostname !== currHost.hostname ||
prevHost.port !== currHost.port ||
prevHost.username !== currHost.username ||
prevHost.label !== currHost.label
);
};
for (const source of managedSources) {
const prevManaged = prevHostsBySource.get(source.id) || [];
const currManaged = currHostsBySource.get(source.id) || [];
console.log(`[ManagedSourceSync] Source ${source.groupName}: prev=${prevManaged.length}, curr=${currManaged.length}`);
if (prevManaged.length !== currManaged.length) {
changedSourceIds.add(source.id);
continue;
}
const prevManagedMap = new Map<string, Host>(prevManaged.map((h) => [h.id, h]));
let sourceChanged = false;
for (const curr of currManaged) {
const prev = prevManagedMap.get(curr.id);
if (!prev) {
sourceChanged = true;
break;
}
// Compare hostChain arrays for ProxyJump changes
const prevChain = prev.hostChain?.hostIds || [];
const currChain = curr.hostChain?.hostIds || [];
const chainChanged =
prevChain.length !== currChain.length ||
prevChain.some((id, i) => id !== currChain[i]);
const hasChanged =
prev.hostname !== curr.hostname ||
prev.port !== curr.port ||
prev.username !== curr.username ||
prev.label !== curr.label ||
prev.group !== curr.group ||
prev.protocol !== curr.protocol ||
chainChanged;
if (hasChanged) {
sourceChanged = true;
break;
}
// Check if any referenced jump hosts changed (even if outside this managed source)
for (const jumpHostId of currChain) {
const prevJumpHost = prevHostMap.get(jumpHostId);
const currJumpHost = currHostMap.get(jumpHostId);
if (hostChanged(prevJumpHost, currJumpHost)) {
sourceChanged = true;
break;
}
}
if (sourceChanged) break;
}
if (sourceChanged) {
changedSourceIds.add(source.id);
}
}
}
if (changedSourceIds.size > 0) {
console.log(`[ManagedSourceSync] Syncing sources:`, Array.from(changedSourceIds));
syncInProgressRef.current = true;
Promise.all(
managedSources
.filter((s) => changedSourceIds.has(s.id))
.map(syncManagedSource),
).then((results) => {
// Batch update lastSyncedAt for all successful syncs to avoid race conditions
const successfulSourceIds = new Set(
results.filter(r => r.success).map(r => r.sourceId)
);
if (successfulSourceIds.size > 0) {
const currentSources = managedSourcesRef.current;
const now = Date.now();
const updatedSources = currentSources.map((s) =>
successfulSourceIds.has(s.id) ? { ...s, lastSyncedAt: now } : s,
);
onUpdateManagedSources(updatedSources);
}
}).finally(() => {
syncInProgressRef.current = false;
// Check if there were changes during sync that need to be processed
// Use ref to get the latest checkAndSync to avoid stale closure
if (pendingSyncRef.current) {
pendingSyncRef.current = false;
checkAndSyncRef.current();
}
});
}
}, [hosts, managedSources, syncManagedSource, onUpdateManagedSources]);
// Keep ref updated with the latest checkAndSync
checkAndSyncRef.current = checkAndSync;
useEffect(() => {
if (syncInProgressRef.current) {
// Mark that we need to re-sync after current sync completes
pendingSyncRef.current = true;
return;
}
checkAndSync();
}, [hosts, managedSources, checkAndSync]);
return {
syncManagedSource,
unmanageSource,
clearAndRemoveSource,
clearAndRemoveSources,
getManagedHostsForSource,
};
};

View File

@@ -76,10 +76,14 @@ export const usePortForwardingAutoStart = ({
if (autoStartExecutedRef.current) return;
if (hosts.length === 0) return;
// Mark as executed immediately to prevent duplicate runs
// (React StrictMode or dependency changes could cause re-runs)
autoStartExecutedRef.current = true;
const runAutoStart = async () => {
// First sync with backend to get any active tunnels
await syncWithBackend();
// Load rules from storage
const rules = localStorageAdapter.read<PortForwardingRule[]>(
STORAGE_KEY_PORT_FORWARDING,
@@ -95,8 +99,6 @@ export const usePortForwardingAutoStart = ({
});
if (autoStartRules.length === 0) return;
autoStartExecutedRef.current = true;
logger.info(`[PortForwardingAutoStart] Starting ${autoStartRules.length} auto-start rules`);
// Start each auto-start rule

View File

@@ -9,7 +9,6 @@ import { localStorageAdapter } from "../../infrastructure/persistence/localStora
import {
clearReconnectTimer,
getActiveConnection,
getActiveRuleIds,
startPortForward,
stopPortForward,
syncWithBackend,
@@ -40,6 +39,7 @@ export interface UsePortForwardingStateResult {
updateRule: (id: string, updates: Partial<PortForwardingRule>) => void;
deleteRule: (id: string) => void;
duplicateRule: (id: string) => void;
importRules: (rules: PortForwardingRule[]) => void;
setRuleStatus: (
id: string,
@@ -63,8 +63,58 @@ export interface UsePortForwardingStateResult {
selectedRule: PortForwardingRule | undefined;
}
// Global Store State
let globalRules: PortForwardingRule[] = [];
let isInitialized = false;
const listeners = new Set<(rules: PortForwardingRule[]) => void>();
// Store Actions
const notifyListeners = () => {
listeners.forEach((listener) => listener(globalRules));
};
const setGlobalRules = (newRules: PortForwardingRule[]) => {
globalRules = newRules;
notifyListeners();
localStorageAdapter.write(STORAGE_KEY_PORT_FORWARDING, newRules);
};
const normalizeRulesWithConnections = (rules: PortForwardingRule[]): PortForwardingRule[] => {
return rules.map((rule): PortForwardingRule => {
const connection = getActiveConnection(rule.id);
if (connection) {
return {
...rule,
status: connection.status,
error: connection.error,
};
}
return {
...rule,
status: "inactive" as const,
error: undefined,
};
});
};
// Initialization Logic
const initializeStore = async () => {
if (isInitialized) return;
isInitialized = true;
await syncWithBackend();
const saved = localStorageAdapter.read<PortForwardingRule[]>(
STORAGE_KEY_PORT_FORWARDING,
);
if (saved && Array.isArray(saved)) {
setGlobalRules(normalizeRulesWithConnections(saved));
}
};
export const usePortForwardingState = (): UsePortForwardingStateResult => {
const [rules, setRules] = useState<PortForwardingRule[]>([]);
const [rules, setRules] = useState<PortForwardingRule[]>(globalRules);
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
const [viewMode, setViewMode] = useStoredViewMode(
STORAGE_KEY_PF_VIEW_MODE,
@@ -81,37 +131,50 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
localStorageAdapter.writeBoolean(STORAGE_KEY_PF_PREFER_FORM_MODE, prefer);
}, []);
// Load rules from storage on mount and sync with backend
// Initialize store on mount (only once globally)
useEffect(() => {
const loadAndSync = async () => {
// First, sync with backend to get any active tunnels
await syncWithBackend();
const saved = localStorageAdapter.read<PortForwardingRule[]>(
STORAGE_KEY_PORT_FORWARDING,
);
if (saved && Array.isArray(saved)) {
// Sync status with active connections in the service layer
const _activeRuleIds = getActiveRuleIds();
const withSyncedStatus = saved.map((r) => {
const conn = getActiveConnection(r.id);
if (conn) {
// This rule has an active connection, preserve its status
return { ...r, status: conn.status, error: conn.error };
}
// No active connection, reset to inactive
return { ...r, status: "inactive" as const, error: undefined };
});
setRules(withSyncedStatus);
}
};
void loadAndSync();
void initializeStore();
}, []);
// Persist rules to storage whenever they change
const persistRules = useCallback((updatedRules: PortForwardingRule[]) => {
localStorageAdapter.write(STORAGE_KEY_PORT_FORWARDING, updatedRules);
// Subscribe to global store
useEffect(() => {
// If global state was updated before we subscribed (e.g. init finished), update local state
if (rules !== globalRules) {
setRules(globalRules);
}
const listener = (newRules: PortForwardingRule[]) => {
setRules(newRules);
};
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}, [rules]);
// Listen for storage events for cross-window sync (main window <-> tray panel)
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
// Only handle changes from our specific key
if (e.key !== STORAGE_KEY_PORT_FORWARDING) return;
// Parse the new value
if (e.newValue) {
try {
const newRules = JSON.parse(e.newValue) as PortForwardingRule[];
if (Array.isArray(newRules)) {
// Update global state without triggering another localStorage write
globalRules = normalizeRulesWithConnections(newRules);
notifyListeners();
}
} catch {
// ignore parse errors
}
}
};
window.addEventListener("storage", handleStorageChange);
return () => window.removeEventListener("storage", handleStorageChange);
}, []);
const addRule = useCallback(
@@ -124,47 +187,38 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
createdAt: Date.now(),
status: "inactive",
};
setRules((prev) => {
const updated = [...prev, newRule];
persistRules(updated);
return updated;
});
const updated = [...globalRules, newRule];
setGlobalRules(updated);
setSelectedRuleId(newRule.id);
return newRule;
},
[persistRules],
[],
);
const updateRule = useCallback(
(id: string, updates: Partial<PortForwardingRule>) => {
setRules((prev) => {
const updated = prev.map((r) =>
r.id === id ? { ...r, ...updates } : r,
);
persistRules(updated);
return updated;
});
const updated = globalRules.map((r) =>
r.id === id ? { ...r, ...updates } : r,
);
setGlobalRules(updated);
},
[persistRules],
[],
);
const deleteRule = useCallback(
(id: string) => {
setRules((prev) => {
const updated = prev.filter((r) => r.id !== id);
persistRules(updated);
return updated;
});
const updated = globalRules.filter((r) => r.id !== id);
setGlobalRules(updated);
if (selectedRuleId === id) {
setSelectedRuleId(null);
}
},
[selectedRuleId, persistRules],
[selectedRuleId],
);
const duplicateRule = useCallback(
(id: string) => {
const original = rules.find((r) => r.id === id);
const original = globalRules.find((r) => r.id === id);
if (!original) return;
const copy: PortForwardingRule = {
@@ -176,33 +230,31 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
error: undefined,
lastUsedAt: undefined,
};
setRules((prev) => {
const updated = [...prev, copy];
persistRules(updated);
return updated;
});
const updated = [...globalRules, copy];
setGlobalRules(updated);
setSelectedRuleId(copy.id);
},
[rules, persistRules],
[],
);
const importRules = useCallback((newRules: PortForwardingRule[]) => {
setGlobalRules(normalizeRulesWithConnections(newRules));
}, []);
const setRuleStatus = useCallback(
(id: string, status: PortForwardingRule["status"], error?: string) => {
setRules((prev) => {
const updated = prev.map((r) => {
if (r.id !== id) return r;
return {
...r,
status,
error,
lastUsedAt: status === "active" ? Date.now() : r.lastUsedAt,
};
});
persistRules(updated);
return updated;
const updated = globalRules.map((r) => {
if (r.id !== id) return r;
return {
...r,
status,
error,
lastUsedAt: status === "active" ? Date.now() : r.lastUsedAt,
};
});
setGlobalRules(updated);
},
[persistRules],
[],
);
const startTunnel = useCallback(
@@ -295,6 +347,7 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
updateRule,
deleteRule,
duplicateRule,
importRules,
setRuleStatus,
startTunnel,

View File

@@ -286,6 +286,69 @@ export const useSessionState = () => {
setWorkspaceRenameValue('');
}, []);
const createWorkspaceWithHosts = useCallback((name: string, hosts: Host[]) => {
if (hosts.length === 0) return;
// Create sessions for each host
const newSessions: TerminalSession[] = hosts.map(host => {
// Handle serial hosts specially
if (host.protocol === 'serial') {
const serialConfig: SerialConfig = host.serialConfig || {
path: host.hostname,
baudRate: host.port || 115200,
dataBits: 8,
stopBits: 1,
parity: 'none',
flowControl: 'none',
localEcho: false,
lineMode: false,
};
const portName = serialConfig.path.split('/').pop() || serialConfig.path;
return {
id: crypto.randomUUID(),
hostId: host.id,
hostLabel: host.label || `Serial: ${portName}`,
hostname: serialConfig.path,
username: '',
status: 'connecting',
protocol: 'serial',
serialConfig: serialConfig,
};
}
return {
id: crypto.randomUUID(),
hostId: host.id,
hostLabel: host.label,
hostname: host.hostname,
username: host.username,
status: 'connecting',
protocol: host.protocol,
port: host.port,
moshEnabled: host.moshEnabled,
};
});
const sessionIds = newSessions.map(s => s.id);
// Create workspace
const workspace = createWorkspaceFromSessionIds(sessionIds, {
title: name,
viewMode: 'split',
});
// Assign workspaceId to sessions
const sessionsWithWorkspace = newSessions.map(s => ({
...s,
workspaceId: workspace.id
}));
setSessions(prev => [...prev, ...sessionsWithWorkspace]);
setWorkspaces(prev => [...prev, workspace]);
setActiveTabId(workspace.id);
}, [setActiveTabId]);
const createWorkspaceFromSessions = useCallback((
baseSessionId: string,
joiningSessionId: string,
@@ -547,6 +610,31 @@ export const useSessionState = () => {
});
}, [setActiveTabId]);
// Copy a session - creates a new session with the same host connection
const copySession = useCallback((sessionId: string) => {
setSessions(prevSessions => {
const session = prevSessions.find(s => s.id === sessionId);
if (!session) return prevSessions;
// Create a new session with the same connection info
const newSession: TerminalSession = {
id: crypto.randomUUID(),
hostId: session.hostId,
hostLabel: session.hostLabel,
hostname: session.hostname,
username: session.username,
status: 'connecting',
protocol: session.protocol,
port: session.port,
moshEnabled: session.moshEnabled,
serialConfig: session.serialConfig,
};
setActiveTabId(newSession.id);
return [...prevSessions, newSession];
});
}, [setActiveTabId]);
// Toggle broadcast mode for a workspace
const toggleBroadcast = useCallback((workspaceId: string) => {
setBroadcastWorkspaceIds(prev => {
@@ -644,6 +732,7 @@ export const useSessionState = () => {
closeSession,
closeWorkspace,
updateSessionStatus,
createWorkspaceWithHosts,
createWorkspaceFromSessions,
addSessionToWorkspace,
updateSplitSizes,
@@ -662,5 +751,7 @@ export const useSessionState = () => {
logViews,
openLogView,
closeLogView,
// Copy session
copySession,
};
};

View File

@@ -1,29 +1,40 @@
import { useCallback,useEffect,useLayoutEffect,useMemo,useState } from 'react';
import { SyncConfig, TerminalSettings, DEFAULT_TERMINAL_SETTINGS, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage } from '../../domain/models';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
import { SyncConfig, TerminalSettings, DEFAULT_TERMINAL_SETTINGS, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat } from '../../domain/models';
import {
STORAGE_KEY_COLOR,
STORAGE_KEY_SYNC,
STORAGE_KEY_TERM_THEME,
STORAGE_KEY_THEME,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_FONT_SIZE,
STORAGE_KEY_TERM_SETTINGS,
STORAGE_KEY_HOTKEY_SCHEME,
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
STORAGE_KEY_HOTKEY_RECORDING,
STORAGE_KEY_CUSTOM_CSS,
STORAGE_KEY_UI_LANGUAGE,
STORAGE_KEY_ACCENT_MODE,
STORAGE_KEY_UI_THEME_LIGHT,
STORAGE_KEY_UI_THEME_DARK,
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
STORAGE_KEY_SFTP_AUTO_SYNC,
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
STORAGE_KEY_COLOR,
STORAGE_KEY_SYNC,
STORAGE_KEY_TERM_THEME,
STORAGE_KEY_THEME,
STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_FONT_SIZE,
STORAGE_KEY_TERM_SETTINGS,
STORAGE_KEY_HOTKEY_SCHEME,
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
STORAGE_KEY_HOTKEY_RECORDING,
STORAGE_KEY_CUSTOM_CSS,
STORAGE_KEY_UI_LANGUAGE,
STORAGE_KEY_ACCENT_MODE,
STORAGE_KEY_UI_THEME_LIGHT,
STORAGE_KEY_UI_THEME_DARK,
STORAGE_KEY_UI_FONT_FAMILY,
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
STORAGE_KEY_SFTP_AUTO_SYNC,
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
STORAGE_KEY_EDITOR_WORD_WRAP,
STORAGE_KEY_SESSION_LOGS_ENABLED,
STORAGE_KEY_SESSION_LOGS_DIR,
STORAGE_KEY_SESSION_LOGS_FORMAT,
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
STORAGE_KEY_CLOSE_TO_TRAY,
} from '../../infrastructure/config/storageKeys';
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
import { useCustomThemes } from '../state/customThemeStore';
import { DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
import { DARK_UI_THEMES, LIGHT_UI_THEMES, UiThemeTokens, getUiThemeById } from '../../infrastructure/config/uiThemes';
import { UI_FONTS, DEFAULT_UI_FONT_ID } from '../../infrastructure/config/uiFonts';
import { uiFontStore, useUIFontsLoaded } from './uiFontStore';
import { useAvailableFonts } from './fontStore';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
@@ -43,6 +54,14 @@ const DEFAULT_HOTKEY_SCHEME: HotkeyScheme =
const DEFAULT_SFTP_DOUBLE_CLICK_BEHAVIOR: 'open' | 'transfer' = 'open';
const DEFAULT_SFTP_AUTO_SYNC = false;
const DEFAULT_SFTP_SHOW_HIDDEN_FILES = false;
const DEFAULT_SFTP_USE_COMPRESSED_UPLOAD = true;
// Editor defaults
const DEFAULT_EDITOR_WORD_WRAP = false;
// Session Logs defaults
const DEFAULT_SESSION_LOGS_ENABLED = false;
const DEFAULT_SESSION_LOGS_FORMAT: SessionLogFormat = 'txt';
const readStoredString = (key: string): string | null => {
const raw = localStorageAdapter.readString(key);
@@ -70,6 +89,20 @@ const isValidUiThemeId = (theme: 'light' | 'dark', value: string): boolean => {
return list.some((preset) => preset.id === value);
};
const isValidUiFontId = (value: string): boolean => {
// Local fonts are always considered valid
if (value.startsWith('local-')) return true;
// Check bundled fonts first, then check dynamically loaded fonts
return UI_FONTS.some((font) => font.id === value) ||
uiFontStore.getAvailableFonts().some((font) => font.id === value);
};
const serializeTerminalSettings = (settings: TerminalSettings): string =>
JSON.stringify(settings);
const areTerminalSettingsEqual = (a: TerminalSettings, b: TerminalSettings): boolean =>
serializeTerminalSettings(a) === serializeTerminalSettings(b);
const applyThemeTokens = (
theme: 'light' | 'dark',
tokens: UiThemeTokens,
@@ -112,6 +145,7 @@ const applyThemeTokens = (
export const useSettingsState = () => {
const availableFonts = useAvailableFonts();
const uiFontsLoaded = useUIFontsLoaded();
const [theme, setTheme] = useState<'dark' | 'light'>(() => {
const stored = readStoredString(STORAGE_KEY_THEME);
return stored && isValidTheme(stored) ? stored : DEFAULT_THEME;
@@ -134,6 +168,10 @@ export const useSettingsState = () => {
const legacyColor = readStoredString(STORAGE_KEY_COLOR);
return legacyColor && isValidHslToken(legacyColor) ? 'custom' : DEFAULT_ACCENT_MODE;
});
const [uiFontFamilyId, setUiFontFamilyId] = useState<string>(() => {
const stored = readStoredString(STORAGE_KEY_UI_FONT_FAMILY);
return stored && isValidUiFontId(stored) ? stored : DEFAULT_UI_FONT_ID;
});
const [syncConfig, setSyncConfig] = useState<SyncConfig | null>(() => localStorageAdapter.read<SyncConfig>(STORAGE_KEY_SYNC));
const [terminalThemeId, setTerminalThemeId] = useState<string>(() => localStorageAdapter.readString(STORAGE_KEY_TERM_THEME) || DEFAULT_TERMINAL_THEME);
const [terminalFontFamilyId, setTerminalFontFamilyId] = useState<string>(() => localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_FAMILY) || DEFAULT_FONT_FAMILY);
@@ -142,7 +180,7 @@ export const useSettingsState = () => {
const stored = readStoredString(STORAGE_KEY_UI_LANGUAGE);
return resolveSupportedLocale(stored || DEFAULT_UI_LOCALE);
});
const [terminalSettings, setTerminalSettings] = useState<TerminalSettings>(() => {
const [terminalSettings, setTerminalSettingsState] = useState<TerminalSettings>(() => {
const stored = localStorageAdapter.read<TerminalSettings>(STORAGE_KEY_TERM_SETTINGS);
return stored ? { ...DEFAULT_TERMINAL_SETTINGS, ...stored } : DEFAULT_TERMINAL_SETTINGS;
});
@@ -173,6 +211,77 @@ export const useSettingsState = () => {
const stored = readStoredString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES);
return stored === 'true' ? true : DEFAULT_SFTP_SHOW_HIDDEN_FILES;
});
const [sftpUseCompressedUpload, setSftpUseCompressedUpload] = useState<boolean>(() => {
const stored = readStoredString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD);
// 兼容旧的设置值
if (stored === 'true' || stored === 'enabled' || stored === 'ask') return true;
if (stored === 'false' || stored === 'disabled') return false;
return DEFAULT_SFTP_USE_COMPRESSED_UPLOAD;
});
// Editor Settings
const [editorWordWrap, setEditorWordWrapState] = useState<boolean>(() => {
const stored = readStoredString(STORAGE_KEY_EDITOR_WORD_WRAP);
return stored === 'true' ? true : DEFAULT_EDITOR_WORD_WRAP;
});
// Session Logs Settings
const [sessionLogsEnabled, setSessionLogsEnabled] = useState<boolean>(() => {
const stored = readStoredString(STORAGE_KEY_SESSION_LOGS_ENABLED);
return stored === 'true' ? true : DEFAULT_SESSION_LOGS_ENABLED;
});
const [sessionLogsDir, setSessionLogsDir] = useState<string>(() => {
return readStoredString(STORAGE_KEY_SESSION_LOGS_DIR) || '';
});
const [sessionLogsFormat, setSessionLogsFormat] = useState<SessionLogFormat>(() => {
const stored = readStoredString(STORAGE_KEY_SESSION_LOGS_FORMAT);
if (stored === 'txt' || stored === 'raw' || stored === 'html') return stored;
return DEFAULT_SESSION_LOGS_FORMAT;
});
// Global Toggle Window Settings (Quake Mode)
const [toggleWindowHotkey, setToggleWindowHotkey] = useState<string>(() => {
const stored = readStoredString(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY);
if (stored !== null) return stored;
// Default: Ctrl+` (Control+backtick) - similar to VS Code terminal toggle
const isMac = typeof navigator !== 'undefined' && /Mac/i.test(navigator.platform);
return isMac ? '⌃ + `' : 'Ctrl + `';
});
const [closeToTray, setCloseToTray] = useState<boolean>(() => {
const stored = readStoredString(STORAGE_KEY_CLOSE_TO_TRAY);
// Default to true (enabled)
if (stored === null) return true;
return stored === 'true';
});
const [hotkeyRegistrationError, setHotkeyRegistrationError] = useState<string | null>(null);
const incomingTerminalSettingsSignatureRef = useRef<string | null>(null);
const localTerminalSettingsVersionRef = useRef(0);
const broadcastedLocalTerminalSettingsVersionRef = useRef(0);
const setTerminalSettings = useCallback((nextValue: SetStateAction<TerminalSettings>) => {
setTerminalSettingsState((prev) => {
const next = typeof nextValue === 'function'
? (nextValue as (prevState: TerminalSettings) => TerminalSettings)(prev)
: nextValue;
if (areTerminalSettingsEqual(prev, next)) {
return prev;
}
localTerminalSettingsVersionRef.current += 1;
return next;
});
}, []);
const mergeIncomingTerminalSettings = useCallback((incoming: Partial<TerminalSettings>) => {
setTerminalSettingsState((prev) => {
const next = { ...prev, ...incoming };
if (areTerminalSettingsEqual(prev, next)) {
return prev;
}
// Mark the exact incoming snapshot so only this state is skipped for IPC rebroadcast.
incomingTerminalSettingsSignatureRef.current = serializeTerminalSettings(next);
return next;
});
}, []);
// Helper to notify other windows about settings changes via IPC
const notifySettingsChanged = useCallback((key: string, value: unknown) => {
@@ -233,12 +342,21 @@ export const useSettingsState = () => {
notifySettingsChanged(STORAGE_KEY_UI_LANGUAGE, uiLanguage);
}, [uiLanguage, notifySettingsChanged]);
// Apply and persist UI font family
// Re-run when fonts finish loading to get correct family for local fonts
useLayoutEffect(() => {
const font = uiFontStore.getFontById(uiFontFamilyId);
document.documentElement.style.setProperty('--font-sans', font.family);
localStorageAdapter.writeString(STORAGE_KEY_UI_FONT_FAMILY, uiFontFamilyId);
notifySettingsChanged(STORAGE_KEY_UI_FONT_FAMILY, uiFontFamilyId);
}, [uiFontFamilyId, uiFontsLoaded, notifySettingsChanged]);
// Listen for settings changes from other windows via IPC
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onSettingsChanged) return;
const unsubscribe = bridge.onSettingsChanged((payload) => {
const { key, value } = payload;
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onSettingsChanged) return;
const unsubscribe = bridge.onSettingsChanged((payload) => {
const { key, value } = payload;
if (
key === STORAGE_KEY_THEME ||
key === STORAGE_KEY_UI_THEME_LIGHT ||
@@ -252,11 +370,16 @@ export const useSettingsState = () => {
if (key === STORAGE_KEY_UI_LANGUAGE && typeof value === 'string') {
const next = resolveSupportedLocale(value);
setUiLanguage((prev) => (prev === next ? prev : next));
document.documentElement.lang = next;
}
document.documentElement.lang = next;
}
if (key === STORAGE_KEY_CUSTOM_CSS && typeof value === 'string') {
syncCustomCssFromStorage();
}
if (key === STORAGE_KEY_UI_FONT_FAMILY && typeof value === 'string') {
if (isValidUiFontId(value)) {
setUiFontFamilyId(value);
}
}
if (key === STORAGE_KEY_TERM_THEME && typeof value === 'string') {
setTerminalThemeId(value);
}
@@ -266,6 +389,21 @@ export const useSettingsState = () => {
if (key === STORAGE_KEY_TERM_FONT_SIZE && typeof value === 'number') {
setTerminalFontSize(value);
}
if (key === STORAGE_KEY_TERM_SETTINGS) {
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value) as Partial<TerminalSettings>;
mergeIncomingTerminalSettings(parsed);
} catch {
// ignore parse errors
}
} else if (value && typeof value === 'object') {
mergeIncomingTerminalSettings(value as Partial<TerminalSettings>);
}
}
if (key === STORAGE_KEY_EDITOR_WORD_WRAP && typeof value === 'boolean') {
setEditorWordWrapState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_HOTKEY_SCHEME && (value === 'disabled' || value === 'mac' || value === 'pc')) {
setHotkeyScheme(value);
}
@@ -291,7 +429,7 @@ export const useSettingsState = () => {
// ignore
}
};
}, [syncAppearanceFromStorage, syncCustomCssFromStorage]);
}, [mergeIncomingTerminalSettings, syncAppearanceFromStorage, syncCustomCssFromStorage]);
useEffect(() => {
const bridge = netcattyBridge.get();
@@ -343,6 +481,11 @@ export const useSettingsState = () => {
setCustomCSS(e.newValue);
}
}
if (e.key === STORAGE_KEY_UI_FONT_FAMILY && e.newValue) {
if (isValidUiFontId(e.newValue) && e.newValue !== uiFontFamilyId) {
setUiFontFamilyId(e.newValue);
}
}
if (e.key === STORAGE_KEY_HOTKEY_SCHEME && e.newValue) {
const newScheme = e.newValue as HotkeyScheme;
if (newScheme !== hotkeyScheme) {
@@ -364,14 +507,14 @@ export const useSettingsState = () => {
}
}
// Sync terminal settings from other windows
if (e.key === STORAGE_KEY_TERM_SETTINGS && e.newValue) {
try {
const newSettings = JSON.parse(e.newValue) as TerminalSettings;
setTerminalSettings(_prev => ({ ...DEFAULT_TERMINAL_SETTINGS, ...newSettings }));
} catch {
// ignore parse errors
}
}
if (e.key === STORAGE_KEY_TERM_SETTINGS && e.newValue) {
try {
const newSettings = JSON.parse(e.newValue) as TerminalSettings;
mergeIncomingTerminalSettings({ ...DEFAULT_TERMINAL_SETTINGS, ...newSettings });
} catch {
// ignore parse errors
}
}
// Sync terminal theme from other windows
if (e.key === STORAGE_KEY_TERM_THEME && e.newValue) {
if (e.newValue !== terminalThemeId) {
@@ -411,11 +554,24 @@ export const useSettingsState = () => {
setSftpShowHiddenFiles(newValue);
}
}
if (e.key === STORAGE_KEY_EDITOR_WORD_WRAP && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== editorWordWrap) {
setEditorWordWrapState(newValue);
}
}
// Sync SFTP compressed upload setting from other windows
if (e.key === STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD && e.newValue !== null) {
const newValue = e.newValue === 'true' || e.newValue === 'enabled';
if (newValue !== sftpUseCompressedUpload) {
setSftpUseCompressedUpload(newValue);
}
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles]);
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, editorWordWrap, mergeIncomingTerminalSettings]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
@@ -434,7 +590,17 @@ export const useSettingsState = () => {
useEffect(() => {
localStorageAdapter.write(STORAGE_KEY_TERM_SETTINGS, terminalSettings);
}, [terminalSettings]);
const currentSignature = serializeTerminalSettings(terminalSettings);
const hasPendingUnbroadcastLocalChanges =
localTerminalSettingsVersionRef.current !== broadcastedLocalTerminalSettingsVersionRef.current;
if (incomingTerminalSettingsSignatureRef.current === currentSignature && !hasPendingUnbroadcastLocalChanges) {
incomingTerminalSettingsSignatureRef.current = null;
return;
}
incomingTerminalSettingsSignatureRef.current = null;
notifySettingsChanged(STORAGE_KEY_TERM_SETTINGS, terminalSettings);
broadcastedLocalTerminalSettingsVersionRef.current = localTerminalSettingsVersionRef.current;
}, [terminalSettings, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_HOTKEY_SCHEME, hotkeyScheme);
@@ -484,6 +650,71 @@ export const useSettingsState = () => {
notifySettingsChanged(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, sftpShowHiddenFiles);
}, [sftpShowHiddenFiles, notifySettingsChanged]);
// Persist SFTP compressed upload setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, sftpUseCompressedUpload ? 'true' : 'false');
notifySettingsChanged(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, sftpUseCompressedUpload);
}, [sftpUseCompressedUpload, notifySettingsChanged]);
// Persist Session Logs settings
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_ENABLED, sessionLogsEnabled ? 'true' : 'false');
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_ENABLED, sessionLogsEnabled);
}, [sessionLogsEnabled, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_DIR, sessionLogsDir);
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_DIR, sessionLogsDir);
}, [sessionLogsDir, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_FORMAT, sessionLogsFormat);
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_FORMAT, sessionLogsFormat);
}, [sessionLogsFormat, notifySettingsChanged]);
// Persist and sync toggle window hotkey setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
notifySettingsChanged(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
// Register/unregister the global hotkey in main process
const bridge = netcattyBridge.get();
if (bridge?.registerGlobalHotkey) {
if (toggleWindowHotkey) {
setHotkeyRegistrationError(null);
bridge
.registerGlobalHotkey(toggleWindowHotkey)
.then((result) => {
if (result?.success === false) {
console.warn('[GlobalHotkey] Hotkey registration failed:', result.error);
setHotkeyRegistrationError(result.error || 'Failed to register hotkey');
}
})
.catch((err) => {
console.warn('[GlobalHotkey] Failed to register hotkey:', err);
setHotkeyRegistrationError(err?.message || 'Failed to register hotkey');
});
} else {
setHotkeyRegistrationError(null);
bridge.unregisterGlobalHotkey?.().catch((err) => {
console.warn('[GlobalHotkey] Failed to unregister hotkey:', err);
});
}
}
}, [toggleWindowHotkey, notifySettingsChanged]);
// Persist and sync close to tray setting
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray ? 'true' : 'false');
notifySettingsChanged(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray);
// Update main process tray behavior
const bridge = netcattyBridge.get();
if (bridge?.setCloseToTray) {
bridge.setCloseToTray(closeToTray).catch((err) => {
console.warn('[SystemTray] Failed to set close-to-tray:', err);
});
}
}, [closeToTray, notifySettingsChanged]);
// Get merged key bindings (defaults + custom overrides)
const keyBindings = useMemo((): KeyBinding[] => {
return DEFAULT_KEY_BINDINGS.map(binding => {
@@ -536,9 +767,14 @@ export const useSettingsState = () => {
localStorageAdapter.write(STORAGE_KEY_SYNC, config);
}, []);
// Subscribe to custom theme changes so editing in-place triggers re-render
const customThemes = useCustomThemes();
const currentTerminalTheme = useMemo(
() => TERMINAL_THEMES.find(t => t.id === terminalThemeId) || TERMINAL_THEMES[0],
[terminalThemeId]
() => TERMINAL_THEMES.find(t => t.id === terminalThemeId)
|| customThemes.find(t => t.id === terminalThemeId)
|| TERMINAL_THEMES[0],
[terminalThemeId, customThemes]
);
const currentTerminalFont = useMemo(
@@ -551,7 +787,7 @@ export const useSettingsState = () => {
value: TerminalSettings[K]
) => {
setTerminalSettings(prev => ({ ...prev, [key]: value }));
}, []);
}, [setTerminalSettings]);
return {
theme,
@@ -564,6 +800,8 @@ export const useSettingsState = () => {
setAccentMode,
customAccent,
setCustomAccent,
uiFontFamilyId,
setUiFontFamilyId,
syncConfig,
updateSyncConfig,
uiLanguage,
@@ -596,6 +834,28 @@ export const useSettingsState = () => {
setSftpAutoSync,
sftpShowHiddenFiles,
setSftpShowHiddenFiles,
sftpUseCompressedUpload,
setSftpUseCompressedUpload,
// Editor Settings
editorWordWrap,
setEditorWordWrap: useCallback((enabled: boolean) => {
setEditorWordWrapState(enabled);
localStorageAdapter.writeString(STORAGE_KEY_EDITOR_WORD_WRAP, String(enabled));
notifySettingsChanged(STORAGE_KEY_EDITOR_WORD_WRAP, enabled);
}, [notifySettingsChanged]),
availableFonts,
// Session Logs
sessionLogsEnabled,
setSessionLogsEnabled,
sessionLogsDir,
setSessionLogsDir,
sessionLogsFormat,
setSessionLogsFormat,
// Global Toggle Window (Quake Mode)
toggleWindowHotkey,
setToggleWindowHotkey,
closeToTray,
setCloseToTray,
hotkeyRegistrationError,
};
};

View File

@@ -1,6 +1,6 @@
import { useCallback } from "react";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
import type { RemoteFile } from "../../types";
import type { RemoteFile, SftpFilenameEncoding } from "../../types";
export const useSftpBackend = () => {
const openSftp = useCallback(async (options: NetcattySSHOptions) => {
@@ -15,34 +15,34 @@ export const useSftpBackend = () => {
return bridge.closeSftp(sftpId);
}, []);
const listSftp = useCallback(async (sftpId: string, path: string) => {
const listSftp = useCallback(async (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => {
const bridge = netcattyBridge.get();
if (!bridge?.listSftp) throw new Error("SFTP bridge unavailable");
return bridge.listSftp(sftpId, path);
return bridge.listSftp(sftpId, path, encoding);
}, []);
const readSftp = useCallback(async (sftpId: string, path: string) => {
const readSftp = useCallback(async (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => {
const bridge = netcattyBridge.get();
if (!bridge?.readSftp) throw new Error("SFTP bridge unavailable");
return bridge.readSftp(sftpId, path);
return bridge.readSftp(sftpId, path, encoding);
}, []);
const readSftpBinary = useCallback(async (sftpId: string, path: string) => {
const readSftpBinary = useCallback(async (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => {
const bridge = netcattyBridge.get();
if (!bridge?.readSftpBinary) throw new Error("readSftpBinary unavailable");
return bridge.readSftpBinary(sftpId, path);
return bridge.readSftpBinary(sftpId, path, encoding);
}, []);
const writeSftp = useCallback(async (sftpId: string, path: string, content: string) => {
const writeSftp = useCallback(async (sftpId: string, path: string, content: string, encoding?: SftpFilenameEncoding) => {
const bridge = netcattyBridge.get();
if (!bridge?.writeSftp) throw new Error("SFTP bridge unavailable");
return bridge.writeSftp(sftpId, path, content);
return bridge.writeSftp(sftpId, path, content, encoding);
}, []);
const writeSftpBinary = useCallback(async (sftpId: string, path: string, content: ArrayBuffer) => {
const writeSftpBinary = useCallback(async (sftpId: string, path: string, content: ArrayBuffer, encoding?: SftpFilenameEncoding) => {
const bridge = netcattyBridge.get();
if (!bridge?.writeSftpBinary) throw new Error("writeSftpBinary unavailable");
return bridge.writeSftpBinary(sftpId, path, content);
return bridge.writeSftpBinary(sftpId, path, content, encoding);
}, []);
const writeSftpBinaryWithProgress = useCallback(
@@ -51,6 +51,7 @@ export const useSftpBackend = () => {
path: string,
content: ArrayBuffer,
transferId: string,
encoding?: SftpFilenameEncoding,
onProgress?: (transferred: number, total: number, speed: number) => void,
onComplete?: () => void,
onError?: (error: string) => void,
@@ -62,6 +63,7 @@ export const useSftpBackend = () => {
path,
content,
transferId,
encoding,
onProgress,
onComplete,
onError,
@@ -70,34 +72,34 @@ export const useSftpBackend = () => {
[],
);
const mkdirSftp = useCallback(async (sftpId: string, path: string) => {
const mkdirSftp = useCallback(async (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => {
const bridge = netcattyBridge.get();
if (!bridge?.mkdirSftp) throw new Error("mkdirSftp unavailable");
return bridge.mkdirSftp(sftpId, path);
return bridge.mkdirSftp(sftpId, path, encoding);
}, []);
const deleteSftp = useCallback(async (sftpId: string, path: string) => {
const deleteSftp = useCallback(async (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => {
const bridge = netcattyBridge.get();
if (!bridge?.deleteSftp) throw new Error("deleteSftp unavailable");
return bridge.deleteSftp(sftpId, path);
return bridge.deleteSftp(sftpId, path, encoding);
}, []);
const renameSftp = useCallback(async (sftpId: string, oldPath: string, newPath: string) => {
const renameSftp = useCallback(async (sftpId: string, oldPath: string, newPath: string, encoding?: SftpFilenameEncoding) => {
const bridge = netcattyBridge.get();
if (!bridge?.renameSftp) throw new Error("renameSftp unavailable");
return bridge.renameSftp(sftpId, oldPath, newPath);
return bridge.renameSftp(sftpId, oldPath, newPath, encoding);
}, []);
const statSftp = useCallback(async (sftpId: string, path: string) => {
const statSftp = useCallback(async (sftpId: string, path: string, encoding?: SftpFilenameEncoding) => {
const bridge = netcattyBridge.get();
if (!bridge?.statSftp) throw new Error("statSftp unavailable");
return bridge.statSftp(sftpId, path);
return bridge.statSftp(sftpId, path, encoding);
}, []);
const chmodSftp = useCallback(async (sftpId: string, path: string, mode: string) => {
const chmodSftp = useCallback(async (sftpId: string, path: string, mode: string, encoding?: SftpFilenameEncoding) => {
const bridge = netcattyBridge.get();
if (!bridge?.chmodSftp) throw new Error("chmodSftp unavailable");
return bridge.chmodSftp(sftpId, path, mode);
return bridge.chmodSftp(sftpId, path, mode, encoding);
}, []);
const listLocalDir = useCallback(async (path: string): Promise<RemoteFile[]> => {
@@ -168,6 +170,12 @@ export const useSftpBackend = () => {
return bridge.cancelTransfer(transferId);
}, []);
const cancelSftpUpload = useCallback(async (transferId: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.cancelSftpUpload) return undefined;
return bridge.cancelSftpUpload(transferId);
}, []);
const onTransferProgress = useCallback((transferId: string, cb: Parameters<NonNullable<NetcattyBridge["onTransferProgress"]>>[1]) => {
const bridge = netcattyBridge.get();
if (!bridge?.onTransferProgress) return undefined;
@@ -180,12 +188,21 @@ export const useSftpBackend = () => {
return bridge.selectApplication();
}, []);
const showSaveDialog = useCallback(async (
defaultPath: string,
filters?: Array<{ name: string; extensions: string[] }>
) => {
const bridge = netcattyBridge.get();
if (!bridge?.showSaveDialog) return null;
return bridge.showSaveDialog(defaultPath, filters);
}, []);
const downloadSftpToTempAndOpen = useCallback(async (
sftpId: string,
remotePath: string,
fileName: string,
appPath: string,
options?: { enableWatch?: boolean }
options?: { enableWatch?: boolean; encoding?: SftpFilenameEncoding }
): Promise<{ localTempPath: string; watchId?: string }> => {
const bridge = netcattyBridge.get();
if (!bridge?.downloadSftpToTemp || !bridge?.openWithApplication) {
@@ -194,7 +211,7 @@ export const useSftpBackend = () => {
// Download the file to temp
console.log("[SFTPBackend] Downloading file to temp", { sftpId, remotePath, fileName });
const tempPath = await bridge.downloadSftpToTemp(sftpId, remotePath, fileName);
const tempPath = await bridge.downloadSftpToTemp(sftpId, remotePath, fileName, options?.encoding);
console.log("[SFTPBackend] File downloaded to temp", { tempPath });
// Register temp file for cleanup when SFTP session closes (regardless of auto-sync setting)
@@ -217,7 +234,7 @@ export const useSftpBackend = () => {
if (options?.enableWatch && bridge.startFileWatch) {
try {
console.log("[SFTPBackend] Starting file watch", { tempPath, remotePath, sftpId });
const result = await bridge.startFileWatch(tempPath, remotePath, sftpId);
const result = await bridge.startFileWatch(tempPath, remotePath, sftpId, options?.encoding);
watchId = result.watchId;
console.log("[SFTPBackend] File watch started successfully", { watchId, tempPath, remotePath });
} catch (err) {
@@ -257,9 +274,10 @@ export const useSftpBackend = () => {
startStreamTransfer,
cancelTransfer,
cancelSftpUpload,
onTransferProgress,
selectApplication,
showSaveDialog,
downloadSftpToTempAndOpen,
};
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,24 @@
import { useEffect, useState } from "react";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
/**
* Hook for persisting a boolean value to localStorage.
* @param storageKey - The key to use for localStorage
* @param fallback - The default value if no stored value exists (defaults to false)
* @returns A tuple of [value, setValue] similar to useState
*/
export const useStoredBoolean = (
storageKey: string,
fallback: boolean = false,
) => {
const [value, setValue] = useState<boolean>(() => {
const stored = localStorageAdapter.readBoolean(storageKey);
return stored ?? fallback;
});
useEffect(() => {
localStorageAdapter.writeBoolean(storageKey, value);
}, [storageKey, value]);
return [value, setValue] as const;
};

View File

@@ -1,10 +1,10 @@
import { useEffect, useState } from "react";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
export type ViewMode = "grid" | "list";
export type ViewMode = "grid" | "list" | "tree";
const isViewMode = (value: string | null): value is ViewMode =>
value === "grid" || value === "list";
value === "grid" || value === "list" || value === "tree";
export const useStoredViewMode = (
storageKey: string,

View File

@@ -122,6 +122,12 @@ export const useTerminalBackend = () => {
return bridge.getSessionPwd(sessionId);
}, []);
const getServerStats = useCallback(async (sessionId: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.getServerStats) return { success: false, error: 'getServerStats unavailable' };
return bridge.getServerStats(sessionId);
}, []);
return {
backendAvailable,
telnetAvailable,
@@ -138,6 +144,7 @@ export const useTerminalBackend = () => {
listSerialPorts,
execCommand,
getSessionPwd,
getServerStats,
writeToSession,
resizeSession,
closeSession,

View File

@@ -0,0 +1,72 @@
import { useCallback } from "react";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
export const useTrayPanelBackend = () => {
const hideTrayPanel = useCallback(async () => {
const bridge = netcattyBridge.get();
await bridge?.hideTrayPanel?.();
}, []);
const openMainWindow = useCallback(async () => {
const bridge = netcattyBridge.get();
await bridge?.openMainWindow?.();
}, []);
const quitApp = useCallback(async () => {
const bridge = netcattyBridge.get();
await bridge?.quitApp?.();
}, []);
const jumpToSession = useCallback(async (sessionId: string) => {
const bridge = netcattyBridge.get();
await bridge?.jumpToSessionFromTrayPanel?.(sessionId);
}, []);
const connectToHostFromTrayPanel = useCallback(async (hostId: string) => {
const bridge = netcattyBridge.get();
await bridge?.connectToHostFromTrayPanel?.(hostId);
}, []);
const onTrayPanelCloseRequest = useCallback((callback: () => void) => {
const bridge = netcattyBridge.get();
return bridge?.onTrayPanelCloseRequest?.(callback);
}, []);
const onTrayPanelRefresh = useCallback((callback: () => void) => {
const bridge = netcattyBridge.get();
return bridge?.onTrayPanelRefresh?.(callback);
}, []);
const onTrayPanelMenuData = useCallback(
(
callback: (data: {
sessions?: Array<{ id: string; label: string; hostLabel: string; status: "connecting" | "connected" | "disconnected"; workspaceId?: string; workspaceTitle?: string }>;
portForwardRules?: Array<{
id: string;
label: string;
type: "local" | "remote" | "dynamic";
localPort: number;
remoteHost?: string;
remotePort?: number;
status: "inactive" | "connecting" | "active" | "error";
hostId?: string;
}>;
}) => void,
) => {
const bridge = netcattyBridge.get();
return bridge?.onTrayPanelMenuData?.(callback);
},
[],
);
return {
hideTrayPanel,
openMainWindow,
quitApp,
jumpToSession,
connectToHostFromTrayPanel,
onTrayPanelCloseRequest,
onTrayPanelRefresh,
onTrayPanelMenuData,
};
};

View File

@@ -0,0 +1,47 @@
import { useEffect, useState } from "react";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
export const useTreeExpandedState = (storageKey: string) => {
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => {
const stored = localStorageAdapter.readString(storageKey);
if (stored) {
try {
const paths = JSON.parse(stored) as string[];
return new Set(paths);
} catch {
return new Set();
}
}
return new Set();
});
useEffect(() => {
const pathsArray = Array.from(expandedPaths);
localStorageAdapter.writeString(storageKey, JSON.stringify(pathsArray));
}, [storageKey, expandedPaths]);
const togglePath = (path: string) => {
const newExpanded = new Set(expandedPaths);
if (newExpanded.has(path)) {
newExpanded.delete(path);
} else {
newExpanded.add(path);
}
setExpandedPaths(newExpanded);
};
const expandAll = (allPaths: string[]) => {
setExpandedPaths(new Set(allPaths));
};
const collapseAll = () => {
setExpandedPaths(new Set());
};
return {
expandedPaths,
togglePath,
expandAll,
collapseAll,
};
};

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { normalizeDistroId, sanitizeHost } from "../../domain/host";
import {
ConnectionLog,
@@ -6,6 +6,7 @@ import {
Identity,
KeyCategory,
KnownHost,
ManagedSource,
ShellHistoryEntry,
Snippet,
SSHKey,
@@ -22,11 +23,20 @@ import {
STORAGE_KEY_KEYS,
STORAGE_KEY_KNOWN_HOSTS,
STORAGE_KEY_LEGACY_KEYS,
STORAGE_KEY_MANAGED_SOURCES,
STORAGE_KEY_SHELL_HISTORY,
STORAGE_KEY_SNIPPET_PACKAGES,
STORAGE_KEY_SNIPPETS,
} from "../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
import {
decryptHosts,
decryptIdentities,
decryptKeys,
encryptHosts,
encryptIdentities,
encryptKeys,
} from "../../infrastructure/persistence/secureFieldAdapter";
type ExportableVaultData = {
hosts: Host[];
@@ -95,21 +105,49 @@ export const useVaultState = () => {
const [knownHosts, setKnownHosts] = useState<KnownHost[]>([]);
const [shellHistory, setShellHistory] = useState<ShellHistoryEntry[]>([]);
const [connectionLogs, setConnectionLogs] = useState<ConnectionLog[]>([]);
const [managedSources, setManagedSources] = useState<ManagedSource[]>([]);
// Write-version counters prevent out-of-order async writes from overwriting
// newer data. Each update bumps the counter; the .then() callback only
// persists if its version still matches the latest.
const hostsWriteVersion = useRef(0);
const keysWriteVersion = useRef(0);
const identitiesWriteVersion = useRef(0);
// Read-sequence counters for cross-window storage events. Each incoming
// event bumps the counter; the async decrypt callback only applies state if
// its sequence still matches, preventing stale decrypts from overwriting
// newer data when multiple events arrive in quick succession.
const hostsReadSeq = useRef(0);
const keysReadSeq = useRef(0);
const identitiesReadSeq = useRef(0);
const updateHosts = useCallback((data: Host[]) => {
const cleaned = data.map(sanitizeHost);
setHosts(cleaned);
localStorageAdapter.write(STORAGE_KEY_HOSTS, cleaned);
const ver = ++hostsWriteVersion.current;
encryptHosts(cleaned).then((enc) => {
if (ver === hostsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
});
}, []);
const updateKeys = useCallback((data: SSHKey[]) => {
setKeys(data);
localStorageAdapter.write(STORAGE_KEY_KEYS, data);
const ver = ++keysWriteVersion.current;
encryptKeys(data).then((enc) => {
if (ver === keysWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
});
}, []);
const updateIdentities = useCallback((data: Identity[]) => {
setIdentities(data);
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, data);
const ver = ++identitiesWriteVersion.current;
encryptIdentities(data).then((enc) => {
if (ver === identitiesWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, enc);
});
}, []);
const updateSnippets = useCallback((data: Snippet[]) => {
@@ -132,6 +170,11 @@ export const useVaultState = () => {
localStorageAdapter.write(STORAGE_KEY_KNOWN_HOSTS, data);
}, []);
const updateManagedSources = useCallback((data: ManagedSource[]) => {
setManagedSources(data);
localStorageAdapter.write(STORAGE_KEY_MANAGED_SOURCES, data);
}, []);
const clearVaultData = useCallback(() => {
updateHosts([]);
updateKeys([]);
@@ -140,6 +183,7 @@ export const useVaultState = () => {
updateSnippetPackages([]);
updateCustomGroups([]);
updateKnownHosts([]);
updateManagedSources([]);
localStorageAdapter.remove(STORAGE_KEY_LEGACY_KEYS);
}, [
updateHosts,
@@ -149,6 +193,7 @@ export const useVaultState = () => {
updateSnippetPackages,
updateCustomGroups,
updateKnownHosts,
updateManagedSources,
]);
const addShellHistoryEntry = useCallback(
@@ -261,7 +306,11 @@ export const useVaultState = () => {
// Add to hosts using functional update
setHosts((prevHosts) => {
const updated = [...prevHosts, sanitizeHost(newHost)];
localStorageAdapter.write(STORAGE_KEY_HOSTS, updated);
const ver = ++hostsWriteVersion.current;
encryptHosts(updated).then((enc) => {
if (ver === hostsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
});
return updated;
});
@@ -269,76 +318,120 @@ export const useVaultState = () => {
}, []);
useEffect(() => {
const savedHosts = localStorageAdapter.read<Host[]>(STORAGE_KEY_HOSTS);
const savedKeysRaw = localStorageAdapter.read<unknown[]>(STORAGE_KEY_KEYS);
const savedIdentities =
localStorageAdapter.read<Identity[]>(STORAGE_KEY_IDENTITIES);
const savedGroups = localStorageAdapter.read<string[]>(STORAGE_KEY_GROUPS);
const savedSnippets =
localStorageAdapter.read<Snippet[]>(STORAGE_KEY_SNIPPETS);
const savedSnippetPackages = localStorageAdapter.read<string[]>(
STORAGE_KEY_SNIPPET_PACKAGES,
);
const init = async () => {
const savedHosts = localStorageAdapter.read<Host[]>(STORAGE_KEY_HOSTS);
if (savedHosts) {
const sanitized = savedHosts.map(sanitizeHost);
setHosts(sanitized);
localStorageAdapter.write(STORAGE_KEY_HOSTS, sanitized);
} else {
updateHosts(INITIAL_HOSTS);
}
if (savedHosts) {
// Capture version before the async gap so that any write occurring
// during decryption (storage event, user edit) advances the counter
// and causes this stale result to be discarded.
const ver = ++hostsWriteVersion.current;
const decrypted = await decryptHosts(savedHosts);
if (ver === hostsWriteVersion.current) {
const sanitized = decrypted.map(sanitizeHost);
setHosts(sanitized);
encryptHosts(sanitized).then((enc) => {
if (ver === hostsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
});
}
} else {
updateHosts(INITIAL_HOSTS);
}
// Migrate old keys to new format with source/category fields
if (savedKeysRaw?.length) {
const migratedKeys: SSHKey[] = [];
const legacyKeys: LegacyKeyRecord[] = [];
// Read keys fresh here (not before the hosts await) so we don't apply
// a stale snapshot if keys were updated during host decryption.
const savedKeysRaw = localStorageAdapter.read<unknown[]>(STORAGE_KEY_KEYS);
for (const entry of savedKeysRaw) {
const record =
entry && typeof entry === "object" ? (entry as LegacyKeyRecord) : null;
if (!record) continue;
// Migrate old keys to new format with source/category fields
if (savedKeysRaw?.length) {
const migratedKeys: SSHKey[] = [];
const legacyKeys: LegacyKeyRecord[] = [];
if (isLegacyUnsupportedKey(record)) {
legacyKeys.push(record);
continue;
for (const entry of savedKeysRaw) {
const record =
entry && typeof entry === "object" ? (entry as LegacyKeyRecord) : null;
if (!record) continue;
if (isLegacyUnsupportedKey(record)) {
legacyKeys.push(record);
continue;
}
migratedKeys.push(migrateKey(record as Partial<SSHKey>));
}
migratedKeys.push(migrateKey(record as Partial<SSHKey>));
// Decrypt sensitive fields (passphrase, privateKey)
const keyVer = ++keysWriteVersion.current;
const decryptedKeys = await decryptKeys(migratedKeys);
if (keyVer === keysWriteVersion.current) {
setKeys(decryptedKeys);
encryptKeys(decryptedKeys).then((enc) => {
if (keyVer === keysWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
});
}
if (legacyKeys.length) {
localStorageAdapter.write(STORAGE_KEY_LEGACY_KEYS, legacyKeys);
}
}
setKeys(migratedKeys);
// Persist migrated keys
localStorageAdapter.write(STORAGE_KEY_KEYS, migratedKeys);
if (legacyKeys.length) {
localStorageAdapter.write(STORAGE_KEY_LEGACY_KEYS, legacyKeys);
// Read identities fresh here (not before the hosts/keys awaits) so we
// don't apply a stale snapshot if identities were updated during prior decryption.
const savedIdentities =
localStorageAdapter.read<Identity[]>(STORAGE_KEY_IDENTITIES);
if (savedIdentities) {
const idVer = ++identitiesWriteVersion.current;
const decryptedIds = await decryptIdentities(savedIdentities);
if (idVer === identitiesWriteVersion.current) {
setIdentities(decryptedIds);
encryptIdentities(decryptedIds).then((enc) => {
if (idVer === identitiesWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, enc);
});
}
}
}
if (savedIdentities) setIdentities(savedIdentities);
// Read remaining non-encrypted data fresh after all async gaps above
const savedGroups = localStorageAdapter.read<string[]>(STORAGE_KEY_GROUPS);
const savedSnippets =
localStorageAdapter.read<Snippet[]>(STORAGE_KEY_SNIPPETS);
const savedSnippetPackages = localStorageAdapter.read<string[]>(
STORAGE_KEY_SNIPPET_PACKAGES,
);
if (savedSnippets) setSnippets(savedSnippets);
else updateSnippets(INITIAL_SNIPPETS);
if (savedSnippets) setSnippets(savedSnippets);
else updateSnippets(INITIAL_SNIPPETS);
if (savedGroups) setCustomGroups(savedGroups);
if (savedSnippetPackages) setSnippetPackages(savedSnippetPackages);
if (savedGroups) setCustomGroups(savedGroups);
if (savedSnippetPackages) setSnippetPackages(savedSnippetPackages);
// Load known hosts
const savedKnownHosts = localStorageAdapter.read<KnownHost[]>(
STORAGE_KEY_KNOWN_HOSTS,
);
if (savedKnownHosts) setKnownHosts(savedKnownHosts);
// Load known hosts
const savedKnownHosts = localStorageAdapter.read<KnownHost[]>(
STORAGE_KEY_KNOWN_HOSTS,
);
if (savedKnownHosts) setKnownHosts(savedKnownHosts);
// Load shell history
const savedShellHistory = localStorageAdapter.read<ShellHistoryEntry[]>(
STORAGE_KEY_SHELL_HISTORY,
);
if (savedShellHistory) setShellHistory(savedShellHistory);
// Load shell history
const savedShellHistory = localStorageAdapter.read<ShellHistoryEntry[]>(
STORAGE_KEY_SHELL_HISTORY,
);
if (savedShellHistory) setShellHistory(savedShellHistory);
// Load connection logs
const savedConnectionLogs = localStorageAdapter.read<ConnectionLog[]>(
STORAGE_KEY_CONNECTION_LOGS,
);
if (savedConnectionLogs) setConnectionLogs(savedConnectionLogs);
// Load connection logs
const savedConnectionLogs = localStorageAdapter.read<ConnectionLog[]>(
STORAGE_KEY_CONNECTION_LOGS,
);
if (savedConnectionLogs) setConnectionLogs(savedConnectionLogs);
// Load managed sources
const savedManagedSources = localStorageAdapter.read<ManagedSource[]>(
STORAGE_KEY_MANAGED_SOURCES,
);
if (savedManagedSources) setManagedSources(savedManagedSources);
};
init();
}, [updateHosts, updateSnippets]);
useEffect(() => {
@@ -351,7 +444,17 @@ export const useVaultState = () => {
if (key === STORAGE_KEY_HOSTS) {
const next = safeParse<Host[]>(event.newValue) ?? [];
setHosts(next.map(sanitizeHost));
// Bump write version to invalidate any in-flight encrypt from this
// window — the cross-window data is newer and must not be overwritten.
++hostsWriteVersion.current;
const seq = ++hostsReadSeq.current;
const writeAtStart = hostsWriteVersion.current;
decryptHosts(next).then((dec) => {
// Discard if a newer storage event arrived OR a local write occurred
// during the decrypt (writeVersion would have advanced).
if (seq === hostsReadSeq.current && writeAtStart === hostsWriteVersion.current)
setHosts(dec.map(sanitizeHost));
});
return;
}
@@ -364,13 +467,25 @@ export const useVaultState = () => {
if (!record || isLegacyUnsupportedKey(record)) continue;
migratedKeys.push(migrateKey(record as Partial<SSHKey>));
}
setKeys(migratedKeys);
++keysWriteVersion.current;
const seq = ++keysReadSeq.current;
const writeAtStart = keysWriteVersion.current;
decryptKeys(migratedKeys).then((dec) => {
if (seq === keysReadSeq.current && writeAtStart === keysWriteVersion.current)
setKeys(dec);
});
return;
}
if (key === STORAGE_KEY_IDENTITIES) {
const next = safeParse<Identity[]>(event.newValue) ?? [];
setIdentities(next);
++identitiesWriteVersion.current;
const seq = ++identitiesReadSeq.current;
const writeAtStart = identitiesWriteVersion.current;
decryptIdentities(next).then((dec) => {
if (seq === identitiesReadSeq.current && writeAtStart === identitiesWriteVersion.current)
setIdentities(dec);
});
return;
}
@@ -407,6 +522,12 @@ export const useVaultState = () => {
if (key === STORAGE_KEY_CONNECTION_LOGS) {
const next = safeParse<ConnectionLog[]>(event.newValue) ?? [];
setConnectionLogs(next);
return;
}
if (key === STORAGE_KEY_MANAGED_SOURCES) {
const next = safeParse<ManagedSource[]>(event.newValue) ?? [];
setManagedSources(next);
}
};
@@ -420,7 +541,11 @@ export const useVaultState = () => {
const next = prev.map((h) =>
h.id === hostId ? { ...h, distro: normalized } : h,
);
localStorageAdapter.write(STORAGE_KEY_HOSTS, next);
const ver = ++hostsWriteVersion.current;
encryptHosts(next).then((enc) => {
if (ver === hostsWriteVersion.current)
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
});
return next;
});
}, []);
@@ -474,6 +599,7 @@ export const useVaultState = () => {
knownHosts,
shellHistory,
connectionLogs,
managedSources,
updateHosts,
updateKeys,
updateIdentities,
@@ -481,6 +607,7 @@ export const useVaultState = () => {
updateSnippetPackages,
updateCustomGroups,
updateKnownHosts,
updateManagedSources,
addShellHistoryEntry,
clearShellHistory,
addConnectionLog,

View File

@@ -32,6 +32,9 @@ import {
} from 'lucide-react';
import { useCloudSync } from '../application/state/useCloudSync';
import { useI18n } from '../application/i18n/I18nProvider';
import {
findSyncPayloadEncryptedCredentialPaths,
} from '../domain/credentials';
import type { CloudProvider, ConflictInfo, SyncPayload, WebDAVAuthType, WebDAVConfig, S3Config } from '../domain/sync';
import { cn } from '../lib/utils';
import { Button } from './ui/button';
@@ -482,7 +485,7 @@ const GitHubDeviceFlowModal: React.FC<GitHubDeviceFlowModalProps> = ({
</div>
<Button
onClick={() => window.open(verificationUri, '_blank')}
onClick={() => window.open(verificationUri, "_blank", "noopener,noreferrer")}
className="w-full gap-2 mb-4"
>
<ExternalLink size={14} />
@@ -751,6 +754,17 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
// Clear local data dialog
const [showClearLocalDialog, setShowClearLocalDialog] = useState(false);
const ensureSyncablePayload = useCallback(
(payload: SyncPayload): boolean => {
const encryptedCredentialPaths = findSyncPayloadEncryptedCredentialPaths(payload);
if (encryptedCredentialPaths.length === 0) return true;
toast.error(t('sync.credentialsUnavailable'), t('sync.toast.errorTitle'));
return false;
},
[t],
);
// Handle conflict detection
useEffect(() => {
if (sync.currentConflict) {
@@ -958,6 +972,7 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
const handleSync = async (provider: CloudProvider) => {
try {
const payload = onBuildPayload();
if (!ensureSyncablePayload(payload)) return;
const result = await sync.syncToProvider(provider, payload);
if (result.success) {
@@ -982,6 +997,7 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
} else if (resolution === 'USE_LOCAL') {
// Re-sync with local data
const localPayload = onBuildPayload();
if (!ensureSyncablePayload(localPayload)) return;
await sync.syncNow(localPayload);
toast.success(t('cloudSync.resolve.uploaded'));
}
@@ -1556,6 +1572,16 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
return;
}
let payloadForReencrypt: SyncPayload | null = null;
if (sync.hasAnyConnectedProvider) {
const payload = onBuildPayload();
if (!ensureSyncablePayload(payload)) {
setChangeKeyError(t('sync.credentialsUnavailable'));
return;
}
payloadForReencrypt = payload;
}
setIsChangingKey(true);
try {
const ok = await sync.changeMasterKey(currentMasterKey, newMasterKey);
@@ -1564,9 +1590,8 @@ export const SyncDashboard: React.FC<SyncDashboardProps> = ({
return;
}
if (sync.hasAnyConnectedProvider) {
const payload = onBuildPayload();
await sync.syncNow(payload);
if (payloadForReencrypt) {
await sync.syncNow(payloadForReencrypt);
}
toast.success(t('cloudSync.changeKey.updatedToast'));

View File

@@ -7,7 +7,7 @@ import {
Usb,
User,
} from "lucide-react";
import React, { memo, useCallback, useMemo } from "react";
import React, { memo, useCallback, useMemo, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { cn } from "../lib/utils";
import { ConnectionLog, Host } from "../types";
@@ -149,7 +149,11 @@ const ConnectionLogsManager: React.FC<ConnectionLogsManagerProps> = ({
onOpenLogView,
}) => {
const { t } = useI18n();
const RENDER_LIMIT = 100;
const INITIAL_RENDER_LIMIT = 30;
const LOAD_MORE_COUNT = 30;
// Track how many items to show
const [renderLimit, setRenderLimit] = useState(INITIAL_RENDER_LIMIT);
// Sort logs by newest first
const filteredLogs = useMemo(() => {
@@ -157,10 +161,14 @@ const ConnectionLogsManager: React.FC<ConnectionLogsManagerProps> = ({
}, [logs]);
const displayedLogs = useMemo(() => {
return filteredLogs.slice(0, RENDER_LIMIT);
}, [filteredLogs]);
return filteredLogs.slice(0, renderLimit);
}, [filteredLogs, renderLimit]);
const hasMore = filteredLogs.length > RENDER_LIMIT;
const hasMore = filteredLogs.length > renderLimit;
const handleLoadMore = useCallback(() => {
setRenderLimit(prev => prev + LOAD_MORE_COUNT);
}, []);
const handleToggleSaved = useCallback(
(id: string) => onToggleSaved(id),
@@ -222,9 +230,12 @@ const ConnectionLogsManager: React.FC<ConnectionLogsManagerProps> = ({
<>
{renderedItems}
{hasMore && (
<div className="text-center py-4 text-sm text-muted-foreground">
{t("logs.showing", { limit: RENDER_LIMIT, total: filteredLogs.length })}
</div>
<button
onClick={handleLoadMore}
className="w-full py-3 text-sm text-primary hover:bg-secondary/50 transition-colors"
>
{t("logs.loadMore", { count: Math.min(LOAD_MORE_COUNT, filteredLogs.length - renderLimit) })}
</button>
)}
</>
)}

View File

@@ -0,0 +1,143 @@
import { Search } from 'lucide-react';
import React, { useMemo, useState, useEffect } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { Host } from '../types';
import { DistroAvatar } from './DistroAvatar';
import { Button } from './ui/button';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { ScrollArea } from './ui/scroll-area';
interface CreateWorkspaceDialogProps {
isOpen: boolean;
onClose: () => void;
hosts: Host[];
onCreate: (name: string, selectedHosts: Host[]) => void;
}
export const CreateWorkspaceDialog: React.FC<CreateWorkspaceDialogProps> = ({
isOpen,
onClose,
hosts,
onCreate,
}) => {
const { t } = useI18n();
const [name, setName] = useState('');
const [search, setSearch] = useState('');
const [selectedHostIds, setSelectedHostIds] = useState<Set<string>>(new Set());
const filteredHosts = useMemo(() => {
if (!search.trim()) return hosts;
const term = search.toLowerCase();
return hosts.filter(h =>
h.label.toLowerCase().includes(term) ||
h.hostname.toLowerCase().includes(term) ||
(h.group || '').toLowerCase().includes(term)
);
}, [hosts, search]);
const toggleHost = (hostId: string) => {
setSelectedHostIds(prev => {
const next = new Set(prev);
if (next.has(hostId)) {
next.delete(hostId);
} else {
next.add(hostId);
}
return next;
});
};
const handleCreate = () => {
const selected = hosts.filter(h => selectedHostIds.has(h.id));
onCreate(name, selected);
onClose();
};
useEffect(() => {
if (isOpen) {
setName('');
setSearch('');
setSelectedHostIds(new Set());
}
}, [isOpen]);
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-md flex flex-col max-h-[80vh]">
<DialogHeader>
<DialogTitle>{t('dialog.createWorkspace.title', 'Create Workspace')}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2 flex-1 flex flex-col min-h-0">
<div className="space-y-2">
<Label htmlFor="workspace-name">{t('field.name', 'Name')}</Label>
<Input
id="workspace-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('placeholder.workspaceName', 'Workspace Name')}
autoFocus
/>
</div>
<div className="space-y-2 flex-1 flex flex-col min-h-0">
<Label>{t('field.selectHosts', 'Select Hosts')}</Label>
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t('placeholder.searchHosts', 'Search hosts...')}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8"
/>
</div>
<div className="border rounded-md flex-1 min-h-[200px]">
<ScrollArea className="h-full max-h-[300px]">
<div className="p-2 space-y-1">
{filteredHosts.length === 0 ? (
<div className="text-center py-4 text-sm text-muted-foreground">
{t('common.noResults', 'No hosts found')}
</div>
) : (
filteredHosts.map(host => {
const isSelected = selectedHostIds.has(host.id);
return (
<div
key={host.id}
className={`flex items-center gap-3 p-2 rounded-md cursor-pointer hover:bg-muted/50 ${isSelected ? 'bg-primary/10' : ''}`}
onClick={() => toggleHost(host.id)}
>
<div className={`h-4 w-4 border rounded flex items-center justify-center ${isSelected ? 'bg-primary border-primary' : 'border-muted-foreground'}`}>
{isSelected && <div className="h-2 w-2 bg-primary-foreground rounded-sm" />}
</div>
<DistroAvatar host={host} size="sm" fallback={host.label.slice(0, 2).toUpperCase()} />
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{host.label}</div>
<div className="text-xs text-muted-foreground truncate">{host.hostname}</div>
</div>
</div>
);
})
)}
</div>
</ScrollArea>
</div>
<div className="text-xs text-muted-foreground text-right">
{selectedHostIds.size} {t('common.selected', 'selected')}
</div>
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose}>{t('common.cancel', 'Cancel')}</Button>
<Button onClick={handleCreate} disabled={!name.trim() || selectedHostIds.size === 0}>
{t('common.create', 'Create')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -16,6 +16,7 @@ interface GroupTreeItemProps {
onEditGroup: (path: string) => void;
onNewHost: (path: string) => void;
onNewSubfolder: (path: string) => void;
isManagedGroup?: (path: string) => boolean;
}
export const GroupTreeItem: React.FC<GroupTreeItemProps> = ({

View File

@@ -2,6 +2,8 @@ import {
AlertTriangle,
Check,
ChevronDown,
Eye,
EyeOff,
FolderLock,
FolderPlus,
Forward,
@@ -14,6 +16,7 @@ import {
Plus,
Settings2,
Shield,
ShieldAlert,
Tag,
TerminalSquare,
User,
@@ -24,10 +27,10 @@ import {
import React, { useEffect, useMemo, useState, useCallback } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { useApplicationBackend } from "../application/state/useApplicationBackend";
import { TERMINAL_THEMES } from "../infrastructure/config/terminalThemes";
import { customThemeStore } from "../application/state/customThemeStore";
import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "../infrastructure/config/fonts";
import { cn } from "../lib/utils";
import { EnvVar, Host, Identity, ProxyConfig, SSHKey } from "../types";
import { EnvVar, Host, Identity, ManagedSource, ProxyConfig, SSHKey } from "../types";
import { DistroAvatar } from "./DistroAvatar";
import ThemeSelectPanel from "./ThemeSelectPanel";
import {
@@ -36,13 +39,16 @@ import {
AsidePanelFooter,
} from "./ui/aside-panel";
import { Badge } from "./ui/badge";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
import { Button } from "./ui/button";
import { Switch } from "./ui/switch";
import { Card } from "./ui/card";
import { Combobox, ComboboxOption, MultiCombobox } from "./ui/combobox";
import { Input } from "./ui/input";
import { Textarea } from "./ui/textarea";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { ScrollArea } from "./ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
// Import host-details sub-panels
import {
@@ -67,6 +73,7 @@ interface HostDetailsPanelProps {
availableKeys: SSHKey[];
identities: Identity[];
groups: string[];
managedSources?: ManagedSource[];
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)
@@ -81,6 +88,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
availableKeys,
identities,
groups,
managedSources = [],
allTags = [],
allHosts = [],
defaultGroup,
@@ -122,6 +130,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
// Identity suggestion dropdown state (popover anchored to username input)
const [identitySuggestionsOpen, setIdentitySuggestionsOpen] = useState(false);
// Password visibility state
const [showPassword, setShowPassword] = useState(false);
// New group creation state
const [newGroupName, setNewGroupName] = useState("");
const [newGroupParent, setNewGroupParent] = useState("");
@@ -163,6 +174,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
}
setForm(updatedData);
setGroupInputValue(initialData.group || "");
// Reset password visibility when host changes for privacy
setShowPassword(false);
}
}, [initialData]);
@@ -243,12 +256,51 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
};
const handleSubmit = () => {
if (!form.hostname || !form.label) return;
if (!form.hostname) return;
// If label is empty, use hostname as label
let finalLabel = form.label?.trim() || form.hostname;
const finalGroup = groupInputValue.trim() || form.group || "";
// Find the most specific (deepest) managed source that matches the group path
// This handles nested managed groups correctly by preferring exact matches
// and longer paths over shorter prefix matches
const targetManagedSource = managedSources
.filter(s => finalGroup === s.groupName || finalGroup.startsWith(s.groupName + "/"))
.sort((a, b) => b.groupName.length - a.groupName.length)[0];
// Only SSH hosts can be managed (SSH config only supports SSH protocol)
const canBeManaged = !form.protocol || form.protocol === "ssh";
// Strip spaces from label only if host can be managed and is in a managed group
// (SSH config requires no spaces in Host alias)
if (targetManagedSource && canBeManaged) {
finalLabel = finalLabel.replace(/\s/g, '');
}
// Determine managedSourceId:
// - Only SSH hosts can be managed (SSH config only supports SSH protocol)
// - If we found a matching managed source, use its id
// - If managedSources was not provided (empty array) and host already has managedSourceId, preserve it
// - Otherwise, clear it (host is not in a managed group)
let finalManagedSourceId: string | undefined;
if (targetManagedSource && canBeManaged) {
finalManagedSourceId = targetManagedSource.id;
} else if (managedSources.length === 0 && form.managedSourceId && canBeManaged) {
// managedSources not provided, preserve existing value
finalManagedSourceId = form.managedSourceId;
} else {
finalManagedSourceId = undefined;
}
const cleaned: Host = {
...form,
group: groupInputValue.trim() || form.group,
label: finalLabel,
group: finalGroup,
tags: form.tags || [],
port: form.port || 22,
// Clear password if savePassword is explicitly set to false
password: form.savePassword === false ? undefined : form.password,
managedSourceId: finalManagedSourceId,
};
onSave(cleaned);
};
@@ -498,7 +550,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
size="icon"
className="h-8 w-8"
onClick={handleSubmit}
disabled={!form.hostname || !form.label}
disabled={!form.hostname}
aria-label={t("hostDetails.saveAria")}
>
<Check size={16} />
@@ -506,32 +558,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
}
>
<AsidePanelContent>
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center gap-2">
<MapPin size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">
{t("hostDetails.section.address")}
</p>
</div>
<div className="flex items-center gap-2">
<DistroAvatar
host={form as Host}
fallback={
form.label?.slice(0, 2).toUpperCase() ||
form.hostname?.slice(0, 2).toUpperCase() ||
"H"
}
className="h-10 w-10"
/>
<Input
placeholder={t("hostDetails.hostname.placeholder")}
value={form.hostname}
onChange={(e) => update("hostname", e.target.value)}
className="h-10 flex-1"
/>
</div>
</Card>
<Card className="p-3 space-y-3 bg-card border-border/80">
<div className="flex items-center gap-2">
<Settings2 size={14} className="text-muted-foreground" />
@@ -542,7 +568,21 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
<Input
placeholder={t("hostDetails.label.placeholder")}
value={form.label}
onChange={(e) => update("label", e.target.value)}
onChange={(e) => {
let value = e.target.value;
// Only strip spaces if the TARGET group belongs to a managed source
// (don't use form.managedSourceId as it reflects old state before group change)
const targetGroup = groupInputValue.trim() || form.group || "";
const willBeManaged = managedSources.some(s =>
targetGroup === s.groupName || targetGroup.startsWith(s.groupName + "/")
);
// Also check protocol - only SSH hosts can be managed
const canBeManaged = !form.protocol || form.protocol === "ssh";
if (willBeManaged && canBeManaged) {
value = value.replace(/\s/g, '');
}
update("label", value);
}}
className="h-10"
/>
@@ -588,6 +628,32 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</div>
</Card>
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center gap-2">
<MapPin size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">
{t("hostDetails.section.address")}
</p>
</div>
<div className="flex items-center gap-2">
<DistroAvatar
host={form as Host}
fallback={
form.label?.slice(0, 2).toUpperCase() ||
form.hostname?.slice(0, 2).toUpperCase() ||
"H"
}
className="h-10 w-10"
/>
<Input
placeholder={t("hostDetails.hostname.placeholder")}
value={form.hostname}
onChange={(e) => update("hostname", e.target.value)}
className="h-10 flex-1"
/>
</div>
</Card>
<Card className="p-3 space-y-3 bg-card border-border/80">
<div className="flex items-center gap-2">
<KeyRound size={14} className="text-muted-foreground" />
@@ -797,13 +863,36 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
)}
{!selectedIdentity && !form.identityId && (
<Input
placeholder={t("hostDetails.password.placeholder")}
type="password"
value={form.password || ""}
onChange={(e) => update("password", e.target.value)}
className="h-10"
/>
<div className="relative">
<Input
placeholder={t("hostDetails.password.placeholder")}
type={showPassword ? "text" : "password"}
value={form.password || ""}
onChange={(e) => update("password", e.target.value)}
className="h-10 pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground transition-colors"
title={showPassword ? t("hostDetails.password.hide") : t("hostDetails.password.show")}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
)}
{/* Save Password toggle - shown when password is entered */}
{!selectedIdentity && !form.identityId && form.password && (
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground">
{t("hostDetails.password.save")}
</span>
<Switch
checked={form.savePassword ?? true}
onCheckedChange={(val) => update("savePassword" as keyof Host, val)}
/>
</div>
)}
{/* Selected credential display */}
@@ -987,6 +1076,27 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
{t("hostDetails.sftp.sudo.passwordWarning")}
</p>
)}
<div className="space-y-1">
<div className="text-sm font-medium">
{t("hostDetails.sftp.encoding")}
</div>
<div className="text-xs text-muted-foreground">
{t("hostDetails.sftp.encoding.desc")}
</div>
<Select
value={form.sftpEncoding || "auto"}
onValueChange={(val) => update("sftpEncoding", val as Host["sftpEncoding"])}
>
<SelectTrigger className="h-8">
<SelectValue placeholder={t("sftp.encoding.label")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{t("sftp.encoding.auto")}</SelectItem>
<SelectItem value="utf-8">{t("sftp.encoding.utf8")}</SelectItem>
<SelectItem value="gb18030">{t("sftp.encoding.gb18030")}</SelectItem>
</SelectContent>
</Select>
</div>
</Card>
<Card className="p-3 space-y-3 bg-card border-border/80">
@@ -1007,21 +1117,15 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
className="w-12 h-8 rounded-md border border-border/60 flex items-center justify-center text-[6px] font-mono overflow-hidden"
style={{
backgroundColor:
TERMINAL_THEMES.find(
(t) => t.id === (form.theme || "flexoki-dark"),
)?.colors.background || "#100F0F",
customThemeStore.getThemeById(form.theme || "flexoki-dark")?.colors.background || "#100F0F",
color:
TERMINAL_THEMES.find(
(t) => t.id === (form.theme || "flexoki-dark"),
)?.colors.foreground || "#CECDC3",
customThemeStore.getThemeById(form.theme || "flexoki-dark")?.colors.foreground || "#CECDC3",
}}
>
<div className="p-0.5">
<div
style={{
color: TERMINAL_THEMES.find(
(t) => t.id === (form.theme || "flexoki-dark"),
)?.colors.green,
color: customThemeStore.getThemeById(form.theme || "flexoki-dark")?.colors.green,
}}
>
$
@@ -1029,9 +1133,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</div>
</div>
<span className="text-sm flex-1">
{TERMINAL_THEMES.find(
(t) => t.id === (form.theme || "flexoki-dark"),
)?.name || "Flexoki Dark"}
{customThemeStore.getThemeById(form.theme || "flexoki-dark")?.name || "Flexoki Dark"}
</span>
</button>
@@ -1122,6 +1224,30 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
)}
</Card>
{/* Legacy Algorithms */}
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center gap-2">
<ShieldAlert size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t("hostDetails.section.legacyAlgorithms")}</p>
</div>
<ToggleRow
label={t("hostDetails.legacyAlgorithms")}
enabled={!!form.legacyAlgorithms}
onToggle={() => update("legacyAlgorithms", !form.legacyAlgorithms)}
/>
<p className="text-xs text-muted-foreground break-words">
{t("hostDetails.legacyAlgorithms.desc")}
</p>
{form.legacyAlgorithms && (
<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.legacyAlgorithms.warning")}
</p>
</div>
)}
</Card>
{/* Proxy via Hosts (Jump Hosts / ProxyJump) */}
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center justify-between">
@@ -1198,36 +1324,50 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</Card>
{/* Proxy Configuration */}
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Globe size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t("hostDetails.proxy")}</p>
</div>
{form.proxyConfig?.host ? (
<Badge variant="secondary" className="text-xs">
{form.proxyConfig.type?.toUpperCase()} {form.proxyConfig.host}:
{form.proxyConfig.port}
</Badge>
) : (
<Badge
variant="outline"
className="text-xs text-muted-foreground"
>
{t("hostDetails.proxy.none")}
</Badge>
)}
<Card className="p-3 space-y-2 bg-card border-border/80 overflow-hidden">
<div className="flex items-center gap-2">
<Globe size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t("hostDetails.proxy")}</p>
</div>
<Button
variant="ghost"
className="w-full h-9 justify-start gap-2 text-sm"
onClick={() => setActiveSubPanel("proxy")}
>
<Plus size={14} />
{form.proxyConfig?.host
? t("hostDetails.proxy.edit")
: t("hostDetails.proxy.configure")}
</Button>
{form.proxyConfig?.host ? (
<button
className="w-full min-w-0 grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 p-2 rounded-md bg-secondary/50 hover:bg-secondary transition-colors cursor-pointer overflow-hidden"
onClick={() => setActiveSubPanel("proxy")}
>
<Badge variant="secondary" className="text-xs shrink-0">
{form.proxyConfig.type?.toUpperCase()}
</Badge>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm">
{form.proxyConfig.host}:{form.proxyConfig.port}
</span>
</TooltipTrigger>
<TooltipContent side="bottom" align="start" className="max-w-xs break-all">
{form.proxyConfig.type?.toUpperCase()} {form.proxyConfig.host}:{form.proxyConfig.port}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<X
size={14}
className="text-muted-foreground hover:text-destructive flex-shrink-0"
onClick={(e) => {
e.stopPropagation();
clearProxyConfig();
}}
/>
</button>
) : (
<Button
variant="ghost"
className="w-full h-9 justify-start gap-2 text-sm"
onClick={() => setActiveSubPanel("proxy")}
>
<Plus size={14} />
{t("hostDetails.proxy.configure")}
</Button>
)}
</Card>
{/* Environment Variables */}
@@ -1277,11 +1417,12 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
<TerminalSquare size={14} className="text-muted-foreground" />
<p className="text-xs font-semibold">{t("hostDetails.startupCommand")}</p>
</div>
<Input
<Textarea
placeholder={t("hostDetails.startupCommand.placeholder")}
value={form.startupCommand || ""}
onChange={(e) => update("startupCommand", e.target.value)}
className="h-9"
className="min-h-[80px] font-mono text-sm"
rows={3}
/>
<p className="text-xs text-muted-foreground">
{t("hostDetails.startupCommand.help")}
@@ -1357,35 +1498,20 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
className="w-12 h-8 rounded-md border border-border/60 flex items-center justify-center text-[6px] font-mono overflow-hidden"
style={{
backgroundColor:
TERMINAL_THEMES.find(
(t) =>
t.id ===
(form.protocols?.find((p) => p.protocol === "telnet")
?.theme ||
form.theme ||
"flexoki-dark"),
customThemeStore.getThemeById(
form.protocols?.find((p) => p.protocol === "telnet")?.theme || form.theme || "flexoki-dark"
)?.colors.background || "#100F0F",
color:
TERMINAL_THEMES.find(
(t) =>
t.id ===
(form.protocols?.find((p) => p.protocol === "telnet")
?.theme ||
form.theme ||
"flexoki-dark"),
customThemeStore.getThemeById(
form.protocols?.find((p) => p.protocol === "telnet")?.theme || form.theme || "flexoki-dark"
)?.colors.foreground || "#CECDC3",
}}
>
<div className="p-0.5">
<div
style={{
color: TERMINAL_THEMES.find(
(t) =>
t.id ===
(form.protocols?.find((p) => p.protocol === "telnet")
?.theme ||
form.theme ||
"flexoki-dark"),
color: customThemeStore.getThemeById(
form.protocols?.find((p) => p.protocol === "telnet")?.theme || form.theme || "flexoki-dark"
)?.colors.green,
}}
>
@@ -1394,13 +1520,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
</div>
</div>
<span className="text-sm flex-1">
{TERMINAL_THEMES.find(
(t) =>
t.id ===
(form.protocols?.find((p) => p.protocol === "telnet")
?.theme ||
form.theme ||
"flexoki-dark"),
{customThemeStore.getThemeById(
form.protocols?.find((p) => p.protocol === "telnet")?.theme || form.theme || "flexoki-dark"
)?.name || "Flexoki Dark"}
</span>
</button>
@@ -1423,7 +1544,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
<Button
className="w-full h-10"
onClick={handleSubmit}
disabled={!form.hostname || !form.label}
disabled={!form.hostname}
>
{t("common.save")}
</Button>

View File

@@ -280,6 +280,29 @@ const HostForm: React.FC<HostFormProps> = ({
}
/>
</div>
<div className="space-y-1">
<Label htmlFor="sftp-encoding">
{t("hostDetails.sftp.encoding")}
</Label>
<Select
value={formData.sftpEncoding || "auto"}
onValueChange={(val) =>
setFormData({ ...formData, sftpEncoding: val as Host["sftpEncoding"] })
}
>
<SelectTrigger id="sftp-encoding">
<SelectValue placeholder={t("sftp.encoding.label")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{t("sftp.encoding.auto")}</SelectItem>
<SelectItem value="utf-8">{t("sftp.encoding.utf8")}</SelectItem>
<SelectItem value="gb18030">{t("sftp.encoding.gb18030")}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("hostDetails.sftp.encoding.desc")}
</p>
</div>
<Label>{t("hostForm.auth.method")}</Label>
<div className="grid grid-cols-2 gap-4">

553
components/HostTreeView.tsx Normal file
View File

@@ -0,0 +1,553 @@
import { CheckSquare, ChevronRight, FileSymlink, Folder, FolderOpen, Monitor, Server, Square, Expand, Minimize2 } from 'lucide-react';
import React, { useMemo } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { useTreeExpandedState } from '../application/state/useTreeExpandedState';
import { sanitizeHost } from '../domain/host';
import { STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED } from '../infrastructure/config/storageKeys';
import { cn } from '../lib/utils';
import { GroupNode, Host } from '../types';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from './ui/context-menu';
import { DistroAvatar } from './DistroAvatar';
import { Button } from './ui/button';
interface HostTreeViewProps {
groupTree: GroupNode[];
hosts: Host[];
sortMode?: 'az' | 'za' | 'newest' | 'oldest' | 'group';
expandedPaths?: Set<string>;
onTogglePath?: (path: string) => void;
onExpandAll?: (paths: string[]) => void;
onCollapseAll?: () => void;
onConnect: (host: Host) => void;
onEditHost: (host: Host) => void;
onDuplicateHost: (host: Host) => void;
onDeleteHost: (host: Host) => void;
onCopyCredentials: (host: Host) => void;
onNewHost: (groupPath?: string) => void;
onNewGroup: (parentPath?: string) => void;
onEditGroup: (groupPath: string) => void;
onDeleteGroup: (groupPath: string) => void;
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
moveGroup: (sourcePath: string, targetPath: string) => void;
managedGroupPaths?: Set<string>;
onUnmanageGroup?: (groupPath: string) => void;
isMultiSelectMode?: boolean;
selectedHostIds?: Set<string>;
toggleHostSelection?: (hostId: string) => void;
}
interface TreeNodeProps {
node: GroupNode;
depth: number;
sortMode: 'az' | 'za' | 'newest' | 'oldest' | 'group';
expandedPaths: Set<string>;
onToggle: (path: string) => void;
onConnect: (host: Host) => void;
onEditHost: (host: Host) => void;
onDuplicateHost: (host: Host) => void;
onDeleteHost: (host: Host) => void;
onCopyCredentials: (host: Host) => void;
onNewHost: (groupPath?: string) => void;
onNewGroup: (parentPath?: string) => void;
onEditGroup: (groupPath: string) => void;
onDeleteGroup: (groupPath: string) => void;
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
moveGroup: (sourcePath: string, targetPath: string) => void;
managedGroupPaths?: Set<string>;
onUnmanageGroup?: (groupPath: string) => void;
isMultiSelectMode?: boolean;
selectedHostIds?: Set<string>;
toggleHostSelection?: (hostId: string) => void;
}
const TreeNode: React.FC<TreeNodeProps> = ({
node,
depth,
sortMode,
expandedPaths,
onToggle,
onConnect,
onEditHost,
onDuplicateHost,
onDeleteHost,
onCopyCredentials,
onNewHost,
onNewGroup,
onEditGroup,
onDeleteGroup,
moveHostToGroup,
moveGroup,
managedGroupPaths,
onUnmanageGroup,
isMultiSelectMode,
selectedHostIds,
toggleHostSelection,
}) => {
const { t } = useI18n();
const isExpanded = expandedPaths.has(node.path);
const hasChildren = node.children && Object.keys(node.children).length > 0;
const paddingLeft = `${depth * 20 + 12}px`;
const isManaged = managedGroupPaths?.has(node.path) ?? false;
const childNodes = useMemo(() => {
if (!node.children) return [];
const nodes = Object.values(node.children) as unknown as GroupNode[];
return nodes.sort((a, b) => {
switch (sortMode) {
case 'za':
return b.name.localeCompare(a.name);
case 'newest':
case 'oldest':
// For groups, fall back to name sorting since groups don't have creation dates
return a.name.localeCompare(b.name);
case 'az':
default:
return a.name.localeCompare(b.name);
}
});
}, [node.children, sortMode]);
const sortedHosts = useMemo(() => {
return [...node.hosts].sort((a, b) => {
switch (sortMode) {
case 'az':
return a.label.localeCompare(b.label);
case 'za':
return b.label.localeCompare(a.label);
case 'newest':
return (b.createdAt || 0) - (a.createdAt || 0);
case 'oldest':
return (a.createdAt || 0) - (b.createdAt || 0);
default:
return a.label.localeCompare(b.label);
}
});
}, [node.hosts, sortMode]);
return (
<div>
{/* Group Node */}
<Collapsible open={isExpanded} onOpenChange={() => onToggle(node.path)}>
<ContextMenu>
<ContextMenuTrigger>
<CollapsibleTrigger asChild>
<div
className={cn(
"flex items-center py-2 pr-3 text-sm font-medium cursor-pointer transition-colors select-none group hover:bg-secondary/60 rounded-lg",
)}
style={{ paddingLeft }}
draggable
onDragStart={(e) => e.dataTransfer.setData("group-path", node.path)}
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
const hostId = e.dataTransfer.getData("host-id");
const groupPath = e.dataTransfer.getData("group-path");
if (hostId) moveHostToGroup(hostId, node.path);
if (groupPath) moveGroup(groupPath, node.path);
}}
>
<div className="mr-2 flex-shrink-0 w-4 h-4 flex items-center justify-center">
{(hasChildren || node.hosts.length > 0) && (
<div className={cn("transition-transform duration-200", isExpanded ? "rotate-90" : "")}>
<ChevronRight size={14} />
</div>
)}
</div>
<div className="mr-3 text-primary/80 group-hover:text-primary transition-colors">
{isExpanded ? <FolderOpen size={18} /> : <Folder size={18} />}
</div>
<span className="truncate flex-1 font-semibold">{node.name}</span>
{isManaged && (
<span className="inline-flex items-center gap-1 text-[10px] font-medium px-1.5 py-0.5 rounded bg-primary/15 text-primary shrink-0 mr-1.5">
<FileSymlink size={10} />
Managed
</span>
)}
{(node.hosts.length > 0 || hasChildren) && (
<span className="text-xs opacity-70 bg-background/50 px-2 py-0.5 rounded-full border border-border">
{node.hosts.length}
</span>
)}
</div>
</CollapsibleTrigger>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => onNewHost(node.path)}>
<Server className="mr-2 h-4 w-4" /> {t("vault.hosts.newHost")}
</ContextMenuItem>
<ContextMenuItem onClick={() => onNewGroup(node.path)}>
<Folder className="mr-2 h-4 w-4" /> {t("vault.hosts.newGroup")}
</ContextMenuItem>
<ContextMenuItem onClick={() => onEditGroup(node.path)}>
<FolderOpen className="mr-2 h-4 w-4" /> {t("vault.groups.rename")}
</ContextMenuItem>
<ContextMenuItem
onClick={() => onDeleteGroup(node.path)}
className="text-destructive focus:text-destructive"
>
<FolderOpen className="mr-2 h-4 w-4" /> {t("vault.groups.delete")}
</ContextMenuItem>
{isManaged && onUnmanageGroup && (
<ContextMenuItem onClick={() => onUnmanageGroup(node.path)}>
<FileSymlink className="mr-2 h-4 w-4" /> {t("vault.managedSource.unmanage")}
</ContextMenuItem>
)}
</ContextMenuContent>
</ContextMenu>
<CollapsibleContent>
{/* Child Groups */}
{childNodes.map((child) => (
<TreeNode
key={child.path}
node={child}
depth={depth + 1}
sortMode={sortMode}
expandedPaths={expandedPaths}
onToggle={onToggle}
onConnect={onConnect}
onEditHost={onEditHost}
onDuplicateHost={onDuplicateHost}
onDeleteHost={onDeleteHost}
onCopyCredentials={onCopyCredentials}
onNewHost={onNewHost}
onNewGroup={onNewGroup}
onEditGroup={onEditGroup}
onDeleteGroup={onDeleteGroup}
moveHostToGroup={moveHostToGroup}
moveGroup={moveGroup}
managedGroupPaths={managedGroupPaths}
onUnmanageGroup={onUnmanageGroup}
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
/>
))}
{/* Hosts in this group */}
{sortedHosts.map((host) => (
<HostTreeItem
key={host.id}
host={host}
depth={depth + 1}
onConnect={onConnect}
onEditHost={onEditHost}
onDuplicateHost={onDuplicateHost}
onDeleteHost={onDeleteHost}
onCopyCredentials={onCopyCredentials}
moveHostToGroup={moveHostToGroup}
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
/>
))}
</CollapsibleContent>
</Collapsible>
</div>
);
};
interface HostTreeItemProps {
host: Host;
depth: number;
onConnect: (host: Host) => void;
onEditHost: (host: Host) => void;
onDuplicateHost: (host: Host) => void;
onDeleteHost: (host: Host) => void;
onCopyCredentials: (host: Host) => void;
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
isMultiSelectMode?: boolean;
selectedHostIds?: Set<string>;
toggleHostSelection?: (hostId: string) => void;
}
const HostTreeItem: React.FC<HostTreeItemProps> = ({
host,
depth,
onConnect,
onEditHost,
onDuplicateHost,
onDeleteHost,
onCopyCredentials,
moveHostToGroup: _moveHostToGroup,
isMultiSelectMode,
selectedHostIds,
toggleHostSelection,
}) => {
const { t } = useI18n();
const paddingLeft = `${depth * 20 + 12}px`;
const safeHost = sanitizeHost(host);
const tags = host.tags || [];
const isTelnet = host.protocol === 'telnet';
const displayUsername = isTelnet
? (host.telnetUsername?.trim() || host.username?.trim() || '')
: (host.username?.trim() || '');
const displayPort = isTelnet
? (host.telnetPort ?? host.port ?? 23)
: (host.port ?? 22);
const isSelected = isMultiSelectMode && selectedHostIds?.has(host.id);
return (
<ContextMenu>
<ContextMenuTrigger>
<div
className={cn(
"flex items-center py-2 pr-3 text-sm cursor-pointer transition-colors select-none group hover:bg-secondary/40 rounded-lg",
isSelected ? "bg-primary/10" : "",
)}
style={{ paddingLeft }}
draggable={!isMultiSelectMode}
onDragStart={(e) => e.dataTransfer.setData("host-id", host.id)}
onClick={() => {
if (isMultiSelectMode && toggleHostSelection) {
toggleHostSelection(host.id);
} else {
onConnect(safeHost);
}
}}
>
{isMultiSelectMode && (
<div className="mr-2 flex-shrink-0" onClick={(e) => {
e.stopPropagation();
toggleHostSelection?.(host.id);
}}>
{isSelected ? (
<CheckSquare size={18} className="text-primary" />
) : (
<Square size={18} className="text-muted-foreground" />
)}
</div>
)}
{!isMultiSelectMode && <div className="mr-2 flex-shrink-0 w-4 h-4" />}
<div className="mr-3 flex-shrink-0">
<DistroAvatar host={host} fallback={(host.os || "L")[0].toUpperCase()} size="sm" />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{host.label}</div>
<div className="text-xs text-muted-foreground truncate">
{displayUsername}@{host.hostname}:{displayPort}
</div>
</div>
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
{host.protocol && host.protocol !== 'ssh' && (
<span className="text-xs px-1.5 py-0.5 bg-primary/10 text-primary rounded">
{host.protocol.toUpperCase()}
</span>
)}
{tags.length > 0 && (
<span className="text-xs opacity-60">
{tags.slice(0, 2).join(', ')}
{tags.length > 2 && '...'}
</span>
)}
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => onConnect(safeHost)}>
<Monitor className="mr-2 h-4 w-4" /> {t("vault.hosts.connect")}
</ContextMenuItem>
<ContextMenuItem onClick={() => onEditHost(host)}>
<Server className="mr-2 h-4 w-4" /> {t("action.edit")}
</ContextMenuItem>
<ContextMenuItem onClick={() => onDuplicateHost(host)}>
<Server className="mr-2 h-4 w-4" /> {t("action.duplicate")}
</ContextMenuItem>
<ContextMenuItem onClick={() => onCopyCredentials(host)}>
<Server className="mr-2 h-4 w-4" /> {t("vault.hosts.copyCredentials")}
</ContextMenuItem>
<ContextMenuItem
onClick={() => onDeleteHost(host)}
className="text-destructive focus:text-destructive"
>
<Server className="mr-2 h-4 w-4" /> {t("action.delete")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
};
export const HostTreeView: React.FC<HostTreeViewProps> = ({
groupTree,
hosts,
sortMode = 'az',
expandedPaths: externalExpandedPaths,
onTogglePath: externalOnTogglePath,
onExpandAll: externalOnExpandAll,
onCollapseAll: externalOnCollapseAll,
onConnect,
onEditHost,
onDuplicateHost,
onDeleteHost,
onCopyCredentials,
onNewHost,
onNewGroup,
onEditGroup,
onDeleteGroup,
moveHostToGroup,
moveGroup,
managedGroupPaths,
onUnmanageGroup,
isMultiSelectMode,
selectedHostIds,
toggleHostSelection,
}) => {
const { t } = useI18n();
// Use external state if provided, otherwise use local persistent state
const localTreeState = useTreeExpandedState(STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED);
const expandedPaths = externalExpandedPaths || localTreeState.expandedPaths;
const togglePath = externalOnTogglePath || localTreeState.togglePath;
const expandAll = externalOnExpandAll || localTreeState.expandAll;
const collapseAll = externalOnCollapseAll || localTreeState.collapseAll;
// Get all possible group paths for expand/collapse all functionality
const getAllGroupPaths = (nodes: GroupNode[]): string[] => {
const paths: string[] = [];
const traverse = (nodeList: GroupNode[]) => {
nodeList.forEach(node => {
paths.push(node.path);
if (node.children) {
traverse(Object.values(node.children) as GroupNode[]);
}
});
};
traverse(nodes);
return paths;
};
const allGroupPaths = useMemo(() => getAllGroupPaths(groupTree), [groupTree]);
const handleExpandAll = () => {
expandAll(allGroupPaths);
};
const handleCollapseAll = () => {
collapseAll();
};
// Get ungrouped hosts (hosts without a group or with empty group) and sort them
const ungroupedHosts = useMemo(() => {
const hosts_without_group = hosts.filter(host => !host.group || host.group === '');
return hosts_without_group.sort((a, b) => {
switch (sortMode) {
case 'az':
return a.label.localeCompare(b.label);
case 'za':
return b.label.localeCompare(a.label);
case 'newest':
return (b.createdAt || 0) - (a.createdAt || 0);
case 'oldest':
return (a.createdAt || 0) - (b.createdAt || 0);
default:
return a.label.localeCompare(b.label);
}
});
}, [hosts, sortMode]);
// Sort group tree based on sort mode
const sortedGroupTree = useMemo(() => {
return [...groupTree].sort((a, b) => {
switch (sortMode) {
case 'za':
return b.name.localeCompare(a.name);
case 'newest':
case 'oldest':
// For groups, fall back to name sorting since groups don't have creation dates
return a.name.localeCompare(b.name);
case 'az':
default:
return a.name.localeCompare(b.name);
}
});
}, [groupTree, sortMode]);
return (
<div className="space-y-1">
{/* Expand/Collapse controls */}
{groupTree.length > 0 && (
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-border/30">
<Button
variant="ghost"
size="sm"
onClick={handleExpandAll}
className="h-7 px-2 text-xs"
>
<Expand size={12} className="mr-1" />
{t("vault.tree.expandAll")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleCollapseAll}
className="h-7 px-2 text-xs"
>
<Minimize2 size={12} className="mr-1" />
{t("vault.tree.collapseAll")}
</Button>
</div>
)}
{/* Group tree */}
{sortedGroupTree.map((node) => (
<TreeNode
key={node.path}
node={node}
depth={0}
sortMode={sortMode}
expandedPaths={expandedPaths}
onToggle={togglePath}
onConnect={onConnect}
onEditHost={onEditHost}
onDuplicateHost={onDuplicateHost}
onDeleteHost={onDeleteHost}
onCopyCredentials={onCopyCredentials}
onNewHost={onNewHost}
onNewGroup={onNewGroup}
onEditGroup={onEditGroup}
onDeleteGroup={onDeleteGroup}
moveHostToGroup={moveHostToGroup}
moveGroup={moveGroup}
managedGroupPaths={managedGroupPaths}
onUnmanageGroup={onUnmanageGroup}
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
/>
))}
{/* Ungrouped hosts at root level */}
{ungroupedHosts.map((host) => (
<HostTreeItem
key={host.id}
host={host}
depth={0}
onConnect={onConnect}
onEditHost={onEditHost}
onDuplicateHost={onDuplicateHost}
onDeleteHost={onDeleteHost}
onCopyCredentials={onCopyCredentials}
moveHostToGroup={moveHostToGroup}
isMultiSelectMode={isMultiSelectMode}
selectedHostIds={selectedHostIds}
toggleHostSelection={toggleHostSelection}
/>
))}
{/* Empty state */}
{ungroupedHosts.length === 0 && groupTree.length === 0 && (
<div className="text-center py-12 text-muted-foreground">
<Server size={48} className="mx-auto mb-4 opacity-50" />
<p className="text-sm">{t("vault.hosts.empty")}</p>
</div>
)}
</div>
);
};

View File

@@ -23,6 +23,7 @@ import { STORAGE_KEY_VAULT_KEYS_VIEW_MODE } from "../infrastructure/config/stora
import { logger } from "../lib/logger";
import { cn } from "../lib/utils";
import { Host, Identity, KeyType, SSHKey } from "../types";
import { ManagedSource } from "../domain/models";
import { useKeychainBackend } from "../application/state/useKeychainBackend";
import SelectHostPanel from "./SelectHostPanel";
import {
@@ -68,6 +69,7 @@ interface KeychainManagerProps {
identities?: Identity[];
hosts?: Host[];
customGroups?: string[];
managedSources?: ManagedSource[];
onSave: (key: SSHKey) => void;
onUpdate: (key: SSHKey) => void;
onDelete: (id: string) => void;
@@ -83,6 +85,7 @@ const KeychainManager: React.FC<KeychainManagerProps> = ({
identities = [],
hosts = [],
customGroups = [],
managedSources = [],
onSave,
onUpdate,
onDelete,
@@ -913,6 +916,8 @@ echo $3 >> "$FILE"`);
<ImportKeyPanel
draftKey={draftKey}
setDraftKey={setDraftKey}
showPassphrase={showPassphrase}
setShowPassphrase={setShowPassphrase}
onImport={handleImport}
/>
)}
@@ -1111,6 +1116,8 @@ echo $3 >> "$FILE"`);
privateKey: hostPrivateKey,
command,
timeout: 30000,
enableKeyboardInteractive: true,
sessionId: `export-key:${exportHost.id}:${panel.key.id}`,
});
// Check result - code 0, null, or undefined with no stderr is success
@@ -1282,6 +1289,7 @@ echo $3 >> "$FILE"`);
onBack={() => setShowHostSelector(false)}
onContinue={() => setShowHostSelector(false)}
availableKeys={keys}
managedSources={managedSources}
onSaveHost={onSaveHost}
onCreateGroup={onCreateGroup}
/>

View File

@@ -2,12 +2,13 @@ import { Terminal as XTerm } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { WebglAddon } from "@xterm/addon-webgl";
import "@xterm/xterm/css/xterm.css";
import { FileText, Palette, X } from "lucide-react";
import { FileText, Download, Palette, X } from "lucide-react";
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { cn } from "../lib/utils";
import { ConnectionLog, TerminalTheme } from "../types";
import { TERMINAL_THEMES } from "../infrastructure/config/terminalThemes";
import { useCustomThemes } from "../application/state/customThemeStore";
import { Button } from "./ui/button";
import ThemeCustomizeModal from "./terminal/ThemeCustomizeModal";
@@ -34,14 +35,20 @@ const LogViewComponent: React.FC<LogViewProps> = ({
const fitAddonRef = useRef<FitAddon | null>(null);
const [isReady, setIsReady] = useState(false);
const [themeModalOpen, setThemeModalOpen] = useState(false);
const [isExporting, setIsExporting] = useState(false);
// Subscribe to custom theme changes so editing triggers re-render
const customThemes = useCustomThemes();
// Use log's saved theme/fontSize or fall back to defaults
const currentTheme = useMemo(() => {
if (log.themeId) {
return TERMINAL_THEMES.find(t => t.id === log.themeId) || defaultTerminalTheme;
return TERMINAL_THEMES.find(t => t.id === log.themeId)
|| customThemes.find(t => t.id === log.themeId)
|| defaultTerminalTheme;
}
return defaultTerminalTheme;
}, [log.themeId, defaultTerminalTheme]);
}, [log.themeId, defaultTerminalTheme, customThemes]);
const currentFontSize = log.fontSize ?? defaultFontSize;
@@ -67,6 +74,30 @@ const LogViewComponent: React.FC<LogViewProps> = ({
onUpdateLog(log.id, { fontSize });
}, [log.id, onUpdateLog]);
// Handle export
const handleExport = useCallback(async () => {
if (!log.terminalData || isExporting) return;
setIsExporting(true);
try {
const { netcattyBridge } = await import("../infrastructure/services/netcattyBridge");
const bridge = netcattyBridge.get();
if (bridge?.exportSessionLog) {
await bridge.exportSessionLog({
terminalData: log.terminalData,
hostLabel: log.hostLabel,
hostname: log.hostname,
startTime: log.startTime,
format: 'txt',
});
}
} catch (err) {
console.error('Failed to export session log:', err);
} finally {
setIsExporting(false);
}
}, [log.terminalData, log.hostLabel, log.hostname, log.startTime, isExporting]);
// Initialize terminal
useEffect(() => {
if (!containerRef.current || !isVisible) return;
@@ -216,6 +247,21 @@ const LogViewComponent: React.FC<LogViewProps> = ({
</div>
</div>
<div className="flex items-center gap-2">
{/* Export button */}
{log.terminalData && (
<Button
variant="ghost"
size="sm"
className="gap-1.5 h-8 px-2"
onClick={handleExport}
disabled={isExporting}
title={t("logView.export")}
>
<Download size={14} />
<span className="text-xs">{t("logView.export")}</span>
</Button>
)}
{/* Theme & font customization button */}
<Button
variant="ghost"

View File

@@ -0,0 +1,169 @@
/**
* Passphrase Modal
* Modal for requesting passphrase for encrypted SSH keys
*/
import { Eye, EyeOff, KeyRound, Loader2 } from "lucide-react";
import React, { useCallback, useEffect, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { Button } from "./ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "./ui/dialog";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
export interface PassphraseRequest {
requestId: string;
keyPath: string;
keyName: string;
hostname?: string;
}
interface PassphraseModalProps {
request: PassphraseRequest | null;
onSubmit: (requestId: string, passphrase: string) => void;
onCancel: (requestId: string) => void;
onSkip?: (requestId: string) => void;
}
export const PassphraseModal: React.FC<PassphraseModalProps> = ({
request,
onSubmit,
onCancel,
onSkip,
}) => {
const { t } = useI18n();
const [passphrase, setPassphrase] = useState("");
const [showPassphrase, setShowPassphrase] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
// Reset state when request changes
useEffect(() => {
if (request) {
setPassphrase("");
setShowPassphrase(false);
setIsSubmitting(false);
}
}, [request]);
const handleSubmit = useCallback(() => {
if (!request || isSubmitting || !passphrase) return;
setIsSubmitting(true);
onSubmit(request.requestId, passphrase);
}, [request, passphrase, onSubmit, isSubmitting]);
const handleCancel = useCallback(() => {
if (!request) return;
onCancel(request.requestId);
}, [request, onCancel]);
const handleSkip = useCallback(() => {
if (!request || !onSkip) return;
onSkip(request.requestId);
}, [request, onSkip]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && !isSubmitting && passphrase) {
e.preventDefault();
handleSubmit();
}
},
[handleSubmit, isSubmitting, passphrase]
);
if (!request) return null;
const keyDisplayName = request.keyName || request.keyPath.split("/").pop() || "SSH Key";
return (
<Dialog open={!!request} onOpenChange={(open) => !open && handleCancel()}>
<DialogContent className="sm:max-w-[425px]" hideCloseButton>
<DialogHeader>
<div className="flex items-center gap-3 mb-2">
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
<KeyRound className="h-5 w-5 text-primary" />
</div>
<div>
<DialogTitle>{t("passphrase.title")}</DialogTitle>
<DialogDescription className="mt-1">
{request.hostname
? t("passphrase.descWithHost", { keyName: keyDisplayName, hostname: request.hostname })
: t("passphrase.desc", { keyName: keyDisplayName })}
</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="passphrase-input">
{t("passphrase.label")}
</Label>
<div className="relative">
<Input
id="passphrase-input"
type={showPassphrase ? "text" : "password"}
value={passphrase}
onChange={(e) => setPassphrase(e.target.value)}
onKeyDown={handleKeyDown}
placeholder=""
className="pr-10"
autoFocus
disabled={isSubmitting}
/>
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground disabled:opacity-50 p-1"
onClick={() => setShowPassphrase(!showPassphrase)}
disabled={isSubmitting}
>
{showPassphrase ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
<p className="text-xs text-muted-foreground">
{t("passphrase.keyPath")}: <code className="text-xs">{request.keyPath}</code>
</p>
</div>
</div>
<div className="flex items-center justify-between pt-2">
<div className="flex gap-2">
<Button
variant="secondary"
onClick={handleCancel}
disabled={isSubmitting}
>
{t("common.cancel")}
</Button>
{onSkip && (
<Button
variant="ghost"
onClick={handleSkip}
disabled={isSubmitting}
>
{t("passphrase.skip")}
</Button>
)}
</div>
<Button onClick={handleSubmit} disabled={isSubmitting || !passphrase}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("passphrase.unlocking")}
</>
) : (
t("passphrase.unlock")
)}
</Button>
</div>
</DialogContent>
</Dialog>
);
};
export default PassphraseModal;

View File

@@ -15,6 +15,7 @@ import { useI18n } from "../application/i18n/I18nProvider";
import { usePortForwardingState } from "../application/state/usePortForwardingState";
import {
Host,
ManagedSource,
PortForwardingRule,
PortForwardingType,
SSHKey,
@@ -64,6 +65,7 @@ interface PortForwardingProps {
keys: SSHKey[];
identities?: import('../domain/models').Identity[];
customGroups: string[];
managedSources?: ManagedSource[];
onNewHost?: () => void;
onSaveHost?: (host: Host) => void;
onCreateGroup?: (groupPath: string) => void;
@@ -74,6 +76,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
keys,
identities = [],
customGroups: _customGroups,
managedSources = [],
onNewHost: _onNewHost,
onSaveHost,
onCreateGroup: _onCreateGroup,
@@ -698,6 +701,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
<RuleCard
key={rule.id}
rule={rule}
host={hosts.find((h) => h.id === rule.hostId)}
viewMode={viewMode}
isSelected={selectedRuleId === rule.id}
isPending={pendingOperations.has(rule.id)}
@@ -844,6 +848,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
onContinue={() => setShowHostSelector(false)}
availableKeys={keys}
identities={identities}
managedSources={managedSources}
onSaveHost={onSaveHost}
onCreateGroup={_onCreateGroup}
/>

View File

@@ -6,7 +6,7 @@ import {
Terminal,
TerminalSquare,
} from "lucide-react";
import React, { memo, useEffect, useRef, useState } from "react";
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { Host, TerminalSession, Workspace } from "../types";
import { KeyBinding } from "../domain/models";
@@ -17,10 +17,45 @@ type QuickSwitcherItem = {
data?: Host | TerminalSession | Workspace;
};
import { DistroAvatar } from "./DistroAvatar";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { ScrollArea } from "./ui/scroll-area";
// Compute once at module level
const IS_MAC = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform);
// Memoized host item component to prevent unnecessary re-renders
const HostItem = memo(({
host,
isSelected,
onSelect,
onMouseEnter,
}: {
host: Host;
isSelected: boolean;
onSelect: (host: Host) => void;
onMouseEnter: () => void;
}) => (
<div
className={`flex items-center justify-between px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
}`}
onClick={() => onSelect(host)}
onMouseEnter={onMouseEnter}
>
<div className="flex items-center gap-3 min-w-0">
<DistroAvatar
host={host}
fallback={host.label.slice(0, 2).toUpperCase()}
size="sm"
/>
<span className="text-sm font-medium truncate">{host.label}</span>
</div>
<div className="text-[11px] text-muted-foreground">
{host.group ? `Personal / ${host.group}` : "Personal"}
</div>
</div>
));
HostItem.displayName = "HostItem";
interface QuickSwitcherProps {
isOpen: boolean;
query: string;
@@ -32,7 +67,7 @@ interface QuickSwitcherProps {
onSelectTab: (tabId: string) => void;
onClose: () => void;
onCreateLocalTerminal?: () => void;
onCreateWorkspace?: () => void;
// onCreateWorkspace removed - feature not currently used
keyBindings?: KeyBinding[];
}
@@ -47,19 +82,16 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
onSelectTab,
onClose,
onCreateLocalTerminal,
onCreateWorkspace,
keyBindings,
}) => {
const { t } = useI18n();
// Get hotkey display strings
const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform);
const getHotkeyLabel = (actionId: string) => {
const getHotkeyLabel = useCallback((actionId: string) => {
const binding = keyBindings?.find(k => k.id === actionId);
if (!binding) return '';
return isMac ? binding.mac : binding.pc;
};
return IS_MAC ? binding.mac : binding.pc;
}, [keyBindings]);
const quickSwitchKey = getHotkeyLabel('quick-switch');
const [isFocused, setIsFocused] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
@@ -67,7 +99,6 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
// Reset state when opening
useEffect(() => {
if (isOpen) {
setIsFocused(false);
setSelectedIndex(0);
// Auto focus the input after a short delay
setTimeout(() => {
@@ -93,15 +124,17 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isOpen, onClose]);
if (!isOpen) return null;
// Memoize orphan sessions
const orphanSessions = useMemo(
() => sessions.filter((s) => !s.workspaceId),
[sessions]
);
const showCategorized = isFocused || query.trim().length > 0;
// Always show categorized view (Hosts/Tabs/Quick connect)
const showCategorized = true;
// Get orphan sessions (sessions without workspace)
const orphanSessions = sessions.filter((s) => !s.workspaceId);
// Build categorized items for navigation
const buildFlatItems = () => {
// Memoize flat items list and index map
const { flatItems, itemIndexMap } = useMemo(() => {
const items: QuickSwitcherItem[] = [];
if (showCategorized) {
@@ -127,10 +160,21 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
);
}
return items;
};
// Build index map for O(1) lookup
const indexMap = new Map<string, number>();
items.forEach((item, idx) => {
indexMap.set(`${item.type}:${item.id}`, idx);
});
const flatItems = buildFlatItems();
return { flatItems: items, itemIndexMap: indexMap };
}, [showCategorized, results, orphanSessions, workspaces]);
// O(1) index lookup
const getItemIndex = useCallback((type: string, id: string) => {
return itemIndexMap.get(`${type}:${id}`) ?? -1;
}, [itemIndexMap]);
if (!isOpen) return null;
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") {
@@ -165,40 +209,6 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
}
};
// Helper to get item index in flat list
const getItemIndex = (type: string, id: string) => {
return flatItems.findIndex((item) => item.type === type && item.id === id);
};
const renderHostItem = (host: Host) => {
const idx = getItemIndex("host", host.id);
const isSelected = idx === selectedIndex;
return (
<div
key={host.id}
className={`flex items-center justify-between px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
}`}
onClick={() => {
onSelect(host);
}}
onMouseEnter={() => setSelectedIndex(idx)}
>
<div className="flex items-center gap-3 min-w-0">
<DistroAvatar
host={host}
fallback={host.label.slice(0, 2).toUpperCase()}
size="sm"
/>
<span className="text-sm font-medium truncate">{host.label}</span>
</div>
<div className="text-[11px] text-muted-foreground">
{host.group ? `Personal / ${host.group}` : "Personal"}
</div>
</div>
);
};
return (
<div
className="fixed inset-x-0 top-12 z-50 flex justify-center pt-2"
@@ -219,7 +229,6 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
onQueryChange(e.target.value);
setSelectedIndex(0);
}}
onFocus={() => setIsFocused(true)}
onKeyDown={handleKeyDown}
placeholder={t("qs.search.placeholder")}
className="flex-1 h-8 border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 px-0 text-sm"
@@ -232,193 +241,163 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
</div>
<ScrollArea className="flex-1 h-full">
{!showCategorized ? (
/* Default view: Recent connections with header */
<div>
<div className="px-4 py-2 flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">
{t("qs.recentConnections")}
</span>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
className="h-6 px-2 text-[11px]"
onClick={() => onCreateWorkspace?.()}
>
{t("qs.createWorkspace")}
</Button>
<Button
size="sm"
variant="outline"
className="h-6 px-2 text-[11px]"
disabled
>
{t("qs.restore")}
</Button>
</div>
</div>
<div>
{results.length > 0 ? (
results.map(renderHostItem)
) : (
<div className="px-4 py-6 text-sm text-muted-foreground text-center">
No recent connections
</div>
)}
</div>
{/* Categorized view: Hosts/Tabs/Quick connect */}
<div>
{/* Jump To hint */}
<div className="px-4 py-2 flex items-center gap-2">
<span className="text-xs text-muted-foreground">{t("qs.jumpTo")}</span>
{quickSwitchKey && (
<kbd className="text-[10px] text-muted-foreground bg-muted px-1 py-0.5 rounded">
{quickSwitchKey.replace(/ \+ /g, '+')}
</kbd>
)}
</div>
) : (
/* Focused/searching view: Categorized items */
{/* Hosts section */}
{results.length > 0 && (
<div>
<div className="px-4 py-1.5">
<span className="text-xs font-medium text-muted-foreground">
Hosts
</span>
</div>
{results.map((host) => (
<HostItem
key={host.id}
host={host}
isSelected={getItemIndex("host", host.id) === selectedIndex}
onSelect={onSelect}
onMouseEnter={() => setSelectedIndex(getItemIndex("host", host.id))}
/>
))}
</div>
)}
{/* Tabs section */}
<div>
{/* Jump To hint */}
<div className="px-4 py-2 flex items-center gap-2">
<span className="text-xs text-muted-foreground">{t("qs.jumpTo")}</span>
{quickSwitchKey && (
<kbd className="text-[10px] text-muted-foreground bg-muted px-1 py-0.5 rounded">
{quickSwitchKey.replace(/ \+ /g, '+')}
</kbd>
)}
<div className="px-4 py-1.5">
<span className="text-xs font-medium text-muted-foreground">
Tabs
</span>
</div>
{/* Hosts section */}
{results.length > 0 && (
<div>
<div className="px-4 py-1.5">
<span className="text-xs font-medium text-muted-foreground">
Hosts
{/* Built-in tabs */}
{["vault", "sftp"].map((tabId) => {
const idx = getItemIndex("tab", tabId);
const isSelected = idx === selectedIndex;
const icon =
tabId === "vault" ? (
<Shield size={16} />
) : (
<Folder size={16} />
);
const label = tabId === "vault" ? "Vaults" : "SFTP";
return (
<div
key={tabId}
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
}`}
onClick={() => {
onSelectTab(tabId);
onClose();
}}
onMouseEnter={() => setSelectedIndex(idx)}
>
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
{icon}
</div>
<span className="text-sm font-medium">{label}</span>
</div>
);
})}
{/* Workspaces */}
{workspaces.map((workspace) => {
const idx = getItemIndex("workspace", workspace.id);
const isSelected = idx === selectedIndex;
return (
<div
key={workspace.id}
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
}`}
onClick={() => {
onSelectTab(workspace.id);
onClose();
}}
onMouseEnter={() => setSelectedIndex(idx)}
>
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
<LayoutGrid size={16} />
</div>
<span className="text-sm font-medium">
{workspace.title}
</span>
</div>
{results.map(renderHostItem)}
);
})}
{/* Orphan sessions */}
{orphanSessions.map((session) => {
const idx = getItemIndex("tab", session.id);
const isSelected = idx === selectedIndex;
return (
<div
key={session.id}
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
}`}
onClick={() => {
onSelectTab(session.id);
onClose();
}}
onMouseEnter={() => setSelectedIndex(idx)}
>
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
<TerminalSquare size={16} />
</div>
<span className="text-sm font-medium">
{session.hostLabel}
</span>
</div>
);
})}
</div>
{/* Quick connect section */}
<div>
<div className="px-4 py-1.5">
<span className="text-xs font-medium text-muted-foreground">
Quick connect
</span>
</div>
{/* Local Terminal */}
{onCreateLocalTerminal && (
<div
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${getItemIndex("action", "local-terminal") === selectedIndex
? "bg-primary/15"
: "hover:bg-muted/50"
}`}
onClick={() => {
onCreateLocalTerminal();
onClose();
}}
onMouseEnter={() =>
setSelectedIndex(getItemIndex("action", "local-terminal"))
}
>
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
<Terminal size={16} />
</div>
<span className="text-sm font-medium">{t("qs.localTerminal")}</span>
</div>
)}
{/* Tabs section */}
<div>
<div className="px-4 py-1.5">
<span className="text-xs font-medium text-muted-foreground">
Tabs
</span>
</div>
{/* Built-in tabs */}
{["vault", "sftp"].map((tabId) => {
const idx = getItemIndex("tab", tabId);
const isSelected = idx === selectedIndex;
const icon =
tabId === "vault" ? (
<Shield size={16} />
) : (
<Folder size={16} />
);
const label = tabId === "vault" ? "Vaults" : "SFTP";
return (
<div
key={tabId}
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
}`}
onClick={() => {
onSelectTab(tabId);
onClose();
}}
onMouseEnter={() => setSelectedIndex(idx)}
>
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
{icon}
</div>
<span className="text-sm font-medium">{label}</span>
</div>
);
})}
{/* Workspaces */}
{workspaces.map((workspace) => {
const idx = getItemIndex("workspace", workspace.id);
const isSelected = idx === selectedIndex;
return (
<div
key={workspace.id}
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
}`}
onClick={() => {
onSelectTab(workspace.id);
onClose();
}}
onMouseEnter={() => setSelectedIndex(idx)}
>
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
<LayoutGrid size={16} />
</div>
<span className="text-sm font-medium">
{workspace.title}
</span>
</div>
);
})}
{/* Orphan sessions */}
{orphanSessions.map((session) => {
const idx = getItemIndex("tab", session.id);
const isSelected = idx === selectedIndex;
return (
<div
key={session.id}
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
}`}
onClick={() => {
onSelectTab(session.id);
onClose();
}}
onMouseEnter={() => setSelectedIndex(idx)}
>
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
<TerminalSquare size={16} />
</div>
<span className="text-sm font-medium">
{session.hostLabel}
</span>
</div>
);
})}
</div>
{/* Quick connect section */}
<div>
<div className="px-4 py-1.5">
<span className="text-xs font-medium text-muted-foreground">
Quick connect
</span>
</div>
{/* Local Terminal */}
{onCreateLocalTerminal && (
<div
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${getItemIndex("action", "local-terminal") === selectedIndex
? "bg-primary/15"
: "hover:bg-muted/50"
}`}
onClick={() => {
onCreateLocalTerminal();
onClose();
}}
onMouseEnter={() =>
setSelectedIndex(getItemIndex("action", "local-terminal"))
}
>
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
<Terminal size={16} />
</div>
<span className="text-sm font-medium">{t("qs.localTerminal")}</span>
</div>
)}
{/* Serial removed (not supported) */}
</div>
{/* Serial removed (not supported) */}
</div>
)}
</div>
</ScrollArea>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,7 @@ import React, { useMemo, useState } from "react";
import { cn } from "../lib/utils";
import { useI18n } from "../application/i18n/I18nProvider";
import { Host, SSHKey } from "../types";
import { ManagedSource } from "../domain/models";
import { DistroAvatar } from "./DistroAvatar";
import HostDetailsPanel from "./HostDetailsPanel";
import { Button } from "./ui/button";
@@ -31,6 +32,7 @@ interface SelectHostPanelProps {
// Props for inline host creation
availableKeys?: SSHKey[];
identities?: import('../domain/models').Identity[];
managedSources?: ManagedSource[];
onSaveHost?: (host: Host) => void;
onCreateGroup?: (groupPath: string) => void;
title?: string;
@@ -49,6 +51,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
onNewHost,
availableKeys = [],
identities = [],
managedSources = [],
onSaveHost,
onCreateGroup,
title,
@@ -63,21 +66,26 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [showNewHostPanel, setShowNewHostPanel] = useState(false);
const selectableHosts = useMemo(
() => hosts.filter((host) => host.protocol !== "serial"),
[hosts]
);
// Get all unique tags from hosts
const allTags = useMemo(() => {
const tagSet = new Set<string>();
hosts.forEach((h) => {
selectableHosts.forEach((h) => {
if (h.tags) {
h.tags.forEach((tag) => tagSet.add(tag));
}
});
return Array.from(tagSet).sort();
}, [hosts]);
}, [selectableHosts]);
// Get unique group paths from both hosts and customGroups
const allGroupPaths = useMemo(() => {
const pathSet = new Set<string>();
hosts.forEach((h) => {
selectableHosts.forEach((h) => {
if (h.group) {
// Add all parent paths as well
const parts = h.group.split("/");
@@ -88,7 +96,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
});
customGroups.forEach((g) => pathSet.add(g));
return Array.from(pathSet).sort();
}, [hosts, customGroups]);
}, [selectableHosts, customGroups]);
// Get groups at current level
const groupsWithCounts = useMemo(() => {
@@ -102,7 +110,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
const topLevel = path.split("/")[0];
if (!seen.has(topLevel)) {
seen.add(topLevel);
const count = hosts.filter(
const count = selectableHosts.filter(
(h) =>
h.group &&
(h.group === topLevel || h.group.startsWith(`${topLevel}/`)),
@@ -116,7 +124,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
const fullPath = `${prefix}${nextLevel}`;
if (!seen.has(fullPath)) {
seen.add(fullPath);
const count = hosts.filter(
const count = selectableHosts.filter(
(h) =>
h.group &&
(h.group === fullPath || h.group.startsWith(`${fullPath}/`)),
@@ -127,11 +135,11 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
});
return groups;
}, [allGroupPaths, currentPath, hosts]);
}, [allGroupPaths, currentPath, selectableHosts]);
// Get hosts at current level with filtering and sorting
const filteredHosts = useMemo(() => {
let result = hosts;
let result = selectableHosts;
// Filter by current path
if (currentPath) {
@@ -177,7 +185,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
});
return result;
}, [hosts, currentPath, searchQuery, selectedTags, sortMode]);
}, [selectableHosts, currentPath, searchQuery, selectedTags, sortMode]);
// Build breadcrumb from current path
const breadcrumbs = useMemo(() => {
@@ -356,7 +364,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
<div className="flex-1 min-w-0">
<div className="font-medium">{host.label}</div>
<div className="text-xs text-muted-foreground truncate">
{host.protocol || "ssh"}, {host.username}
{host.username}@{host.hostname}:{host.port || 22}
</div>
</div>
{isSelected && (
@@ -387,7 +395,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
if (onContinue) {
onContinue();
} else {
const host = hosts.find((h) => selectedHostIds.includes(h.id));
const host = selectableHosts.find((h) => selectedHostIds.includes(h.id));
if (host) {
onSelect(host);
}
@@ -407,6 +415,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
availableKeys={availableKeys}
identities={identities}
groups={customGroups}
managedSources={managedSources}
allHosts={hosts}
onSave={(host) => {
onSaveHost(host);

View File

@@ -71,7 +71,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
}, [closeSettingsWindow]);
return (
<div className="h-screen flex flex-col bg-background text-foreground">
<div className="h-screen flex flex-col bg-background text-foreground font-sans">
<div className="shrink-0 border-b border-border app-drag">
<div className="flex items-center justify-between px-4 pt-3">
{isMac && <div className="h-6" />}
@@ -158,6 +158,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
setAccentMode={settings.setAccentMode}
customAccent={settings.customAccent}
setCustomAccent={settings.setCustomAccent}
uiFontFamilyId={settings.uiFontFamilyId}
setUiFontFamilyId={settings.setUiFontFamilyId}
uiLanguage={settings.uiLanguage}
setUiLanguage={settings.setUiLanguage}
customCSS={settings.customCSS}
@@ -201,7 +203,21 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
</React.Suspense>
)}
{mountedTabs.has("system") && <SettingsSystemTab />}
{mountedTabs.has("system") && (
<SettingsSystemTab
sessionLogsEnabled={settings.sessionLogsEnabled}
setSessionLogsEnabled={settings.setSessionLogsEnabled}
sessionLogsDir={settings.sessionLogsDir}
setSessionLogsDir={settings.setSessionLogsDir}
sessionLogsFormat={settings.sessionLogsFormat}
setSessionLogsFormat={settings.setSessionLogsFormat}
toggleWindowHotkey={settings.toggleWindowHotkey}
setToggleWindowHotkey={settings.setToggleWindowHotkey}
closeToTray={settings.closeToTray}
setCloseToTray={settings.setCloseToTray}
hotkeyRegistrationError={settings.hotkeyRegistrationError}
/>
)}
</div>
</Tabs>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -1,10 +1,11 @@
import { Check, ChevronDown, Clock, Copy, Edit2, FileCode, FolderPlus, LayoutGrid, List as ListIcon, Loader2, Package, Play, Plus, Search, Trash2 } from 'lucide-react';
import { Check, ChevronDown, Clock, Copy, Edit2, FileCode, FolderPlus, Keyboard, LayoutGrid, List as ListIcon, Loader2, Package, Play, Plus, RotateCcw, Search, Trash2 } from 'lucide-react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { useStoredViewMode } from '../application/state/useStoredViewMode';
import { STORAGE_KEY_VAULT_SNIPPETS_VIEW_MODE } from '../infrastructure/config/storageKeys';
import { cn } from '../lib/utils';
import { cn, isMacPlatform } from '../lib/utils';
import { Host, ShellHistoryEntry, Snippet, SSHKey } from '../types';
import { HotkeyScheme, KeyBinding, keyEventToString, ManagedSource, matchesKeyBinding, parseKeyCombo } from '../domain/models';
import { DistroAvatar } from './DistroAvatar';
import SelectHostPanel from './SelectHostPanel';
import { AsidePanel, AsidePanelContent } from './ui/aside-panel';
@@ -24,12 +25,15 @@ interface SnippetsManagerProps {
hosts: Host[];
customGroups?: string[];
shellHistory: ShellHistoryEntry[];
hotkeyScheme: HotkeyScheme;
keyBindings: KeyBinding[];
onSave: (snippet: Snippet) => void;
onDelete: (id: string) => void;
onPackagesChange: (packages: string[]) => void;
onRunSnippet?: (snippet: Snippet, targetHosts: Host[]) => void;
// Props for inline host creation
availableKeys?: SSHKey[];
managedSources?: ManagedSource[];
onSaveHost?: (host: Host) => void;
onCreateGroup?: (groupPath: string) => void;
}
@@ -44,11 +48,14 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
hosts,
customGroups = [],
shellHistory,
hotkeyScheme,
keyBindings,
onSave,
onDelete,
onPackagesChange,
onRunSnippet,
availableKeys = [],
managedSources = [],
onSaveHost,
onCreateGroup,
}) => {
@@ -67,6 +74,12 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
const [newPackageName, setNewPackageName] = useState('');
const [isPackageDialogOpen, setIsPackageDialogOpen] = useState(false);
// Rename package state
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
const [renamingPackagePath, setRenamingPackagePath] = useState<string | null>(null);
const [renamePackageName, setRenamePackageName] = useState('');
const [renameError, setRenameError] = useState('');
// Search, sort, and view mode state
const [search, setSearch] = useState('');
const [viewMode, setViewMode] = useStoredViewMode(
@@ -80,6 +93,187 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
const historyScrollRef = useRef<HTMLDivElement>(null);
const [isLoadingMore, setIsLoadingMore] = useState(false);
// Shortkey recording state
const [isRecordingShortkey, setIsRecordingShortkey] = useState(false);
const [shortkeyError, setShortkeyError] = useState<string | null>(null);
const existingShortkeys = useMemo(() => (
snippets.filter(s => Boolean(s.shortkey) && s.id !== editingSnippet.id)
), [snippets, editingSnippet.id]);
const isMac = useMemo(() => (
hotkeyScheme === 'mac' || (hotkeyScheme === 'disabled' && isMacPlatform())
), [hotkeyScheme]);
const activeSystemBindings = useMemo(() => {
return keyBindings.flatMap((binding) => {
const entries: { binding: string; isMac: boolean }[] = [];
const macBinding = binding.mac;
const pcBinding = binding.pc;
if (hotkeyScheme === 'mac') {
if (macBinding && macBinding !== 'Disabled') {
entries.push({ binding: macBinding, isMac: true });
}
return entries;
}
if (hotkeyScheme === 'pc') {
if (pcBinding && pcBinding !== 'Disabled') {
entries.push({ binding: pcBinding, isMac: false });
}
return entries;
}
if (macBinding && macBinding !== 'Disabled') {
entries.push({ binding: macBinding, isMac: true });
}
if (pcBinding && pcBinding !== 'Disabled') {
entries.push({ binding: pcBinding, isMac: false });
}
return entries;
});
}, [hotkeyScheme, keyBindings]);
const buildKeyEventFromString = useCallback((keyString: string) => {
const parsed = parseKeyCombo(keyString);
if (!parsed) return null;
const modifiers = new Set(parsed.modifiers);
const key = parsed.key;
const normalizedKey = (() => {
switch (key) {
case 'Space':
return ' ';
case '↑':
return 'ArrowUp';
case '↓':
return 'ArrowDown';
case '←':
return 'ArrowLeft';
case '→':
return 'ArrowRight';
case 'Esc':
return 'Escape';
case '⌫':
return 'Backspace';
case 'Del':
return 'Delete';
case '↵':
return 'Enter';
case '⇥':
return 'Tab';
default:
return key.length === 1 ? key.toLowerCase() : key;
}
})();
return new KeyboardEvent('keydown', {
key: normalizedKey,
metaKey: modifiers.has('⌘') || modifiers.has('Win'),
ctrlKey: modifiers.has('⌃') || modifiers.has('Ctrl'),
altKey: modifiers.has('⌥') || modifiers.has('Alt'),
shiftKey: modifiers.has('Shift'),
});
}, []);
// Validate shortkey for conflicts (case-insensitive comparison)
const normalizeKeyString = useCallback((value: string) => (
value.toLowerCase().replace(/\s+/g, '')
), []);
const validateShortkey = useCallback((key: string): string | null => {
if (!key) return null;
const syntheticEvent = buildKeyEventFromString(key);
if (syntheticEvent) {
const conflictsSystem = activeSystemBindings.some(({ binding, isMac: bindingIsMac }) => (
matchesKeyBinding(syntheticEvent, binding, bindingIsMac)
));
if (conflictsSystem) {
return t('snippets.shortkey.error.systemConflict');
}
}
// Check other snippet shortcuts
if (syntheticEvent) {
for (const snippet of existingShortkeys) {
if (snippet.shortkey && matchesKeyBinding(syntheticEvent, snippet.shortkey, isMac)) {
return t('snippets.shortkey.error.snippetConflict', { name: snippet.label });
}
}
} else {
const normalizedKey = normalizeKeyString(key);
const conflictingSnippet = existingShortkeys.find(snippet => (
snippet.shortkey && normalizeKeyString(snippet.shortkey) === normalizedKey
));
if (conflictingSnippet) {
return t('snippets.shortkey.error.snippetConflict', { name: conflictingSnippet.label });
}
}
return null;
}, [
activeSystemBindings,
buildKeyEventFromString,
existingShortkeys,
isMac,
normalizeKeyString,
t,
]);
// Handle shortkey recording
useEffect(() => {
if (!isRecordingShortkey) return;
const handleKeyDown = (e: KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
// Escape cancels recording
if (e.key === 'Escape') {
setIsRecordingShortkey(false);
setShortkeyError(null);
return;
}
// Skip pure modifier keys
if (['Meta', 'Control', 'Alt', 'Shift'].includes(e.key)) return;
const keyString = keyEventToString(e, isMac);
// Validate the new shortkey
const error = validateShortkey(keyString);
if (error) {
setShortkeyError(error);
// Don't stop recording, let user try again
return;
}
setShortkeyError(null);
setEditingSnippet(prev => ({ ...prev, shortkey: keyString }));
setIsRecordingShortkey(false);
};
const handleClick = () => {
setIsRecordingShortkey(false);
setShortkeyError(null);
};
// Delay adding click handler by 100ms to prevent the button click that
// initiated recording from immediately triggering the click handler
const timer = setTimeout(() => {
window.addEventListener('click', handleClick, true);
}, 100);
window.addEventListener('keydown', handleKeyDown, true);
return () => {
clearTimeout(timer);
window.removeEventListener('keydown', handleKeyDown, true);
window.removeEventListener('click', handleClick, true);
};
}, [isRecordingShortkey, isMac, validateShortkey]);
const handleEdit = (snippet?: Snippet) => {
if (snippet) {
setEditingSnippet(snippet);
@@ -105,6 +299,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
tags: editingSnippet.tags || [],
package: editingSnippet.package || '',
targets: targetSelection,
shortkey: editingSnippet.shortkey,
});
setRightPanelMode('none');
}
@@ -144,23 +339,60 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
const displayedPackages = useMemo(() => {
if (!selectedPackage) {
const roots = packages
// Separate absolute paths (starting with /) from relative paths
const absolutePaths = packages.filter(p => p.startsWith('/'));
const relativePaths = packages.filter(p => !p.startsWith('/'));
const results: { name: string; path: string; count: number }[] = [];
// Process relative paths (traditional behavior)
const relativeRoots = relativePaths
.map((p) => p.split('/')[0])
.filter(Boolean);
return Array.from(new Set(roots)).map((name) => {
const path = name;
const count = snippets.filter((s) => (s.package || '') === path).length;
return { name, path, count };
.filter((name): name is string => Boolean(name) && name.length > 0);
Array.from(new Set(relativeRoots)).forEach((name: string) => {
const path: string = name;
const count = snippets.filter((s) => {
const pkg = s.package || '';
return pkg === path || pkg.startsWith(path + '/');
}).length;
results.push({ name, path, count });
});
// Process absolute paths - show them as separate roots with "/" prefix
const absoluteRoots = absolutePaths
.map((p) => {
const cleanPath = p.substring(1); // Remove leading slash
const firstSegment = cleanPath.split('/')[0];
return firstSegment;
})
.filter((name): name is string => Boolean(name) && name.length > 0);
Array.from(new Set(absoluteRoots)).forEach((name: string) => {
const path: string = `/${name}`;
const displayName: string = `/${name}`; // Show with leading slash to distinguish
const count = snippets.filter((s) => {
const pkg = s.package || '';
return pkg === path || pkg.startsWith(path + '/');
}).length;
results.push({ name: displayName, path, count });
});
return results;
}
const prefix = selectedPackage + '/';
const children = packages
.filter((p) => p.startsWith(prefix))
.map((p) => p.replace(prefix, '').split('/')[0])
.filter(Boolean);
.filter((name): name is string => Boolean(name) && name.length > 0);
return Array.from(new Set(children)).map((name) => {
const path = `${selectedPackage}/${name}`;
const count = snippets.filter((s) => (s.package || '') === path).length;
// Count snippets in this package AND all nested packages
const count = snippets.filter((s) => {
const pkg = s.package || '';
return pkg === path || pkg.startsWith(path + '/');
}).length;
return { name, path, count };
});
}, [packages, selectedPackage, snippets]);
@@ -191,28 +423,76 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
const breadcrumb = useMemo(() => {
if (!selectedPackage) return [];
const isAbsolute = selectedPackage.startsWith('/');
const parts = selectedPackage.split('/').filter(Boolean);
return parts.map((name, idx) => ({ name, path: parts.slice(0, idx + 1).join('/') }));
return parts.map((name, idx) => {
const pathSegments = parts.slice(0, idx + 1);
const path = isAbsolute ? `/${pathSegments.join('/')}` : pathSegments.join('/');
return { name, path };
});
}, [selectedPackage]);
const createPackage = () => {
const name = newPackageName.trim();
if (!name) return;
const full = selectedPackage ? `${selectedPackage}/${name}` : name;
if (!packages.includes(full)) onPackagesChange([...packages, full]);
// Allow leading slash and validate the rest - allow hyphens anywhere in package names
if (!/^\/?([\w-]+(\/[\w-]+)*)\/?$/.test(name)) {
// Could add toast notification here for invalid characters
return;
}
// Normalize path construction to avoid double slashes
let full: string;
if (selectedPackage) {
// Strip leading slash from name when we're inside a package to avoid double slashes
const normalizedName = name.startsWith('/') ? name.substring(1) : name;
full = `${selectedPackage}/${normalizedName}`;
} else {
// At root level, preserve the leading slash if user intended it
full = name;
}
// Strip trailing slash to ensure consistent path handling
if (full.endsWith('/')) {
full = full.slice(0, -1);
}
// Check for duplicate package names (case-insensitive)
const existingPackage = packages.find(p => p.toLowerCase() === full.toLowerCase());
if (existingPackage) {
// Could add toast notification here for duplicate package
return;
}
onPackagesChange([...packages, full]);
setNewPackageName('');
setIsPackageDialogOpen(false);
};
const deletePackage = (path: string) => {
// Remove the package and all its children
const keep = packages.filter((p) => !(p === path || p.startsWith(path + '/')));
// Move all snippets from deleted packages to root
const updatedSnippets = snippets.map((s) => {
if (!s.package) return s;
if (s.package === path || s.package.startsWith(path + '/')) return { ...s, package: '' };
if (s.package === path || s.package.startsWith(path + '/')) {
return { ...s, package: '' };
}
return s;
});
// Update packages first, then save snippets
onPackagesChange(keep);
updatedSnippets.forEach(onSave);
// Only save snippets that were actually modified
const modifiedSnippets = updatedSnippets.filter((s, index) =>
s.package !== snippets[index].package
);
modifiedSnippets.forEach(onSave);
// Reset selected package if it was deleted
if (selectedPackage && (selectedPackage === path || selectedPackage.startsWith(path + '/'))) {
setSelectedPackage(null);
}
@@ -220,24 +500,125 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
const movePackage = (source: string, target: string | null) => {
const name = source.split('/').pop() || '';
const newPath = target ? `${target}/${name}` : name;
const isAbsolute = source.startsWith('/');
const newPath = target ? `${target}/${name}` : (isAbsolute ? `/${name}` : name);
if (newPath === source || newPath.startsWith(source + '/')) return;
// Check if target path already exists
if (packages.includes(newPath)) return;
const updatedPackages = packages.map((p) => {
if (p === source) return newPath;
if (p.startsWith(source + '/')) return p.replace(source, newPath);
// Use more precise replacement to avoid substring issues
if (p.startsWith(source + '/')) {
return newPath + p.substring(source.length);
}
return p;
});
const updatedSnippets = snippets.map((s) => {
if (!s.package) return s;
if (s.package === source) return { ...s, package: newPath };
if (s.package.startsWith(source + '/')) return { ...s, package: s.package.replace(source, newPath) };
// Use more precise replacement to avoid substring issues
if (s.package.startsWith(source + '/')) {
return { ...s, package: newPath + s.package.substring(source.length) };
}
return s;
});
onPackagesChange(Array.from(new Set(updatedPackages)));
updatedSnippets.forEach(onSave);
if (selectedPackage === source) setSelectedPackage(newPath);
};
const openRenameDialog = (path: string) => {
const name = path.split('/').pop() || '';
setRenamingPackagePath(path);
setRenamePackageName(name);
setRenameError('');
setIsRenameDialogOpen(true);
};
const renamePackage = () => {
if (!renamingPackagePath) return;
const newName = renamePackageName.trim();
// Validate: empty name
if (!newName) {
setRenameError(t('snippets.renameDialog.error.empty'));
return;
}
// Validate: same rules as createPackage - only allow letters, numbers, hyphens, underscores
// Since we're renaming a single segment (no slashes allowed), use the segment-level pattern
if (!/^[\w-]+$/.test(newName)) {
setRenameError(t('snippets.renameDialog.error.invalidChars'));
return;
}
// Build new path
const parts = renamingPackagePath.split('/');
parts[parts.length - 1] = newName;
const newPath = parts.join('/');
// Validate: same name
if (newPath === renamingPackagePath) {
setIsRenameDialogOpen(false);
return;
}
// Validate: duplicate (case-insensitive)
const existingPackage = packages.find(p => p.toLowerCase() === newPath.toLowerCase());
if (existingPackage) {
setRenameError(t('snippets.renameDialog.error.duplicate'));
return;
}
// Update all packages with this path or nested under it
const updatedPackages = packages.map((p) => {
if (p === renamingPackagePath) return newPath;
if (p.startsWith(renamingPackagePath + '/')) {
return newPath + p.substring(renamingPackagePath.length);
}
return p;
});
// Update all snippets with this package or nested under it
const updatedSnippets = snippets.map((s) => {
if (!s.package) return s;
if (s.package === renamingPackagePath) return { ...s, package: newPath };
if (s.package.startsWith(renamingPackagePath + '/')) {
return { ...s, package: newPath + s.package.substring(renamingPackagePath.length) };
}
return s;
});
onPackagesChange(Array.from(new Set(updatedPackages)));
updatedSnippets.forEach(onSave);
// Update selected package if it was renamed
if (selectedPackage === renamingPackagePath) {
setSelectedPackage(newPath);
} else if (selectedPackage?.startsWith(renamingPackagePath + '/')) {
setSelectedPackage(newPath + selectedPackage.substring(renamingPackagePath.length));
}
// Update editingSnippet.package if it's in the renamed package (fixes stale state when editing)
if (editingSnippet.package) {
if (editingSnippet.package === renamingPackagePath) {
setEditingSnippet(prev => ({ ...prev, package: newPath }));
} else if (editingSnippet.package.startsWith(renamingPackagePath + '/')) {
setEditingSnippet(prev => ({
...prev,
package: newPath + prev.package!.substring(renamingPackagePath.length)
}));
}
}
setIsRenameDialogOpen(false);
};
const moveSnippet = (id: string, pkg: string | null) => {
const sn = snippets.find((s) => s.id === id);
if (!sn) return;
@@ -246,11 +627,36 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
// Package options for Combobox
const packageOptions: ComboboxOption[] = useMemo(() => {
return packages.map(p => ({
value: p,
label: p.includes('/') ? p.split('/').pop()! : p,
sublabel: p.includes('/') ? p : undefined,
}));
// Generate all possible parent paths for each package
const allPaths = new Set<string>();
packages.forEach(pkg => {
// Add the full package path
allPaths.add(pkg);
// Add all parent paths
const parts = pkg.split('/').filter(Boolean);
const isAbsolute = pkg.startsWith('/');
for (let i = 1; i < parts.length; i++) {
const parentPath = (isAbsolute ? '/' : '') + parts.slice(0, i).join('/');
allPaths.add(parentPath);
}
});
return Array.from(allPaths)
.sort((a, b) => {
// Sort by depth first (shorter paths first), then alphabetically
const depthA = (a.match(/\//g) || []).length;
const depthB = (b.match(/\//g) || []).length;
if (depthA !== depthB) return depthA - depthB;
return a.localeCompare(b);
})
.map(p => ({
value: p,
label: p.includes('/') ? p.split('/').pop()! : p,
sublabel: p.includes('/') ? p : undefined,
}));
}, [packages]);
// Shell history lazy loading
@@ -310,6 +716,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
onBack={handleTargetPickerBack}
onContinue={handleTargetPickerBack}
availableKeys={availableKeys}
managedSources={managedSources}
onSaveHost={onSaveHost}
onCreateGroup={onCreateGroup}
title={t('snippets.targets.add')}
@@ -354,7 +761,13 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
<Combobox
options={packageOptions}
value={editingSnippet.package || selectedPackage || ''}
onValueChange={(val) => setEditingSnippet({ ...editingSnippet, package: val })}
onValueChange={(val) => {
setEditingSnippet({ ...editingSnippet, package: val });
// If selecting an implicit parent path, persist it to packages
if (val && !packages.includes(val)) {
onPackagesChange([...packages, val]);
}
}}
placeholder={t('snippets.field.packagePlaceholder')}
allowCreate={true}
onCreateNew={(val) => {
@@ -379,6 +792,50 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
/>
</Card>
{/* Shortkey */}
<Card className="p-3 space-y-2 bg-card border-border/80">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold text-muted-foreground">{t('snippets.field.shortkey')}</p>
{editingSnippet.shortkey && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => {
setEditingSnippet(prev => ({ ...prev, shortkey: undefined }));
setShortkeyError(null);
}}
title={t('snippets.shortkey.clear')}
>
<RotateCcw size={12} />
</Button>
)}
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsRecordingShortkey(true);
setShortkeyError(null);
}}
className={cn(
"w-full h-10 px-3 text-sm font-mono rounded-lg border transition-colors flex items-center justify-center gap-2",
isRecordingShortkey
? "border-primary bg-primary/10 animate-pulse"
: "border-border hover:border-primary/50 bg-background"
)}
>
<Keyboard size={14} className="text-muted-foreground" />
{isRecordingShortkey
? t('snippets.shortkey.recording')
: editingSnippet.shortkey || t('snippets.shortkey.placeholder')}
</button>
{shortkeyError && (
<p className="text-xs text-destructive">{shortkeyError}</p>
)}
<p className="text-[11px] text-muted-foreground">{t('snippets.shortkey.hint')}</p>
</Card>
{/* Targets */}
<Card className="p-3 space-y-3 bg-card border-border/80">
<div className="flex items-center justify-between">
@@ -565,12 +1022,12 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
{!snippets.length && displayedPackages.length === 0 && (
<div className="flex-1 flex items-center justify-center px-4">
<div className="max-w-md w-full text-center space-y-3 py-12 rounded-2xl bg-secondary/60 border border-border/60 shadow-lg">
<div className="mx-auto h-12 w-12 rounded-xl bg-muted text-muted-foreground flex items-center justify-center">
<FileCode size={22} />
<div className="flex flex-col items-center justify-center text-muted-foreground">
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
<FileCode size={32} className="opacity-60" />
</div>
<div className="text-sm font-semibold text-foreground">{t('snippets.empty.title')}</div>
<div className="text-xs text-muted-foreground px-8">{t('snippets.empty.desc')}</div>
<h3 className="text-lg font-semibold text-foreground mb-2">{t('snippets.empty.title')}</h3>
<p className="text-sm text-center max-w-sm">{t('snippets.empty.desc')}</p>
</div>
</div>
)}
@@ -624,6 +1081,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => setSelectedPackage(pkg.path)}>{t('action.open')}</ContextMenuItem>
<ContextMenuItem onClick={() => openRenameDialog(pkg.path)}>{t('common.rename')}</ContextMenuItem>
<ContextMenuItem className="text-destructive" onClick={() => deletePackage(pkg.path)}>{t('action.delete')}</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
@@ -667,6 +1125,11 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
{snippet.command.replace(/\s+/g, ' ') || t('snippets.commandFallback')}
</div>
</div>
{snippet.shortkey && (
<div className="shrink-0 px-2 py-1 text-[10px] font-mono rounded border border-border bg-muted/50 text-muted-foreground">
{snippet.shortkey}
</div>
)}
{viewMode === 'list' && (
<Button
variant="ghost"
@@ -729,6 +1192,8 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
value={newPackageName}
onChange={(e) => setNewPackageName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && createPackage()}
pattern="^/?([\w-]+(/[\w-]+)*)?/?$"
title="Package names can contain letters, numbers, hyphens, underscores, and forward slashes. Can optionally start with /"
/>
<p className="text-[11px] text-muted-foreground">{t('snippets.packageDialog.hint')}</p>
</div>
@@ -742,6 +1207,40 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
</div>
)}
{/* Rename Package Dialog */}
{isRenameDialogOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<Card className="w-full max-w-sm p-4 space-y-4">
<div>
<p className="text-sm font-semibold">{t('snippets.renameDialog.title')}</p>
<p className="text-xs text-muted-foreground">{t('snippets.renameDialog.currentPath', { path: renamingPackagePath })}</p>
</div>
<div className="space-y-2">
<Label>{t('field.name')}</Label>
<Input
autoFocus
placeholder={t('snippets.renameDialog.placeholder')}
value={renamePackageName}
onChange={(e) => {
setRenamePackageName(e.target.value);
setRenameError('');
}}
onKeyDown={(e) => e.key === 'Enter' && renamePackage()}
/>
{renameError && (
<p className="text-[11px] text-destructive">{renameError}</p>
)}
</div>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setIsRenameDialogOpen(false)}>
{t('common.cancel')}
</Button>
<Button onClick={renamePackage}>{t('common.rename')}</Button>
</div>
</Card>
</div>
)}
{/* Right Panel */}
{renderRightPanel()}
</div>

View File

@@ -3,7 +3,7 @@ import { FitAddon } from "@xterm/addon-fit";
import { SerializeAddon } from "@xterm/addon-serialize";
import { SearchAddon } from "@xterm/addon-search";
import "@xterm/xterm/css/xterm.css";
import { Maximize2, Radio } from "lucide-react";
import { Cpu, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine } from "lucide-react";
import React, { memo, useEffect, useMemo, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { useI18n } from "../application/i18n/I18nProvider";
@@ -26,21 +26,75 @@ import { useTerminalBackend } from "../application/state/useTerminalBackend";
import KnownHostConfirmDialog, { HostKeyInfo } from "./KnownHostConfirmDialog";
import SFTPModal from "./SFTPModal";
import { Button } from "./ui/button";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card";
import { toast } from "./ui/toast";
import { useAvailableFonts } from "../application/state/fontStore";
import { TERMINAL_THEMES } from "../infrastructure/config/terminalThemes";
import { useCustomThemes } from "../application/state/customThemeStore";
import { TerminalConnectionDialog } from "./terminal/TerminalConnectionDialog";
import { TerminalToolbar } from "./terminal/TerminalToolbar";
import { TerminalComposeBar } from "./terminal/TerminalComposeBar";
import { TerminalContextMenu } from "./terminal/TerminalContextMenu";
import { TerminalSearchBar } from "./terminal/TerminalSearchBar";
import { createHighlightProcessor } from "./terminal/keywordHighlight";
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
import { createXTermRuntime, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
import { XTERM_PERFORMANCE_CONFIG } from "../infrastructure/config/xtermPerformance";
import { useTerminalSearch } from "./terminal/hooks/useTerminalSearch";
import { useTerminalContextActions } from "./terminal/hooks/useTerminalContextActions";
import { useTerminalAuthState } from "./terminal/hooks/useTerminalAuthState";
import { useServerStats } from "./terminal/hooks/useServerStats";
import { extractDropEntries, getPathForFile, DropEntry } from "../lib/sftpFileUtils";
/**
* Extract unique root paths from drop entries for local terminal path insertion.
* For nested files, extracts the root folder path; for single files, uses the full path.
* Paths with spaces are quoted.
*/
function extractRootPathsFromDropEntries(dropEntries: DropEntry[]): string[] {
const paths: string[] = [];
const seenPaths = new Set<string>();
for (const entry of dropEntries) {
if (!entry.file) continue;
const fullPath = getPathForFile(entry.file);
if (!fullPath) continue;
const pathParts = entry.relativePath.split('/');
if (pathParts.length > 1) {
// Nested file in a folder - extract the root folder path
const rootFolderName = pathParts[0];
const separator = fullPath.includes('\\') ? '\\' : '/';
// Find the position of the root folder name in the full path
const rootFolderIndex = fullPath.lastIndexOf(separator + rootFolderName + separator);
const altRootFolderIndex = fullPath.lastIndexOf(separator + rootFolderName);
const folderStartIndex = rootFolderIndex !== -1
? rootFolderIndex + 1
: (altRootFolderIndex !== -1 ? altRootFolderIndex + 1 : -1);
if (folderStartIndex !== -1) {
const folderEndIndex = folderStartIndex + rootFolderName.length;
const folderPath = fullPath.substring(0, folderEndIndex);
if (!seenPaths.has(folderPath)) {
paths.push(folderPath.includes(' ') ? `"${folderPath}"` : folderPath);
seenPaths.add(folderPath);
}
}
} else {
// Single file (not in a folder)
if (!seenPaths.has(fullPath)) {
paths.push(fullPath.includes(' ') ? `"${fullPath}"` : fullPath);
seenPaths.add(fullPath);
}
}
}
return paths;
}
interface TerminalProps {
host: Host;
@@ -85,9 +139,24 @@ interface TerminalProps {
onSplitVertical?: () => void;
isBroadcastEnabled?: boolean;
onToggleBroadcast?: () => void;
onToggleComposeBar?: () => void;
isWorkspaceComposeBarOpen?: boolean;
onBroadcastInput?: (data: string, sourceSessionId: string) => void;
}
// Helper function to format network speed (bytes/sec) to human-readable format
function formatNetSpeed(bytesPerSec: number): string {
if (bytesPerSec < 1024) {
return `${bytesPerSec}B/s`;
} else if (bytesPerSec < 1024 * 1024) {
return `${(bytesPerSec / 1024).toFixed(1)}K/s`;
} else if (bytesPerSec < 1024 * 1024 * 1024) {
return `${(bytesPerSec / (1024 * 1024)).toFixed(1)}M/s`;
} else {
return `${(bytesPerSec / (1024 * 1024 * 1024)).toFixed(1)}G/s`;
}
}
const TerminalComponent: React.FC<TerminalProps> = ({
host,
keys,
@@ -126,6 +195,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onSplitVertical,
isBroadcastEnabled,
onToggleBroadcast,
onToggleComposeBar,
isWorkspaceComposeBarOpen,
onBroadcastInput,
}) => {
// Timeout for connection - increased to 120s to allow time for keyboard-interactive (2FA) authentication
@@ -149,13 +220,34 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const terminalSettingsRef = useRef(terminalSettings);
terminalSettingsRef.current = terminalSettings;
const highlightProcessorRef = useRef<(text: string) => string>((t) => t);
useEffect(() => {
highlightProcessorRef.current = createHighlightProcessor(
terminalSettings?.keywordHighlightRules ?? [],
terminalSettings?.keywordHighlightEnabled ?? false,
);
}, [terminalSettings?.keywordHighlightEnabled, terminalSettings?.keywordHighlightRules]);
if (xtermRuntimeRef.current) {
// Merge global rules with host-level rules
// Host-level rules are appended to global rules, allowing hosts to add custom highlighting
const globalRules = terminalSettings?.keywordHighlightRules ?? [];
const hostRules = host?.keywordHighlightRules ?? [];
// Check if highlighting is enabled at either global or host level
const globalEnabled = terminalSettings?.keywordHighlightEnabled ?? false;
const hostEnabled = host?.keywordHighlightEnabled ?? false;
// Merge rules: include only rules from enabled sources
const mergedRules = [
...(globalEnabled ? globalRules : []),
...(hostEnabled ? hostRules : [])
];
// Enable highlighting if either global or host-level is enabled
const isEnabled = globalEnabled || hostEnabled;
xtermRuntimeRef.current.keywordHighlighter.setRules(mergedRules, isEnabled);
}
}, [
terminalSettings?.keywordHighlightEnabled,
terminalSettings?.keywordHighlightRules,
host?.keywordHighlightEnabled,
host?.keywordHighlightRules
]);
const hotkeySchemeRef = useRef(hotkeyScheme);
const keyBindingsRef = useRef(keyBindings);
@@ -169,6 +261,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
isBroadcastEnabledRef.current = isBroadcastEnabled;
onBroadcastInputRef.current = onBroadcastInput;
// Snippets ref for shortkey support in terminal
const snippetsRef = useRef(snippets);
snippetsRef.current = snippets;
const terminalBackend = useTerminalBackend();
const { resizeSession } = terminalBackend;
@@ -196,6 +292,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
currentHostLabel: string;
} | null>(null);
// Drag and drop state
const [isDraggingOver, setIsDraggingOver] = useState(false);
const dragCounterRef = useRef(0);
const [pendingUploadEntries, setPendingUploadEntries] = useState<DropEntry[]>([]);
const [isComposeBarOpen, setIsComposeBarOpen] = useState(false);
const terminalSearch = useTerminalSearch({ searchAddonRef, termRef });
const {
isSearchOpen,
@@ -208,6 +310,19 @@ const TerminalComponent: React.FC<TerminalProps> = ({
handleCloseSearch,
} = terminalSearch;
// Check if this is a local or serial connection (doesn't need connection dialog during connecting)
const isLocalConnection = host.protocol === "local";
const isSerialConnection = host.protocol === "serial";
// Server stats (CPU, Memory, Disk) for Linux servers
const { stats: serverStats } = useServerStats({
sessionId,
enabled: terminalSettings?.showServerStats ?? true,
refreshInterval: terminalSettings?.serverStatsRefreshInterval ?? 5,
isLinux: host.os === 'linux',
isConnected: status === 'connected',
});
useEffect(() => {
if (!error) {
lastToastedErrorRef.current = null;
@@ -236,13 +351,17 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const [pendingHostKeyInfo, setPendingHostKeyInfo] = useState<HostKeyInfo | null>(null);
const pendingConnectionRef = useRef<(() => void) | null>(null);
// Subscribe to custom theme changes so editing triggers re-render
const customThemes = useCustomThemes();
const effectiveTheme = useMemo(() => {
if (host.theme) {
const hostTheme = TERMINAL_THEMES.find((t) => t.id === host.theme);
const hostTheme = TERMINAL_THEMES.find((t) => t.id === host.theme)
|| customThemes.find((t) => t.id === host.theme);
if (hostTheme) return hostTheme;
}
return terminalTheme;
}, [host.theme, terminalTheme]);
}, [host.theme, terminalTheme, customThemes]);
const resolvedChainHosts =
(host.hostChain?.hostIds
@@ -298,7 +417,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
disposeExitRef,
fitAddonRef,
serializeAddonRef,
highlightProcessorRef,
pendingAuthRef,
updateStatus,
setStatus,
@@ -309,6 +427,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
setProgressLogs,
setProgressValue,
setChainProgress,
t,
onSessionExit,
onTerminalDataCapture,
onOsDetected,
@@ -342,6 +461,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onHotkeyActionRef,
isBroadcastEnabledRef,
onBroadcastInputRef,
snippetsRef,
sessionId,
statusRef,
onCommandExecuted,
@@ -359,6 +479,20 @@ const TerminalComponent: React.FC<TerminalProps> = ({
serializeAddonRef.current = runtime.serializeAddon;
searchAddonRef.current = runtime.searchAddon;
// Apply merged keyword highlight rules immediately after runtime creation
// This fixes a timing issue where the useEffect for keyword highlighting
// runs before the runtime is created, causing host-level rules to be missed
const globalRules = terminalSettingsRef.current?.keywordHighlightRules ?? [];
const hostRules = host?.keywordHighlightRules ?? [];
const globalEnabled = terminalSettingsRef.current?.keywordHighlightEnabled ?? false;
const hostEnabled = host?.keywordHighlightEnabled ?? false;
const mergedRules = [
...(globalEnabled ? globalRules : []),
...(hostEnabled ? hostRules : [])
];
const isEnabled = globalEnabled || hostEnabled;
runtime.keywordHighlighter.setRules(mergedRules, isEnabled);
const term = runtime.term;
if (host.protocol === "serial") {
@@ -427,8 +561,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
useEffect(() => {
if (status !== "connecting" || auth.needsAuth) return;
// Local terminal and serial connections don't need timeout/progress UI
if (isLocalConnection || isSerialConnection) return;
// Only show SSH-specific scripted logs for SSH connections
const isSSH = host.protocol !== "serial" && host.protocol !== "local" && host.protocol !== "telnet" && host.hostname !== "localhost";
const isSSH = host.protocol !== "telnet";
let stepTimer: ReturnType<typeof setInterval> | undefined;
if (isSSH) {
@@ -525,7 +662,18 @@ const TerminalComponent: React.FC<TerminalProps> = ({
| 700
| 800
| 900;
termRef.current.options.fontWeightBold = terminalSettings.fontWeightBold as
const resolvedFontWeightBold = (() => {
const fontFamily = termRef.current?.options.fontFamily || "";
if (typeof document === "undefined" || !document.fonts?.check) {
return terminalSettings.fontWeightBold;
}
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${fontFamily}`;
return document.fonts.check(weightSpec)
? terminalSettings.fontWeightBold
: terminalSettings.fontWeight;
})();
termRef.current.options.fontWeightBold = resolvedFontWeightBold as
| 100
| 200
| 300
@@ -543,6 +691,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
termRef.current.options.scrollOnUserInput = terminalSettings.scrollOnInput;
termRef.current.options.altClickMovesCursor = !terminalSettings.altAsMeta;
termRef.current.options.wordSeparator = terminalSettings.wordSeparators;
termRef.current.options.ignoreBracketedPasteMode = terminalSettings.disableBracketedPaste ?? false;
}
setTimeout(() => safeFit(), 50);
@@ -601,6 +750,27 @@ const TerminalComponent: React.FC<TerminalProps> = ({
logger.warn("Fit after fonts ready failed", err);
}
if (terminalSettings && termRef.current) {
const fontFamily = termRef.current.options?.fontFamily || "";
const effectiveFontSize = host.fontSize || fontSize;
if (typeof document !== "undefined" && document.fonts?.check) {
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${fontFamily}`;
const resolvedBold = document.fonts.check(weightSpec)
? terminalSettings.fontWeightBold
: terminalSettings.fontWeight;
termRef.current.options.fontWeightBold = resolvedBold as
| 100
| 200
| 300
| 400
| 500
| 600
| 700
| 800
| 900;
}
}
const id = sessionRef.current;
if (id && term) {
try {
@@ -618,7 +788,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return () => {
cancelled = true;
};
}, [host.id, sessionId, resizeSession]);
}, [host.id, host.fontFamily, host.fontSize, fontFamilyId, fontSize, resizeSession, sessionId, terminalSettings]);
useEffect(() => {
if (!containerRef.current || !fitAddonRef.current) return;
@@ -718,11 +888,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
};
}, []);
const disableBracketedPasteRef = useRef(terminalSettings?.disableBracketedPaste ?? false);
disableBracketedPasteRef.current = terminalSettings?.disableBracketedPaste ?? false;
const terminalContextActions = useTerminalContextActions({
termRef,
sessionRef,
terminalBackend,
onHasSelectionChange: setHasSelection,
disableBracketedPasteRef,
});
const handleSnippetClick = (cmd: string) => {
@@ -828,6 +1002,95 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}
};
// Drag and drop handlers
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current++;
if (e.dataTransfer.types.includes('Files')) {
setIsDraggingOver(true);
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.dataTransfer.types.includes('Files')) {
e.dataTransfer.dropEffect = 'copy';
}
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current--;
if (dragCounterRef.current === 0) {
setIsDraggingOver(false);
}
};
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounterRef.current = 0;
setIsDraggingOver(false);
if (!e.dataTransfer.types.includes('Files')) {
return;
}
// Only handle drops on connected terminals
if (status !== 'connected') {
toast.error(t("terminal.dragDrop.notConnected"), t("terminal.dragDrop.errorTitle"));
return;
}
try {
const dropEntries = await extractDropEntries(e.dataTransfer);
if (dropEntries.length === 0) {
return;
}
if (isLocalConnection) {
// Local terminal: Insert absolute paths
const paths = extractRootPathsFromDropEntries(dropEntries);
if (paths.length > 0 && termRef.current && sessionRef.current) {
const pathsText = paths.join(' ');
// Write the paths to the terminal
terminalBackend.writeToSession(sessionRef.current, pathsText);
termRef.current.focus();
}
} else {
// Remote terminal: Trigger SFTP upload
// Get current working directory for SFTP initial path
let initialPath: string | undefined = undefined;
if (sessionRef.current) {
try {
const result = await terminalBackend.getSessionPwd(sessionRef.current);
if (result.success && result.cwd) {
initialPath = result.cwd;
}
} catch {
// Silently fail and open SFTP without initial path
}
}
setPendingUploadEntries(dropEntries);
// Use flushSync to ensure sftpInitialPath is updated synchronously
// before setShowSFTP(true) triggers the modal open
flushSync(() => {
setSftpInitialPath(initialPath);
});
setShowSFTP(true);
}
} catch (error) {
logger.error("Failed to handle file drop", error);
toast.error(t("terminal.dragDrop.errorMessage"), t("terminal.dragDrop.errorTitle"));
}
};
const renderControls = (opts?: { showClose?: boolean }) => (
<TerminalToolbar
status={status}
@@ -848,6 +1111,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onClose={() => onCloseSession?.(sessionId)}
isSearchOpen={isSearchOpen}
onToggleSearch={handleToggleSearch}
isComposeBarOpen={inWorkspace ? isWorkspaceComposeBarOpen : isComposeBarOpen}
onToggleComposeBar={inWorkspace ? onToggleComposeBar : () => setIsComposeBarOpen(prev => !prev)}
/>
);
@@ -864,6 +1129,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
<TerminalContextMenu
hasSelection={hasSelection}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
rightClickBehavior={terminalSettings?.rightClickBehavior}
onCopy={terminalContextActions.onCopy}
onPaste={terminalContextActions.onPaste}
@@ -874,7 +1140,37 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onSplitVertical={onSplitVertical}
onClose={inWorkspace ? () => onCloseSession?.(sessionId) : undefined}
>
<div className="relative h-full w-full flex overflow-hidden bg-gradient-to-br from-[#050910] via-[#06101a] to-[#0b1220]">
<div
className={cn(
"relative h-full w-full flex overflow-hidden bg-gradient-to-br from-[#050910] via-[#06101a] to-[#0b1220]",
isComposeBarOpen && !inWorkspace && "flex-col"
)}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* Drag and drop overlay */}
{isDraggingOver && (
<div className="absolute inset-0 z-50 bg-blue-600/20 backdrop-blur-sm border-4 border-dashed border-blue-400 pointer-events-none flex items-center justify-center">
<div className="bg-background/90 backdrop-blur-md rounded-lg shadow-lg p-6 border border-border">
<div className="text-center">
<div className="text-lg font-semibold mb-2">
{isLocalConnection
? t("terminal.dragDrop.localTitle")
: t("terminal.dragDrop.remoteTitle")
}
</div>
<div className="text-sm text-muted-foreground">
{isLocalConnection
? t("terminal.dragDrop.localMessage")
: t("terminal.dragDrop.remoteMessage")
}
</div>
</div>
</div>
</div>
)}
<div className="absolute left-0 right-0 top-0 z-20 pointer-events-none">
<div
className="flex items-center gap-1 px-2 py-0.5 backdrop-blur-md pointer-events-auto min-w-0 border-b-[0.5px]"
@@ -898,6 +1194,294 @@ const TerminalComponent: React.FC<TerminalProps> = ({
)}
/>
</div>
{/* Server Stats Display - Linux only */}
{host.os === 'linux' && terminalSettings?.showServerStats && status === 'connected' && serverStats.lastUpdated && (
<div className="flex items-center gap-2.5 ml-2 text-[10px] opacity-80 flex-nowrap overflow-hidden min-w-0">
{/* CPU with HoverCard for per-core details */}
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild>
<button
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
title={t("terminal.serverStats.cpu")}
>
<Cpu size={10} className="flex-shrink-0" />
<span>
{serverStats.cpu !== null ? `${serverStats.cpu}%` : '--'}
{serverStats.cpuCores !== null && ` (${serverStats.cpuCores}C)`}
</span>
</button>
</HoverCardTrigger>
<HoverCardContent
className="w-auto p-3"
side="bottom"
align="start"
sideOffset={8}
>
<div className="text-xs space-y-2">
<div className="font-medium text-sm mb-2">{t("terminal.serverStats.cpuCores")}</div>
{serverStats.cpuPerCore.length > 0 ? (
<div className="grid gap-1.5" style={{ gridTemplateColumns: `repeat(${Math.min(4, serverStats.cpuPerCore.length)}, 1fr)` }}>
{serverStats.cpuPerCore.map((usage, index) => (
<div key={index} className="flex flex-col items-center gap-1 min-w-[48px]">
<div className="text-[10px] text-muted-foreground">Core {index}</div>
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all",
usage >= 90 ? "bg-red-500" : usage >= 70 ? "bg-amber-500" : "bg-emerald-500"
)}
style={{ width: `${usage}%` }}
/>
</div>
<div className={cn(
"text-[11px] font-medium",
usage >= 90 ? "text-red-400" : usage >= 70 ? "text-amber-400" : "text-emerald-400"
)}>
{usage}%
</div>
</div>
))}
</div>
) : (
<div className="text-muted-foreground">{t("terminal.serverStats.noData")}</div>
)}
</div>
</HoverCardContent>
</HoverCard>
{/* Memory with HoverCard for htop-style bar and top processes */}
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild>
<button
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
title={t("terminal.serverStats.memory")}
>
<MemoryStick size={10} className="flex-shrink-0" />
<span>
{serverStats.memUsed !== null && serverStats.memTotal !== null
? `${(serverStats.memUsed / 1024).toFixed(1)}/${(serverStats.memTotal / 1024).toFixed(1)}G`
: '--'}
</span>
</button>
</HoverCardTrigger>
<HoverCardContent
className="w-auto p-3"
side="bottom"
align="start"
sideOffset={8}
>
<div className="text-xs space-y-3 min-w-[280px]">
<div className="font-medium text-sm">{t("terminal.serverStats.memoryDetails")}</div>
{/* htop-style memory bar */}
{serverStats.memTotal !== null && (
<div className="space-y-1.5">
<div className="w-full h-3 bg-muted rounded overflow-hidden flex">
{/* Used (green) */}
{serverStats.memUsed !== null && serverStats.memUsed > 0 && (
<div
className="h-full bg-emerald-500"
style={{ width: `${(serverStats.memUsed / serverStats.memTotal) * 100}%` }}
title={`${t("terminal.serverStats.memUsed")}: ${(serverStats.memUsed / 1024).toFixed(1)}G`}
/>
)}
{/* Buffers (blue) */}
{serverStats.memBuffers !== null && serverStats.memBuffers > 0 && (
<div
className="h-full bg-blue-500"
style={{ width: `${(serverStats.memBuffers / serverStats.memTotal) * 100}%` }}
title={`${t("terminal.serverStats.memBuffers")}: ${(serverStats.memBuffers / 1024).toFixed(1)}G`}
/>
)}
{/* Cached (amber/orange) */}
{serverStats.memCached !== null && serverStats.memCached > 0 && (
<div
className="h-full bg-amber-500"
style={{ width: `${(serverStats.memCached / serverStats.memTotal) * 100}%` }}
title={`${t("terminal.serverStats.memCached")}: ${(serverStats.memCached / 1024).toFixed(1)}G`}
/>
)}
</div>
{/* Legend */}
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[10px]">
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm bg-emerald-500" />
<span>{t("terminal.serverStats.memUsed")}: {serverStats.memUsed !== null ? `${(serverStats.memUsed / 1024).toFixed(1)}G` : '--'}</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm bg-blue-500" />
<span>{t("terminal.serverStats.memBuffers")}: {serverStats.memBuffers !== null ? `${(serverStats.memBuffers / 1024).toFixed(1)}G` : '--'}</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm bg-amber-500" />
<span>{t("terminal.serverStats.memCached")}: {serverStats.memCached !== null ? `${(serverStats.memCached / 1024).toFixed(1)}G` : '--'}</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm bg-muted border border-border" />
<span>{t("terminal.serverStats.memFree")}: {serverStats.memFree !== null ? `${(serverStats.memFree / 1024).toFixed(1)}G` : '--'}</span>
</div>
</div>
</div>
)}
{/* Swap bar */}
{serverStats.swapTotal !== null && serverStats.swapTotal > 0 && (
<div className="space-y-1.5">
<div className="font-medium text-[11px] text-muted-foreground">{t("terminal.serverStats.swap")}</div>
<div className="w-full h-3 bg-muted rounded overflow-hidden flex">
{serverStats.swapUsed !== null && serverStats.swapUsed > 0 && (
<div
className="h-full bg-rose-500"
style={{ width: `${(serverStats.swapUsed / serverStats.swapTotal) * 100}%` }}
title={`${t("terminal.serverStats.swapUsed")}: ${(serverStats.swapUsed / 1024).toFixed(1)}G`}
/>
)}
</div>
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[10px]">
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm bg-rose-500" />
<span>{t("terminal.serverStats.swapUsed")}: {serverStats.swapUsed !== null ? `${(serverStats.swapUsed / 1024).toFixed(1)}G` : '--'}</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm bg-muted border border-border" />
<span>{t("terminal.serverStats.swapFree")}: {serverStats.swapTotal !== null && serverStats.swapUsed !== null ? `${((serverStats.swapTotal - serverStats.swapUsed) / 1024).toFixed(1)}G` : '--'}</span>
</div>
<div className="flex items-center gap-1">
<span className="text-muted-foreground">{t("terminal.serverStats.swapTotal")}: {`${(serverStats.swapTotal / 1024).toFixed(1)}G`}</span>
</div>
</div>
</div>
)}
{/* Top 10 processes */}
{serverStats.topProcesses.length > 0 && (
<div className="space-y-1.5">
<div className="font-medium text-[11px] text-muted-foreground">{t("terminal.serverStats.topProcesses")}</div>
<div className="space-y-0.5 max-h-[150px] overflow-y-auto">
{serverStats.topProcesses.map((proc, index) => (
<div key={index} className="flex items-center gap-2 text-[10px]">
<span className="w-[32px] text-right text-muted-foreground">{proc.memPercent.toFixed(1)}%</span>
<div className="flex-1 h-1 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-emerald-500 rounded-full"
style={{ width: `${Math.min(100, proc.memPercent * 2)}%` }}
/>
</div>
<span className="flex-shrink-0 font-mono truncate max-w-[140px]" title={proc.command}>
{proc.command.split('/').pop()?.split(' ')[0] || proc.command}
</span>
</div>
))}
</div>
</div>
)}
</div>
</HoverCardContent>
</HoverCard>
{/* Disk - with HoverCard for disk details */}
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild>
<button
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
title={t("terminal.serverStats.disk")}
>
<HardDrive size={10} className="flex-shrink-0" />
<span className={cn(
serverStats.diskPercent !== null && serverStats.diskPercent >= 90 && "text-red-400",
serverStats.diskPercent !== null && serverStats.diskPercent >= 80 && serverStats.diskPercent < 90 && "text-amber-400"
)}>
{serverStats.diskUsed !== null && serverStats.diskTotal !== null && serverStats.diskPercent !== null
? `${serverStats.diskUsed}/${serverStats.diskTotal}G (${serverStats.diskPercent}%)`
: serverStats.diskPercent !== null
? `${serverStats.diskPercent}%`
: '--'}
</span>
</button>
</HoverCardTrigger>
<HoverCardContent
className="w-auto p-3"
side="bottom"
align="start"
sideOffset={8}
>
<div className="text-xs space-y-2">
<div className="font-medium text-sm mb-2">{t("terminal.serverStats.diskDetails")}</div>
{serverStats.disks.length > 0 ? (
<div className="space-y-2 max-h-[200px] overflow-y-auto">
{serverStats.disks.map((disk, index) => (
<div key={index} className="flex flex-col gap-1 min-w-[180px]">
<div className="flex items-center justify-between gap-4">
<span className="text-[10px] text-muted-foreground font-mono truncate max-w-[120px]" title={disk.mountPoint}>
{disk.mountPoint}
</span>
<span className={cn(
"text-[11px] font-medium whitespace-nowrap",
disk.percent >= 90 ? "text-red-400" : disk.percent >= 80 ? "text-amber-400" : "text-emerald-400"
)}>
{disk.used}/{disk.total}G ({disk.percent}%)
</span>
</div>
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all",
disk.percent >= 90 ? "bg-red-500" : disk.percent >= 80 ? "bg-amber-500" : "bg-emerald-500"
)}
style={{ width: `${disk.percent}%` }}
/>
</div>
</div>
))}
</div>
) : (
<div className="text-muted-foreground">{t("terminal.serverStats.noData")}</div>
)}
</div>
</HoverCardContent>
</HoverCard>
{/* Network - with HoverCard for per-interface details */}
{serverStats.netInterfaces.length > 0 && (
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild>
<button
className="flex items-center gap-1 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
title={t("terminal.serverStats.network")}
>
<ArrowDownToLine size={9} className="flex-shrink-0 text-emerald-400" />
<span>{formatNetSpeed(serverStats.netRxSpeed)}</span>
<ArrowUpFromLine size={9} className="flex-shrink-0 text-sky-400" />
<span>{formatNetSpeed(serverStats.netTxSpeed)}</span>
</button>
</HoverCardTrigger>
<HoverCardContent
className="w-auto p-3"
side="bottom"
align="start"
sideOffset={8}
>
<div className="text-xs space-y-2">
<div className="font-medium text-sm mb-2">{t("terminal.serverStats.networkDetails")}</div>
<div className="space-y-2 max-h-[200px] overflow-y-auto">
{serverStats.netInterfaces.map((iface, index) => (
<div key={index} className="flex items-center justify-between gap-4 min-w-[200px]">
<span className="text-[10px] text-muted-foreground font-mono">
{iface.name}
</span>
<div className="flex items-center gap-2">
<span className="flex items-center gap-0.5 text-emerald-400">
<ArrowDownToLine size={9} />
{formatNetSpeed(iface.rxSpeed)}
</span>
<span className="flex items-center gap-0.5 text-sky-400">
<ArrowUpFromLine size={9} />
{formatNetSpeed(iface.txSpeed)}
</span>
</div>
</div>
))}
</div>
</div>
</HoverCardContent>
</HoverCard>
)}
</div>
)}
<div className="flex-1" />
<div className="flex items-center gap-0.5 flex-shrink-0">
{inWorkspace && onToggleBroadcast && (
@@ -979,49 +1563,71 @@ const TerminalComponent: React.FC<TerminalProps> = ({
</div>
)}
{status !== "connected" && !needsHostKeyVerification && (
<TerminalConnectionDialog
host={host}
status={status}
error={error}
progressValue={progressValue}
chainProgress={chainProgress}
needsAuth={auth.needsAuth}
showLogs={showLogs}
_setShowLogs={setShowLogs}
keys={keys}
authProps={{
authMethod: auth.authMethod,
setAuthMethod: auth.setAuthMethod,
authUsername: auth.authUsername,
setAuthUsername: auth.setAuthUsername,
authPassword: auth.authPassword,
setAuthPassword: auth.setAuthPassword,
authKeyId: auth.authKeyId,
setAuthKeyId: auth.setAuthKeyId,
authPassphrase: auth.authPassphrase,
setAuthPassphrase: auth.setAuthPassphrase,
showAuthPassphrase: auth.showAuthPassphrase,
setShowAuthPassphrase: auth.setShowAuthPassphrase,
showAuthPassword: auth.showAuthPassword,
setShowAuthPassword: auth.setShowAuthPassword,
authRetryMessage: auth.authRetryMessage,
onSubmit: () => auth.submit(),
onSubmitWithoutSave: () => auth.submit({ saveToHost: false }),
onCancel: handleCancelConnect,
isValid: auth.isValid,
}}
progressProps={{
timeLeft,
isCancelling,
progressLogs,
onCancel: handleCancelConnect,
onRetry: handleRetry,
}}
/>
)}
{/* Connection dialog: skip for local/serial during connecting phase, but show on error */}
{status !== "connected" && !needsHostKeyVerification && !(
(isLocalConnection || isSerialConnection) && status === "connecting"
) && (
<TerminalConnectionDialog
host={host}
status={status}
error={error}
progressValue={progressValue}
chainProgress={chainProgress}
needsAuth={auth.needsAuth}
showLogs={showLogs}
_setShowLogs={setShowLogs}
keys={keys}
authProps={{
authMethod: auth.authMethod,
setAuthMethod: auth.setAuthMethod,
authUsername: auth.authUsername,
setAuthUsername: auth.setAuthUsername,
authPassword: auth.authPassword,
setAuthPassword: auth.setAuthPassword,
authKeyId: auth.authKeyId,
setAuthKeyId: auth.setAuthKeyId,
authPassphrase: auth.authPassphrase,
setAuthPassphrase: auth.setAuthPassphrase,
showAuthPassphrase: auth.showAuthPassphrase,
setShowAuthPassphrase: auth.setShowAuthPassphrase,
showAuthPassword: auth.showAuthPassword,
setShowAuthPassword: auth.setShowAuthPassword,
authRetryMessage: auth.authRetryMessage,
onSubmit: () => auth.submit(),
onSubmitWithoutSave: () => auth.submit({ saveToHost: false }),
onCancel: handleCancelConnect,
isValid: auth.isValid,
}}
progressProps={{
timeLeft,
isCancelling,
progressLogs,
onCancel: handleCancelConnect,
onRetry: handleRetry,
}}
/>
)}
</div>
{/* Compose Bar (solo sessions only; workspace uses TerminalLayer's global bar) */}
{isComposeBarOpen && !inWorkspace && (
<TerminalComposeBar
onSend={(text) => {
if (sessionRef.current) {
const payload = text + '\r';
terminalBackend.writeToSession(sessionRef.current, payload);
onBroadcastInput?.(payload, sessionRef.current);
}
}}
onClose={() => {
setIsComposeBarOpen(false);
termRef.current?.focus();
}}
isBroadcastEnabled={isBroadcastEnabled}
themeColors={effectiveTheme.colors}
/>
)}
<SFTPModal
host={host}
credentials={(() => {
@@ -1081,11 +1687,17 @@ const TerminalComponent: React.FC<TerminalProps> = ({
proxy: proxyConfig,
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
sftpSudo: host.sftpSudo,
legacyAlgorithms: host.legacyAlgorithms,
};
})()}
open={showSFTP && status === "connected"}
onClose={() => setShowSFTP(false)}
onClose={() => {
setShowSFTP(false);
setPendingUploadEntries([]);
}}
initialPath={sftpInitialPath}
initialEntriesToUpload={pendingUploadEntries}
onUpdateHost={onUpdateHost}
/>
</div>
</TerminalContextMenu>

View File

@@ -9,6 +9,9 @@ import { cn } from '../lib/utils';
import { Host, Identity, KnownHost, SSHKey, Snippet, TerminalSession, TerminalTheme, Workspace, WorkspaceNode } from '../types';
import { DistroAvatar } from './DistroAvatar';
import Terminal from './Terminal';
import { TerminalComposeBar } from './terminal/TerminalComposeBar';
import { TERMINAL_THEMES } from '../infrastructure/config/terminalThemes';
import { useCustomThemes } from '../application/state/customThemeStore';
import { Button } from './ui/button';
import { ScrollArea } from './ui/scroll-area';
@@ -179,6 +182,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}
}, [activeWorkspace, sessions, terminalBackend]);
// Workspace-level compose bar state
const [isComposeBarOpen, setIsComposeBarOpen] = useState(false);
// Pre-compute host lookup map for O(1) access
const hostMap = useMemo(() => {
const map = new Map<string, Host>();
@@ -429,6 +435,48 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const isFocusMode = activeWorkspace?.viewMode === 'focus';
const focusedSessionId = activeWorkspace?.focusedSessionId;
// Subscribe to custom theme changes so editing triggers re-render
const customThemes = useCustomThemes();
// 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]);
// Handle compose bar send for workspace mode
const handleComposeSend = useCallback((text: string) => {
if (!activeWorkspace) return;
const payload = text + '\r';
const broadcastEnabled = isBroadcastEnabled?.(activeWorkspace.id);
if (broadcastEnabled) {
// Send to all sessions in the workspace
const allSessionIds = sessions
.filter(s => s.workspaceId === activeWorkspace.id)
.map(s => s.id);
for (const sid of allSessionIds) {
terminalBackend.writeToSession(sid, payload);
}
} else {
// Validate focusedSessionId is a live session, then fallback to first available
const workspaceSessions = sessions.filter(s => s.workspaceId === activeWorkspace.id);
const validFocusedId = focusedSessionId && workspaceSessions.some(s => s.id === focusedSessionId)
? focusedSessionId
: undefined;
const targetId = validFocusedId ?? workspaceSessions[0]?.id;
if (targetId) {
terminalBackend.writeToSession(targetId, payload);
}
}
}, [activeWorkspace, focusedSessionId, sessions, terminalBackend, isBroadcastEnabled]);
useEffect(() => {
if (isFocusMode && dropHint) {
setDropHint(null);
@@ -569,198 +617,222 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
return (
<div
ref={workspaceOuterRef}
className="absolute inset-0 bg-background flex"
className="absolute inset-0 bg-background flex flex-col"
style={{ display: isTerminalLayerVisible ? 'flex' : 'none', zIndex: isTerminalLayerVisible ? 10 : 0 }}
>
{/* Focus mode sidebar */}
{isFocusMode && renderFocusModeSidebar()}
<div className="flex-1 flex min-h-0 relative">
{/* Focus mode sidebar */}
{isFocusMode && renderFocusModeSidebar()}
{draggingSessionId && !isFocusMode && (
<div
ref={workspaceOverlayRef}
className="absolute inset-0 z-30"
onDragOver={(e) => {
if (isFocusMode) return;
if (!e.dataTransfer.types.includes('session-id')) return;
e.preventDefault();
e.stopPropagation();
const hint = computeSplitHint(e);
setDropHint(hint);
}}
onDragLeave={(e) => {
if (!e.dataTransfer.types.includes('session-id')) return;
setDropHint(null);
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
handleWorkspaceDrop(e);
}}
>
{dropHint && (
<div className="absolute inset-0 pointer-events-none">
<div
className="absolute bg-emerald-600/35 border border-emerald-400/70 backdrop-blur-sm transition-all duration-150"
style={{
width: dropHint.rect ? `${dropHint.rect.w}px` : dropHint.direction === 'vertical' ? '50%' : '100%',
height: dropHint.rect ? `${dropHint.rect.h}px` : dropHint.direction === 'vertical' ? '100%' : '50%',
left: dropHint.rect ? `${dropHint.rect.x}px` : dropHint.direction === 'vertical' ? (dropHint.position === 'left' ? 0 : '50%') : 0,
top: dropHint.rect ? `${dropHint.rect.y}px` : dropHint.direction === 'vertical' ? 0 : (dropHint.position === 'top' ? 0 : '50%'),
}}
/>
</div>
)}
</div>
)}
<div ref={workspaceInnerRef} className={cn("absolute overflow-hidden", isFocusMode ? "left-56 right-0 top-0 bottom-0" : "inset-0")}>
{sessions.map(session => {
// Use pre-computed host to avoid creating new objects on every render
const host = sessionHostsMap.get(session.id)!;
const inActiveWorkspace = !!activeWorkspace && session.workspaceId === activeWorkspace.id;
const isActiveSolo = activeTabId === session.id && !activeWorkspace && isTerminalLayerVisible;
{draggingSessionId && !isFocusMode && (
<div
ref={workspaceOverlayRef}
className="absolute inset-0 z-30"
onDragOver={(e) => {
if (isFocusMode) return;
if (!e.dataTransfer.types.includes('session-id')) return;
e.preventDefault();
e.stopPropagation();
const hint = computeSplitHint(e);
setDropHint(hint);
}}
onDragLeave={(e) => {
if (!e.dataTransfer.types.includes('session-id')) return;
setDropHint(null);
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
handleWorkspaceDrop(e);
}}
>
{dropHint && (
<div className="absolute inset-0 pointer-events-none">
<div
className="absolute bg-emerald-600/35 border border-emerald-400/70 backdrop-blur-sm transition-all duration-150"
style={{
width: dropHint.rect ? `${dropHint.rect.w}px` : dropHint.direction === 'vertical' ? '50%' : '100%',
height: dropHint.rect ? `${dropHint.rect.h}px` : dropHint.direction === 'vertical' ? '100%' : '50%',
left: dropHint.rect ? `${dropHint.rect.x}px` : dropHint.direction === 'vertical' ? (dropHint.position === 'left' ? 0 : '50%') : 0,
top: dropHint.rect ? `${dropHint.rect.y}px` : dropHint.direction === 'vertical' ? 0 : (dropHint.position === 'top' ? 0 : '50%'),
}}
/>
</div>
)}
</div>
)}
<div ref={workspaceInnerRef} className={cn("absolute overflow-hidden", isFocusMode ? "left-56 right-0 top-0 bottom-0" : "inset-0")}>
{sessions.map(session => {
// Use pre-computed host to avoid creating new objects on every render
const host = sessionHostsMap.get(session.id)!;
const inActiveWorkspace = !!activeWorkspace && session.workspaceId === activeWorkspace.id;
const isActiveSolo = activeTabId === session.id && !activeWorkspace && isTerminalLayerVisible;
// In focus mode, only the focused session is visible
const isFocusedInWorkspace = isFocusMode && inActiveWorkspace && session.id === focusedSessionId;
const isSplitViewVisible = !isFocusMode && inActiveWorkspace;
// In focus mode, only the focused session is visible
const isFocusedInWorkspace = isFocusMode && inActiveWorkspace && session.id === focusedSessionId;
const isSplitViewVisible = !isFocusMode && inActiveWorkspace;
const isVisible = ((isFocusedInWorkspace || isSplitViewVisible || isActiveSolo) && isTerminalLayerVisible);
const isVisible = ((isFocusedInWorkspace || isSplitViewVisible || isActiveSolo) && isTerminalLayerVisible);
// In focus mode, use full area; in split mode, use computed rects
const rect = (isSplitViewVisible && !isFocusMode) ? activeWorkspaceRects[session.id] : null;
// In focus mode, use full area; in split mode, use computed rects
const rect = (isSplitViewVisible && !isFocusMode) ? activeWorkspaceRects[session.id] : null;
const layoutStyle = rect
? {
left: `${rect.x}px`,
top: `${rect.y}px`,
width: `${rect.w}px`,
height: `${rect.h}px`,
const layoutStyle = rect
? {
left: `${rect.x}px`,
top: `${rect.y}px`,
width: `${rect.w}px`,
height: `${rect.h}px`,
}
: { left: 0, top: 0, width: '100%', height: '100%' };
const style: React.CSSProperties = { ...layoutStyle };
if (!isVisible) {
style.display = 'none';
}
: { left: 0, top: 0, width: '100%', height: '100%' };
const style: React.CSSProperties = { ...layoutStyle };
// Check if this pane is the focused one in the workspace
const isFocusedPane = inActiveWorkspace && !isFocusMode && session.id === focusedSessionId;
if (!isVisible) {
style.display = 'none';
}
// Check if this pane is the focused one in the workspace
const isFocusedPane = inActiveWorkspace && !isFocusMode && session.id === focusedSessionId;
return (
<div
key={session.id}
data-session-id={session.id}
className={cn(
"absolute bg-background",
inActiveWorkspace && "workspace-pane",
isVisible && "z-10",
isFocusedPane && "ring-1 ring-primary/50 ring-inset"
)}
style={style}
tabIndex={-1}
onClick={() => {
// Set focused session when clicking on a pane in split view
if (inActiveWorkspace && !isFocusMode && activeWorkspace) {
onSetWorkspaceFocusedSession?.(activeWorkspace.id, session.id);
}
}}
>
<Terminal
host={host}
keys={keys}
identities={identities}
snippets={snippets}
allHosts={hosts}
knownHosts={knownHosts}
isVisible={isVisible}
inWorkspace={inActiveWorkspace}
isResizing={!!resizing}
isFocusMode={isFocusMode}
isFocused={isFocusedPane}
fontFamilyId={terminalFontFamilyId}
fontSize={fontSize}
terminalTheme={terminalTheme}
terminalSettings={terminalSettings}
sessionId={session.id}
startupCommand={session.startupCommand}
serialConfig={session.serialConfig}
onUpdateTerminalThemeId={onUpdateTerminalThemeId}
onUpdateTerminalFontFamilyId={onUpdateTerminalFontFamilyId}
onUpdateTerminalFontSize={onUpdateTerminalFontSize}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
onHotkeyAction={onHotkeyAction}
onCloseSession={handleCloseSession}
onStatusChange={handleStatusChange}
onSessionExit={handleSessionExit}
onTerminalDataCapture={handleTerminalDataCapture}
onOsDetected={handleOsDetected}
onUpdateHost={handleUpdateHost}
onAddKnownHost={handleAddKnownHost}
onCommandExecuted={handleCommandExecuted}
onExpandToFocus={inActiveWorkspace && !isFocusMode && activeWorkspace ? () => onToggleWorkspaceViewMode?.(activeWorkspace.id) : undefined}
onSplitHorizontal={onSplitSession ? () => onSplitSession(session.id, 'horizontal') : undefined}
onSplitVertical={onSplitSession ? () => onSplitSession(session.id, 'vertical') : undefined}
isBroadcastEnabled={inActiveWorkspace && activeWorkspace ? isBroadcastEnabled?.(activeWorkspace.id) : false}
onToggleBroadcast={inActiveWorkspace && activeWorkspace ? () => onToggleBroadcast?.(activeWorkspace.id) : undefined}
onBroadcastInput={inActiveWorkspace && activeWorkspace && isBroadcastEnabled?.(activeWorkspace.id) ? handleBroadcastInput : undefined}
/>
</div>
);
})}
{/* Only show resizers in split view mode, not in focus mode */}
{!isFocusMode && activeResizers.map(handle => {
const isVertical = handle.direction === 'vertical';
// Expand hit area perpendicular to the split line, but stay within bounds
// Vertical split (left-right): expand horizontally, keep vertical bounds
// Horizontal split (top-bottom): expand vertically, keep horizontal bounds
const left = isVertical ? handle.rect.x - 3 : handle.rect.x;
const top = isVertical ? handle.rect.y : handle.rect.y - 3;
const width = isVertical ? handle.rect.w + 6 : handle.rect.w;
const height = isVertical ? handle.rect.h : handle.rect.h + 6;
return (
<div
key={handle.id}
className={cn("absolute group", isVertical ? "cursor-ew-resize" : "cursor-ns-resize")}
style={{
left: `${left}px`,
top: `${top}px`,
width: `${width}px`,
height: `${height}px`,
zIndex: 25,
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
const ws = activeWorkspace;
if (!ws) return;
const split = findSplitNode(ws.root, handle.splitId);
const childCount = split && split.type === 'split' ? split.children.length : 0;
const sizes = split && split.type === 'split' && split.sizes && split.sizes.length === childCount
? split.sizes
: Array(childCount).fill(1);
setResizing({
workspaceId: ws.id,
splitId: handle.splitId,
index: handle.index,
direction: handle.direction,
startSizes: sizes.length ? sizes : [1, 1],
startArea: handle.splitArea,
startClient: { x: e.clientX, y: e.clientY },
});
}}
>
return (
<div
key={session.id}
data-session-id={session.id}
className={cn(
"absolute bg-border/70 group-hover:bg-primary/60 transition-colors",
isVertical ? "w-px h-full left-1/2 -translate-x-1/2" : "h-px w-full top-1/2 -translate-y-1/2"
"absolute bg-background",
inActiveWorkspace && "workspace-pane",
isVisible && "z-10",
isFocusedPane && "ring-1 ring-primary/50 ring-inset"
)}
/>
</div>
);
})}
style={style}
tabIndex={-1}
onClick={() => {
// Set focused session when clicking on a pane in split view
if (inActiveWorkspace && !isFocusMode && activeWorkspace) {
onSetWorkspaceFocusedSession?.(activeWorkspace.id, session.id);
}
}}
>
<Terminal
host={host}
keys={keys}
identities={identities}
snippets={snippets}
allHosts={hosts}
knownHosts={knownHosts}
isVisible={isVisible}
inWorkspace={inActiveWorkspace}
isResizing={!!resizing}
isFocusMode={isFocusMode}
isFocused={isFocusedPane}
fontFamilyId={terminalFontFamilyId}
fontSize={fontSize}
terminalTheme={terminalTheme}
terminalSettings={terminalSettings}
sessionId={session.id}
startupCommand={session.startupCommand}
serialConfig={session.serialConfig}
onUpdateTerminalThemeId={onUpdateTerminalThemeId}
onUpdateTerminalFontFamilyId={onUpdateTerminalFontFamilyId}
onUpdateTerminalFontSize={onUpdateTerminalFontSize}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
onHotkeyAction={onHotkeyAction}
onCloseSession={handleCloseSession}
onStatusChange={handleStatusChange}
onSessionExit={handleSessionExit}
onTerminalDataCapture={handleTerminalDataCapture}
onOsDetected={handleOsDetected}
onUpdateHost={handleUpdateHost}
onAddKnownHost={handleAddKnownHost}
onCommandExecuted={handleCommandExecuted}
onExpandToFocus={inActiveWorkspace && !isFocusMode && activeWorkspace ? () => onToggleWorkspaceViewMode?.(activeWorkspace.id) : undefined}
onSplitHorizontal={onSplitSession ? () => onSplitSession(session.id, 'horizontal') : undefined}
onSplitVertical={onSplitSession ? () => onSplitSession(session.id, 'vertical') : undefined}
isBroadcastEnabled={inActiveWorkspace && activeWorkspace ? isBroadcastEnabled?.(activeWorkspace.id) : false}
onToggleBroadcast={inActiveWorkspace && activeWorkspace ? () => onToggleBroadcast?.(activeWorkspace.id) : undefined}
onToggleComposeBar={inActiveWorkspace ? () => setIsComposeBarOpen(prev => !prev) : undefined}
isWorkspaceComposeBarOpen={inActiveWorkspace ? isComposeBarOpen : undefined}
onBroadcastInput={inActiveWorkspace && activeWorkspace && isBroadcastEnabled?.(activeWorkspace.id) ? handleBroadcastInput : undefined}
/>
</div>
);
})}
{/* Only show resizers in split view mode, not in focus mode */}
{!isFocusMode && activeResizers.map(handle => {
const isVertical = handle.direction === 'vertical';
// Expand hit area perpendicular to the split line, but stay within bounds
// Vertical split (left-right): expand horizontally, keep vertical bounds
// Horizontal split (top-bottom): expand vertically, keep horizontal bounds
const left = isVertical ? handle.rect.x - 3 : handle.rect.x;
const top = isVertical ? handle.rect.y : handle.rect.y - 3;
const width = isVertical ? handle.rect.w + 6 : handle.rect.w;
const height = isVertical ? handle.rect.h : handle.rect.h + 6;
return (
<div
key={handle.id}
className={cn("absolute group", isVertical ? "cursor-ew-resize" : "cursor-ns-resize")}
style={{
left: `${left}px`,
top: `${top}px`,
width: `${width}px`,
height: `${height}px`,
zIndex: 25,
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
const ws = activeWorkspace;
if (!ws) return;
const split = findSplitNode(ws.root, handle.splitId);
const childCount = split && split.type === 'split' ? split.children.length : 0;
const sizes = split && split.type === 'split' && split.sizes && split.sizes.length === childCount
? split.sizes
: Array(childCount).fill(1);
setResizing({
workspaceId: ws.id,
splitId: handle.splitId,
index: handle.index,
direction: handle.direction,
startSizes: sizes.length ? sizes : [1, 1],
startArea: handle.splitArea,
startClient: { x: e.clientX, y: e.clientY },
});
}}
>
<div
className={cn(
"absolute bg-border/70 group-hover:bg-primary/60 transition-colors",
isVertical ? "w-px h-full left-1/2 -translate-x-1/2" : "h-px w-full top-1/2 -translate-y-1/2"
)}
/>
</div>
);
})}
</div>
</div>
{/* Global compose bar for workspace mode */}
{activeWorkspace && isComposeBarOpen && (
<TerminalComposeBar
onSend={handleComposeSend}
onClose={() => {
setIsComposeBarOpen(false);
// Refocus the terminal pane (matching solo-session behavior)
if (focusedSessionId) {
requestAnimationFrame(() => {
const pane = document.querySelector(`[data-session-id="${focusedSessionId}"]`);
const textarea = pane?.querySelector('textarea.xterm-helper-textarea') as HTMLTextAreaElement | null;
textarea?.focus();
});
}
}}
isBroadcastEnabled={isBroadcastEnabled?.(activeWorkspace.id)}
themeColors={composeBarThemeColors}
/>
)}
</div>
);
};

View File

@@ -5,9 +5,10 @@ import {
CloudUpload,
Loader2,
Search,
WrapText,
X,
} from 'lucide-react';
import Editor, { type OnMount, loader } from '@monaco-editor/react';
import Editor, { type OnMount, loader, useMonaco } from '@monaco-editor/react';
import type * as Monaco from 'monaco-editor';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
@@ -18,6 +19,7 @@ const monacoBasePath = import.meta.env.DEV
loader.config({ paths: { vs: monacoBasePath } });
import { useI18n } from '../application/i18n/I18nProvider';
import { useClipboardBackend } from '../application/state/useClipboardBackend';
import { getLanguageId, getLanguageName, getSupportedLanguages } from '../lib/sftpFileUtils';
import { Button } from './ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
@@ -30,6 +32,8 @@ interface TextEditorModalProps {
fileName: string;
initialContent: string;
onSave: (content: string) => Promise<void>;
editorWordWrap: boolean;
onToggleWordWrap: () => void;
}
// Map our language IDs to Monaco language IDs
@@ -82,35 +86,120 @@ const languageIdToMonaco = (langId: string): string => {
return mapping[langId] || 'plaintext';
};
// Convert HSL string "h s% l%" to hex color
const hslToHex = (hslString: string): string => {
const parts = hslString.trim().split(/\s+/);
if (parts.length < 3) return '#1e1e1e';
const h = parseFloat(parts[0]) / 360;
const s = parseFloat(parts[1].replace('%', '')) / 100;
const l = parseFloat(parts[2].replace('%', '')) / 100;
const hue2rgb = (p: number, q: number, t: number) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
let r: number, g: number, b: number;
if (s === 0) {
r = g = b = l;
} else {
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1 / 3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1 / 3);
}
const toHex = (x: number) => {
const hex = Math.round(x * 255).toString(16);
return hex.length === 1 ? '0' + hex : hex;
};
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
};
// Get background color from CSS variable
const getBackgroundColor = (): string => {
const bgValue = getComputedStyle(document.documentElement)
.getPropertyValue('--background')
.trim();
return bgValue ? hslToHex(bgValue) : '#1e1e1e';
};
export const TextEditorModal: React.FC<TextEditorModalProps> = ({
open,
onClose,
fileName,
initialContent,
onSave,
editorWordWrap,
onToggleWordWrap,
}) => {
const { t } = useI18n();
const { readClipboardText: readClipboardTextFromBridge } = useClipboardBackend();
const monaco = useMonaco();
const [content, setContent] = useState(initialContent);
const [saving, setSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const [languageId, setLanguageId] = useState(() => getLanguageId(fileName));
const editorRef = useRef<Monaco.editor.IStandaloneCodeEditor | null>(null);
// 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());
// Track theme from document.documentElement class (syncs with app theme)
const [isDarkTheme, setIsDarkTheme] = useState(() =>
document.documentElement.classList.contains('dark')
);
// Listen for theme changes via MutationObserver on <html> class
// Track background color for custom theme
const [bgColor, setBgColor] = useState(() => getBackgroundColor());
// Custom theme name
const customThemeName = isDarkTheme ? 'netcatty-dark' : 'netcatty-light';
// Define and update custom Monaco themes based on UI background color
useEffect(() => {
if (!monaco) return;
// Define dark theme with custom background
monaco.editor.defineTheme('netcatty-dark', {
base: 'vs-dark',
inherit: true,
rules: [],
colors: {
'editor.background': bgColor,
},
});
// Define light theme with custom background
monaco.editor.defineTheme('netcatty-light', {
base: 'vs',
inherit: true,
rules: [],
colors: {
'editor.background': bgColor,
},
});
// Apply the current theme
monaco.editor.setTheme(customThemeName);
}, [monaco, isDarkTheme, bgColor, customThemeName]);
// Listen for theme changes via MutationObserver on <html> class and style
useEffect(() => {
const root = document.documentElement;
const observer = new MutationObserver(() => {
const updateTheme = () => {
setIsDarkTheme(root.classList.contains('dark'));
});
observer.observe(root, { attributes: true, attributeFilter: ['class'] });
setBgColor(getBackgroundColor());
};
const observer = new MutationObserver(updateTheme);
observer.observe(root, { attributes: true, attributeFilter: ['class', 'style'] });
return () => observer.disconnect();
}, []);
@@ -148,6 +237,58 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
handleSaveRef.current = handleSave;
}, [handleSave]);
const readClipboardText = useCallback(async (): Promise<string | null> => {
try {
if (navigator.clipboard?.readText) {
return await navigator.clipboard.readText();
}
} catch {
// Fall through to Electron bridge
}
try {
return await readClipboardTextFromBridge();
} catch {
// Both clipboard APIs unavailable; signal failure so caller can fall back.
return null;
}
}, [readClipboardTextFromBridge]);
const handlePaste = useCallback(async () => {
const editor = editorRef.current;
if (!editor) return;
const text = await readClipboardText();
if (text === null) {
// Clipboard read unavailable; fall back to Monaco's native paste.
editor.trigger('keyboard', 'editor.action.clipboardPasteAction', null);
return;
}
if (!text) return;
const selections = editor.getSelections();
if (!selections || selections.length === 0) return;
// Match Monaco's default multicursorPaste:'spread' behavior:
// distribute one line per cursor when line count equals cursor count.
const lines = text.split(/\r\n|\n/);
const distribute = selections.length > 1 && lines.length === selections.length;
editor.executeEdits(
'netcatty-paste',
selections.map((selection, i) => ({
range: selection,
text: distribute ? lines[i] : text,
forceMoveMarkers: true,
})),
);
editor.focus();
}, [readClipboardText]);
useEffect(() => {
handlePasteRef.current = handlePaste;
}, [handlePaste]);
const handleClose = useCallback(() => {
if (hasChanges) {
const confirmed = confirm(t('sftp.editor.unsavedChanges'));
@@ -173,6 +314,11 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
// Trigger Monaco's built-in find widget
editor.trigger('keyboard', 'actions.find', null);
});
// Fallback paste path for Electron environments where Monaco paste can fail.
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyV, () => {
void handlePasteRef.current();
});
}, []);
// Trigger search dialog
@@ -185,7 +331,6 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
const supportedLanguages = useMemo(() => getSupportedLanguages(), []);
const monacoLanguage = useMemo(() => languageIdToMonaco(languageId), [languageId]);
const monacoTheme = isDarkTheme ? 'vs-dark' : 'light';
const languageOptions = useMemo(
() => supportedLanguages.map((lang) => ({ value: lang.id, label: lang.name })),
[supportedLanguages],
@@ -219,6 +364,17 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
<Search size={14} />
</Button>
{/* Word wrap toggle */}
<Button
variant={editorWordWrap ? 'secondary' : 'ghost'}
size="icon"
className="h-7 w-7"
onClick={onToggleWordWrap}
title={t('sftp.editor.wordWrap')}
>
<WrapText size={14} />
</Button>
{/* Language selector */}
<Combobox
options={languageOptions}
@@ -265,13 +421,15 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
value={content}
onChange={handleEditorChange}
onMount={handleEditorMount}
theme={monacoTheme}
theme={customThemeName}
loading={
<div className="absolute inset-0 flex items-center justify-center bg-background">
<Loader2 size={32} className="animate-spin text-muted-foreground" />
</div>
}
options={{
// Prefer native context menu in Electron so right-click Paste uses OS clipboard path.
contextmenu: false,
minimap: { enabled: true },
fontSize: 14,
lineNumbers: 'on',
@@ -280,7 +438,7 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
automaticLayout: true,
tabSize: 2,
insertSpaces: true,
wordWrap: 'off',
wordWrap: editorWordWrap ? 'on' : 'off',
folding: true,
renderWhitespace: 'selection',
bracketPairColorization: { enabled: true },

View File

@@ -1,6 +1,6 @@
import { Users } from 'lucide-react';
import React, { useMemo, useState } from 'react';
import { TERMINAL_THEMES } from '../infrastructure/config/terminalThemes';
import { useCustomThemes } from '../application/state/customThemeStore';
import { cn } from '../lib/utils';
import { TerminalTheme } from '../types';
import {
@@ -63,17 +63,24 @@ const ThemeSelectPanel: React.FC<ThemeSelectPanelProps> = ({
// Reserved for future hover preview feature
const [_hoveredThemeId, setHoveredThemeId] = useState<string | null>(null);
const customThemes = useCustomThemes();
// All themes combined
const allThemes = useMemo(() => {
return [...TERMINAL_THEMES, ...customThemes];
}, [customThemes]);
// Group themes by type - reserved for future sectioned view
const _groupedThemes = useMemo(() => {
const dark = TERMINAL_THEMES.filter(t => t.type === 'dark');
const light = TERMINAL_THEMES.filter(t => t.type === 'light');
const dark = allThemes.filter(t => t.type === 'dark');
const light = allThemes.filter(t => t.type === 'light');
return { dark, light };
}, []);
}, [allThemes]);
// Find selected theme info - reserved for displaying selection details
const _selectedTheme = useMemo(() => {
return TERMINAL_THEMES.find(t => t.id === selectedThemeId);
}, [selectedThemeId]);
return allThemes.find(t => t.id === selectedThemeId);
}, [selectedThemeId, allThemes]);
const renderThemeItem = (theme: TerminalTheme) => {
const isSelected = theme.id === selectedThemeId;
@@ -99,36 +106,12 @@ const ThemeSelectPanel: React.FC<ThemeSelectPanelProps> = ({
)}>
{theme.name}
</div>
{/* Show usage stats or badge */}
<div className="flex items-center gap-1 text-xs text-muted-foreground">
{theme.id === 'netcatty-dark' && (
<span className="text-muted-foreground">Default</span>
)}
{theme.id === 'netcatty-light' && (
<>
<Users size={10} />
<span>Light mode</span>
</>
)}
{theme.id === 'flexoki-dark' && (
<span className="text-xs">new</span>
)}
{theme.id === 'flexoki-light' && (
<span className="text-xs">new</span>
)}
{theme.id.startsWith('kanagawa') && (
<>
<Users size={10} />
<span>{Math.floor(Math.random() * 20000)}</span>
</>
)}
{theme.id.startsWith('hacker') && (
<>
<Users size={10} />
<span>{Math.floor(Math.random() * 15000)}</span>
</>
)}
</div>
{theme.id === 'netcatty-dark' && (
<div className="text-xs text-muted-foreground">Default</div>
)}
{theme.id === 'netcatty-light' && (
<div className="text-xs text-muted-foreground">Light mode</div>
)}
</div>
</button>
);
@@ -146,7 +129,7 @@ const ThemeSelectPanel: React.FC<ThemeSelectPanelProps> = ({
<ScrollArea className="h-full">
<div className="py-2">
{/* All themes in a single list */}
{TERMINAL_THEMES.map(renderThemeItem)}
{allThemes.map(renderThemeItem)}
</div>
</ScrollArea>
</AsidePanelContent>

View File

@@ -25,6 +25,7 @@ interface TopTabsProps {
isMacClient: boolean;
onCloseSession: (sessionId: string, e?: React.MouseEvent) => void;
onRenameSession: (sessionId: string) => void;
onCopySession: (sessionId: string) => void;
onRenameWorkspace: (workspaceId: string) => void;
onCloseWorkspace: (workspaceId: string) => void;
onCloseLogView: (logViewId: string) => void;
@@ -121,6 +122,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
isMacClient,
onCloseSession,
onRenameSession,
onCopySession,
onRenameWorkspace,
onCloseWorkspace,
onCloseLogView,
@@ -410,6 +412,9 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
<ContextMenuItem onClick={() => onRenameSession(session.id)}>
{t('common.rename')}
</ContextMenuItem>
<ContextMenuItem onClick={() => onCopySession(session.id)}>
{t('tabs.copyTab')}
</ContextMenuItem>
<ContextMenuItem className="text-destructive" onClick={() => onCloseSession(session.id)}>
{t('common.close')}
</ContextMenuItem>
@@ -538,8 +543,8 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
{/* Always-on drag stripe so the window can be moved even when tabs fill the bar */}
<div className="absolute inset-x-0 top-0 h-1 app-drag pointer-events-auto z-10" style={dragRegionStyle} aria-hidden />
<div
className="h-8 px-3 flex items-center gap-2 app-drag"
style={{ ...dragRegionStyle, paddingLeft: isMacClient && !isWindowFullscreen ? 76 : 12 }}
className="h-8 flex items-center gap-2 app-drag"
style={{ ...dragRegionStyle, paddingLeft: isMacClient && !isWindowFullscreen ? 76 : 12, paddingRight: isMacClient ? 12 : 0 }}
>
{/* Fixed left tabs: Vaults and SFTP */}
<div className="flex items-center gap-2 flex-shrink-0 app-drag">
@@ -649,8 +654,8 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
</div>
{/* Custom window controls for Windows/Linux */}
{!isMacClient && <WindowControls />}
{/* Small drag shim to the right edge */}
<div className="w-2 h-8 app-drag flex-shrink-0" />
{/* Small drag shim to the right edge (macOS only on Windows the close button should touch the edge) */}
{isMacClient && <div className="w-2 h-8 app-drag flex-shrink-0" />}
</div>
</div>
);

410
components/TrayPanel.tsx Normal file
View File

@@ -0,0 +1,410 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "./ui/button";
import { useSessionState } from "../application/state/useSessionState";
import { usePortForwardingState } from "../application/state/usePortForwardingState";
import { useVaultState } from "../application/state/useVaultState";
import { toast } from "./ui/toast";
import { cn } from "../lib/utils";
import { useI18n } from "../application/i18n/I18nProvider";
import { I18nProvider } from "../application/i18n/I18nProvider";
import { useSettingsState } from "../application/state/useSettingsState";
import { useTrayPanelBackend } from "../application/state/useTrayPanelBackend";
import { useActiveTabId } from "../application/state/activeTabStore";
import { X, Maximize2, ChevronRight, ChevronDown, Power } from "lucide-react";
import { AppLogo } from "./AppLogo";
const StatusDot: React.FC<{ status: "success" | "warning" | "error" | "neutral"; spinning?: boolean }> = ({
status,
spinning,
}) => {
const color =
status === "success"
? "bg-emerald-500"
: status === "warning"
? "bg-amber-500"
: status === "error"
? "bg-rose-500"
: "bg-zinc-500";
return (
<span
className={cn(
"inline-block h-2 w-2 rounded-full",
color,
spinning ? "animate-spin" : "",
)}
/>
);
};
// Session type for workspace grouping
type TraySession = {
id: string;
label: string;
hostLabel: string;
status: "connecting" | "connected" | "disconnected";
workspaceId?: string;
workspaceTitle?: string;
};
// Collapsible workspace group component
const WorkspaceGroup: React.FC<{
workspaceId: string;
title: string;
sessions: TraySession[];
activeTabId: string | null;
jumpToSession: (sessionId: string) => Promise<void>;
t: (key: string) => string;
}> = ({ workspaceId, title, sessions, activeTabId, jumpToSession, t }) => {
const [expanded, setExpanded] = useState(true);
const isAnyActive = sessions.some((s) => s.id === activeTabId) || activeTabId === workspaceId;
return (
<div>
<button
onClick={() => setExpanded(!expanded)}
className={cn(
"w-full text-left px-2 py-1.5 rounded hover:bg-muted flex items-center gap-1",
isAnyActive ? "bg-muted" : "",
)}
>
{expanded ? <ChevronDown size={14} className="text-muted-foreground" /> : <ChevronRight size={14} className="text-muted-foreground" />}
<span className="font-medium truncate">{title}</span>
<span className="ml-auto text-xs text-muted-foreground">{sessions.length}</span>
</button>
{expanded && (
<div className="ml-4 mt-0.5 space-y-0.5">
{sessions.map((s) => (
<button
key={s.id}
title={s.hostLabel || s.label}
onClick={() => {
// Jump to session (using session id)
void jumpToSession(s.id);
}}
className={cn(
"w-full text-left px-2 py-1 rounded hover:bg-muted flex items-center justify-between text-sm",
s.status === "connected" ? "" : "text-muted-foreground",
activeTabId === s.id ? "bg-muted/60" : "",
)}
>
<span className="flex items-center gap-2 min-w-0">
<StatusDot
status={s.status === "connected" ? "success" : s.status === "connecting" ? "warning" : "error"}
spinning={s.status === "connecting"}
/>
<span className="truncate">{s.hostLabel || s.label}</span>
</span>
<span className="ml-2 text-xs text-muted-foreground">{t(`tray.status.${s.status}`)}</span>
</button>
))}
</div>
)}
</div>
);
};
const TrayPanelContent: React.FC = () => {
const { t } = useI18n();
const {
hideTrayPanel,
openMainWindow,
quitApp,
jumpToSession,
onTrayPanelCloseRequest,
onTrayPanelRefresh,
onTrayPanelMenuData,
} = useTrayPanelBackend();
const { hosts, keys } = useVaultState();
useSessionState();
const { rules: portForwardingRules, startTunnel, stopTunnel } = usePortForwardingState();
const activeTabId = useActiveTabId();
const [traySessions, setTraySessions] = useState<TraySession[]>([]);
const jumpableSessions = useMemo(
() => traySessions.filter((s) => s.status === "connected" || s.status === "connecting"),
[traySessions],
);
const activeSession = useMemo(() => {
if (!activeTabId) return null;
return traySessions.find((s) => s.id === activeTabId) || null;
}, [activeTabId, traySessions]);
useEffect(() => {
const unsubscribe = onTrayPanelMenuData?.((data) => {
setTraySessions(data.sessions || []);
});
return () => unsubscribe?.();
}, [onTrayPanelMenuData]);
useEffect(() => {
const unsubscribe = onTrayPanelRefresh?.(() => {
try {
window.dispatchEvent(new Event("storage"));
} catch {
// ignore
}
});
return () => unsubscribe?.();
}, [onTrayPanelRefresh]);
const keysForPf = useMemo(
() => keys.map((k) => ({ id: k.id, privateKey: k.privateKey })),
[keys],
);
const handleClose = useCallback(() => {
void hideTrayPanel();
}, [hideTrayPanel]);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
handleClose();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [handleClose]);
useEffect(() => {
const onPointerDown = (e: PointerEvent) => {
const target = e.target;
if (!(target instanceof Node)) return;
if (document.body && !document.body.contains(target)) return;
// Ignore clicks on interactive elements inside the panel.
if (target instanceof HTMLElement && target.closest("button,a,input,select,textarea,[role='button']")) {
return;
}
// Clicking on background should close panel
const root = document.getElementById("tray-panel-root");
if (root && !root.contains(target)) {
handleClose();
}
};
window.addEventListener("pointerdown", onPointerDown, true);
return () => window.removeEventListener("pointerdown", onPointerDown, true);
}, [handleClose]);
useEffect(() => {
const unsubscribe = onTrayPanelCloseRequest(() => {
handleClose();
});
return () => unsubscribe?.();
}, [handleClose, onTrayPanelCloseRequest]);
const handleOpenMain = useCallback(() => {
void openMainWindow();
}, [openMainWindow]);
const handleQuit = useCallback(() => {
void quitApp();
}, [quitApp]);
return (
<div id="tray-panel-root" className="w-full h-full bg-background/95 backdrop-blur border border-border/60 rounded-lg shadow-lg overflow-hidden flex flex-col">
<div className="px-3 py-2 border-b border-border/60 flex items-center justify-between app-no-drag">
<div className="flex items-center gap-2">
<AppLogo className="w-5 h-5" />
<span className="text-sm font-medium">Netcatty</span>
</div>
<div className="flex items-center gap-1">
<button
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
onClick={handleOpenMain}
title={t("tray.openMainWindow")}
>
<Maximize2 size={14} />
</button>
<button
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
onClick={handleClose}
title="Close"
>
<X size={14} />
</button>
</div>
</div>
<div className="p-2 space-y-3 text-sm flex-1 overflow-y-auto min-h-0">
{jumpableSessions.length > 0 && (() => {
// Group sessions by workspace
const workspaceGroups = new Map<string, { title: string; sessions: typeof jumpableSessions }>();
const soloSessions: typeof jumpableSessions = [];
jumpableSessions.forEach((s) => {
if (s.workspaceId) {
const existing = workspaceGroups.get(s.workspaceId);
if (existing) {
existing.sessions.push(s);
} else {
workspaceGroups.set(s.workspaceId, {
title: s.workspaceTitle || "Workspace",
sessions: [s],
});
}
} else {
soloSessions.push(s);
}
});
return (
<div>
<div className="px-2 py-1 text-xs text-muted-foreground">{t("tray.sessions")}</div>
<div className="space-y-1">
{/* Workspace groups */}
{Array.from(workspaceGroups.entries()).map(([wsId, group]) => (
<WorkspaceGroup
key={wsId}
workspaceId={wsId}
title={group.title}
sessions={group.sessions}
activeTabId={activeTabId}
jumpToSession={jumpToSession}
t={t}
/>
))}
{/* Solo sessions */}
{soloSessions.map((s) => (
<button
key={s.id}
title={s.hostLabel || s.label}
onClick={() => {
void jumpToSession(s.id);
}}
className={cn(
"w-full text-left px-2 py-1.5 rounded hover:bg-muted flex items-center justify-between",
s.status === "connected" ? "" : "text-muted-foreground",
activeTabId === s.id ? "bg-muted" : "",
)}
>
<span className="flex items-center gap-2 min-w-0">
<StatusDot
status={s.status === "connected" ? "success" : s.status === "connecting" ? "warning" : "error"}
spinning={s.status === "connecting"}
/>
<span className="truncate">{s.hostLabel || s.label}</span>
</span>
<span className="ml-2 text-xs text-muted-foreground">{t(`tray.status.${s.status}`)}</span>
</button>
))}
</div>
</div>
);
})()}
{activeSession && (
<div>
<div className="px-2 py-1 text-xs text-muted-foreground">Current</div>
<Button
variant="ghost"
className="w-full justify-start px-2 h-8"
title={activeSession.hostLabel || activeSession.label}
onClick={() => {
void jumpToSession(activeSession.id);
}}
>
<span className="truncate">{activeSession.hostLabel || activeSession.label}</span>
</Button>
</div>
)}
{portForwardingRules.length > 0 && (
<div>
<div className="px-2 py-1 text-xs text-muted-foreground">{t("tray.portForwarding")}</div>
<div className="space-y-1">
{portForwardingRules.map((rule) => {
const isConnecting = rule.status === "connecting";
const isActive = rule.status === "active";
const label = rule.label || (rule.type === "dynamic"
? `SOCKS:${rule.localPort}`
: `${rule.localPort}${rule.remoteHost}:${rule.remotePort}`);
return (
<button
key={rule.id}
disabled={isConnecting}
title={label}
onClick={() => {
const host = rule.hostId ? hosts.find((h) => h.id === rule.hostId) : undefined;
if (!host) {
toast.error(t("pf.error.hostNotFound"));
return;
}
if (isActive) {
void stopTunnel(rule.id);
} else {
void startTunnel(rule, host, keysForPf, (status, error) => {
if (status === "error" && error) toast.error(error);
}, rule.autoStart);
}
}}
className={cn(
"w-full text-left px-2 py-1.5 rounded hover:bg-muted flex items-center justify-between",
isConnecting ? "opacity-60" : "",
)}
>
<span className="flex items-center gap-2 min-w-0">
<StatusDot
status={
rule.status === "active"
? "success"
: rule.status === "connecting"
? "warning"
: rule.status === "error"
? "error"
: "neutral"
}
spinning={rule.status === "connecting"}
/>
<span className="truncate">{label}</span>
</span>
<span className="ml-2 text-xs text-muted-foreground">
{t(`tray.status.${rule.status}`)}
</span>
</button>
);
})}
</div>
</div>
)}
{/* Empty state - show when nothing is active */}
{jumpableSessions.length === 0 && portForwardingRules.length === 0 && (
<div className="flex flex-col items-center justify-center py-8 text-center">
<span className="text-2xl mb-2">😴</span>
<span className="text-sm text-muted-foreground">{t("tray.empty.title")}</span>
<span className="text-xs text-muted-foreground/60 mt-1">{t("tray.empty.subtitle")}</span>
</div>
)}
</div>
{/* Quit button at the bottom */}
<div className="px-3 py-2 border-t border-border/60">
<button
className="w-full text-left px-2 py-1.5 rounded hover:bg-destructive/10 flex items-center gap-2 text-sm text-muted-foreground hover:text-destructive transition-colors"
onClick={handleQuit}
>
<Power size={14} />
<span>{t("tray.quit")}</span>
</button>
</div>
</div>
);
};
const TrayPanel: React.FC = () => {
const settings = useSettingsState();
return (
<I18nProvider locale={settings.uiLanguage}>
<TrayPanelContent />
</I18nProvider>
);
};
export default TrayPanel;

File diff suppressed because it is too large Load Diff

View File

@@ -97,6 +97,8 @@ export const ExportKeyPanel: React.FC<ExportKeyPanelProps> = ({
privateKey: hostPrivateKey,
command,
timeout: 30000,
enableKeyboardInteractive: true,
sessionId: `export-key:${exportHost.id}:${keyItem.id}`,
});
// Check result

View File

@@ -2,7 +2,7 @@
* Import Key Panel - Import existing SSH key
*/
import { Upload } from 'lucide-react';
import { Eye, EyeOff, Upload } from 'lucide-react';
import React,{ useCallback,useRef } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { SSHKey } from '../../types';
@@ -15,12 +15,16 @@ import { detectKeyType } from './utils';
interface ImportKeyPanelProps {
draftKey: Partial<SSHKey>;
setDraftKey: (key: Partial<SSHKey>) => void;
showPassphrase: boolean;
setShowPassphrase: (show: boolean) => void;
onImport: () => void;
}
export const ImportKeyPanel: React.FC<ImportKeyPanelProps> = ({
draftKey,
setDraftKey,
showPassphrase,
setShowPassphrase,
onImport,
}) => {
const { t } = useI18n();
@@ -132,6 +136,41 @@ export const ImportKeyPanel: React.FC<ImportKeyPanelProps> = ({
/>
</div>
<div className="space-y-2">
<Label>{t('terminal.auth.passphrase')}</Label>
<div className="relative">
<Input
type={showPassphrase ? 'text' : 'password'}
value={draftKey.passphrase || ''}
onChange={e => setDraftKey({ ...draftKey, passphrase: e.target.value })}
placeholder={t('keychain.generate.passphrasePlaceholder')}
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8"
onClick={() => setShowPassphrase(!showPassphrase)}
>
{showPassphrase ? <EyeOff size={14} /> : <Eye size={14} />}
</Button>
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="savePassphraseImport"
checked={draftKey.savePassphrase || false}
onChange={e => setDraftKey({ ...draftKey, savePassphrase: e.target.checked })}
className="h-4 w-4 rounded border-border"
/>
<Label htmlFor="savePassphraseImport" className="text-sm font-normal cursor-pointer">
{t('keychain.generate.savePassphrase')}
</Label>
</div>
<div
className="border border-dashed border-border/80 rounded-xl p-4 text-center space-y-2 bg-background/60 transition-colors hover:border-primary/50"
onDrop={handleDrop}

View File

@@ -5,16 +5,18 @@
import { Copy,Loader2,Pencil,Play,Square,Trash2 } from 'lucide-react';
import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { PortForwardingRule } from '../../domain/models';
import { Host, PortForwardingRule } from '../../domain/models';
import { cn } from '../../lib/utils';
import { Button } from '../ui/button';
import { ContextMenu,ContextMenuContent,ContextMenuItem,ContextMenuSeparator,ContextMenuTrigger } from '../ui/context-menu';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
import { getStatusColor,getTypeColor } from './utils';
export type ViewMode = 'grid' | 'list';
export interface RuleCardProps {
rule: PortForwardingRule;
host?: Host; // The relay host for this rule (for tooltip display)
viewMode: ViewMode;
isSelected: boolean;
isPending: boolean;
@@ -28,6 +30,7 @@ export interface RuleCardProps {
export const RuleCard: React.FC<RuleCardProps> = ({
rule,
host,
viewMode,
isSelected,
isPending,
@@ -74,12 +77,39 @@ export const RuleCard: React.FC<RuleCardProps> = ({
/>
</div>
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
<span className="truncate">
{rule.type === 'dynamic'
? t('pf.rule.summary.dynamic', { bindAddress: rule.bindAddress, localPort: rule.localPort })
: t('pf.rule.summary.default', { bindAddress: rule.bindAddress, localPort: rule.localPort, remoteHost: rule.remoteHost, remotePort: rule.remotePort })
}
</span>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<span className="truncate cursor-default">
{rule.type === 'dynamic'
? t('pf.rule.summary.dynamic', { bindAddress: rule.bindAddress, localPort: rule.localPort })
: t('pf.rule.summary.default', { bindAddress: rule.bindAddress, localPort: rule.localPort, remoteHost: rule.remoteHost, remotePort: rule.remotePort })
}
</span>
</TooltipTrigger>
<TooltipContent side="bottom" align="start" className="max-w-xs">
<div className="space-y-1 text-xs">
{host ? (
<>
<div className="font-medium">{t('pf.tooltip.relayHost')}</div>
<div>{t('pf.tooltip.hostLabel')}: {host.label}</div>
<div>{t('pf.tooltip.hostAddress')}: {host.username}@{host.hostname}:{host.port}</div>
</>
) : (
<div className="text-muted-foreground">{t('pf.tooltip.noHost')}</div>
)}
<div className="border-t border-border/40 pt-1 mt-1">
{rule.type === 'dynamic'
? t('pf.tooltip.dynamicDesc')
: rule.type === 'local'
? t('pf.tooltip.localDesc')
: t('pf.tooltip.remoteDesc')
}
</div>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">

View File

@@ -0,0 +1,77 @@
import React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '../../lib/utils';
import type { UIFont } from '../../infrastructure/config/uiFonts';
interface FontSelectProps {
value: string;
fonts: UIFont[];
onChange: (value: string) => void;
className?: string;
disabled?: boolean;
}
export const FontSelect: React.FC<FontSelectProps> = ({
value,
fonts,
onChange,
className,
disabled,
}) => {
const selectedFont = fonts.find(f => f.id === value);
return (
<SelectPrimitive.Root value={value} onValueChange={onChange} disabled={disabled}>
<SelectPrimitive.Trigger
className={cn(
'flex h-9 items-center justify-between rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className
)}
>
<SelectPrimitive.Value>
<span style={{ fontFamily: selectedFont?.family }}>
{selectedFont?.name || value}
</span>
</SelectPrimitive.Value>
<SelectPrimitive.Icon asChild>
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
<SelectPrimitive.Portal>
<SelectPrimitive.Content
className="z-[200000] max-h-80 min-w-[12rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1"
position="popper"
sideOffset={4}
>
<SelectPrimitive.ScrollUpButton className="flex cursor-default items-center justify-center py-1">
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
<SelectPrimitive.Viewport className="p-1 h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]">
{fonts.map((font) => (
<SelectPrimitive.Item
key={font.id}
value={font.id}
className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>
<span style={{ fontFamily: font.family }}>{font.name}</span>
</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))}
</SelectPrimitive.Viewport>
<SelectPrimitive.ScrollDownButton className="flex cursor-default items-center justify-center py-1">
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
</SelectPrimitive.Root>
);
};
export default FontSelect;

View File

@@ -0,0 +1,77 @@
import React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '../../lib/utils';
import type { TerminalFont } from '../../infrastructure/config/fonts';
interface TerminalFontSelectProps {
value: string;
fonts: TerminalFont[];
onChange: (value: string) => void;
className?: string;
disabled?: boolean;
}
export const TerminalFontSelect: React.FC<TerminalFontSelectProps> = ({
value,
fonts,
onChange,
className,
disabled,
}) => {
const selectedFont = fonts.find(f => f.id === value);
return (
<SelectPrimitive.Root value={value} onValueChange={onChange} disabled={disabled}>
<SelectPrimitive.Trigger
className={cn(
'flex h-9 items-center justify-between rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className
)}
>
<SelectPrimitive.Value>
<span style={{ fontFamily: selectedFont?.family }}>
{selectedFont?.name || value}
</span>
</SelectPrimitive.Value>
<SelectPrimitive.Icon asChild>
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
<SelectPrimitive.Portal>
<SelectPrimitive.Content
className="z-[200000] max-h-80 min-w-[14rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1"
position="popper"
sideOffset={4}
>
<SelectPrimitive.ScrollUpButton className="flex cursor-default items-center justify-center py-1">
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
<SelectPrimitive.Viewport className="p-1 h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]">
{fonts.map((font) => (
<SelectPrimitive.Item
key={font.id}
value={font.id}
className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>
<span style={{ fontFamily: font.family }}>{font.name}</span>
</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))}
</SelectPrimitive.Viewport>
<SelectPrimitive.ScrollDownButton className="flex cursor-default items-center justify-center py-1">
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
</SelectPrimitive.Root>
);
};
export default TerminalFontSelect;

View File

@@ -8,6 +8,7 @@ import { createPortal } from 'react-dom';
import { Check, Palette, X } from 'lucide-react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { TERMINAL_THEMES, TerminalThemeConfig } from '../../infrastructure/config/terminalThemes';
import { useCustomThemes } from '../../application/state/customThemeStore';
import { Button } from '../ui/button';
import { cn } from '../../lib/utils';
@@ -74,6 +75,8 @@ export const ThemeSelectModal: React.FC<ThemeSelectModalProps> = ({
return { darkThemes: dark, lightThemes: light };
}, []);
const customThemes = useCustomThemes();
// Handle theme selection - select and close
const handleThemeSelect = useCallback((themeId: string) => {
onSelect(themeId);
@@ -164,6 +167,25 @@ export const ThemeSelectModal: React.FC<ThemeSelectModalProps> = ({
))}
</div>
</div>
{/* Custom Themes Section */}
{customThemes.length > 0 && (
<div className="mt-4">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold px-1">
{t('terminal.customTheme.section')}
</div>
<div className="space-y-1">
{customThemes.map(theme => (
<ThemeItem
key={theme.id}
theme={theme}
isSelected={selectedThemeId === theme.id}
onSelect={handleThemeSelect}
/>
))}
</div>
</div>
)}
</div>
{/* Footer */}

View File

@@ -1,4 +1,6 @@
import React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "../../lib/utils";
import { ScrollArea } from "../ui/scroll-area";
import { TabsContent } from "../ui/tabs";
@@ -38,23 +40,54 @@ interface SelectProps {
disabled?: boolean;
}
export const Select: React.FC<SelectProps> = ({ value, options, onChange, className, disabled }) => (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
className={cn(
"h-9 rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
);
export const Select: React.FC<SelectProps> = ({ value, options, onChange, className, disabled }) => {
const selectedOption = options.find((opt) => opt.value === value);
return (
<SelectPrimitive.Root value={value} onValueChange={onChange} disabled={disabled}>
<SelectPrimitive.Trigger
className={cn(
"flex h-9 items-center justify-between rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className,
)}
>
<SelectPrimitive.Value>{selectedOption?.label ?? value}</SelectPrimitive.Value>
<SelectPrimitive.Icon asChild>
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
<SelectPrimitive.Portal>
<SelectPrimitive.Content
className="z-[200000] max-h-80 min-w-[12rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1"
position="popper"
sideOffset={4}
>
<SelectPrimitive.ScrollUpButton className="flex cursor-default items-center justify-center py-1">
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
<SelectPrimitive.Viewport className="p-1 h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]">
{options.map((opt) => (
<SelectPrimitive.Item
key={opt.value}
value={opt.value}
className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{opt.label}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))}
</SelectPrimitive.Viewport>
<SelectPrimitive.ScrollDownButton className="flex cursor-default items-center justify-center py-1">
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
</SelectPrimitive.Root>
);
};
export const SectionHeader: React.FC<{ title: string; className?: string }> = ({
title,

View File

@@ -2,9 +2,11 @@ import React, { useCallback } from "react";
import { Check, Moon, Palette, Sun } from "lucide-react";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { DARK_UI_THEMES, LIGHT_UI_THEMES } from "../../../infrastructure/config/uiThemes";
import { useAvailableUIFonts } from "../../../application/state/uiFontStore";
import { SUPPORTED_UI_LOCALES } from "../../../infrastructure/config/i18n";
import { cn } from "../../../lib/utils";
import { SectionHeader, SettingsTabContent, SettingRow, Toggle, Select } from "../settings-ui";
import { FontSelect } from "../FontSelect";
export default function SettingsAppearanceTab(props: {
theme: "dark" | "light";
@@ -17,12 +19,15 @@ export default function SettingsAppearanceTab(props: {
setAccentMode: (mode: "theme" | "custom") => void;
customAccent: string;
setCustomAccent: (color: string) => void;
uiFontFamilyId: string;
setUiFontFamilyId: (fontId: string) => void;
uiLanguage: string;
setUiLanguage: (language: string) => void;
customCSS: string;
setCustomCSS: (css: string) => void;
}) {
const { t } = useI18n();
const availableUIFonts = useAvailableUIFonts();
const {
theme,
setTheme,
@@ -34,6 +39,8 @@ export default function SettingsAppearanceTab(props: {
setAccentMode,
customAccent,
setCustomAccent,
uiFontFamilyId,
setUiFontFamilyId,
uiLanguage,
setUiLanguage,
customCSS,
@@ -130,6 +137,17 @@ export default function SettingsAppearanceTab(props: {
className="w-40"
/>
</SettingRow>
<SettingRow
label={t("settings.appearance.uiFont")}
description={t("settings.appearance.uiFont.desc")}
>
<FontSelect
value={uiFontFamilyId}
fonts={availableUIFonts}
onChange={(v) => setUiFontFamilyId(v)}
className="w-48"
/>
</SettingRow>
</div>
<SectionHeader title={t("settings.appearance.uiTheme")} />

View File

@@ -29,7 +29,7 @@ const getOpenerLabel = (
export default function SettingsFileAssociationsTab() {
const { t } = useI18n();
const { getAllAssociations, removeAssociation, setOpenerForExtension } = useSftpFileAssociations();
const { sftpDoubleClickBehavior, setSftpDoubleClickBehavior, sftpAutoSync, setSftpAutoSync, sftpShowHiddenFiles, setSftpShowHiddenFiles } = useSettingsState();
const { sftpDoubleClickBehavior, setSftpDoubleClickBehavior, sftpAutoSync, setSftpAutoSync, sftpShowHiddenFiles, setSftpShowHiddenFiles, sftpUseCompressedUpload, setSftpUseCompressedUpload } = useSettingsState();
const associations = getAllAssociations();
const [editingExtension, setEditingExtension] = useState<string | null>(null);
@@ -213,6 +213,46 @@ export default function SettingsFileAssociationsTab() {
</button>
</div>
{/* Compressed folder upload section */}
<div className="space-y-4">
<SectionHeader title={t('settings.sftp.compressedUpload')} />
<p className="text-sm text-muted-foreground">
{t('settings.sftp.compressedUpload.desc')}
</p>
<button
onClick={() => setSftpUseCompressedUpload(!sftpUseCompressedUpload)}
className={cn(
"w-full text-left p-4 rounded-lg border-2 transition-colors",
sftpUseCompressedUpload
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-secondary/50"
)}
>
<div className="flex items-start gap-3">
<div className={cn(
"h-5 w-5 rounded border-2 flex items-center justify-center mt-0.5 shrink-0",
sftpUseCompressedUpload
? "border-primary bg-primary"
: "border-muted-foreground/30"
)}>
{sftpUseCompressedUpload && (
<svg className="h-3 w-3 text-primary-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</div>
<div className="space-y-1">
<Label className="font-medium cursor-pointer">
{t('settings.sftp.compressedUpload.enable')}
</Label>
<p className="text-sm text-muted-foreground">
{t('settings.sftp.compressedUpload.enableDesc')}
</p>
</div>
</div>
</button>
</div>
{/* File associations section */}
<div className="space-y-4">
<SectionHeader title={t('settings.sftpFileAssociations.title')} />

View File

@@ -115,7 +115,7 @@ export default function SettingsShortcutsTab(props: {
};
}, [recordingBindingId, recordingScheme, setIsHotkeyRecording]);
const categories = useMemo(() => ["tabs", "terminal", "navigation", "app"] as const, []);
const categories = useMemo(() => ["tabs", "terminal", "navigation", "app", "sftp"] as const, []);
return (
<SettingsTabContent value="shortcuts">
@@ -179,7 +179,7 @@ export default function SettingsShortcutsTab(props: {
return (
<div key={binding.id} className="flex items-center justify-between px-4 py-2">
<span className="text-sm">{binding.label}</span>
<span className="text-sm">{t(`settings.shortcuts.binding.${binding.id}`) !== `settings.shortcuts.binding.${binding.id}` ? t(`settings.shortcuts.binding.${binding.id}`) : binding.label}</span>
<div className="flex items-center gap-2">
{isSpecialBinding ? (
<div className="flex items-center gap-1">

View File

@@ -1,12 +1,16 @@
/**
* Settings System Tab - System information and temp file management
* Settings System Tab - System information, temp file management, session logs, and global hotkey
*/
import { FolderOpen, HardDrive, RefreshCw, Trash2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { FileText, FolderOpen, HardDrive, Keyboard, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
import React, { useCallback, useEffect, useState } from "react";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { getCredentialProtectionAvailability } from "../../../infrastructure/services/credentialProtection";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { SessionLogFormat, keyEventToString } from "../../../domain/models";
import { TabsContent } from "../../ui/tabs";
import { Button } from "../../ui/button";
import { Toggle, Select, SettingRow } from "../settings-ui";
import { cn } from "../../../lib/utils";
interface TempDirInfo {
path: string;
@@ -22,18 +26,49 @@ function formatBytes(bytes: number): string {
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}
const SettingsSystemTab: React.FC = () => {
interface SettingsSystemTabProps {
sessionLogsEnabled: boolean;
setSessionLogsEnabled: (enabled: boolean) => void;
sessionLogsDir: string;
setSessionLogsDir: (dir: string) => void;
sessionLogsFormat: SessionLogFormat;
setSessionLogsFormat: (format: SessionLogFormat) => void;
toggleWindowHotkey: string;
setToggleWindowHotkey: (hotkey: string) => void;
closeToTray: boolean;
setCloseToTray: (enabled: boolean) => void;
hotkeyRegistrationError: string | null;
}
const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
sessionLogsEnabled,
setSessionLogsEnabled,
sessionLogsDir,
setSessionLogsDir,
sessionLogsFormat,
setSessionLogsFormat,
toggleWindowHotkey,
setToggleWindowHotkey,
closeToTray,
setCloseToTray,
hotkeyRegistrationError,
}) => {
const { t } = useI18n();
const isMac = typeof navigator !== "undefined" && /Mac/i.test(navigator.platform);
const [tempDirInfo, setTempDirInfo] = useState<TempDirInfo | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isClearing, setIsClearing] = useState(false);
const [clearResult, setClearResult] = useState<{ deletedCount: number; failedCount: number } | null>(null);
const [isRecordingHotkey, setIsRecordingHotkey] = useState(false);
const [hotkeyError, setHotkeyError] = useState<string | null>(null);
const [credentialsAvailable, setCredentialsAvailable] = useState<boolean | null>(null);
const [isCheckingCredentials, setIsCheckingCredentials] = useState(false);
const loadTempDirInfo = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.getTempDirInfo) return;
setIsLoading(true);
try {
const info = await bridge.getTempDirInfo();
@@ -49,10 +84,24 @@ const SettingsSystemTab: React.FC = () => {
loadTempDirInfo();
}, [loadTempDirInfo]);
const loadCredentialProtectionStatus = useCallback(async () => {
setIsCheckingCredentials(true);
try {
const available = await getCredentialProtectionAvailability();
setCredentialsAvailable(available);
} finally {
setIsCheckingCredentials(false);
}
}, []);
useEffect(() => {
void loadCredentialProtectionStatus();
}, [loadCredentialProtectionStatus]);
const handleClearTempFiles = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.clearTempDir) return;
setIsClearing(true);
setClearResult(null);
try {
@@ -73,6 +122,87 @@ const SettingsSystemTab: React.FC = () => {
await bridge.openTempDir();
}, [tempDirInfo]);
const handleSelectSessionLogsDir = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.selectSessionLogsDir) return;
try {
const result = await bridge.selectSessionLogsDir();
if (result.success && result.directory) {
setSessionLogsDir(result.directory);
}
} catch (err) {
console.error("[SettingsSystemTab] Failed to select directory:", err);
}
}, [setSessionLogsDir]);
const handleOpenSessionLogsDir = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!sessionLogsDir || !bridge?.openSessionLogsDir) return;
try {
await bridge.openSessionLogsDir(sessionLogsDir);
} catch (err) {
console.error("[SettingsSystemTab] Failed to open directory:", err);
}
}, [sessionLogsDir]);
// Handle global toggle hotkey recording
const cancelHotkeyRecording = useCallback(() => {
setIsRecordingHotkey(false);
}, []);
const handleResetHotkey = useCallback(() => {
// Reset to default hotkey (Ctrl+` or ⌃+` on Mac)
const defaultHotkey = isMac ? '⌃ + `' : 'Ctrl + `';
setToggleWindowHotkey(defaultHotkey);
setHotkeyError(null);
}, [isMac, setToggleWindowHotkey]);
// Hotkey recording effect
useEffect(() => {
if (!isRecordingHotkey) return;
const handleKeyDown = (e: KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.key === "Escape") {
cancelHotkeyRecording();
return;
}
// Ignore modifier-only keys
if (["Meta", "Control", "Alt", "Shift"].includes(e.key)) return;
const keyString = keyEventToString(e, isMac);
setToggleWindowHotkey(keyString);
setHotkeyError(null);
cancelHotkeyRecording();
};
const handleClick = () => {
cancelHotkeyRecording();
};
const timer = setTimeout(() => {
window.addEventListener("click", handleClick, true);
}, 100);
window.addEventListener("keydown", handleKeyDown, true);
return () => {
clearTimeout(timer);
window.removeEventListener("keydown", handleKeyDown, true);
window.removeEventListener("click", handleClick, true);
};
}, [isRecordingHotkey, isMac, setToggleWindowHotkey, cancelHotkeyRecording]);
const formatOptions = [
{ value: "txt", label: t("settings.sessionLogs.formatTxt") },
{ value: "raw", label: t("settings.sessionLogs.formatRaw") },
{ value: "html", label: t("settings.sessionLogs.formatHtml") },
];
return (
<TabsContent
value="system"
@@ -88,6 +218,59 @@ const SettingsSystemTab: React.FC = () => {
</p>
</div>
{/* Credential Protection Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<HardDrive size={18} className="text-muted-foreground" />
<h3 className="text-base font-medium">{t("settings.system.credentials.title")}</h3>
</div>
<div className="bg-muted/30 rounded-lg p-4 space-y-3">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm text-muted-foreground">
{t("settings.system.credentials.status")}
</p>
<p
className={cn(
"text-sm font-medium mt-1",
credentialsAvailable === true && "text-emerald-600 dark:text-emerald-400",
credentialsAvailable === false && "text-amber-600 dark:text-amber-400",
)}
>
{isCheckingCredentials
? t("settings.system.credentials.checking")
: credentialsAvailable === true
? t("settings.system.credentials.available")
: credentialsAvailable === false
? t("settings.system.credentials.unavailable")
: t("settings.system.credentials.unknown")}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={loadCredentialProtectionStatus}
disabled={isCheckingCredentials}
className="gap-1.5"
>
<RefreshCw size={14} className={isCheckingCredentials ? "animate-spin" : ""} />
{t("settings.system.refresh")}
</Button>
</div>
{credentialsAvailable === false && (
<p className="text-xs text-amber-700 dark:text-amber-400">
{t("settings.system.credentials.unavailableHint")}
</p>
)}
<p className="text-xs text-muted-foreground">
{t("settings.system.credentials.portabilityHint")}
</p>
</div>
</div>
{/* Temp Directory Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
@@ -171,6 +354,143 @@ const SettingsSystemTab: React.FC = () => {
{t("settings.system.tempDirectoryHint")}
</p>
</div>
{/* Session Logs Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<FileText size={18} className="text-muted-foreground" />
<h3 className="text-base font-medium">{t("settings.sessionLogs.title")}</h3>
</div>
<div className="bg-muted/30 rounded-lg p-4 space-y-4">
{/* Enable Toggle */}
<SettingRow
label={t("settings.sessionLogs.enableAutoSave")}
description={t("settings.sessionLogs.enableAutoSaveDesc")}
>
<Toggle
checked={sessionLogsEnabled}
onChange={setSessionLogsEnabled}
/>
</SettingRow>
{/* Directory Selection */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{t("settings.sessionLogs.directory")}</span>
</div>
<div className="flex items-center gap-2">
<div className="flex-1 min-w-0">
<div className="bg-background border border-input rounded-md px-3 py-2 text-sm font-mono truncate">
{sessionLogsDir || t("settings.sessionLogs.noDirectory")}
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={handleSelectSessionLogsDir}
className="shrink-0"
>
{t("settings.sessionLogs.browse")}
</Button>
{sessionLogsDir && (
<Button
variant="ghost"
size="icon"
onClick={handleOpenSessionLogsDir}
className="shrink-0"
title={t("settings.sessionLogs.openFolder")}
>
<FolderOpen size={16} />
</Button>
)}
</div>
<p className="text-xs text-muted-foreground">
{t("settings.sessionLogs.directoryHint")}
</p>
</div>
{/* Format Selection */}
<SettingRow
label={t("settings.sessionLogs.format")}
description={t("settings.sessionLogs.formatDesc")}
>
<Select
value={sessionLogsFormat}
options={formatOptions}
onChange={(val) => setSessionLogsFormat(val as SessionLogFormat)}
className="w-32"
disabled={!sessionLogsEnabled}
/>
</SettingRow>
</div>
<p className="text-xs text-muted-foreground">
{t("settings.sessionLogs.hint")}
</p>
</div>
{/* Global Toggle Window Section (Quake Mode) */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Keyboard size={18} className="text-muted-foreground" />
<h3 className="text-base font-medium">{t("settings.globalHotkey.title")}</h3>
</div>
<div className="bg-muted/30 rounded-lg p-4 space-y-4">
{/* Toggle Window Hotkey */}
<SettingRow
label={t("settings.globalHotkey.toggleWindow")}
description={t("settings.globalHotkey.toggleWindowDesc")}
>
<div className="flex items-center gap-2">
<button
onClick={(e) => {
e.stopPropagation();
setIsRecordingHotkey(true);
}}
className={cn(
"px-3 py-1.5 text-sm font-mono rounded border transition-colors min-w-[100px] text-center",
isRecordingHotkey
? "border-primary bg-primary/10 animate-pulse"
: "border-border hover:border-primary/50",
)}
>
{isRecordingHotkey
? t("settings.shortcuts.recording")
: toggleWindowHotkey || t("settings.globalHotkey.notSet")}
</button>
{toggleWindowHotkey && (
<button
onClick={handleResetHotkey}
className="p-1 hover:bg-muted rounded"
title={t("settings.globalHotkey.reset")}
>
<RotateCcw size={14} />
</button>
)}
</div>
</SettingRow>
{(hotkeyError || hotkeyRegistrationError) && (
<p className="text-sm text-destructive">{hotkeyError || hotkeyRegistrationError}</p>
)}
{/* Close to Tray */}
<SettingRow
label={t("settings.globalHotkey.closeToTray")}
description={t("settings.globalHotkey.closeToTrayDesc")}
>
<Toggle
checked={closeToTray}
onChange={setCloseToTray}
/>
</SettingRow>
</div>
<p className="text-xs text-muted-foreground">
{t("settings.globalHotkey.hint")}
</p>
</div>
</div>
</div>
</TabsContent>

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useState, useMemo } from "react";
import { AlertCircle, ChevronRight, Minus, Plus, RotateCcw } from "lucide-react";
import React, { useCallback, useEffect, useRef, useState, useMemo } from "react";
import { AlertCircle, ChevronRight, Import, Minus, Palette, Pencil, Plus, RotateCcw, Trash2 } from "lucide-react";
import type {
CursorShape,
LinkModifier,
@@ -11,12 +11,17 @@ import { DEFAULT_KEYWORD_HIGHLIGHT_RULES } from "../../../domain/models";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { MAX_FONT_SIZE, MIN_FONT_SIZE, type TerminalFont } from "../../../infrastructure/config/fonts";
import { TERMINAL_THEMES } from "../../../infrastructure/config/terminalThemes";
import { customThemeStore, useCustomThemes } from "../../../application/state/customThemeStore";
import { parseItermcolors } from "../../../infrastructure/parsers/itermcolorsParser";
import { cn } from "../../../lib/utils";
import { Button } from "../../ui/button";
import { Input } from "../../ui/input";
import { Label } from "../../ui/label";
import { SectionHeader, Select, SettingsTabContent, SettingRow, Toggle } from "../settings-ui";
import { ThemeSelectModal } from "../ThemeSelectModal";
import { TerminalFontSelect } from "../TerminalFontSelect";
import { CustomThemeModal } from "../../terminal/CustomThemeModal";
import type { TerminalTheme } from "../../../domain/models";
// Theme preview button component
const ThemePreviewButton: React.FC<{
@@ -50,13 +55,13 @@ const ThemePreviewButton: React.FC<{
<span className="inline-block w-1.5 h-2 animate-pulse" style={{ backgroundColor: c.cursor }} />
</div>
</div>
{/* Theme info */}
<div className="flex-1 min-w-0">
<div className="text-sm font-medium">{theme.name}</div>
<div className="text-xs text-muted-foreground capitalize">{theme.type}</div>
</div>
{/* Action button area */}
<div className="flex items-center gap-2 text-muted-foreground">
<span className="text-xs">{buttonLabel}</span>
@@ -99,11 +104,86 @@ export default function SettingsTerminalTab(props: {
const [dirValidation, setDirValidation] = useState<{ valid: boolean; message?: string } | null>(null);
const [themeModalOpen, setThemeModalOpen] = useState(false);
// Subscribe to custom theme changes so editing in-place triggers re-render
const customThemes = useCustomThemes();
// Get current selected theme
const currentTheme = useMemo(() => {
return TERMINAL_THEMES.find(t => t.id === terminalThemeId) || TERMINAL_THEMES[0];
return TERMINAL_THEMES.find(t => t.id === terminalThemeId)
|| customThemes.find(t => t.id === terminalThemeId)
|| TERMINAL_THEMES[0];
}, [terminalThemeId, customThemes]);
// Import .itermcolors file
const importFileRef = useRef<HTMLInputElement>(null);
const handleImportItermcolors = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) {
console.log('[Settings] No file selected');
return;
}
console.log('[Settings] File selected:', file.name, 'size:', file.size);
const name = file.name.replace(/\.(itermcolors|xml)$/i, '');
const reader = new FileReader();
reader.onload = () => {
const xml = reader.result as string;
console.log('[Settings] File read successfully, length:', xml.length);
const parsed = parseItermcolors(xml, name);
if (parsed) {
console.log('[Settings] Theme parsed successfully:', parsed.id, parsed.name);
customThemeStore.addTheme(parsed);
setTerminalThemeId(parsed.id);
} else {
console.error('[Settings] Failed to parse .itermcolors file:', file.name);
window.alert(t('terminal.customTheme.importError') || 'Failed to parse the selected file. Please ensure it is a valid .itermcolors XML file.');
}
};
reader.onerror = () => {
console.error('[Settings] Failed to read file:', file.name, reader.error);
};
reader.readAsText(file);
e.target.value = '';
}, [setTerminalThemeId, t]);
// New custom theme modal
const [customThemeModalOpen, setCustomThemeModalOpen] = useState(false);
const [customThemeData, setCustomThemeData] = useState<TerminalTheme | null>(null);
const [isEditingTheme, setIsEditingTheme] = useState(false);
// Check if current theme is a custom theme
const isCustomTheme = useMemo(() => {
return currentTheme?.isCustom === true;
}, [currentTheme]);
const handleNewCustomTheme = useCallback(() => {
const base = TERMINAL_THEMES.find(t => t.id === terminalThemeId)
|| customThemeStore.getThemeById(terminalThemeId)
|| TERMINAL_THEMES[0];
const newTheme: TerminalTheme = {
...base,
id: `custom-${Date.now()}`,
name: `${base.name} (Custom)`,
isCustom: true,
colors: { ...base.colors },
};
setCustomThemeData(newTheme);
setIsEditingTheme(false);
setCustomThemeModalOpen(true);
}, [terminalThemeId]);
const handleEditCustomTheme = useCallback(() => {
if (!currentTheme?.isCustom) return;
setCustomThemeData({ ...currentTheme, colors: { ...currentTheme.colors } });
setIsEditingTheme(true);
setCustomThemeModalOpen(true);
}, [currentTheme]);
const handleDeleteCustomTheme = useCallback(() => {
if (!currentTheme?.isCustom) return;
customThemeStore.deleteTheme(currentTheme.id);
setTerminalThemeId(TERMINAL_THEMES[0].id);
}, [currentTheme, setTerminalThemeId]);
// Fetch default shell on mount
useEffect(() => {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
@@ -193,7 +273,7 @@ export default function SettingsTerminalTab(props: {
onClick={() => setThemeModalOpen(true)}
buttonLabel={t("settings.terminal.theme.selectButton")}
/>
<ThemeSelectModal
open={themeModalOpen}
onClose={() => setThemeModalOpen(false)}
@@ -201,17 +281,97 @@ export default function SettingsTerminalTab(props: {
onSelect={setTerminalThemeId}
/>
{/* Theme action buttons */}
<div className="flex items-center gap-2 -mt-1">
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={handleNewCustomTheme}
>
<Palette size={14} />
{t('terminal.customTheme.new')}
</Button>
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => importFileRef.current?.click()}
>
<Import size={14} />
{t('terminal.customTheme.import')}
</Button>
{isCustomTheme && (
<>
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={handleEditCustomTheme}
>
<Pencil size={14} />
{t('common.edit')}
</Button>
<Button
variant="outline"
size="sm"
className="gap-1.5 text-destructive hover:text-destructive"
onClick={handleDeleteCustomTheme}
>
<Trash2 size={14} />
{t('common.delete')}
</Button>
</>
)}
<input
ref={importFileRef}
type="file"
accept=".itermcolors"
className="hidden"
onChange={handleImportItermcolors}
/>
</div>
{/* Custom Theme Modal */}
{customThemeData && (
<CustomThemeModal
open={customThemeModalOpen}
theme={customThemeData}
isNew={!isEditingTheme}
onSave={(theme) => {
if (isEditingTheme) {
customThemeStore.updateTheme(theme.id, theme);
} else {
customThemeStore.addTheme(theme);
}
setTerminalThemeId(theme.id);
setCustomThemeModalOpen(false);
setCustomThemeData(null);
}}
onDelete={isEditingTheme ? (themeId) => {
customThemeStore.deleteTheme(themeId);
setTerminalThemeId(TERMINAL_THEMES[0].id);
setCustomThemeModalOpen(false);
setCustomThemeData(null);
} : undefined}
onCancel={() => {
setCustomThemeModalOpen(false);
setCustomThemeData(null);
}}
/>
)}
<SectionHeader title={t("settings.terminal.section.font")} />
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
<SettingRow
label={t("settings.terminal.font.family")}
description={t("settings.terminal.font.family.desc")}
>
<Select
<TerminalFontSelect
value={terminalFontFamilyId}
options={availableFonts.map((f) => ({ value: f.id, label: f.name }))}
fonts={availableFonts}
onChange={(id) => setTerminalFontFamilyId(id)}
className="w-40"
className="w-48"
/>
</SettingRow>
@@ -313,7 +473,7 @@ export default function SettingsTerminalTab(props: {
onChange={(v) =>
updateTerminalSetting("terminalEmulationType", v as TerminalEmulationType)
}
className="w-36"
className="w-44"
/>
</SettingRow>
</div>
@@ -408,6 +568,13 @@ export default function SettingsTerminalTab(props: {
<Toggle checked={terminalSettings.middleClickPaste} onChange={(v) => updateTerminalSetting("middleClickPaste", v)} />
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.bracketedPaste")}
description={t("settings.terminal.behavior.bracketedPaste.desc")}
>
<Toggle checked={!terminalSettings.disableBracketedPaste} onChange={(v) => updateTerminalSetting("disableBracketedPaste", !v)} />
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.scrollOnInput")}
description={t("settings.terminal.behavior.scrollOnInput.desc")}
@@ -608,6 +775,62 @@ export default function SettingsTerminalTab(props: {
/>
</SettingRow>
</div>
<SectionHeader title={t("settings.terminal.section.serverStats")} />
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
<SettingRow
label={t("settings.terminal.serverStats.show")}
description={t("settings.terminal.serverStats.show.desc")}
>
<Toggle
checked={terminalSettings.showServerStats}
onChange={(v) => updateTerminalSetting("showServerStats", v)}
/>
</SettingRow>
{terminalSettings.showServerStats && (
<SettingRow
label={t("settings.terminal.serverStats.refreshInterval")}
description={t("settings.terminal.serverStats.refreshInterval.desc")}
>
<div className="flex items-center gap-2">
<Input
type="number"
min={5}
max={300}
value={terminalSettings.serverStatsRefreshInterval}
onChange={(e) => {
const val = parseInt(e.target.value) || 5;
if (val >= 5 && val <= 300) {
updateTerminalSetting("serverStatsRefreshInterval", val);
}
}}
className="w-20"
/>
<span className="text-sm text-muted-foreground">{t("settings.terminal.serverStats.seconds")}</span>
</div>
</SettingRow>
)}
</div>
<SectionHeader title={t("settings.terminal.section.rendering")} />
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
<SettingRow
label={t("settings.terminal.rendering.renderer")}
description={t("settings.terminal.rendering.renderer.desc")}
>
<Select
value={terminalSettings.rendererType}
options={[
{ value: "auto", label: t("settings.terminal.rendering.auto") },
{ value: "webgl", label: "WebGL" },
{ value: "canvas", label: "Canvas" },
]}
onChange={(v) => updateTerminalSetting("rendererType", v as "auto" | "webgl" | "canvas")}
className="w-32"
/>
</SettingRow>
</div>
</SettingsTabContent>
);
}

View File

@@ -0,0 +1,186 @@
import React from "react";
import { Loader2 } from "lucide-react";
import { Button } from "../ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
import { Input } from "../ui/input";
import type { RemoteFile } from "../../types";
interface PermissionsState {
owner: { read: boolean; write: boolean; execute: boolean };
group: { read: boolean; write: boolean; execute: boolean };
others: { read: boolean; write: boolean; execute: boolean };
}
interface SftpModalDialogsProps {
t: (key: string, params?: Record<string, unknown>) => string;
showRenameDialog: boolean;
setShowRenameDialog: (open: boolean) => void;
renameTarget: RemoteFile | null;
renameName: string;
setRenameName: (value: string) => void;
handleRename: () => void;
isRenaming: boolean;
showPermissionsDialog: boolean;
setShowPermissionsDialog: (open: boolean) => void;
permissionsTarget: RemoteFile | null;
permissions: PermissionsState;
togglePermission: (role: "owner" | "group" | "others", perm: "read" | "write" | "execute") => void;
getOctalPermissions: () => string;
getSymbolicPermissions: () => string;
handleSavePermissions: () => void;
isChangingPermissions: boolean;
showCreateDialog: boolean;
setShowCreateDialog: (open: boolean) => void;
createType: "file" | "folder";
createName: string;
setCreateName: (value: string) => void;
isCreating: boolean;
handleCreateSubmit: () => void;
}
export const SftpModalDialogs: React.FC<SftpModalDialogsProps> = ({
t,
showRenameDialog,
setShowRenameDialog,
renameTarget,
renameName,
setRenameName,
handleRename,
isRenaming,
showPermissionsDialog,
setShowPermissionsDialog,
permissionsTarget,
permissions,
togglePermission,
getOctalPermissions,
getSymbolicPermissions,
handleSavePermissions,
isChangingPermissions,
showCreateDialog,
setShowCreateDialog,
createType,
createName,
setCreateName,
isCreating,
handleCreateSubmit,
}) => (
<>
<Dialog open={showRenameDialog} onOpenChange={setShowRenameDialog}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>{t("sftp.rename.title")}</DialogTitle>
<DialogDescription className="truncate">
{renameTarget?.name}
</DialogDescription>
</DialogHeader>
<div className="py-4">
<Input
value={renameName}
onChange={(e) => setRenameName(e.target.value)}
placeholder={t("sftp.rename.placeholder")}
onKeyDown={(e) => {
if (e.key === "Enter") handleRename();
}}
autoFocus
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowRenameDialog(false)}>
{t("common.cancel")}
</Button>
<Button onClick={handleRename} disabled={isRenaming || !renameName.trim()}>
{isRenaming ? <Loader2 size={14} className="mr-2 animate-spin" /> : null}
{t("common.apply")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showPermissionsDialog} onOpenChange={setShowPermissionsDialog}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>{t("sftp.permissions.title")}</DialogTitle>
<DialogDescription className="truncate">
{permissionsTarget?.name}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-3">
{(["owner", "group", "others"] as const).map((role) => (
<div key={role} className="flex items-center gap-4">
<div className="w-16 text-sm font-medium">
{t(`sftp.permissions.${role}`)}
</div>
<div className="flex gap-3">
{(["read", "write", "execute"] as const).map((perm) => (
<label key={perm} className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={permissions[role][perm]}
onChange={() => togglePermission(role, perm)}
className="rounded border-border"
/>
<span className="text-xs">
{perm === "read" ? "R" : perm === "write" ? "W" : "X"}
</span>
</label>
))}
</div>
</div>
))}
</div>
<div className="flex items-center justify-between pt-2 border-t border-border/60">
<div className="text-xs text-muted-foreground">
{t("sftp.permissions.octal")}: <span className="font-mono text-foreground">{getOctalPermissions()}</span>
</div>
<div className="text-xs text-muted-foreground">
{t("sftp.permissions.symbolic")}: <span className="font-mono text-foreground">{getSymbolicPermissions()}</span>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowPermissionsDialog(false)}>
{t("common.cancel")}
</Button>
<Button onClick={handleSavePermissions} disabled={isChangingPermissions}>
{isChangingPermissions ? <Loader2 size={14} className="mr-2 animate-spin" /> : null}
{t("common.apply")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>
{t(createType === "folder" ? "sftp.newFolder" : "sftp.newFile")}
</DialogTitle>
<DialogDescription>
{t(createType === "folder" ? "sftp.prompt.newFolderName" : "sftp.fileName.placeholder")}
</DialogDescription>
</DialogHeader>
<div className="py-4">
<Input
value={createName}
onChange={(e) => setCreateName(e.target.value)}
placeholder={t(createType === "folder" ? "sftp.prompt.newFolderName" : "sftp.fileName.placeholder")}
onKeyDown={(e) => {
if (e.key === "Enter") handleCreateSubmit();
}}
autoFocus
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCreateDialog(false)}>
{t("common.cancel")}
</Button>
<Button onClick={handleCreateSubmit} disabled={isCreating || !createName.trim()}>
{isCreating ? <Loader2 size={14} className="mr-2 animate-spin" /> : null}
{t("common.apply")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);

View File

@@ -0,0 +1,436 @@
import React from "react";
import { Download, Edit2, Folder, FolderOpen, FolderUp, Link, Loader2, MoreHorizontal, Plus, RefreshCw, Shield, Trash2, Upload } from "lucide-react";
import { cn } from "../../lib/utils";
import type { RemoteFile } from "../../types";
import { isKnownBinaryFile } from "../../lib/sftpFileUtils";
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from "../ui/context-menu";
import { Button } from "../ui/button";
import { getFileIcon } from "./fileIcons";
interface VisibleRow {
file: RemoteFile;
index: number;
top: number;
}
interface SftpModalFileListProps {
t: (key: string, params?: Record<string, unknown>) => string;
currentPath: string;
isLocalSession: boolean;
files: RemoteFile[];
selectedFiles: Set<string>;
dragActive: boolean;
loading: boolean;
loadingTextContent: boolean;
reconnecting: boolean;
columnWidths: { name: number; size: number; modified: number; actions: number };
sortField: "name" | "size" | "modified";
sortOrder: "asc" | "desc";
shouldVirtualize: boolean;
totalHeight: number;
visibleRows: VisibleRow[];
fileListRef: React.RefObject<HTMLDivElement>;
inputRef: React.RefObject<HTMLInputElement>;
folderInputRef: React.RefObject<HTMLInputElement>;
handleSort: (field: "name" | "size" | "modified") => void;
handleResizeStart: (field: string, e: React.MouseEvent) => void;
handleFileListScroll: (e: React.UIEvent<HTMLDivElement>) => void;
handleDrag: (e: React.DragEvent) => void;
handleDrop: (e: React.DragEvent) => void;
handleFileClick: (file: RemoteFile, index: number, e: React.MouseEvent) => void;
handleFileDoubleClick: (file: RemoteFile) => void;
handleDownload: (file: RemoteFile) => void;
handleDelete: (file: RemoteFile) => void;
handleOpenFile: (file: RemoteFile) => void;
openFileOpenerDialog: (file: RemoteFile) => void;
handleEditFile: (file: RemoteFile) => void;
openRenameDialog: (file: RemoteFile) => void;
openPermissionsDialog: (file: RemoteFile) => void;
handleNavigate: (path: string) => void;
handleCreateFolder: () => void;
handleCreateFile: () => void;
handleDownloadSelected: () => void;
handleDeleteSelected: () => void;
loadFiles: (path: string, options?: { force?: boolean }) => void;
formatBytes: (bytes: number | string) => string;
formatDate: (dateStr: string | number | undefined) => string;
}
export const SftpModalFileList: React.FC<SftpModalFileListProps> = ({
t,
currentPath,
isLocalSession,
files,
selectedFiles,
dragActive,
loading,
loadingTextContent,
reconnecting,
columnWidths,
sortField,
sortOrder,
shouldVirtualize,
totalHeight,
visibleRows,
fileListRef,
inputRef,
folderInputRef,
handleSort,
handleResizeStart,
handleFileListScroll,
handleDrag,
handleDrop,
handleFileClick,
handleFileDoubleClick,
handleDownload,
handleDelete,
handleOpenFile,
openFileOpenerDialog,
handleEditFile,
openRenameDialog,
openPermissionsDialog,
handleNavigate,
handleCreateFolder,
handleCreateFile,
handleDownloadSelected,
handleDeleteSelected,
loadFiles,
formatBytes,
formatDate,
}) => (
<>
<div
className="shrink-0 bg-muted/80 backdrop-blur-sm border-b border-border/60 px-4 py-2 flex items-center text-xs font-medium text-muted-foreground select-none"
style={{
display: "grid",
gridTemplateColumns: `${columnWidths.name}% ${columnWidths.size}% ${columnWidths.modified}% ${columnWidths.actions}%`,
}}
>
<div
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2"
onClick={() => handleSort("name")}
>
<span>{t("sftp.columns.name")}</span>
{sortField === "name" && (
<span className="text-primary">{sortOrder === "asc" ? "^" : "v"}</span>
)}
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
onMouseDown={(e) => handleResizeStart("name", e)}
/>
</div>
<div
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2"
onClick={() => handleSort("size")}
>
<span>{t("sftp.columns.size")}</span>
{sortField === "size" && (
<span className="text-primary">{sortOrder === "asc" ? "^" : "v"}</span>
)}
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
onMouseDown={(e) => handleResizeStart("size", e)}
/>
</div>
<div
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2"
onClick={() => handleSort("modified")}
>
<span>{t("sftp.columns.modified")}</span>
{sortField === "modified" && (
<span className="text-primary">{sortOrder === "asc" ? "^" : "v"}</span>
)}
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
onMouseDown={(e) => handleResizeStart("modified", e)}
/>
</div>
<div className="text-right">{t("sftp.columns.actions")}</div>
</div>
<div
ref={fileListRef}
className={cn(
"flex-1 min-h-0 overflow-y-auto relative",
dragActive && "bg-primary/5 ring-2 ring-inset ring-primary",
)}
onScroll={handleFileListScroll}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
>
{dragActive && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
<div className="bg-background/95 p-6 rounded-xl shadow-lg border-2 border-dashed border-primary text-primary font-medium flex flex-col items-center gap-2">
<Upload size={32} />
<span>{t("sftp.dropFilesHere")}</span>
</div>
</div>
)}
{loading && files.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
)}
{loadingTextContent && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-20">
<div className="flex flex-col items-center gap-2">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">
{t("sftp.status.loading")}
</span>
</div>
</div>
)}
{reconnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-20">
<div className="flex flex-col items-center gap-3 p-6 rounded-xl bg-secondary/90 border border-border/60 shadow-lg">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<div className="text-center">
<div className="text-sm font-medium">{t("sftp.reconnecting.title")}</div>
<div className="text-xs text-muted-foreground mt-1">
{t("sftp.reconnecting.desc")}
</div>
</div>
</div>
</div>
)}
{files.length === 0 && !loading && (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<Folder size={48} className="mb-3 opacity-50" />
<div className="text-sm font-medium">{t("sftp.emptyDirectory")}</div>
<div className="text-xs mt-1">{t("sftp.dragDropToUpload")}</div>
</div>
)}
<ContextMenu>
<ContextMenuTrigger asChild>
<div
className={shouldVirtualize ? "relative" : "divide-y divide-border/30"}
style={shouldVirtualize ? { height: totalHeight } : undefined}
>
{visibleRows.map(({ file, index: idx, top }) => {
const isNavigableDirectory =
file.type === "directory" ||
(file.type === "symlink" && file.linkTarget === "directory");
const isDownloadableFile =
file.type === "file" ||
(file.type === "symlink" && file.linkTarget === "file");
const isParentEntry = file.name === "..";
return (
<ContextMenu key={file.name}>
<ContextMenuTrigger>
<div
data-sftp-modal-row="true"
className={cn(
"px-4 py-2.5 items-center hover:bg-muted/50 cursor-pointer transition-colors text-sm",
selectedFiles.has(file.name) && !isParentEntry && "bg-primary/10",
shouldVirtualize ? "absolute left-0 right-0 border-b border-border/30" : "",
)}
style={
shouldVirtualize
? {
top,
display: "grid",
gridTemplateColumns: `${columnWidths.name}% ${columnWidths.size}% ${columnWidths.modified}% ${columnWidths.actions}%`,
}
: {
display: "grid",
gridTemplateColumns: `${columnWidths.name}% ${columnWidths.size}% ${columnWidths.modified}% ${columnWidths.actions}%`,
}
}
onClick={(e) => handleFileClick(file, idx, e)}
onDoubleClick={() => handleFileDoubleClick(file)}
>
<div className="flex items-center gap-3 min-w-0">
<div className="relative shrink-0 h-7 w-7 flex items-center justify-center">
{getFileIcon(
file.name,
isNavigableDirectory,
file.type === "symlink" && !isNavigableDirectory,
)}
{file.type === "symlink" && (
<Link
size={10}
className="absolute -bottom-0.5 -right-0.5 text-muted-foreground"
aria-hidden="true"
/>
)}
</div>
<span
className={cn(
"truncate font-medium",
file.type === "symlink" && "italic pr-1",
)}
>
{file.name}
{file.type === "symlink" && (
<span className="sr-only"> (symbolic link)</span>
)}
</span>
</div>
<div className="text-xs text-muted-foreground">
{isNavigableDirectory ? "--" : formatBytes(file.size)}
</div>
<div className="text-xs text-muted-foreground truncate">
{formatDate(file.lastModified)}
</div>
<div className="flex items-center justify-end gap-1">
{isDownloadableFile && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={(e) => {
e.stopPropagation();
handleDownload(file);
}}
title={t("sftp.context.download")}
>
<Download size={14} />
</Button>
)}
{!isParentEntry && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleDelete(file);
}}
title={t("sftp.context.delete")}
>
<Trash2 size={14} />
</Button>
)}
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
{isParentEntry ? (
<ContextMenuItem
onClick={() => {
const segments = currentPath.split("/").filter(Boolean);
segments.pop();
const parentPath =
segments.length === 0 ? "/" : `/${segments.join("/")}`;
handleNavigate(parentPath);
}}
>
{t("sftp.context.open")}
</ContextMenuItem>
) : (
<>
{isNavigableDirectory && (
<>
<ContextMenuItem
onClick={() =>
handleNavigate(
currentPath === "/"
? `/${file.name}`
: `${currentPath}/${file.name}`,
)
}
>
<FolderOpen size={14} className="mr-2" />
{t("sftp.context.open")}
</ContextMenuItem>
{!isLocalSession && (
<ContextMenuItem onClick={() => handleDownload(file)}>
<Download size={14} className="mr-2" />
{t("sftp.context.download")}
</ContextMenuItem>
)}
</>
)}
{isDownloadableFile && (
<>
<ContextMenuItem onClick={() => handleOpenFile(file)}>
<FolderOpen size={14} className="mr-2" />
{t("sftp.context.open")}
</ContextMenuItem>
<ContextMenuItem onClick={() => openFileOpenerDialog(file)}>
<MoreHorizontal size={14} className="mr-2" />
{t("sftp.context.openWith")}
</ContextMenuItem>
{!isKnownBinaryFile(file.name) && (
<ContextMenuItem onClick={() => handleEditFile(file)}>
<Edit2 size={14} className="mr-2" />
{t("sftp.context.edit")}
</ContextMenuItem>
)}
<ContextMenuSeparator />
<ContextMenuItem onClick={() => handleDownload(file)}>
<Download size={14} className="mr-2" />
{t("sftp.context.download")}
</ContextMenuItem>
</>
)}
<ContextMenuItem onClick={() => openRenameDialog(file)}>
<Edit2 size={14} className="mr-2" />
{t("sftp.context.rename")}
</ContextMenuItem>
{!isLocalSession && (
<ContextMenuItem onClick={() => openPermissionsDialog(file)}>
<Shield size={14} className="mr-2" />
{t("sftp.context.permissions")}
</ContextMenuItem>
)}
<ContextMenuItem
className="text-destructive"
onClick={() => handleDelete(file)}
>
<Trash2 size={14} className="mr-2" />
{t("sftp.context.delete")}
</ContextMenuItem>
</>
)}
</ContextMenuContent>
</ContextMenu>
);
})}
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={handleCreateFolder}>
<Plus className="h-4 w-4 mr-2" /> {t("sftp.newFolder")}
</ContextMenuItem>
<ContextMenuItem onClick={handleCreateFile}>
<Plus className="h-4 w-4 mr-2" /> {t("sftp.newFile")}
</ContextMenuItem>
<ContextMenuItem onClick={() => inputRef.current?.click()}>
<Upload className="h-4 w-4 mr-2" /> {t("sftp.uploadFiles")}
</ContextMenuItem>
<ContextMenuItem onClick={() => folderInputRef.current?.click()}>
<FolderUp className="h-4 w-4 mr-2" /> {t("sftp.uploadFolder")}
</ContextMenuItem>
<ContextMenuItem onClick={() => loadFiles(currentPath, { force: true })}>
<RefreshCw className="h-4 w-4 mr-2" /> {t("sftp.context.refresh")}
</ContextMenuItem>
{selectedFiles.size > 0 && (
<>
<ContextMenuItem onClick={handleDownloadSelected}>
<Download className="h-4 w-4 mr-2" />
{t("sftp.context.downloadSelected", { count: selectedFiles.size })}
</ContextMenuItem>
<ContextMenuItem
className="text-destructive"
onClick={handleDeleteSelected}
>
<Trash2 className="h-4 w-4 mr-2" />
{t("sftp.context.deleteSelected", { count: selectedFiles.size })}
</ContextMenuItem>
</>
)}
</ContextMenuContent>
</ContextMenu>
</div>
</>
);

View File

@@ -0,0 +1,61 @@
import React from "react";
import { Download, Trash2 } from "lucide-react";
import { Button } from "../ui/button";
import type { RemoteFile } from "../../types";
interface SftpModalFooterProps {
t: (key: string, params?: Record<string, unknown>) => string;
files: RemoteFile[];
selectedFiles: Set<string>;
loading: boolean;
uploading: boolean;
onDownloadSelected: () => void;
onDeleteSelected: () => void;
}
export const SftpModalFooter: React.FC<SftpModalFooterProps> = ({
t,
files,
selectedFiles,
loading,
uploading,
onDownloadSelected,
onDeleteSelected,
}) => (
<div className="px-4 py-2 border-t border-border/60 flex items-center justify-between text-xs text-muted-foreground bg-muted/30 flex-shrink-0">
<span>
{t("sftp.itemsCount", { count: files.length })}
{selectedFiles.size > 0 && (
<>
<span className="mx-2">|</span>
<span className="text-primary">
{t("sftp.selectedCount", { count: selectedFiles.size })}
</span>
<Button
variant="ghost"
size="sm"
className="h-5 px-2 ml-2 text-xs text-primary hover:text-primary"
onClick={onDownloadSelected}
>
<Download size={10} className="mr-1" /> {t("sftp.context.download")}
</Button>
<Button
variant="ghost"
size="sm"
className="h-5 px-2 text-xs text-destructive hover:text-destructive"
onClick={onDeleteSelected}
>
<Trash2 size={10} className="mr-1" /> {t("sftp.context.delete")}
</Button>
</>
)}
</span>
<span>
{loading
? t("sftp.status.loading")
: uploading
? t("sftp.status.uploading")
: t("sftp.status.ready")}
</span>
</div>
);

View File

@@ -0,0 +1,448 @@
import React, { useEffect, useState } from "react";
import { ArrowUp, Bookmark, Check, ChevronRight, FilePlus, FolderPlus, FolderUp, Home, Languages, MoreHorizontal, RefreshCw, Trash2, Upload } from "lucide-react";
import { cn } from "../../lib/utils";
import type { Host, SftpFilenameEncoding } from "../../types";
import { useSftpBookmarks } from "../sftp/hooks/useSftpBookmarks";
import { DistroAvatar } from "../DistroAvatar";
import { Button } from "../ui/button";
import { DialogHeader, DialogTitle } from "../ui/dialog";
import { Input } from "../ui/input";
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from "../ui/popover";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
interface BreadcrumbPart {
part: string;
originalIndex: number;
}
interface SftpModalHeaderProps {
t: (key: string, params?: Record<string, unknown>) => string;
host: Host;
credentials: { username?: string; hostname: string; port?: number };
showEncoding: boolean;
filenameEncoding: SftpFilenameEncoding;
onFilenameEncodingChange: (encoding: SftpFilenameEncoding) => void;
currentPath: string;
isEditingPath: boolean;
editingPathValue: string;
setEditingPathValue: (value: string) => void;
handlePathSubmit: () => void;
handlePathKeyDown: (e: React.KeyboardEvent) => void;
handlePathDoubleClick: () => void;
isAtRoot: boolean;
rootLabel: string;
isRefreshing: boolean;
onUp: () => void;
onHome: () => void;
onRefresh: () => void;
visibleBreadcrumbs: BreadcrumbPart[];
hiddenBreadcrumbs: BreadcrumbPart[];
needsBreadcrumbTruncation: boolean;
breadcrumbs: string[];
onBreadcrumbSelect: (index: number) => void;
onRootSelect: () => void;
inputRef: React.RefObject<HTMLInputElement>;
folderInputRef: React.RefObject<HTMLInputElement>;
pathInputRef: React.RefObject<HTMLInputElement>;
uploading: boolean;
onTriggerUpload: () => void;
onTriggerFolderUpload: () => void;
onCreateFolder: () => void;
onCreateFile: () => void;
onFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
onFolderSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
onUpdateHost?: (host: Host) => void;
onNavigateToBookmark?: (path: string) => void;
}
export const SftpModalHeader: React.FC<SftpModalHeaderProps> = ({
t,
host,
credentials,
showEncoding,
filenameEncoding,
onFilenameEncodingChange,
currentPath,
isEditingPath,
editingPathValue,
setEditingPathValue,
handlePathSubmit,
handlePathKeyDown,
handlePathDoubleClick,
isAtRoot,
rootLabel,
isRefreshing,
onUp,
onHome,
onRefresh,
visibleBreadcrumbs,
hiddenBreadcrumbs,
needsBreadcrumbTruncation,
breadcrumbs,
onBreadcrumbSelect,
onRootSelect,
inputRef,
folderInputRef,
pathInputRef,
uploading,
onTriggerUpload,
onTriggerFolderUpload,
onCreateFolder,
onCreateFile,
onFileSelect,
onFolderSelect,
onUpdateHost,
onNavigateToBookmark,
}) => {
// Delay tooltip activation to prevent flickering when modal opens
const [tooltipsReady, setTooltipsReady] = useState(false);
const [openTooltip, setOpenTooltip] = useState<string | null>(null);
// Bookmarks
const {
bookmarks,
isCurrentPathBookmarked,
toggleBookmark,
deleteBookmark,
} = useSftpBookmarks({
host,
currentPath,
onUpdateHost,
});
useEffect(() => {
const timer = setTimeout(() => setTooltipsReady(true), 500);
return () => clearTimeout(timer);
}, []);
const handleTooltipOpenChange = (id: string) => (open: boolean) => {
if (!tooltipsReady) return;
setOpenTooltip(open ? id : null);
};
return (
<>
<DialogHeader className="px-4 py-3 border-b border-border/60 flex-shrink-0">
<div className="flex items-center gap-3 pr-8">
<DistroAvatar
host={host}
fallback={host.label.slice(0, 2).toUpperCase()}
className="h-8 w-8"
/>
<div className="flex-1 min-w-0">
<DialogTitle className="text-sm font-semibold">
{host.label}
</DialogTitle>
<div className="text-xs text-muted-foreground font-mono">
{credentials.username || "root"}@{credentials.hostname}:
{credentials.port || 22}
</div>
</div>
</div>
</DialogHeader>
<TooltipProvider delayDuration={500} skipDelayDuration={800} disableHoverableContent>
<div className="px-4 py-2 border-b border-border/60 flex items-center gap-2 flex-shrink-0 bg-muted/30">
<Tooltip open={openTooltip === 'up'} onOpenChange={handleTooltipOpenChange('up')}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onUp}
disabled={isAtRoot}
>
<ArrowUp size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.nav.up")}</TooltipContent>
</Tooltip>
<Tooltip open={openTooltip === 'home'} onOpenChange={handleTooltipOpenChange('home')}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onHome}
>
<Home size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.nav.home")}</TooltipContent>
</Tooltip>
<Tooltip open={openTooltip === 'refresh'} onOpenChange={handleTooltipOpenChange('refresh')}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onRefresh}
>
<RefreshCw
size={14}
className={cn(isRefreshing && "animate-spin")}
/>
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.nav.refresh")}</TooltipContent>
</Tooltip>
{/* Bookmark button */}
{onUpdateHost && (
<Popover>
<Tooltip open={openTooltip === 'bookmark'} onOpenChange={handleTooltipOpenChange('bookmark')}>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
>
<Bookmark
size={14}
className={cn(
isCurrentPathBookmarked && "fill-yellow-500 text-yellow-500"
)}
/>
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>
{isCurrentPathBookmarked ? t("sftp.bookmark.remove") : t("sftp.bookmark.add")}
</TooltipContent>
</Tooltip>
<PopoverContent className="w-56 p-1" align="start">
{/* Toggle button */}
<button
className="w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors"
onClick={toggleBookmark}
>
<Bookmark
size={12}
className={cn(
"shrink-0",
isCurrentPathBookmarked && "fill-yellow-500 text-yellow-500"
)}
/>
{isCurrentPathBookmarked ? t("sftp.bookmark.remove") : t("sftp.bookmark.add")}
</button>
{/* Divider + list */}
{bookmarks.length > 0 && (
<>
<div className="my-1 border-t border-border/60" />
{bookmarks.map((bm) => (
<div
key={bm.id}
className="group flex items-center gap-1 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors cursor-pointer"
onClick={() => onNavigateToBookmark?.(bm.path)}
title={bm.path}
>
<Bookmark size={10} className="shrink-0 text-muted-foreground" />
<span className="flex-1 truncate">{bm.label}</span>
<span className="flex-1 truncate text-muted-foreground text-[10px]">{bm.path}</span>
<Button
variant="ghost"
size="icon"
className="h-4 w-4 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
onClick={(e) => {
e.stopPropagation();
deleteBookmark(bm.id);
}}
>
<Trash2 size={10} />
</Button>
</div>
))}
</>
)}
{bookmarks.length === 0 && (
<div className="p-2 text-xs text-muted-foreground text-center">
{t("sftp.bookmark.empty")}
</div>
)}
</PopoverContent>
</Popover>
)}
{showEncoding && (
<Popover>
<Tooltip open={openTooltip === 'encoding'} onOpenChange={handleTooltipOpenChange('encoding')}>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
>
<Languages size={14} />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>{t("sftp.encoding.label")}</TooltipContent>
</Tooltip>
<PopoverContent className="w-36 p-1" align="start">
{(["auto", "utf-8", "gb18030"] as const).map((encoding) => (
<PopoverClose asChild key={encoding}>
<button
className={cn(
"w-full flex items-center gap-2 px-2 py-1.5 text-sm rounded-sm hover:bg-secondary transition-colors",
filenameEncoding === encoding && "bg-secondary"
)}
onClick={() => onFilenameEncodingChange(encoding)}
>
<Check
size={14}
className={cn(
"shrink-0",
filenameEncoding === encoding ? "opacity-100" : "opacity-0"
)}
/>
{t(`sftp.encoding.${encoding === "utf-8" ? "utf8" : encoding}`)}
</button>
</PopoverClose>
))}
</PopoverContent>
</Popover>
)}
<div className="flex items-center gap-1 text-sm flex-1 min-w-0 overflow-hidden">
{isEditingPath ? (
<Input
ref={pathInputRef}
value={editingPathValue}
onChange={(e) => setEditingPathValue(e.target.value)}
onBlur={handlePathSubmit}
onKeyDown={handlePathKeyDown}
className="h-7 text-sm bg-background"
autoFocus
/>
) : (
<div
className="flex items-center gap-1 flex-1 min-w-0 cursor-text hover:bg-secondary/50 rounded px-1 py-0.5 transition-colors"
onDoubleClick={handlePathDoubleClick}
title={currentPath}
>
<button
className="text-muted-foreground hover:text-foreground px-1 shrink-0"
onClick={onRootSelect}
>
{rootLabel}
</button>
{visibleBreadcrumbs.map(({ part, originalIndex }, displayIdx) => {
const isLast = originalIndex === breadcrumbs.length - 1;
const showEllipsisBefore =
needsBreadcrumbTruncation && displayIdx === 1;
return (
<React.Fragment key={originalIndex}>
{showEllipsisBefore && (
<>
<ChevronRight
size={12}
className="text-muted-foreground flex-shrink-0"
/>
<span
className="text-muted-foreground px-1 shrink-0 flex items-center cursor-default"
title={`${t("sftp.showHiddenPaths")}: ${hiddenBreadcrumbs
.map((h) => h.part)
.join(" > ")}`}
>
<MoreHorizontal size={14} />
</span>
</>
)}
<ChevronRight
size={12}
className="text-muted-foreground flex-shrink-0"
/>
<button
className={cn(
"text-muted-foreground hover:text-foreground truncate px-1 max-w-[100px]",
isLast && "text-foreground font-medium",
)}
onClick={() => onBreadcrumbSelect(originalIndex)}
title={part}
>
{part}
</button>
</React.Fragment>
);
})}
</div>
)}
</div>
<div className="flex items-center gap-1 ml-auto">
<Tooltip open={openTooltip === 'upload'} onOpenChange={handleTooltipOpenChange('upload')}>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-7 w-7"
onClick={onTriggerUpload}
disabled={uploading}
>
<Upload size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.upload")}</TooltipContent>
</Tooltip>
<Tooltip open={openTooltip === 'uploadFolder'} onOpenChange={handleTooltipOpenChange('uploadFolder')}>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-7 w-7"
onClick={onTriggerFolderUpload}
disabled={uploading}
>
<FolderUp size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.uploadFolder")}</TooltipContent>
</Tooltip>
<Tooltip open={openTooltip === 'newFolder'} onOpenChange={handleTooltipOpenChange('newFolder')}>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-7 w-7"
onClick={onCreateFolder}
>
<FolderPlus size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.newFolder")}</TooltipContent>
</Tooltip>
<Tooltip open={openTooltip === 'newFile'} onOpenChange={handleTooltipOpenChange('newFile')}>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-7 w-7"
onClick={onCreateFile}
>
<FilePlus size={14} />
</Button>
</TooltipTrigger>
<TooltipContent>{t("sftp.newFile")}</TooltipContent>
</Tooltip>
<input
type="file"
className="hidden"
ref={inputRef}
onChange={onFileSelect}
multiple
/>
<input
type="file"
className="hidden"
ref={folderInputRef}
onChange={onFolderSelect}
webkitdirectory=""
multiple
/>
</div>
</div>
</TooltipProvider>
</>
);
};

View File

@@ -0,0 +1,224 @@
import React from "react";
import { Download, Loader2, Upload, X, XCircle } from "lucide-react";
import { cn } from "../../lib/utils";
import { Button } from "../ui/button";
interface TransferTask {
id: string;
fileName: string;
totalBytes: number;
transferredBytes: number;
progress: number;
speed: number;
status: "pending" | "uploading" | "downloading" | "completed" | "failed" | "cancelled";
error?: string;
direction: "upload" | "download";
}
interface SftpModalUploadTasksProps {
tasks: TransferTask[];
t: (key: string, params?: Record<string, unknown>) => string;
onCancel?: () => void;
onCancelTask?: (taskId: string) => void;
onDismiss?: (taskId: string) => void;
}
export const SftpModalUploadTasks: React.FC<SftpModalUploadTasksProps> = ({ tasks, t, onCancel, onCancelTask, onDismiss }) => {
if (tasks.length === 0) return null;
// Helper function to get localized display name for compressed uploads
const getDisplayName = (task: TransferTask) => {
// Check for explicit phase marker format: "folderName|phase"
// This is the format sent by uploadService.ts for compressed uploads
if (task.fileName.includes('|')) {
const pipeIndex = task.fileName.lastIndexOf('|');
const baseName = task.fileName.substring(0, pipeIndex);
const phase = task.fileName.substring(pipeIndex + 1);
if (phase === 'compressing' || phase === 'extracting' || phase === 'uploading' || phase === 'compressed') {
const phaseLabel = t(`sftp.upload.phase.${phase}`);
return `${baseName} (${phaseLabel})`;
}
}
// Check for exact matches of phase status strings (legacy support)
if (task.fileName === t('sftp.upload.compressing') || task.fileName === 'Compressing...' || task.fileName === 'Compressing') {
return t('sftp.upload.compressing');
}
if (task.fileName === t('sftp.upload.extracting') || task.fileName === 'Extracting...' || task.fileName === 'Extracting') {
return t('sftp.upload.extracting');
}
if (task.fileName === t('sftp.upload.scanning') || task.fileName === 'Scanning files...' || task.fileName === 'Scanning files') {
return t('sftp.upload.scanning');
}
// Check if this is a compressed upload task (legacy format)
if (task.fileName.includes('(compressed)')) {
const baseName = task.fileName.replace(' (compressed)', '');
return `${baseName} (${t('sftp.upload.compressed')})`;
}
return task.fileName;
};
return (
<div className="border-t border-border/60 bg-secondary/50 flex-shrink-0">
<div className="max-h-40 overflow-y-auto overflow-x-hidden">
{[...tasks].reverse().map((task) => {
const formatSpeed = (bytesPerSec: number) => {
if (bytesPerSec <= 0) return "";
if (bytesPerSec >= 1024 * 1024)
return `${(bytesPerSec / (1024 * 1024)).toFixed(1)} MB/s`;
if (bytesPerSec >= 1024)
return `${(bytesPerSec / 1024).toFixed(1)} KB/s`;
return `${Math.round(bytesPerSec)} B/s`;
};
const formatBytes = (bytes: number) => {
if (bytes === 0) return "0 B";
if (bytes >= 1024 * 1024)
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${bytes} B`;
};
const remainingBytes = task.totalBytes - task.transferredBytes;
const effectiveSpeed = task.speed > 0 ? task.speed : 0;
const remainingTime =
effectiveSpeed > 0 ? Math.ceil(remainingBytes / effectiveSpeed) : 0;
const remainingStr =
remainingTime > 60
? `~${Math.ceil(remainingTime / 60)}m left`
: remainingTime > 0
? `~${remainingTime}s left`
: "";
return (
<div
key={task.id}
className="px-4 py-2.5 flex items-center gap-3 border-b border-border/30 last:border-b-0"
>
<div className="shrink-0">
{(task.status === "uploading" || task.status === "downloading") && (
<Loader2 size={14} className="animate-spin text-primary" />
)}
{task.status === "pending" && (
task.direction === "download"
? <Download size={14} className="text-muted-foreground animate-pulse" />
: <Upload size={14} className="text-muted-foreground animate-pulse" />
)}
{task.status === "completed" && (
task.direction === "download"
? <Download size={14} className="text-green-500" />
: <Upload size={14} className="text-green-500" />
)}
{task.status === "failed" && (
<XCircle size={14} className="text-destructive" />
)}
{task.status === "cancelled" && (
<XCircle size={14} className="text-muted-foreground" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-medium truncate">
{getDisplayName(task)}
</span>
{(task.status === "uploading" || task.status === "downloading") && effectiveSpeed > 0 && (
<span className="text-[10px] text-primary font-mono shrink-0">
{formatSpeed(effectiveSpeed)}
</span>
)}
{(task.status === "uploading" || task.status === "downloading") && remainingStr && (
<span className="text-[10px] text-muted-foreground shrink-0">
{remainingStr}
</span>
)}
</div>
{(task.status === "uploading" || task.status === "downloading" || task.status === "pending") && (
<div className="mt-1.5 flex items-center gap-2">
<div className="flex-1 h-1.5 bg-secondary rounded-full overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all duration-150",
task.status === "pending"
? "bg-muted-foreground/50 animate-pulse w-full"
: "bg-primary",
)}
style={{
width:
task.status === "uploading" || task.status === "downloading"
? `${task.progress}%`
: undefined,
}}
/>
</div>
<span className="text-[10px] text-muted-foreground font-mono shrink-0 w-8 text-right">
{task.status === "uploading" || task.status === "downloading" ? `${Math.round(task.progress)}%` : "..."}
</span>
</div>
)}
{(task.status === "uploading" || task.status === "downloading") && task.totalBytes > 0 && (
<div className="text-[10px] text-muted-foreground mt-0.5 font-mono">
{formatBytes(task.transferredBytes)} / {formatBytes(task.totalBytes)}
</div>
)}
{task.status === "completed" && (
<div className="text-[10px] text-green-600 mt-0.5">
{t(task.direction === "download" ? "sftp.download.completed" : "sftp.upload.completed")} - {formatBytes(task.totalBytes)}
</div>
)}
{task.status === "cancelled" && (
<div className="text-[10px] text-muted-foreground mt-0.5">
{t(task.direction === "download" ? "sftp.download.cancelled" : "sftp.upload.cancelled")}
</div>
)}
{task.status === "failed" && task.error && (
<div className="text-[10px] text-destructive truncate mt-0.5">
{task.error}
</div>
)}
</div>
<div className="shrink-0 flex items-center gap-1">
{task.status === "pending" && (
<span className="text-[10px] text-muted-foreground">
{t("sftp.task.waiting")}
</span>
)}
{(task.status === "uploading" || task.status === "downloading" || task.status === "pending") && (onCancelTask || onCancel) && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive hover:text-destructive"
onClick={() => {
// For download tasks or when onCancelTask is available, use task-specific cancel
if (onCancelTask) {
onCancelTask(task.id);
} else if (onCancel) {
onCancel();
}
}}
title={t("sftp.action.cancel")}
>
<X size={12} />
</Button>
)}
{(task.status === "completed" || task.status === "failed" || task.status === "cancelled") && onDismiss && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-foreground"
onClick={() => onDismiss(task.id)}
title={t("sftp.action.dismiss")}
>
<X size={12} />
</Button>
)}
</div>
</div>
);
})}
</div>
</div>
);
};

View File

@@ -0,0 +1,149 @@
import {
Database,
ExternalLink,
File,
FileArchive,
FileAudio,
FileCode,
FileImage,
FileSpreadsheet,
FileText,
FileType,
FileVideo,
Folder,
Globe,
Lock,
Settings,
Terminal,
} from "lucide-react";
import React from "react";
export const getFileIcon = (fileName: string, isDirectory: boolean, isSymlink?: boolean) => {
if (isDirectory)
return (
<Folder
size={18}
fill="currentColor"
fillOpacity={0.2}
className="text-blue-400"
/>
);
if (isSymlink) {
return <ExternalLink size={18} className="text-cyan-500" />;
}
const ext = fileName.split(".").pop()?.toLowerCase() || "";
if (["doc", "docx", "rtf", "odt"].includes(ext))
return <FileText size={18} className="text-blue-500" />;
if (["xls", "xlsx", "csv", "ods"].includes(ext))
return <FileSpreadsheet size={18} className="text-green-500" />;
if (["ppt", "pptx", "odp"].includes(ext))
return <FileType size={18} className="text-orange-500" />;
if (["pdf"].includes(ext))
return <FileText size={18} className="text-red-500" />;
if (["js", "jsx", "ts", "tsx", "mjs", "cjs"].includes(ext))
return <FileCode size={18} className="text-yellow-500" />;
if (["py", "pyc", "pyw"].includes(ext))
return <FileCode size={18} className="text-blue-400" />;
if (["sh", "bash", "zsh", "fish", "bat", "cmd", "ps1"].includes(ext))
return <Terminal size={18} className="text-green-400" />;
if (["c", "cpp", "h", "hpp", "cc", "cxx"].includes(ext))
return <FileCode size={18} className="text-blue-600" />;
if (["java", "class", "jar"].includes(ext))
return <FileCode size={18} className="text-orange-600" />;
if (["go"].includes(ext))
return <FileCode size={18} className="text-cyan-500" />;
if (["rs"].includes(ext))
return <FileCode size={18} className="text-orange-400" />;
if (["rb"].includes(ext))
return <FileCode size={18} className="text-red-400" />;
if (["php"].includes(ext))
return <FileCode size={18} className="text-purple-500" />;
if (["html", "htm", "xhtml"].includes(ext))
return <Globe size={18} className="text-orange-500" />;
if (["css", "scss", "sass", "less"].includes(ext))
return <FileCode size={18} className="text-blue-500" />;
if (["vue", "svelte"].includes(ext))
return <FileCode size={18} className="text-green-500" />;
if (["json", "json5"].includes(ext))
return <FileCode size={18} className="text-yellow-600" />;
if (["xml", "xsl", "xslt"].includes(ext))
return <FileCode size={18} className="text-orange-400" />;
if (["yml", "yaml"].includes(ext))
return <Settings size={18} className="text-pink-400" />;
if (["toml", "ini", "conf", "cfg", "config"].includes(ext))
return <Settings size={18} className="text-gray-400" />;
if (["env"].includes(ext))
return <Lock size={18} className="text-yellow-500" />;
if (["sql", "sqlite", "db"].includes(ext))
return <Database size={18} className="text-blue-400" />;
if (
[
"jpg",
"jpeg",
"png",
"gif",
"bmp",
"webp",
"svg",
"ico",
"tiff",
"tif",
"heic",
"heif",
"avif",
].includes(ext)
)
return <FileImage size={18} className="text-purple-400" />;
if (
[
"mp4",
"mkv",
"avi",
"mov",
"wmv",
"flv",
"webm",
"m4v",
"3gp",
"mpeg",
"mpg",
].includes(ext)
)
return <FileVideo size={18} className="text-pink-500" />;
if (
["mp3", "wav", "flac", "aac", "ogg", "m4a", "wma", "opus", "aiff"].includes(
ext,
)
)
return <FileAudio size={18} className="text-green-400" />;
if (
[
"zip",
"rar",
"7z",
"tar",
"gz",
"bz2",
"xz",
"tgz",
"tbz2",
"lz",
"lzma",
"cab",
"iso",
"dmg",
].includes(ext)
)
return <FileArchive size={18} className="text-yellow-600" />;
return <File size={18} className="text-muted-foreground" />;
};

View File

@@ -0,0 +1,140 @@
import { useCallback, useState } from "react";
import type { RemoteFile } from "../../../types";
import { toast } from "../../ui/toast";
interface UseSftpModalCreateDeleteParams {
currentPath: string;
isLocalSession: boolean;
joinPath: (base: string, name: string) => string;
ensureSftp: () => Promise<string>;
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
deleteLocalFile: (path: string) => Promise<void>;
deleteSftp: (sftpId: string, path: string) => Promise<void>;
mkdirLocal: (path: string) => Promise<void>;
mkdirSftp: (sftpId: string, path: string) => Promise<void>;
writeLocalFile: (path: string, data: ArrayBuffer) => Promise<void>;
writeSftpBinary: (sftpId: string, path: string, data: ArrayBuffer) => Promise<void>;
writeSftp: (sftpId: string, path: string, data: string) => Promise<void>;
t: (key: string, params?: Record<string, unknown>) => string;
}
interface UseSftpModalCreateDeleteResult {
handleDelete: (file: RemoteFile) => Promise<void>;
handleCreateFolder: () => void;
handleCreateFile: () => void;
// Create dialog state
showCreateDialog: boolean;
setShowCreateDialog: (open: boolean) => void;
createType: "file" | "folder";
createName: string;
setCreateName: (value: string) => void;
isCreating: boolean;
handleCreateSubmit: () => Promise<void>;
}
export const useSftpModalCreateDelete = ({
currentPath,
isLocalSession,
joinPath,
ensureSftp,
loadFiles,
deleteLocalFile,
deleteSftp,
mkdirLocal,
mkdirSftp,
writeLocalFile,
writeSftpBinary,
writeSftp,
t,
}: UseSftpModalCreateDeleteParams): UseSftpModalCreateDeleteResult => {
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [createType, setCreateType] = useState<"file" | "folder">("folder");
const [createName, setCreateName] = useState("");
const [isCreating, setIsCreating] = useState(false);
const handleDelete = useCallback(
async (file: RemoteFile) => {
if (file.name === "..") return;
if (!confirm(t("sftp.deleteConfirm.single", { name: file.name }))) return;
try {
const fullPath = joinPath(currentPath, file.name);
if (isLocalSession) {
await deleteLocalFile(fullPath);
} else {
await deleteSftp(await ensureSftp(), fullPath);
}
await loadFiles(currentPath, { force: true });
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.deleteFailed"),
"SFTP",
);
}
},
[currentPath, deleteLocalFile, deleteSftp, ensureSftp, isLocalSession, joinPath, loadFiles, t],
);
const handleCreateFolder = useCallback(() => {
setCreateType("folder");
setCreateName("");
setShowCreateDialog(true);
}, []);
const handleCreateFile = useCallback(() => {
setCreateType("file");
setCreateName("");
setShowCreateDialog(true);
}, []);
const handleCreateSubmit = useCallback(async () => {
const name = createName.trim();
if (!name || isCreating) return;
setIsCreating(true);
try {
const fullPath = joinPath(currentPath, name);
if (createType === "folder") {
if (isLocalSession) {
await mkdirLocal(fullPath);
} else {
await mkdirSftp(await ensureSftp(), fullPath);
}
} else {
if (isLocalSession) {
await writeLocalFile(fullPath, new ArrayBuffer(0));
} else {
try {
await writeSftpBinary(await ensureSftp(), fullPath, new ArrayBuffer(0));
} catch {
await writeSftp(await ensureSftp(), fullPath, "");
}
}
}
setShowCreateDialog(false);
setCreateName("");
await loadFiles(currentPath, { force: true });
} catch (e) {
toast.error(
e instanceof Error
? e.message
: t(createType === "folder" ? "sftp.error.createFolderFailed" : "sftp.error.createFileFailed"),
"SFTP",
);
} finally {
setIsCreating(false);
}
}, [createName, createType, currentPath, ensureSftp, isCreating, isLocalSession, joinPath, loadFiles, mkdirLocal, mkdirSftp, t, writeLocalFile, writeSftp, writeSftpBinary]);
return {
handleDelete,
handleCreateFolder,
handleCreateFile,
showCreateDialog,
setShowCreateDialog,
createType,
createName,
setCreateName,
isCreating,
handleCreateSubmit,
};
};

View File

@@ -0,0 +1,277 @@
import type { RemoteFile } from "../../../types";
import { useSftpModalCreateDelete } from "./useSftpModalCreateDelete";
import { useSftpModalRename } from "./useSftpModalRename";
import { useSftpModalPermissions } from "./useSftpModalPermissions";
import { useSftpModalTextEditor } from "./useSftpModalTextEditor";
import { useSftpModalFileOpener } from "./useSftpModalFileOpener";
import type { FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
interface UseSftpModalFileActionsParams {
currentPath: string;
isLocalSession: boolean;
joinPath: (base: string, name: string) => string;
ensureSftp: () => Promise<string>;
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
readLocalFile: (path: string) => Promise<ArrayBuffer>;
readSftp: (sftpId: string, path: string) => Promise<string>;
writeLocalFile: (path: string, data: ArrayBuffer) => Promise<void>;
writeSftp: (sftpId: string, path: string, data: string) => Promise<void>;
writeSftpBinary: (sftpId: string, path: string, data: ArrayBuffer) => Promise<void>;
deleteLocalFile: (path: string) => Promise<void>;
deleteSftp: (sftpId: string, path: string) => Promise<void>;
mkdirLocal: (path: string) => Promise<void>;
mkdirSftp: (sftpId: string, path: string) => Promise<void>;
renameSftp: (sftpId: string, oldPath: string, newPath: string) => Promise<void>;
chmodSftp: (sftpId: string, path: string, permissions: string) => Promise<void>;
statSftp: (sftpId: string, path: string) => Promise<{ permissions?: string }>;
t: (key: string, params?: Record<string, unknown>) => string;
sftpAutoSync: boolean;
getOpenerForFile: (name: string) => { openerType: FileOpenerType; systemApp?: SystemAppInfo } | null;
setOpenerForExtension: (ext: string, openerType: FileOpenerType, systemApp?: SystemAppInfo) => void;
downloadSftpToTempAndOpen: (sftpId: string, path: string, fileName: string, appPath: string, opts: { enableWatch: boolean }) => Promise<void>;
selectApplication: () => Promise<{ path: string; name: string } | null>;
}
interface UseSftpModalFileActionsResult {
handleDelete: (file: RemoteFile) => Promise<void>;
handleCreateFolder: () => void;
handleCreateFile: () => void;
showCreateDialog: boolean;
setShowCreateDialog: (open: boolean) => void;
createType: "file" | "folder";
createName: string;
setCreateName: (value: string) => void;
isCreating: boolean;
handleCreateSubmit: () => Promise<void>;
showRenameDialog: boolean;
setShowRenameDialog: (open: boolean) => void;
renameTarget: RemoteFile | null;
renameName: string;
setRenameName: (value: string) => void;
isRenaming: boolean;
openRenameDialog: (file: RemoteFile) => void;
handleRename: () => Promise<void>;
showPermissionsDialog: boolean;
setShowPermissionsDialog: (open: boolean) => void;
permissionsTarget: RemoteFile | null;
permissions: {
owner: { read: boolean; write: boolean; execute: boolean };
group: { read: boolean; write: boolean; execute: boolean };
others: { read: boolean; write: boolean; execute: boolean };
};
isChangingPermissions: boolean;
openPermissionsDialog: (file: RemoteFile) => Promise<void>;
togglePermission: (role: "owner" | "group" | "others", perm: "read" | "write" | "execute") => void;
getOctalPermissions: () => string;
getSymbolicPermissions: () => string;
handleSavePermissions: () => Promise<void>;
showFileOpenerDialog: boolean;
setShowFileOpenerDialog: (open: boolean) => void;
fileOpenerTarget: RemoteFile | null;
setFileOpenerTarget: (target: RemoteFile | null) => void;
openFileOpenerDialog: (file: RemoteFile) => void;
handleFileOpenerSelect: (
openerType: FileOpenerType,
setAsDefault: boolean,
systemApp?: SystemAppInfo,
) => Promise<void>;
handleSelectSystemApp: () => Promise<SystemAppInfo | null>;
showTextEditor: boolean;
setShowTextEditor: (open: boolean) => void;
textEditorTarget: RemoteFile | null;
setTextEditorTarget: (target: RemoteFile | null) => void;
textEditorContent: string;
setTextEditorContent: (value: string) => void;
loadingTextContent: boolean;
handleEditFile: (file: RemoteFile) => Promise<void>;
handleSaveTextFile: (content: string) => Promise<void>;
handleOpenFile: (file: RemoteFile) => Promise<void>;
}
export const useSftpModalFileActions = ({
currentPath,
isLocalSession,
joinPath,
ensureSftp,
loadFiles,
readLocalFile,
readSftp,
writeLocalFile,
writeSftp,
writeSftpBinary,
deleteLocalFile,
deleteSftp,
mkdirLocal,
mkdirSftp,
renameSftp,
chmodSftp,
statSftp,
t,
sftpAutoSync,
getOpenerForFile,
setOpenerForExtension,
downloadSftpToTempAndOpen,
selectApplication,
}: UseSftpModalFileActionsParams): UseSftpModalFileActionsResult => {
const {
handleDelete,
handleCreateFolder,
handleCreateFile,
showCreateDialog,
setShowCreateDialog,
createType,
createName,
setCreateName,
isCreating,
handleCreateSubmit,
} =
useSftpModalCreateDelete({
currentPath,
isLocalSession,
joinPath,
ensureSftp,
loadFiles,
deleteLocalFile,
deleteSftp,
mkdirLocal,
mkdirSftp,
writeLocalFile,
writeSftpBinary,
writeSftp,
t,
});
const {
showRenameDialog,
setShowRenameDialog,
renameTarget,
renameName,
setRenameName,
isRenaming,
openRenameDialog,
handleRename,
} = useSftpModalRename({
currentPath,
isLocalSession,
joinPath,
ensureSftp,
loadFiles,
renameSftp,
t,
});
const {
showPermissionsDialog,
setShowPermissionsDialog,
permissionsTarget,
permissions,
isChangingPermissions,
openPermissionsDialog,
togglePermission,
getOctalPermissions,
getSymbolicPermissions,
handleSavePermissions,
} = useSftpModalPermissions({
currentPath,
isLocalSession,
joinPath,
ensureSftp,
loadFiles,
chmodSftp,
statSftp,
t,
});
const {
showTextEditor,
setShowTextEditor,
textEditorTarget,
setTextEditorTarget,
textEditorContent,
setTextEditorContent,
loadingTextContent,
handleEditFile,
handleSaveTextFile,
} = useSftpModalTextEditor({
currentPath,
isLocalSession,
joinPath,
ensureSftp,
readLocalFile,
readSftp,
writeLocalFile,
writeSftp,
t,
});
const {
showFileOpenerDialog,
setShowFileOpenerDialog,
fileOpenerTarget,
setFileOpenerTarget,
openFileOpenerDialog,
handleOpenFile,
handleFileOpenerSelect,
handleSelectSystemApp,
} = useSftpModalFileOpener({
currentPath,
isLocalSession,
joinPath,
ensureSftp,
sftpAutoSync,
getOpenerForFile,
setOpenerForExtension,
downloadSftpToTempAndOpen,
selectApplication,
t,
handleEditFile,
});
return {
handleDelete,
handleCreateFolder,
handleCreateFile,
showCreateDialog,
setShowCreateDialog,
createType,
createName,
setCreateName,
isCreating,
handleCreateSubmit,
showRenameDialog,
setShowRenameDialog,
renameTarget,
renameName,
setRenameName,
isRenaming,
openRenameDialog,
handleRename,
showPermissionsDialog,
setShowPermissionsDialog,
permissionsTarget,
permissions,
isChangingPermissions,
openPermissionsDialog,
togglePermission,
getOctalPermissions,
getSymbolicPermissions,
handleSavePermissions,
showFileOpenerDialog,
setShowFileOpenerDialog,
fileOpenerTarget,
setFileOpenerTarget,
openFileOpenerDialog,
handleFileOpenerSelect,
handleSelectSystemApp,
showTextEditor,
setShowTextEditor,
textEditorTarget,
setTextEditorTarget,
textEditorContent,
setTextEditorContent,
loadingTextContent,
handleEditFile,
handleSaveTextFile,
handleOpenFile,
};
};

View File

@@ -0,0 +1,154 @@
import { useCallback, useState } from "react";
import type { RemoteFile } from "../../../types";
import { toast } from "../../ui/toast";
import { getFileExtension, FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
interface UseSftpModalFileOpenerParams {
currentPath: string;
isLocalSession: boolean;
joinPath: (base: string, name: string) => string;
ensureSftp: () => Promise<string>;
sftpAutoSync: boolean;
getOpenerForFile: (name: string) => { openerType: FileOpenerType; systemApp?: SystemAppInfo } | null;
setOpenerForExtension: (ext: string, openerType: FileOpenerType, systemApp?: SystemAppInfo) => void;
downloadSftpToTempAndOpen: (sftpId: string, path: string, fileName: string, appPath: string, opts: { enableWatch: boolean }) => Promise<void>;
selectApplication: () => Promise<{ path: string; name: string } | null>;
t: (key: string, params?: Record<string, unknown>) => string;
handleEditFile: (file: RemoteFile) => Promise<void>;
}
interface UseSftpModalFileOpenerResult {
showFileOpenerDialog: boolean;
setShowFileOpenerDialog: (open: boolean) => void;
fileOpenerTarget: RemoteFile | null;
setFileOpenerTarget: (target: RemoteFile | null) => void;
openFileOpenerDialog: (file: RemoteFile) => void;
handleOpenFile: (file: RemoteFile) => Promise<void>;
handleFileOpenerSelect: (
openerType: FileOpenerType,
setAsDefault: boolean,
systemApp?: SystemAppInfo,
) => Promise<void>;
handleSelectSystemApp: () => Promise<SystemAppInfo | null>;
}
export const useSftpModalFileOpener = ({
currentPath,
isLocalSession,
joinPath,
ensureSftp,
sftpAutoSync,
getOpenerForFile,
setOpenerForExtension,
downloadSftpToTempAndOpen,
selectApplication,
t,
handleEditFile,
}: UseSftpModalFileOpenerParams): UseSftpModalFileOpenerResult => {
const [showFileOpenerDialog, setShowFileOpenerDialog] = useState(false);
const [fileOpenerTarget, setFileOpenerTarget] = useState<RemoteFile | null>(null);
const openFileOpenerDialog = useCallback((file: RemoteFile) => {
setFileOpenerTarget(file);
setShowFileOpenerDialog(true);
}, []);
const handleOpenFile = useCallback(async (file: RemoteFile) => {
const savedOpener = getOpenerForFile(file.name);
if (savedOpener) {
if (savedOpener.openerType === "builtin-editor") {
await handleEditFile(file);
} else if (savedOpener.openerType === "system-app" && savedOpener.systemApp) {
try {
const fullPath = joinPath(currentPath, file.name);
if (isLocalSession) {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
if (bridge?.openWithApplication) {
await bridge.openWithApplication(fullPath, savedOpener.systemApp.path);
}
} else {
const sftpId = await ensureSftp();
await downloadSftpToTempAndOpen(
sftpId,
fullPath,
file.name,
savedOpener.systemApp.path,
{ enableWatch: sftpAutoSync },
);
}
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.openFailed"),
"SFTP",
);
}
}
} else {
openFileOpenerDialog(file);
}
}, [currentPath, downloadSftpToTempAndOpen, ensureSftp, getOpenerForFile, handleEditFile, isLocalSession, joinPath, openFileOpenerDialog, sftpAutoSync, t]);
const handleFileOpenerSelect = useCallback(
async (openerType: FileOpenerType, setAsDefault: boolean, systemApp?: SystemAppInfo) => {
if (!fileOpenerTarget) return;
if (setAsDefault) {
const ext = getFileExtension(fileOpenerTarget.name);
setOpenerForExtension(ext, openerType, systemApp);
}
setShowFileOpenerDialog(false);
if (openerType === "builtin-editor") {
await handleEditFile(fileOpenerTarget);
} else if (openerType === "system-app" && systemApp) {
try {
const fullPath = joinPath(currentPath, fileOpenerTarget.name);
if (isLocalSession) {
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
if (bridge?.openWithApplication) {
await bridge.openWithApplication(fullPath, systemApp.path);
}
} else {
const sftpId = await ensureSftp();
await downloadSftpToTempAndOpen(
sftpId,
fullPath,
fileOpenerTarget.name,
systemApp.path,
{ enableWatch: sftpAutoSync },
);
}
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.openFailed"),
"SFTP",
);
}
}
setFileOpenerTarget(null);
},
[currentPath, downloadSftpToTempAndOpen, ensureSftp, fileOpenerTarget, handleEditFile, isLocalSession, joinPath, sftpAutoSync, setOpenerForExtension, t],
);
const handleSelectSystemApp = useCallback(async (): Promise<SystemAppInfo | null> => {
const result = await selectApplication();
if (result) {
return { path: result.path, name: result.name };
}
return null;
}, [selectApplication]);
return {
showFileOpenerDialog,
setShowFileOpenerDialog,
fileOpenerTarget,
setFileOpenerTarget,
openFileOpenerDialog,
handleOpenFile,
handleFileOpenerSelect,
handleSelectSystemApp,
};
};

View File

@@ -0,0 +1,156 @@
/**
* useSftpModalKeyboardShortcuts
*
* Hook that handles keyboard shortcuts for SFTPModal operations.
* Supports select all, rename, delete, refresh, and new folder.
* Note: Copy/Cut/Paste are not supported in the modal as it's a single-pane view.
*/
import { useCallback, useEffect } from "react";
import { KeyBinding, matchesKeyBinding } from "../../../domain/models";
import type { RemoteFile } from "../../../types";
// SFTP Modal action names that we handle (subset of main SFTP actions)
const SFTP_MODAL_ACTIONS = new Set([
"sftpSelectAll",
"sftpRename",
"sftpDelete",
"sftpRefresh",
"sftpNewFolder",
]);
interface UseSftpModalKeyboardShortcutsParams {
keyBindings: KeyBinding[];
hotkeyScheme: "disabled" | "mac" | "pc";
open: boolean;
files: RemoteFile[];
visibleFiles: RemoteFile[];
selectedFiles: Set<string>;
setSelectedFiles: (files: Set<string>) => void;
onRefresh: () => void;
onRename?: (file: RemoteFile) => void;
onDelete?: (fileNames: string[]) => void;
onNewFolder?: () => void;
}
/**
* Check if a keyboard event matches any SFTP action
*/
const matchSftpAction = (
e: KeyboardEvent,
keyBindings: KeyBinding[],
isMac: boolean
): { action: string; binding: KeyBinding } | null => {
for (const binding of keyBindings) {
if (binding.category !== "sftp") continue;
const keyStr = isMac ? binding.mac : binding.pc;
if (matchesKeyBinding(e, keyStr, isMac)) {
return { action: binding.action, binding };
}
}
return null;
};
export const useSftpModalKeyboardShortcuts = ({
keyBindings,
hotkeyScheme,
open,
files,
visibleFiles,
selectedFiles,
setSelectedFiles,
onRefresh,
onRename,
onDelete,
onNewFolder,
}: UseSftpModalKeyboardShortcutsParams) => {
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
// Skip if shortcuts are disabled or modal is not open
if (hotkeyScheme === "disabled" || !open) return;
// Skip if focus is on an input element
const target = e.target as HTMLElement;
const isEditableTarget =
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable ||
!!target.closest?.(".monaco-editor, .monaco-diff-editor, .monaco-inputbox");
if (isEditableTarget) {
return;
}
const isMac = hotkeyScheme === "mac";
const matched = matchSftpAction(e, keyBindings, isMac);
if (!matched) return;
const { action } = matched;
if (!SFTP_MODAL_ACTIONS.has(action)) return;
// Prevent default behavior
e.preventDefault();
e.stopPropagation();
switch (action) {
case "sftpSelectAll": {
// Select all files
const allFileNames = new Set(
visibleFiles.filter((f) => f.name !== "..").map((f) => f.name)
);
setSelectedFiles(allFileNames);
break;
}
case "sftpRename": {
// Trigger rename for the first selected file
const selectedArray = Array.from(selectedFiles);
if (selectedArray.length !== 1) return;
const file = files.find((f) => f.name === selectedArray[0]);
if (file && onRename) {
onRename(file);
}
break;
}
case "sftpDelete": {
// Delete selected files
const selectedArray = Array.from(selectedFiles);
if (selectedArray.length === 0) return;
onDelete?.(selectedArray);
break;
}
case "sftpRefresh": {
// Refresh file list
onRefresh();
break;
}
case "sftpNewFolder": {
// Create new folder
onNewFolder?.();
break;
}
}
},
[
hotkeyScheme,
open,
files,
visibleFiles,
selectedFiles,
setSelectedFiles,
onRefresh,
onRename,
onDelete,
onNewFolder,
keyBindings,
]
);
useEffect(() => {
// Use capture phase to intercept before other handlers
window.addEventListener("keydown", handleKeyDown, true);
return () => window.removeEventListener("keydown", handleKeyDown, true);
}, [handleKeyDown]);
};

View File

@@ -0,0 +1,135 @@
import React, { useCallback, useMemo, useRef, useState } from "react";
import { breadcrumbPathAt, getBreadcrumbs, getRootPath, getWindowsDrive, isWindowsPath } from "../pathUtils";
interface UseSftpModalPathParams {
currentPath: string;
isLocalSession: boolean;
localHomePath: string | null;
onNavigate: (path: string) => void;
maxVisibleBreadcrumbParts?: number;
}
interface UseSftpModalPathResult {
isEditingPath: boolean;
editingPathValue: string;
setEditingPathValue: (value: string) => void;
pathInputRef: React.RefObject<HTMLInputElement>;
handlePathDoubleClick: () => void;
handlePathSubmit: () => void;
handlePathKeyDown: (e: React.KeyboardEvent) => void;
breadcrumbs: string[];
visibleBreadcrumbs: { part: string; originalIndex: number }[];
hiddenBreadcrumbs: { part: string; originalIndex: number }[];
needsBreadcrumbTruncation: boolean;
breadcrumbPathAtForIndex: (index: number) => string;
rootLabel: string;
rootPath: string;
}
export const useSftpModalPath = ({
currentPath,
isLocalSession,
localHomePath,
onNavigate,
maxVisibleBreadcrumbParts = 4,
}: UseSftpModalPathParams): UseSftpModalPathResult => {
const [isEditingPath, setIsEditingPath] = useState(false);
const [editingPathValue, setEditingPathValue] = useState("");
const pathInputRef = useRef<HTMLInputElement>(null);
const handlePathDoubleClick = () => {
setEditingPathValue(currentPath);
setIsEditingPath(true);
setTimeout(() => pathInputRef.current?.select(), 0);
};
const handlePathSubmit = () => {
const fallbackPath = localHomePath || getRootPath(currentPath, isLocalSession);
const newPath = editingPathValue.trim() || fallbackPath;
setIsEditingPath(false);
if (newPath !== currentPath) {
if (isLocalSession) {
onNavigate(newPath);
} else {
onNavigate(newPath.startsWith("/") ? newPath : `/${newPath}`);
}
}
};
const handlePathKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
handlePathSubmit();
} else if (e.key === "Escape") {
setIsEditingPath(false);
}
};
const breadcrumbs = useMemo(
() => getBreadcrumbs(currentPath, isLocalSession),
[currentPath, isLocalSession],
);
const { visibleBreadcrumbs, hiddenBreadcrumbs, needsBreadcrumbTruncation } =
useMemo(() => {
if (breadcrumbs.length <= maxVisibleBreadcrumbParts) {
return {
visibleBreadcrumbs: breadcrumbs.map((part, idx) => ({ part, originalIndex: idx })),
hiddenBreadcrumbs: [] as { part: string; originalIndex: number }[],
needsBreadcrumbTruncation: false,
};
}
const firstPart = [{ part: breadcrumbs[0], originalIndex: 0 }];
const lastPartsCount = maxVisibleBreadcrumbParts - 1;
const lastParts = breadcrumbs.slice(-lastPartsCount).map((part, idx) => ({
part,
originalIndex: breadcrumbs.length - lastPartsCount + idx,
}));
const hidden = breadcrumbs.slice(1, -lastPartsCount).map((part, idx) => ({
part,
originalIndex: idx + 1,
}));
return {
visibleBreadcrumbs: [...firstPart, ...lastParts],
hiddenBreadcrumbs: hidden,
needsBreadcrumbTruncation: true,
};
}, [breadcrumbs, maxVisibleBreadcrumbParts]);
const breadcrumbPathAtForIndex = useCallback(
(index: number) =>
breadcrumbPathAt(breadcrumbs, index, currentPath, isLocalSession),
[breadcrumbs, currentPath, isLocalSession],
);
const rootLabel = useMemo(
() =>
isLocalSession && isWindowsPath(currentPath)
? getWindowsDrive(currentPath) ?? "C:"
: "/",
[currentPath, isLocalSession],
);
const rootPath = useMemo(
() => getRootPath(currentPath, isLocalSession),
[currentPath, isLocalSession],
);
return {
isEditingPath,
editingPathValue,
setEditingPathValue,
pathInputRef,
handlePathDoubleClick,
handlePathSubmit,
handlePathKeyDown,
breadcrumbs,
visibleBreadcrumbs,
hiddenBreadcrumbs,
needsBreadcrumbTruncation,
breadcrumbPathAtForIndex,
rootLabel,
rootPath,
};
};

View File

@@ -0,0 +1,189 @@
import { useCallback, useState } from "react";
import type { RemoteFile } from "../../../types";
import { toast } from "../../ui/toast";
interface UseSftpModalPermissionsParams {
currentPath: string;
isLocalSession: boolean;
joinPath: (base: string, name: string) => string;
ensureSftp: () => Promise<string>;
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
chmodSftp: (sftpId: string, path: string, permissions: string) => Promise<void>;
statSftp: (sftpId: string, path: string) => Promise<{ permissions?: string }>;
t: (key: string, params?: Record<string, unknown>) => string;
}
interface PermissionsState {
owner: { read: boolean; write: boolean; execute: boolean };
group: { read: boolean; write: boolean; execute: boolean };
others: { read: boolean; write: boolean; execute: boolean };
}
interface UseSftpModalPermissionsResult {
showPermissionsDialog: boolean;
setShowPermissionsDialog: (open: boolean) => void;
permissionsTarget: RemoteFile | null;
permissions: PermissionsState;
isChangingPermissions: boolean;
openPermissionsDialog: (file: RemoteFile) => Promise<void>;
togglePermission: (role: "owner" | "group" | "others", perm: "read" | "write" | "execute") => void;
getOctalPermissions: () => string;
getSymbolicPermissions: () => string;
handleSavePermissions: () => Promise<void>;
}
export const useSftpModalPermissions = ({
currentPath,
isLocalSession,
joinPath,
ensureSftp,
loadFiles,
chmodSftp,
statSftp,
t,
}: UseSftpModalPermissionsParams): UseSftpModalPermissionsResult => {
const [showPermissionsDialog, setShowPermissionsDialog] = useState(false);
const [permissionsTarget, setPermissionsTarget] = useState<RemoteFile | null>(null);
const [permissions, setPermissions] = useState<PermissionsState>({
owner: { read: false, write: false, execute: false },
group: { read: false, write: false, execute: false },
others: { read: false, write: false, execute: false },
});
const [isChangingPermissions, setIsChangingPermissions] = useState(false);
const parsePermissions = useCallback((perms: string | undefined) => {
const defaultPerms = {
owner: { read: false, write: false, execute: false },
group: { read: false, write: false, execute: false },
others: { read: false, write: false, execute: false },
};
if (!perms) return defaultPerms;
if (/^[0-7]{3,4}$/.test(perms)) {
const octal = perms.length === 4 ? perms.slice(1) : perms;
const ownerBits = parseInt(octal[0], 10);
const groupBits = parseInt(octal[1], 10);
const othersBits = parseInt(octal[2], 10);
return {
owner: {
read: (ownerBits & 4) !== 0,
write: (ownerBits & 2) !== 0,
execute: (ownerBits & 1) !== 0,
},
group: {
read: (groupBits & 4) !== 0,
write: (groupBits & 2) !== 0,
execute: (groupBits & 1) !== 0,
},
others: {
read: (othersBits & 4) !== 0,
write: (othersBits & 2) !== 0,
execute: (othersBits & 1) !== 0,
},
};
}
const pStr = perms.length === 10 ? perms.slice(1) : perms;
if (pStr.length >= 9) {
return {
owner: {
read: pStr[0] === "r",
write: pStr[1] === "w",
execute: pStr[2] === "x" || pStr[2] === "s",
},
group: {
read: pStr[3] === "r",
write: pStr[4] === "w",
execute: pStr[5] === "x" || pStr[5] === "s",
},
others: {
read: pStr[6] === "r",
write: pStr[7] === "w",
execute: pStr[8] === "x" || pStr[8] === "t",
},
};
}
return defaultPerms;
}, []);
const openPermissionsDialog = useCallback(async (file: RemoteFile) => {
if (isLocalSession) {
toast.error("Permissions not available for local files", "SFTP");
return;
}
setPermissionsTarget(file);
let permsStr = file.permissions;
try {
const fullPath = joinPath(currentPath, file.name);
const stat = await statSftp(await ensureSftp(), fullPath);
if (stat.permissions) {
permsStr = stat.permissions;
}
} catch (e) {
console.warn("Failed to fetch file permissions:", e);
}
setPermissions(parsePermissions(permsStr));
setShowPermissionsDialog(true);
}, [currentPath, ensureSftp, isLocalSession, joinPath, parsePermissions, statSftp]);
const togglePermission = useCallback(
(role: "owner" | "group" | "others", perm: "read" | "write" | "execute") => {
setPermissions((prev) => ({
...prev,
[role]: { ...prev[role], [perm]: !prev[role][perm] },
}));
},
[],
);
const getOctalPermissions = useCallback(() => {
const getNum = (p: { read: boolean; write: boolean; execute: boolean }) =>
(p.read ? 4 : 0) + (p.write ? 2 : 0) + (p.execute ? 1 : 0);
return `${getNum(permissions.owner)}${getNum(permissions.group)}${getNum(permissions.others)}`;
}, [permissions]);
const getSymbolicPermissions = useCallback(() => {
const getSym = (p: { read: boolean; write: boolean; execute: boolean }) =>
`${p.read ? "r" : "-"}${p.write ? "w" : "-"}${p.execute ? "x" : "-"}`;
return (
getSym(permissions.owner) +
getSym(permissions.group) +
getSym(permissions.others)
);
}, [permissions]);
const handleSavePermissions = useCallback(async () => {
if (!permissionsTarget || isChangingPermissions) return;
setIsChangingPermissions(true);
try {
const fullPath = joinPath(currentPath, permissionsTarget.name);
await chmodSftp(await ensureSftp(), fullPath, getOctalPermissions());
setShowPermissionsDialog(false);
setPermissionsTarget(null);
await loadFiles(currentPath, { force: true });
toast.success(t("sftp.permissions.success"), "SFTP");
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.permissions.failed"),
"SFTP",
);
} finally {
setIsChangingPermissions(false);
}
}, [chmodSftp, currentPath, ensureSftp, getOctalPermissions, isChangingPermissions, joinPath, loadFiles, permissionsTarget, t]);
return {
showPermissionsDialog,
setShowPermissionsDialog,
permissionsTarget,
permissions,
isChangingPermissions,
openPermissionsDialog,
togglePermission,
getOctalPermissions,
getSymbolicPermissions,
handleSavePermissions,
};
};

View File

@@ -0,0 +1,85 @@
import { useCallback, useState } from "react";
import type { RemoteFile } from "../../../types";
import { toast } from "../../ui/toast";
interface UseSftpModalRenameParams {
currentPath: string;
isLocalSession: boolean;
joinPath: (base: string, name: string) => string;
ensureSftp: () => Promise<string>;
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
renameSftp: (sftpId: string, oldPath: string, newPath: string) => Promise<void>;
t: (key: string, params?: Record<string, unknown>) => string;
}
interface UseSftpModalRenameResult {
showRenameDialog: boolean;
setShowRenameDialog: (open: boolean) => void;
renameTarget: RemoteFile | null;
renameName: string;
setRenameName: (value: string) => void;
isRenaming: boolean;
openRenameDialog: (file: RemoteFile) => void;
handleRename: () => Promise<void>;
}
export const useSftpModalRename = ({
currentPath,
isLocalSession,
joinPath,
ensureSftp,
loadFiles,
renameSftp,
t,
}: UseSftpModalRenameParams): UseSftpModalRenameResult => {
const [showRenameDialog, setShowRenameDialog] = useState(false);
const [renameTarget, setRenameTarget] = useState<RemoteFile | null>(null);
const [renameName, setRenameName] = useState("");
const [isRenaming, setIsRenaming] = useState(false);
const openRenameDialog = useCallback((file: RemoteFile) => {
setRenameTarget(file);
setRenameName(file.name);
setShowRenameDialog(true);
}, []);
const handleRename = useCallback(async () => {
if (!renameTarget || !renameName.trim() || isRenaming) return;
if (renameName.trim() === renameTarget.name) {
setShowRenameDialog(false);
return;
}
setIsRenaming(true);
try {
const oldPath = joinPath(currentPath, renameTarget.name);
const newPath = joinPath(currentPath, renameName.trim());
if (isLocalSession) {
toast.error("Local rename not implemented", "SFTP");
} else {
await renameSftp(await ensureSftp(), oldPath, newPath);
}
setShowRenameDialog(false);
setRenameTarget(null);
setRenameName("");
await loadFiles(currentPath, { force: true });
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.renameFailed"),
"SFTP",
);
} finally {
setIsRenaming(false);
}
}, [currentPath, ensureSftp, isLocalSession, joinPath, loadFiles, renameName, renameSftp, renameTarget, t, isRenaming]);
return {
showRenameDialog,
setShowRenameDialog,
renameTarget,
renameName,
setRenameName,
isRenaming,
openRenameDialog,
handleRename,
};
};

View File

@@ -0,0 +1,99 @@
import React, { useCallback, useRef } from "react";
import type { RemoteFile } from "../../../types";
interface UseSftpModalSelectionParams {
files: RemoteFile[];
setSelectedFiles: (value: Set<string> | ((prev: Set<string>) => Set<string>)) => void;
currentPath: string;
joinPath: (base: string, name: string) => string;
onNavigate: (path: string) => void;
onOpenFile: (file: RemoteFile) => void;
onNavigateUp: () => void;
}
interface UseSftpModalSelectionResult {
handleFileClick: (file: RemoteFile, index: number, e: React.MouseEvent) => void;
handleFileDoubleClick: (file: RemoteFile) => void;
}
export const useSftpModalSelection = ({
files,
setSelectedFiles,
currentPath,
joinPath,
onNavigate,
onOpenFile,
onNavigateUp,
}: UseSftpModalSelectionParams): UseSftpModalSelectionResult => {
const lastSelectedIndexRef = useRef<number | null>(null);
const handleFileClick = useCallback(
(file: RemoteFile, index: number, e: React.MouseEvent) => {
if (file.name === "..") return;
if (file.type === "directory") {
if (e.shiftKey && lastSelectedIndexRef.current !== null) {
const start = Math.min(lastSelectedIndexRef.current, index);
const end = Math.max(lastSelectedIndexRef.current, index);
const newSelection = new Set<string>();
for (let i = start; i <= end; i++) {
if (files[i] && files[i].type !== "directory") {
newSelection.add(files[i].name);
}
}
setSelectedFiles(newSelection);
} else if (e.ctrlKey || e.metaKey) {
setSelectedFiles((prev) => {
const next = new Set(prev);
return next;
});
}
return;
}
if (e.shiftKey && lastSelectedIndexRef.current !== null) {
const start = Math.min(lastSelectedIndexRef.current, index);
const end = Math.max(lastSelectedIndexRef.current, index);
const newSelection = new Set<string>();
for (let i = start; i <= end; i++) {
if (files[i] && files[i].type !== "directory") {
newSelection.add(files[i].name);
}
}
setSelectedFiles(newSelection);
} else if (e.ctrlKey || e.metaKey) {
setSelectedFiles((prev) => {
const next = new Set(prev);
if (next.has(file.name)) {
next.delete(file.name);
} else {
next.add(file.name);
}
return next;
});
lastSelectedIndexRef.current = index;
} else {
setSelectedFiles(new Set([file.name]));
lastSelectedIndexRef.current = index;
}
},
[files, setSelectedFiles],
);
const handleFileDoubleClick = useCallback(
(file: RemoteFile) => {
if (file.name === "..") {
onNavigateUp();
return;
}
if (file.type === "directory" || (file.type === "symlink" && file.linkTarget === "directory")) {
onNavigate(joinPath(currentPath, file.name));
} else {
onOpenFile(file);
}
},
[currentPath, joinPath, onNavigate, onNavigateUp, onOpenFile],
);
return { handleFileClick, handleFileDoubleClick };
};

View File

@@ -0,0 +1,427 @@
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import type { Host, RemoteFile } from "../../../types";
import { logger } from "../../../lib/logger";
import { toast } from "../../ui/toast";
interface UseSftpModalSessionParams {
open: boolean;
host: Host;
credentials: {
username?: string;
hostname: string;
port?: number;
password?: string;
privateKey?: string;
certificate?: string;
passphrase?: string;
publicKey?: string;
keyId?: string;
keySource?: "generated" | "imported";
proxy?: NetcattyProxyConfig;
jumpHosts?: NetcattyJumpHost[];
sftpSudo?: boolean;
legacyAlgorithms?: boolean;
};
initialPath?: string;
isLocalSession: boolean;
t: (key: string, params?: Record<string, unknown>) => string;
openSftp: (params: {
sessionId: string;
hostname: string;
username: string;
port: number;
password?: string;
privateKey?: string;
certificate?: string;
passphrase?: string;
publicKey?: string;
keyId?: string;
keySource?: "generated" | "imported";
proxy?: NetcattyProxyConfig;
jumpHosts?: NetcattyJumpHost[];
sudo?: boolean;
legacyAlgorithms?: boolean;
}) => Promise<string>;
closeSftp: (sftpId: string) => Promise<void>;
listSftp: (sftpId: string, path: string) => Promise<RemoteFile[]>;
listLocalDir: (path: string) => Promise<RemoteFile[]>;
getHomeDir: () => Promise<string | null>;
onClearSelection: () => void;
}
interface UseSftpModalSessionResult {
currentPath: string;
setCurrentPath: (path: string) => void;
files: RemoteFile[];
setFiles: (files: RemoteFile[]) => void;
loading: boolean;
setLoading: (loading: boolean) => void;
reconnecting: boolean;
sessionVersion: number;
ensureSftp: () => Promise<string>;
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
closeSftpSession: () => Promise<void>;
localHomeRef: React.MutableRefObject<string | null>;
}
export const useSftpModalSession = ({
open,
host,
credentials,
initialPath,
isLocalSession,
t,
openSftp,
closeSftp,
listSftp,
listLocalDir,
getHomeDir,
onClearSelection,
}: UseSftpModalSessionParams): UseSftpModalSessionResult => {
const [currentPath, setCurrentPath] = useState("/");
const [files, setFiles] = useState<RemoteFile[]>([]);
const [loading, setLoading] = useState(false);
const [reconnecting, setReconnecting] = useState(false);
const [sessionVersion, setSessionVersion] = useState(0);
const sftpIdRef = useRef<string | null>(null);
const closingPromiseRef = useRef<Promise<void> | null>(null);
const initializedRef = useRef(false);
const lastInitialPathRef = useRef<string | undefined>(undefined);
const localHomeRef = useRef<string | null>(null);
const reconnectingRef = useRef(false);
const reconnectAttemptsRef = useRef(0);
const MAX_RECONNECT_ATTEMPTS = 3;
const DIR_CACHE_TTL_MS = 10_000;
const dirCacheRef = useRef<
Map<string, { files: RemoteFile[]; timestamp: number }>
>(new Map());
const loadSeqRef = useRef(0);
const bumpSessionVersion = useCallback(() => {
setSessionVersion((prev) => prev + 1);
}, []);
const ensureSftp = useCallback(async () => {
if (isLocalSession) throw new Error("Local session does not use SFTP");
if (closingPromiseRef.current) {
await closingPromiseRef.current;
}
if (sftpIdRef.current) return sftpIdRef.current;
const sftpId = await openSftp({
sessionId: `sftp-modal-${host.id}`,
hostname: credentials.hostname,
username: credentials.username || "root",
port: credentials.port || 22,
password: credentials.password,
privateKey: credentials.privateKey,
certificate: credentials.certificate,
passphrase: credentials.passphrase,
publicKey: credentials.publicKey,
keyId: credentials.keyId,
keySource: credentials.keySource,
proxy: credentials.proxy,
jumpHosts: credentials.jumpHosts,
sudo: credentials.sftpSudo,
legacyAlgorithms: credentials.legacyAlgorithms,
});
if (sftpIdRef.current !== sftpId) {
sftpIdRef.current = sftpId;
bumpSessionVersion();
}
return sftpId;
}, [
isLocalSession,
host.id,
credentials.hostname,
credentials.username,
credentials.port,
credentials.password,
credentials.privateKey,
credentials.certificate,
credentials.passphrase,
credentials.publicKey,
credentials.keyId,
credentials.keySource,
credentials.proxy,
credentials.jumpHosts,
credentials.sftpSudo,
credentials.legacyAlgorithms,
bumpSessionVersion,
openSftp,
]);
const closeSftpSession = useCallback(async () => {
if (isLocalSession) {
if (sftpIdRef.current !== null) {
sftpIdRef.current = null;
bumpSessionVersion();
}
return;
}
// Clear ref before awaiting backend close to avoid handing out a stale ID
// if the modal is reopened while close is still in flight.
const sftpIdToClose = sftpIdRef.current;
if (sftpIdToClose !== null) {
sftpIdRef.current = null;
bumpSessionVersion();
}
if (!sftpIdToClose) {
return;
}
const currentClosePromise = (async () => {
try {
await closeSftp(sftpIdToClose);
} catch {
// Silently ignore close errors - connection may already be closed
} finally {
if (closingPromiseRef.current === currentClosePromise) {
closingPromiseRef.current = null;
}
}
})();
closingPromiseRef.current = currentClosePromise;
await currentClosePromise;
}, [bumpSessionVersion, closeSftp, isLocalSession]);
const isSessionError = useCallback((err: unknown): boolean => {
if (!(err instanceof Error)) return false;
const msg = err.message.toLowerCase();
return (
msg.includes("session not found") ||
msg.includes("sftp session") ||
msg.includes("not found") ||
msg.includes("closed") ||
msg.includes("connection reset") ||
msg.includes("write after end") ||
msg.includes("no response") ||
msg.includes("client disconnected")
);
}, []);
const handleSessionError = useCallback(async () => {
if (reconnectingRef.current) return;
reconnectingRef.current = true;
setReconnecting(true);
reconnectAttemptsRef.current = 0;
while (reconnectAttemptsRef.current < MAX_RECONNECT_ATTEMPTS) {
try {
reconnectAttemptsRef.current += 1;
await closeSftpSession();
await ensureSftp();
reconnectingRef.current = false;
setReconnecting(false);
return;
} catch (err) {
logger.warn(
`[SFTP] Reconnect attempt ${reconnectAttemptsRef.current} failed`,
err,
);
if (reconnectAttemptsRef.current >= MAX_RECONNECT_ATTEMPTS) {
reconnectingRef.current = false;
setReconnecting(false);
toast.error(t("sftp.error.reconnectFailed"), "SFTP");
return;
}
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
}, [closeSftpSession, ensureSftp, t]);
const loadFiles = useCallback(
async (path: string, options?: { force?: boolean }) => {
const requestId = ++loadSeqRef.current;
setLoading(true);
onClearSelection();
try {
if (isLocalSession) {
const list = await listLocalDir(path);
if (requestId === loadSeqRef.current) {
setFiles(list);
}
return;
}
const cacheKey = `${host.id}::${path}`;
const cached = dirCacheRef.current.get(cacheKey);
const isFresh =
cached && Date.now() - cached.timestamp < DIR_CACHE_TTL_MS;
if (cached && isFresh && !options?.force) {
setFiles(cached.files);
return;
}
const sftpId = await ensureSftp();
const list = await listSftp(sftpId, path);
if (requestId !== loadSeqRef.current) return;
setFiles(list);
dirCacheRef.current.set(cacheKey, {
files: list,
timestamp: Date.now(),
});
} catch (e) {
if (!isLocalSession && isSessionError(e) && files.length > 0) {
logger.info("[SFTP] Session lost, attempting to reconnect...");
handleSessionError();
return;
}
logger.error("Failed to load files", e);
toast.error(
e instanceof Error ? e.message : t("sftp.error.loadFailed"),
"SFTP",
);
setFiles([]);
} finally {
if (loadSeqRef.current === requestId) {
setLoading(false);
}
}
},
[ensureSftp, host.id, isLocalSession, listLocalDir, listSftp, t, isSessionError, handleSessionError, files.length, onClearSelection],
);
useLayoutEffect(() => {
if (!open) return;
const cacheKey = `${host.id}::${currentPath}`;
const cached = dirCacheRef.current.get(cacheKey);
const isFresh = cached && Date.now() - cached.timestamp < DIR_CACHE_TTL_MS;
if (!isFresh) {
setFiles([]);
onClearSelection();
}
}, [currentPath, host.id, onClearSelection, open]);
useEffect(() => {
if (open) {
if (!initializedRef.current || lastInitialPathRef.current !== initialPath) {
initializedRef.current = true;
lastInitialPathRef.current = initialPath;
onClearSelection();
setLoading(true);
if (isLocalSession) {
(async () => {
const homePath = await getHomeDir();
localHomeRef.current = homePath ?? null;
const startPath = initialPath || homePath || "/";
try {
const list = await listLocalDir(startPath);
setCurrentPath(startPath);
setFiles(list);
dirCacheRef.current.set(`${host.id}::${startPath}`, {
files: list,
timestamp: Date.now(),
});
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.loadFailed"),
"SFTP",
);
} finally {
setLoading(false);
}
})();
return;
}
(async () => {
const homePath = await getHomeDir();
localHomeRef.current = homePath ?? null;
if (initialPath) {
try {
const sftpId = await ensureSftp();
const list = await listSftp(sftpId, initialPath);
setCurrentPath(initialPath);
setFiles(list);
dirCacheRef.current.set(`${host.id}::${initialPath}`, {
files: list,
timestamp: Date.now(),
});
setLoading(false);
return;
} catch {
logger.warn(
`[SFTP] Initial path ${initialPath} not accessible, falling back to home`,
);
}
}
try {
const sftpId = await ensureSftp();
const list = await listSftp(sftpId, homePath || "/");
setCurrentPath(homePath || "/");
setFiles(list);
dirCacheRef.current.set(`${host.id}::${homePath || "/"}`, {
files: list,
timestamp: Date.now(),
});
setLoading(false);
} catch {
logger.warn(`[SFTP] Home ${homePath} not accessible, using /`);
try {
const sftpId = await ensureSftp();
const list = await listSftp(sftpId, "/");
setCurrentPath("/");
setFiles(list);
dirCacheRef.current.set(`${host.id}::/`, {
files: list,
timestamp: Date.now(),
});
} catch (e) {
logger.error("[SFTP] Failed to load root directory", e);
toast.error(t("sftp.error.loadFailed"), "SFTP");
} finally {
setLoading(false);
}
}
})();
return;
}
void loadFiles(currentPath);
} else {
loadSeqRef.current += 1;
initializedRef.current = false;
}
}, [
closeSftpSession,
currentPath,
ensureSftp,
getHomeDir,
host.id,
initialPath,
isLocalSession,
listLocalDir,
listSftp,
loadFiles,
onClearSelection,
open,
t,
]);
useEffect(() => {
return () => {
void closeSftpSession();
};
}, [closeSftpSession]);
return {
currentPath,
setCurrentPath,
files,
setFiles,
loading,
setLoading,
reconnecting,
sessionVersion,
ensureSftp,
loadFiles,
closeSftpSession,
localHomeRef,
};
};

View File

@@ -0,0 +1,76 @@
import React, { useCallback, useRef, useState } from "react";
export type SortField = "name" | "size" | "modified";
export type SortOrder = "asc" | "desc";
interface UseSftpModalSortingResult {
sortField: SortField;
sortOrder: SortOrder;
columnWidths: { name: number; size: number; modified: number; actions: number };
handleSort: (field: SortField) => void;
handleResizeStart: (field: string, e: React.MouseEvent) => void;
}
export const useSftpModalSorting = (): UseSftpModalSortingResult => {
const [sortField, setSortField] = useState<SortField>("name");
const [sortOrder, setSortOrder] = useState<SortOrder>("asc");
const [columnWidths, setColumnWidths] = useState({
name: 45,
size: 15,
modified: 25,
actions: 15,
});
const resizingRef = useRef<{
field: string;
startX: number;
startWidth: number;
} | null>(null);
const handleSort = (field: SortField) => {
if (sortField === field) {
setSortOrder((prev) => (prev === "asc" ? "desc" : "asc"));
} else {
setSortField(field);
setSortOrder("asc");
}
};
const handleResizeMove = useCallback((e: MouseEvent) => {
if (!resizingRef.current) return;
const diff = e.clientX - resizingRef.current.startX;
const newWidth = Math.max(
10,
Math.min(60, resizingRef.current.startWidth + diff / 5),
);
setColumnWidths((prev) => ({
...prev,
[resizingRef.current!.field]: newWidth,
}));
}, []);
const handleResizeEnd = useCallback(() => {
resizingRef.current = null;
document.removeEventListener("mousemove", handleResizeMove);
document.removeEventListener("mouseup", handleResizeEnd);
}, [handleResizeMove]);
const handleResizeStart = (field: string, e: React.MouseEvent) => {
e.preventDefault();
resizingRef.current = {
field,
startX: e.clientX,
startWidth: columnWidths[field as keyof typeof columnWidths],
};
document.addEventListener("mousemove", handleResizeMove);
document.addEventListener("mouseup", handleResizeEnd);
};
return {
sortField,
sortOrder,
columnWidths,
handleSort,
handleResizeStart,
};
};

View File

@@ -0,0 +1,87 @@
import { useCallback, useState } from "react";
import type { RemoteFile } from "../../../types";
import { toast } from "../../ui/toast";
interface UseSftpModalTextEditorParams {
currentPath: string;
isLocalSession: boolean;
joinPath: (base: string, name: string) => string;
ensureSftp: () => Promise<string>;
readLocalFile: (path: string) => Promise<ArrayBuffer>;
readSftp: (sftpId: string, path: string) => Promise<string>;
writeLocalFile: (path: string, data: ArrayBuffer) => Promise<void>;
writeSftp: (sftpId: string, path: string, data: string) => Promise<void>;
t: (key: string, params?: Record<string, unknown>) => string;
}
interface UseSftpModalTextEditorResult {
showTextEditor: boolean;
setShowTextEditor: (open: boolean) => void;
textEditorTarget: RemoteFile | null;
setTextEditorTarget: (target: RemoteFile | null) => void;
textEditorContent: string;
setTextEditorContent: (value: string) => void;
loadingTextContent: boolean;
handleEditFile: (file: RemoteFile) => Promise<void>;
handleSaveTextFile: (content: string) => Promise<void>;
}
export const useSftpModalTextEditor = ({
currentPath,
isLocalSession,
joinPath,
ensureSftp,
readLocalFile,
readSftp,
writeLocalFile,
writeSftp,
t,
}: UseSftpModalTextEditorParams): UseSftpModalTextEditorResult => {
const [showTextEditor, setShowTextEditor] = useState(false);
const [textEditorTarget, setTextEditorTarget] = useState<RemoteFile | null>(null);
const [textEditorContent, setTextEditorContent] = useState("");
const [loadingTextContent, setLoadingTextContent] = useState(false);
const handleEditFile = useCallback(async (file: RemoteFile) => {
try {
setLoadingTextContent(true);
setTextEditorTarget(file);
const fullPath = joinPath(currentPath, file.name);
const content = isLocalSession
? await readLocalFile(fullPath).then((buf) => new TextDecoder().decode(buf))
: await readSftp(await ensureSftp(), fullPath);
setTextEditorContent(content);
setShowTextEditor(true);
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.loadFailed"),
"SFTP",
);
} finally {
setLoadingTextContent(false);
}
}, [currentPath, ensureSftp, isLocalSession, joinPath, readLocalFile, readSftp, t]);
const handleSaveTextFile = useCallback(async (content: string) => {
if (!textEditorTarget) return;
const fullPath = joinPath(currentPath, textEditorTarget.name);
if (isLocalSession) {
const encoder = new TextEncoder();
await writeLocalFile(fullPath, encoder.encode(content).buffer);
} else {
await writeSftp(await ensureSftp(), fullPath, content);
}
}, [currentPath, ensureSftp, isLocalSession, joinPath, textEditorTarget, writeLocalFile, writeSftp]);
return {
showTextEditor,
setShowTextEditor,
textEditorTarget,
setTextEditorTarget,
textEditorContent,
setTextEditorContent,
loadingTextContent,
handleEditFile,
handleSaveTextFile,
};
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,123 @@
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { RemoteFile } from "../../../types";
interface UseSftpModalVirtualListParams {
open: boolean;
sortedFiles: RemoteFile[];
}
interface UseSftpModalVirtualListResult {
fileListRef: React.RefObject<HTMLDivElement>;
rowHeight: number;
handleFileListScroll: (e: React.UIEvent<HTMLDivElement>) => void;
shouldVirtualize: boolean;
totalHeight: number;
visibleRows: { file: RemoteFile; index: number; top: number }[];
}
export const useSftpModalVirtualList = ({
open,
sortedFiles,
}: UseSftpModalVirtualListParams): UseSftpModalVirtualListResult => {
const fileListRef = useRef<HTMLDivElement>(null);
const scrollFrameRef = useRef<number | null>(null);
const [scrollTop, setScrollTop] = useState(0);
const [viewportHeight, setViewportHeight] = useState(0);
const [rowHeight, setRowHeight] = useState(40);
useLayoutEffect(() => {
const container = fileListRef.current;
if (!container || !open) return;
const update = () => setViewportHeight(container.clientHeight);
update();
const raf = window.requestAnimationFrame(update);
const resizeObserver = new ResizeObserver(update);
resizeObserver.observe(container);
return () => {
resizeObserver.disconnect();
window.cancelAnimationFrame(raf);
};
}, [open, sortedFiles.length]);
useLayoutEffect(() => {
const container = fileListRef.current;
if (!container || !open || sortedFiles.length === 0) return;
const raf = window.requestAnimationFrame(() => {
const rowElement = container.querySelector(
'[data-sftp-modal-row="true"]',
) as HTMLElement | null;
if (!rowElement) return;
const nextHeight = Math.round(rowElement.getBoundingClientRect().height);
if (nextHeight && Math.abs(nextHeight - rowHeight) > 1) {
setRowHeight(nextHeight);
}
});
return () => window.cancelAnimationFrame(raf);
}, [open, rowHeight, sortedFiles.length]);
useEffect(() => {
return () => {
if (scrollFrameRef.current !== null) {
window.cancelAnimationFrame(scrollFrameRef.current);
}
};
}, []);
const handleFileListScroll = useCallback(
(e: React.UIEvent<HTMLDivElement>) => {
const nextTop = e.currentTarget.scrollTop;
if (scrollFrameRef.current !== null) return;
scrollFrameRef.current = window.requestAnimationFrame(() => {
scrollFrameRef.current = null;
setScrollTop(nextTop);
});
},
[],
);
const { shouldVirtualize, totalHeight, visibleRows } = useMemo(() => {
const overscan = 6;
const canVirtualize = open && viewportHeight > 0 && rowHeight > 0;
const shouldVirtualizeLocal = canVirtualize && sortedFiles.length > 50;
const totalHeightLocal = shouldVirtualizeLocal
? sortedFiles.length * rowHeight
: 0;
const startIndex = shouldVirtualizeLocal
? Math.max(0, Math.floor(scrollTop / rowHeight) - overscan)
: 0;
const endIndex = shouldVirtualizeLocal
? Math.min(
sortedFiles.length - 1,
Math.ceil((scrollTop + viewportHeight) / rowHeight) + overscan,
)
: sortedFiles.length - 1;
const visibleRowsLocal = shouldVirtualizeLocal
? sortedFiles
.slice(startIndex, endIndex + 1)
.map((file, idx) => ({
file,
index: startIndex + idx,
top: (startIndex + idx) * rowHeight,
}))
: sortedFiles.map((file, index) => ({
file,
index,
top: 0,
}));
return {
shouldVirtualize: shouldVirtualizeLocal,
totalHeight: totalHeightLocal,
visibleRows: visibleRowsLocal,
};
}, [open, rowHeight, scrollTop, sortedFiles, viewportHeight]);
return {
fileListRef,
rowHeight,
handleFileListScroll,
shouldVirtualize,
totalHeight,
visibleRows,
};
};

View File

@@ -0,0 +1,83 @@
export const isWindowsPath = (path: string): boolean => /^[A-Za-z]:/.test(path);
export const normalizeWindowsRoot = (path: string): string => {
const normalized = path.replace(/\//g, "\\");
if (/^[A-Za-z]:\\$/.test(normalized)) return normalized;
if (/^[A-Za-z]:$/.test(normalized)) return `${normalized}\\`;
return normalized;
};
export const joinPath = (base: string, name: string, isLocalSession: boolean): string => {
if (isLocalSession && isWindowsPath(base)) {
const normalizedBase = normalizeWindowsRoot(base).replace(/[\\/]+$/, "");
return `${normalizedBase}\\${name}`;
}
if (base === "/") return `/${name}`;
return `${base}/${name}`;
};
export const isRootPath = (path: string, isLocalSession: boolean): boolean => {
if (isLocalSession && isWindowsPath(path)) {
return /^[A-Za-z]:\\?$/.test(path.replace(/\//g, "\\"));
}
return path === "/";
};
export const getParentPath = (path: string, isLocalSession: boolean): string => {
if (isLocalSession && isWindowsPath(path)) {
const normalized = normalizeWindowsRoot(path).replace(/[\\]+$/, "");
const drive = normalized.slice(0, 2);
if (/^[A-Za-z]:$/.test(normalized) || /^[A-Za-z]:\\$/.test(normalized)) {
return `${drive}\\`;
}
const rest = normalized.slice(2).replace(/^[\\]+/, "");
const parts = rest ? rest.split(/[\\]+/).filter(Boolean) : [];
if (parts.length <= 1) return `${drive}\\`;
parts.pop();
return `${drive}\\${parts.join("\\")}`;
}
if (path === "/") return "/";
const parts = path.split("/").filter(Boolean);
parts.pop();
return parts.length ? `/${parts.join("/")}` : "/";
};
export const getRootPath = (path: string, isLocalSession: boolean): string => {
if (isLocalSession && isWindowsPath(path)) {
const drive = path.replace(/\//g, "\\").slice(0, 2);
return `${drive}\\`;
}
return "/";
};
export const getWindowsDrive = (path: string): string | null => {
if (!isWindowsPath(path)) return null;
const normalized = path.replace(/\//g, "\\");
return /^[A-Za-z]:/.test(normalized) ? normalized.slice(0, 2) : null;
};
export const getBreadcrumbs = (path: string, isLocalSession: boolean): string[] => {
if (isLocalSession && isWindowsPath(path)) {
const normalized = normalizeWindowsRoot(path).replace(/[\\]+$/, "");
const rest = normalized.slice(2).replace(/^[\\]+/, "");
const parts = rest ? rest.split(/[\\]+/).filter(Boolean) : [];
return parts;
}
return path === "/" ? [] : path.split("/").filter(Boolean);
};
export const breadcrumbPathAt = (
breadcrumbs: string[],
idx: number,
currentPath: string,
isLocalSession: boolean,
): string => {
if (isLocalSession) {
const drive = getWindowsDrive(currentPath);
if (drive) {
const rest = breadcrumbs.slice(0, idx + 1).join("\\");
return rest ? `${drive}\\${rest}` : `${drive}\\`;
}
}
return "/" + breadcrumbs.slice(0, idx + 1).join("/");
};

View File

@@ -0,0 +1,16 @@
export const formatBytes = (bytes: number | string): string => {
const numBytes = typeof bytes === "string" ? parseInt(bytes, 10) : bytes;
if (isNaN(numBytes) || numBytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(numBytes) / Math.log(1024));
const size = numBytes / Math.pow(1024, i);
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
};
export const formatDate = (dateStr: string | number | undefined): string => {
if (!dateStr) return "--";
const date = typeof dateStr === "number" ? new Date(dateStr) : new Date(dateStr);
if (isNaN(date.getTime())) return String(dateStr);
const pad = (value: number) => value.toString().padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
};

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