Compare commits

...

33 Commits

Author SHA1 Message Date
陈大猫
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
65 changed files with 4316 additions and 1023 deletions

View File

@@ -13,12 +13,24 @@ 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
- name: linux-arm64
os: ubuntu-24.04-arm
pack_script: pack:linux-arm64
env:
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
@@ -50,29 +62,23 @@ jobs:
echo "Setting version to ${VERSION}"
npm pkg set version="${VERSION}"
- name: Build package (macOS)
if: matrix.os == 'macos-latest'
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
ELECTRON_BUILDER_PUBLISH: "never"
run: npm run pack:mac
# On ARM64 runners, electron-builder's post-build @electron/rebuild incorrectly
# tries to restore native modules to x64, but the ARM g++ doesn't support -m64.
# Setting npm_config_arch=arm64 ensures node-gyp uses the correct host architecture.
- name: Set native module arch for ARM64
if: matrix.name == 'linux-arm64'
run: echo "npm_config_arch=arm64" >> "$GITHUB_ENV"
- name: Build package (Windows)
if: matrix.os == 'windows-latest'
- name: Build package
env:
CSC_IDENTITY_AUTO_DISCOVERY: ${{ matrix.name == 'macos' && 'false' || '' }}
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/*.exe

27
App.tsx
View File

@@ -14,6 +14,7 @@ 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';
@@ -1082,6 +1083,30 @@ 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]);
@@ -1184,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}

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...',
@@ -57,6 +58,9 @@ const en: Messages = {
'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',
@@ -80,6 +84,14 @@ 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',
@@ -218,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',
@@ -324,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',
@@ -513,6 +529,9 @@ 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',
@@ -781,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',
@@ -896,6 +919,11 @@ 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',
@@ -916,6 +944,10 @@ const en: Messages = {
'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',
@@ -952,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...',
@@ -967,10 +1003,50 @@ const en: Messages = {
'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':

View File

@@ -42,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': '设置',
@@ -65,6 +68,14 @@ 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': '会话日志',
@@ -191,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} 分钟前',
@@ -355,6 +367,9 @@ 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': '大小',
@@ -496,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': '直连',
@@ -582,6 +601,11 @@ 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': '关闭会话',
@@ -602,6 +626,10 @@ const zhCN: Messages = {
'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': '已挂载磁盘',
@@ -638,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': '已断开',
@@ -654,10 +686,50 @@ const zhCN: Messages = {
'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':
'数据会在本地加密后再同步,云端不会看到明文。设置主密钥以启用安全同步。',
@@ -816,6 +888,7 @@ const zhCN: Messages = {
'common.import': '导入',
'common.generate': '生成',
'common.delete': '删除',
'common.edit': '编辑',
'common.clear': '清除',
'common.optional': '可选',
'common.selectPlaceholder': '请选择...',
@@ -1024,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': '输出时自动滚动',
@@ -1086,6 +1162,35 @@ const zhCN: Messages = {
'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',

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

@@ -20,12 +20,12 @@ export const useSftpHostCredentials = ({
const proxyConfig = host.proxyConfig
? {
type: host.proxyConfig.type,
host: host.proxyConfig.host,
port: host.proxyConfig.port,
username: host.proxyConfig.username,
password: host.proxyConfig.password,
}
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;
@@ -63,6 +63,7 @@ export const useSftpHostCredentials = ({
password: resolved.password,
privateKey: key?.privateKey,
certificate: key?.certificate,
passphrase: resolved.passphrase || key?.passphrase,
publicKey: key?.publicKey,
keyId: resolved.keyId,
keySource: key?.source,

View File

@@ -162,11 +162,16 @@ export const useSftpTransfers = ({
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: transferred,
totalBytes: total || t.totalBytes,
speed,
transferredBytes: normalizedTransferred,
totalBytes: normalizedTotal,
speed: Number.isFinite(speed) && speed > 0 ? speed : 0,
};
}),
);
@@ -773,7 +778,27 @@ export const useSftpTransfers = ({
const updateExternalUpload = useCallback((taskId: string, updates: Partial<TransferTask>) => {
setTransfers((prev) =>
prev.map((t) => (t.id === taskId ? { ...t, ...updates } : t)),
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;
}),
);
}, []);

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

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

View File

@@ -30,6 +30,7 @@ import {
} 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';
@@ -766,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(

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

@@ -16,6 +16,7 @@ import {
Plus,
Settings2,
Shield,
ShieldAlert,
Tag,
TerminalSquare,
User,
@@ -26,7 +27,7 @@ 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, ManagedSource, ProxyConfig, SSHKey } from "../types";
@@ -38,6 +39,7 @@ 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";
@@ -1115,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,
}}
>
$
@@ -1137,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>
@@ -1230,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">
@@ -1306,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 */}
@@ -1466,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,
}}
>
@@ -1503,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>

View File

@@ -8,6 +8,7 @@ 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";
@@ -36,13 +37,18 @@ const LogViewComponent: React.FC<LogViewProps> = ({
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;

View File

@@ -42,6 +42,7 @@ interface SFTPModalProps {
proxy?: NetcattyProxyConfig;
jumpHosts?: NetcattyJumpHost[];
sftpSudo?: boolean;
legacyAlgorithms?: boolean;
};
open: boolean;
onClose: () => void;
@@ -49,6 +50,8 @@ interface SFTPModalProps {
initialPath?: string;
/** Initial entries to upload when SFTP modal opens. Used for drag-and-drop to terminal. */
initialEntriesToUpload?: DropEntry[];
/** Callback to update the host (e.g. for bookmark persistence). */
onUpdateHost?: (host: Host) => void;
}
const SFTPModal: React.FC<SFTPModalProps> = ({
@@ -58,6 +61,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
onClose,
initialPath,
initialEntriesToUpload,
onUpdateHost,
}) => {
const {
openSftp,
@@ -204,6 +208,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
loading,
setLoading,
reconnecting,
sessionVersion,
ensureSftp,
loadFiles,
closeSftpSession,
@@ -296,6 +301,13 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
handleDelete,
handleCreateFolder,
handleCreateFile,
showCreateDialog,
setShowCreateDialog,
createType,
createName,
setCreateName,
isCreating,
handleCreateSubmit,
showRenameDialog,
setShowRenameDialog,
renameTarget,
@@ -392,8 +404,37 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
t,
useCompressedUpload: sftpUseCompressedUpload,
});
const hasEverOpenedRef = useRef(false);
const hasActiveTransferTasks = useMemo(
() =>
uploadTasks.some(
(task) =>
task.status === "pending" ||
task.status === "uploading" ||
task.status === "downloading",
),
[uploadTasks],
);
useEffect(() => {
if (open) {
hasEverOpenedRef.current = true;
return;
}
if (!hasEverOpenedRef.current) return;
if (uploading || hasActiveTransferTasks) return;
void closeSftpSession();
}, [closeSftpSession, hasActiveTransferTasks, open, sessionVersion, uploading]);
const handleClose = async () => {
if (uploading || hasActiveTransferTasks) {
onClose();
return;
}
await closeSftpSession();
onClose();
};
@@ -438,7 +479,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
// Display files with parent entry (like SftpView)
const displayFiles = useMemo(() => {
// Filter hidden files using utility function
const visibleFiles = filterHiddenFiles(files, sftpShowHiddenFiles);
const visibleFiles = filterHiddenFiles(files, sftpShowHiddenFiles, isLocalSession);
// Check if we're at root
const atRoot = isRootPathForSession(currentPath);
@@ -452,7 +493,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
lastModified: undefined,
};
return [parentEntry, ...visibleFiles.filter((f) => f.name !== "..")];
}, [files, currentPath, isRootPathForSession, sftpShowHiddenFiles]);
}, [files, currentPath, isRootPathForSession, sftpShowHiddenFiles, isLocalSession]);
// Sorted files
const sortedFiles = useMemo(() => {
@@ -526,7 +567,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
// Find the files to pass to confirm dialog
if (fileNames.length === 0) return;
if (!confirm(t("sftp.deleteConfirm.title", { count: fileNames.length }))) return;
// Delete files
(async () => {
try {
@@ -642,6 +683,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
onCreateFile={handleCreateFile}
onFileSelect={handleFileSelect}
onFolderSelect={handleFolderSelect}
onUpdateHost={onUpdateHost}
onNavigateToBookmark={(path) => setCurrentPath(path)}
/>
<SftpModalFileList
@@ -718,6 +761,13 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
getSymbolicPermissions={getSymbolicPermissions}
handleSavePermissions={handleSavePermissions}
isChangingPermissions={isChangingPermissions}
showCreateDialog={showCreateDialog}
setShowCreateDialog={setShowCreateDialog}
createType={createType}
createName={createName}
setCreateName={setCreateName}
isCreating={isCreating}
handleCreateSubmit={handleCreateSubmit}
/>
{/* File Opener Dialog */}

View File

@@ -48,9 +48,10 @@ interface SftpViewProps {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
updateHosts: (hosts: Host[]) => void;
}
const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) => {
const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updateHosts }) => {
const { t } = useI18n();
const isActive = useIsSftpActive();
const {
@@ -167,7 +168,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
});
const visibleTransfers = useMemo(
() => sftp.transfers.slice(-5),
() => [...sftp.transfers].reverse().slice(0, 5),
[sftp.transfers],
);
@@ -213,6 +214,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
return (
<SftpContextProvider
hosts={hosts}
updateHosts={updateHosts}
draggedFiles={draggedFiles}
dragCallbacks={dragCallbacks}
leftCallbacks={leftCallbacks}

View File

@@ -30,9 +30,11 @@ 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 { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
@@ -137,6 +139,8 @@ interface TerminalProps {
onSplitVertical?: () => void;
isBroadcastEnabled?: boolean;
onToggleBroadcast?: () => void;
onToggleComposeBar?: () => void;
isWorkspaceComposeBarOpen?: boolean;
onBroadcastInput?: (data: string, sourceSessionId: string) => void;
}
@@ -191,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
@@ -290,6 +296,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
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 {
@@ -344,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
@@ -416,6 +427,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
setProgressLogs,
setProgressValue,
setChainProgress,
t,
onSessionExit,
onTerminalDataCapture,
onOsDetected,
@@ -679,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);
@@ -875,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) => {
@@ -1094,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)}
/>
);
@@ -1122,7 +1141,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
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]"
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}
@@ -1299,6 +1321,34 @@ const TerminalComponent: React.FC<TerminalProps> = ({
</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">
@@ -1559,6 +1609,25 @@ const TerminalComponent: React.FC<TerminalProps> = ({
)}
</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={(() => {
@@ -1618,6 +1687,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
proxy: proxyConfig,
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
sftpSudo: host.sftpSudo,
legacyAlgorithms: host.legacyAlgorithms,
};
})()}
open={showSFTP && status === "connected"}
@@ -1627,6 +1697,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}}
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

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

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

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

@@ -4,6 +4,7 @@
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";
@@ -61,6 +62,8 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
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();
@@ -81,6 +84,20 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
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;
@@ -201,6 +218,59 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
</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">

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,6 +11,8 @@ 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";
@@ -18,6 +20,8 @@ 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<{
@@ -51,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>
@@ -100,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;
@@ -194,7 +273,7 @@ export default function SettingsTerminalTab(props: {
onClick={() => setThemeModalOpen(true)}
buttonLabel={t("settings.terminal.theme.selectButton")}
/>
<ThemeSelectModal
open={themeModalOpen}
onClose={() => setThemeModalOpen(false)}
@@ -202,6 +281,86 @@ 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
@@ -314,7 +473,7 @@ export default function SettingsTerminalTab(props: {
onChange={(v) =>
updateTerminalSetting("terminalEmulationType", v as TerminalEmulationType)
}
className="w-36"
className="w-44"
/>
</SettingRow>
</div>
@@ -409,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")}

View File

@@ -29,6 +29,13 @@ interface SftpModalDialogsProps {
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> = ({
@@ -49,6 +56,13 @@ export const SftpModalDialogs: React.FC<SftpModalDialogsProps> = ({
getSymbolicPermissions,
handleSavePermissions,
isChangingPermissions,
showCreateDialog,
setShowCreateDialog,
createType,
createName,
setCreateName,
isCreating,
handleCreateSubmit,
}) => (
<>
<Dialog open={showRenameDialog} onOpenChange={setShowRenameDialog}>
@@ -135,5 +149,38 @@ export const SftpModalDialogs: React.FC<SftpModalDialogsProps> = ({
</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

@@ -1,7 +1,8 @@
import React, { useEffect, useState } from "react";
import { ArrowUp, Check, ChevronRight, FilePlus, FolderPlus, FolderUp, Home, Languages, MoreHorizontal, RefreshCw, Upload } from "lucide-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";
@@ -50,6 +51,8 @@ interface SftpModalHeaderProps {
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> = ({
@@ -88,11 +91,25 @@ export const SftpModalHeader: React.FC<SftpModalHeaderProps> = ({
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);
@@ -169,6 +186,82 @@ export const SftpModalHeader: React.FC<SftpModalHeaderProps> = ({
</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')}>

View File

@@ -64,7 +64,7 @@ export const SftpModalUploadTasks: React.FC<SftpModalUploadTasksProps> = ({ task
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.map((task) => {
{[...tasks].reverse().map((task) => {
const formatSpeed = (bytesPerSec: number) => {
if (bytesPerSec <= 0) return "";
if (bytesPerSec >= 1024 * 1024)
@@ -83,8 +83,9 @@ export const SftpModalUploadTasks: React.FC<SftpModalUploadTasksProps> = ({ task
};
const remainingBytes = task.totalBytes - task.transferredBytes;
const effectiveSpeed = task.speed > 0 ? task.speed : 0;
const remainingTime =
task.speed > 0 ? Math.ceil(remainingBytes / task.speed) : 0;
effectiveSpeed > 0 ? Math.ceil(remainingBytes / effectiveSpeed) : 0;
const remainingStr =
remainingTime > 60
? `~${Math.ceil(remainingTime / 60)}m left`
@@ -123,9 +124,9 @@ export const SftpModalUploadTasks: React.FC<SftpModalUploadTasksProps> = ({ task
<span className="text-xs font-medium truncate">
{getDisplayName(task)}
</span>
{(task.status === "uploading" || task.status === "downloading") && task.speed > 0 && (
{(task.status === "uploading" || task.status === "downloading") && effectiveSpeed > 0 && (
<span className="text-[10px] text-primary font-mono shrink-0">
{formatSpeed(task.speed)}
{formatSpeed(effectiveSpeed)}
</span>
)}
{(task.status === "uploading" || task.status === "downloading") && remainingStr && (

View File

@@ -1,4 +1,4 @@
import { useCallback } from "react";
import { useCallback, useState } from "react";
import type { RemoteFile } from "../../../types";
import { toast } from "../../ui/toast";
@@ -20,8 +20,16 @@ interface UseSftpModalCreateDeleteParams {
interface UseSftpModalCreateDeleteResult {
handleDelete: (file: RemoteFile) => Promise<void>;
handleCreateFolder: () => Promise<void>;
handleCreateFile: () => 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 = ({
@@ -39,6 +47,11 @@ export const useSftpModalCreateDelete = ({
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;
@@ -62,47 +75,66 @@ export const useSftpModalCreateDelete = ({
[currentPath, deleteLocalFile, deleteSftp, ensureSftp, isLocalSession, joinPath, loadFiles, t],
);
const handleCreateFolder = useCallback(async () => {
const folderName = prompt(t("sftp.prompt.newFolderName"));
if (!folderName) return;
try {
const fullPath = joinPath(currentPath, folderName);
if (isLocalSession) {
await mkdirLocal(fullPath);
} else {
await mkdirSftp(await ensureSftp(), fullPath);
}
await loadFiles(currentPath, { force: true });
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.createFolderFailed"),
"SFTP",
);
}
}, [currentPath, ensureSftp, isLocalSession, joinPath, loadFiles, mkdirLocal, mkdirSftp, t]);
const handleCreateFolder = useCallback(() => {
setCreateType("folder");
setCreateName("");
setShowCreateDialog(true);
}, []);
const handleCreateFile = useCallback(async () => {
const fileName = prompt(t("sftp.fileName.placeholder"));
if (!fileName) return;
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, fileName);
if (isLocalSession) {
await writeLocalFile(fullPath, new ArrayBuffer(0));
const fullPath = joinPath(currentPath, name);
if (createType === "folder") {
if (isLocalSession) {
await mkdirLocal(fullPath);
} else {
await mkdirSftp(await ensureSftp(), fullPath);
}
} else {
try {
await writeSftpBinary(await ensureSftp(), fullPath, new ArrayBuffer(0));
} catch {
await writeSftp(await ensureSftp(), fullPath, "");
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("sftp.error.createFileFailed"),
e instanceof Error
? e.message
: t(createType === "folder" ? "sftp.error.createFolderFailed" : "sftp.error.createFileFailed"),
"SFTP",
);
} finally {
setIsCreating(false);
}
}, [currentPath, ensureSftp, isLocalSession, joinPath, loadFiles, t, writeLocalFile, writeSftp, writeSftpBinary]);
}, [createName, createType, currentPath, ensureSftp, isCreating, isLocalSession, joinPath, loadFiles, mkdirLocal, mkdirSftp, t, writeLocalFile, writeSftp, writeSftpBinary]);
return { handleDelete, handleCreateFolder, handleCreateFile };
return {
handleDelete,
handleCreateFolder,
handleCreateFile,
showCreateDialog,
setShowCreateDialog,
createType,
createName,
setCreateName,
isCreating,
handleCreateSubmit,
};
};

View File

@@ -34,8 +34,15 @@ interface UseSftpModalFileActionsParams {
interface UseSftpModalFileActionsResult {
handleDelete: (file: RemoteFile) => Promise<void>;
handleCreateFolder: () => Promise<void>;
handleCreateFile: () => 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;
@@ -106,7 +113,18 @@ export const useSftpModalFileActions = ({
downloadSftpToTempAndOpen,
selectApplication,
}: UseSftpModalFileActionsParams): UseSftpModalFileActionsResult => {
const { handleDelete, handleCreateFolder, handleCreateFile } =
const {
handleDelete,
handleCreateFolder,
handleCreateFile,
showCreateDialog,
setShowCreateDialog,
createType,
createName,
setCreateName,
isCreating,
handleCreateSubmit,
} =
useSftpModalCreateDelete({
currentPath,
isLocalSession,
@@ -213,6 +231,13 @@ export const useSftpModalFileActions = ({
handleDelete,
handleCreateFolder,
handleCreateFile,
showCreateDialog,
setShowCreateDialog,
createType,
createName,
setCreateName,
isCreating,
handleCreateSubmit,
showRenameDialog,
setShowRenameDialog,
renameTarget,

View File

@@ -20,6 +20,7 @@ interface UseSftpModalSessionParams {
proxy?: NetcattyProxyConfig;
jumpHosts?: NetcattyJumpHost[];
sftpSudo?: boolean;
legacyAlgorithms?: boolean;
};
initialPath?: string;
isLocalSession: boolean;
@@ -39,6 +40,7 @@ interface UseSftpModalSessionParams {
proxy?: NetcattyProxyConfig;
jumpHosts?: NetcattyJumpHost[];
sudo?: boolean;
legacyAlgorithms?: boolean;
}) => Promise<string>;
closeSftp: (sftpId: string) => Promise<void>;
listSftp: (sftpId: string, path: string) => Promise<RemoteFile[]>;
@@ -55,6 +57,7 @@ interface UseSftpModalSessionResult {
loading: boolean;
setLoading: (loading: boolean) => void;
reconnecting: boolean;
sessionVersion: number;
ensureSftp: () => Promise<string>;
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
closeSftpSession: () => Promise<void>;
@@ -79,7 +82,9 @@ export const useSftpModalSession = ({
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);
@@ -93,9 +98,15 @@ export const useSftpModalSession = ({
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}`,
@@ -112,8 +123,12 @@ export const useSftpModalSession = ({
proxy: credentials.proxy,
jumpHosts: credentials.jumpHosts,
sudo: credentials.sftpSudo,
legacyAlgorithms: credentials.legacyAlgorithms,
});
sftpIdRef.current = sftpId;
if (sftpIdRef.current !== sftpId) {
sftpIdRef.current = sftpId;
bumpSessionVersion();
}
return sftpId;
}, [
isLocalSession,
@@ -131,19 +146,46 @@ export const useSftpModalSession = ({
credentials.proxy,
credentials.jumpHosts,
credentials.sftpSudo,
credentials.legacyAlgorithms,
bumpSessionVersion,
openSftp,
]);
const closeSftpSession = useCallback(async () => {
if (!isLocalSession && sftpIdRef.current) {
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(sftpIdRef.current);
await closeSftp(sftpIdToClose);
} catch {
// Silently ignore close errors - connection may already be closed
} finally {
if (closingPromiseRef.current === currentClosePromise) {
closingPromiseRef.current = null;
}
}
}
sftpIdRef.current = null;
}, [closeSftp, isLocalSession]);
})();
closingPromiseRef.current = currentClosePromise;
await currentClosePromise;
}, [bumpSessionVersion, closeSftp, isLocalSession]);
const isSessionError = useCallback((err: unknown): boolean => {
if (!(err instanceof Error)) return false;
@@ -169,14 +211,7 @@ export const useSftpModalSession = ({
while (reconnectAttemptsRef.current < MAX_RECONNECT_ATTEMPTS) {
try {
reconnectAttemptsRef.current += 1;
if (sftpIdRef.current) {
try {
await closeSftp(sftpIdRef.current);
} catch {
// ignore
}
sftpIdRef.current = null;
}
await closeSftpSession();
await ensureSftp();
reconnectingRef.current = false;
setReconnecting(false);
@@ -195,7 +230,7 @@ export const useSftpModalSession = ({
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
}, [closeSftp, ensureSftp, t]);
}, [closeSftpSession, ensureSftp, t]);
const loadFiles = useCallback(
async (path: string, options?: { force?: boolean }) => {
@@ -351,7 +386,6 @@ export const useSftpModalSession = ({
void loadFiles(currentPath);
} else {
loadSeqRef.current += 1;
void closeSftpSession();
initializedRef.current = false;
}
}, [
@@ -384,6 +418,7 @@ export const useSftpModalSession = ({
loading,
setLoading,
reconnecting,
sessionVersion,
ensureSftp,
loadFiles,
closeSftpSession,

View File

@@ -251,13 +251,22 @@ export const useSftpModalTransfers = ({
if (task.status === "completed" || task.status === "failed" || task.status === "cancelled") {
return task;
}
const totalBytes = progress.total > 0 ? progress.total : task.totalBytes;
const clampedTransferred = Math.max(
task.transferredBytes,
Math.min(progress.transferred, totalBytes > 0 ? totalBytes : progress.transferred)
);
const rawPercent = totalBytes > 0 ? (clampedTransferred / totalBytes) * 100 : task.progress;
const clampedPercent = Math.max(task.progress, Math.min(rawPercent, 100));
return {
...task,
status: "uploading" as const,
progress: progress.percent,
transferredBytes: progress.transferred,
speed: progress.speed,
totalBytes,
progress: clampedPercent,
transferredBytes: clampedTransferred,
speed: Number.isFinite(progress.speed) && progress.speed > 0 ? progress.speed : 0,
};
})
);
@@ -453,10 +462,18 @@ export const useSftpModalTransfers = ({
task.id === transferId
? {
...task,
transferredBytes: transferred,
totalBytes: total,
progress: total > 0 ? Math.round((transferred / total) * 100) : 0,
speed,
transferredBytes: Math.max(
task.transferredBytes,
Math.min(transferred, total > 0 ? total : transferred)
),
totalBytes: total > 0 ? total : task.totalBytes,
progress: (() => {
const effectiveTotal = total > 0 ? total : task.totalBytes;
if (effectiveTotal <= 0) return task.progress;
const percent = (Math.max(task.transferredBytes, transferred) / effectiveTotal) * 100;
return Math.max(task.progress, Math.min(percent, 100));
})(),
speed: Number.isFinite(speed) && speed > 0 ? speed : 0,
}
: task
)
@@ -467,7 +484,13 @@ export const useSftpModalTransfers = ({
setUploadTasks(prev =>
prev.map(task =>
task.id === transferId
? { ...task, status: "completed" as const, progress: 100 }
? {
...task,
status: "completed" as const,
progress: 100,
transferredBytes: task.totalBytes > 0 ? task.totalBytes : task.transferredBytes,
speed: 0,
}
: task
)
);

View File

@@ -88,6 +88,8 @@ export const useIsPaneActive = (side: "left" | "right", paneId: string): boolean
export interface SftpContextValue {
// Hosts list for connection picker
hosts: Host[];
// Host updater for bookmark persistence
updateHosts: (hosts: Host[]) => void;
// Drag state (shared between panes)
draggedFiles: { name: string; isDirectory: boolean; side: "left" | "right" }[] | null;
@@ -132,6 +134,12 @@ export const useSftpHosts = () => {
return context.hosts;
};
// Hook to get host updater
export const useSftpUpdateHosts = () => {
const context = useSftpContext();
return context.updateHosts;
};
// Hook to get showHiddenFiles setting
export const useSftpShowHiddenFiles = (): boolean => {
const context = useSftpContext();
@@ -140,6 +148,7 @@ export const useSftpShowHiddenFiles = (): boolean => {
interface SftpContextProviderProps {
hosts: Host[];
updateHosts: (hosts: Host[]) => void;
draggedFiles: { name: string; isDirectory: boolean; side: "left" | "right" }[] | null;
dragCallbacks: SftpDragCallbacks;
leftCallbacks: SftpPaneCallbacks;
@@ -150,6 +159,7 @@ interface SftpContextProviderProps {
export const SftpContextProvider: React.FC<SftpContextProviderProps> = ({
hosts,
updateHosts,
draggedFiles,
dragCallbacks,
leftCallbacks,
@@ -162,13 +172,14 @@ export const SftpContextProvider: React.FC<SftpContextProviderProps> = ({
const value = useMemo<SftpContextValue>(
() => ({
hosts,
updateHosts,
draggedFiles,
dragCallbacks,
leftCallbacks,
rightCallbacks,
showHiddenFiles,
}),
[hosts, draggedFiles, dragCallbacks, leftCallbacks, rightCallbacks, showHiddenFiles],
[hosts, updateHosts, draggedFiles, dragCallbacks, leftCallbacks, rightCallbacks, showHiddenFiles],
);
return <SftpContext.Provider value={value}>{children}</SftpContext.Provider>;

View File

@@ -1,12 +1,13 @@
import React from "react";
import { ChevronLeft, FilePlus, Folder, FolderPlus, Home, RefreshCw, Search, X } from "lucide-react";
import { Bookmark, Check, ChevronLeft, FilePlus, Folder, FolderPlus, Home, Languages, RefreshCw, Search, Trash2, X } from "lucide-react";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select";
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from "../ui/popover";
import { cn } from "../../lib/utils";
import { SftpBreadcrumb } from "./index";
import type { SftpFilenameEncoding } from "../../types";
import type { SftpPane } from "../../application/state/sftp/types";
import type { SftpBookmark } from "../../domain/models";
interface SftpPaneToolbarProps {
t: (key: string, params?: Record<string, unknown>) => string;
@@ -39,6 +40,12 @@ interface SftpPaneToolbarProps {
setFileNameError: (value: string | null) => void;
setShowNewFileDialog: (open: boolean) => void;
setShowNewFolderDialog: (open: boolean) => void;
// Bookmark props
bookmarks: SftpBookmark[];
isCurrentPathBookmarked: boolean;
onToggleBookmark: () => void;
onNavigateToBookmark: (path: string) => void;
onDeleteBookmark: (id: string) => void;
}
export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
@@ -72,6 +79,11 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
setFileNameError,
setShowNewFileDialog,
setShowNewFolderDialog,
bookmarks,
isCurrentPathBookmarked,
onToggleBookmark,
onNavigateToBookmark,
onDeleteBookmark,
}) => (
<>
{/* Toolbar - always visible when connected */}
@@ -154,21 +166,113 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
</div>
)}
{/* Bookmark button with dropdown */}
{!pane.connection?.isLocal && (
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className={cn("h-5 w-5 shrink-0", isCurrentPathBookmarked && "text-yellow-500")}
title={isCurrentPathBookmarked ? t("sftp.bookmark.remove") : t("sftp.bookmark.add")}
onClick={(e) => {
// If not bookmarked, toggle directly instead of opening popover
if (!isCurrentPathBookmarked && bookmarks.length === 0) {
e.preventDefault();
onToggleBookmark();
}
}}
>
<Bookmark size={12} fill={isCurrentPathBookmarked ? "currentColor" : "none"} />
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-0" align="start">
<div className="p-2 border-b border-border/40">
<Button
variant={isCurrentPathBookmarked ? "secondary" : "ghost"}
size="sm"
className="w-full justify-start text-xs h-7"
onClick={onToggleBookmark}
>
<Bookmark size={12} fill={isCurrentPathBookmarked ? "currentColor" : "none"} className={cn("mr-2", isCurrentPathBookmarked && "text-yellow-500")} />
{isCurrentPathBookmarked ? t("sftp.bookmark.remove") : t("sftp.bookmark.add")}
</Button>
</div>
{bookmarks.length > 0 ? (
<div className="max-h-48 overflow-auto py-1">
{bookmarks.map((bm) => (
<div
key={bm.id}
className="flex items-center gap-1 px-2 py-1 hover:bg-secondary/60 group"
>
<button
type="button"
className="flex-1 text-left text-xs truncate font-mono"
onClick={() => onNavigateToBookmark(bm.path)}
title={bm.path}
>
{bm.label}
<span className="ml-1.5 text-muted-foreground text-[10px]">{bm.path}</span>
</button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 opacity-0 group-hover:opacity-100 shrink-0 text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
onDeleteBookmark(bm.id);
}}
>
<Trash2 size={10} />
</Button>
</div>
))}
</div>
) : (
<div className="p-3 text-xs text-muted-foreground text-center">
{t("sftp.bookmark.empty")}
</div>
)}
</PopoverContent>
</Popover>
)}
<div className="ml-auto flex items-center gap-0.5">
{!pane.connection?.isLocal && (
<Select
value={pane.filenameEncoding}
onValueChange={(value) => onSetFilenameEncoding(value as SftpFilenameEncoding)}
>
<SelectTrigger className="h-6 w-[120px] text-[10px]" title={t("sftp.encoding.label")}>
<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>
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
title={t("sftp.encoding.label")}
>
<Languages size={14} />
</Button>
</PopoverTrigger>
<PopoverContent className="w-36 p-1" align="end">
{(["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-xs rounded-sm hover:bg-secondary transition-colors",
pane.filenameEncoding === encoding && "bg-secondary"
)}
onClick={() => onSetFilenameEncoding(encoding)}
>
<Check
size={12}
className={cn(
"shrink-0",
pane.filenameEncoding === encoding ? "opacity-100" : "opacity-0"
)}
/>
{t(`sftp.encoding.${encoding === "utf-8" ? "utf8" : encoding}`)}
</button>
</PopoverClose>
))}
</PopoverContent>
</Popover>
)}
<Button
variant="ghost"

View File

@@ -1,4 +1,4 @@
import React, { memo, useEffect, useMemo, useRef, useState, useTransition } from "react";
import React, { memo, useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react";
import { useI18n } from "../../application/i18n/I18nProvider";
import { logger } from "../../lib/logger";
import { useRenderTracker } from "../../lib/useRenderTracker";
@@ -13,8 +13,10 @@ import {
useSftpHosts,
useSftpPaneCallbacks,
useSftpShowHiddenFiles,
useSftpUpdateHosts,
} from "./index";
import type { SftpPane } from "../../application/state/sftp/types";
import type { Host } from "../../domain/models";
import { useSftpPaneDialogs } from "./hooks/useSftpPaneDialogs";
import { useSftpPaneDragAndSelect } from "./hooks/useSftpPaneDragAndSelect";
import { useSftpPaneFiles } from "./hooks/useSftpPaneFiles";
@@ -22,6 +24,7 @@ import { useSftpPanePath } from "./hooks/useSftpPanePath";
import { useSftpPaneSorting } from "./hooks/useSftpPaneSorting";
import { useSftpPaneVirtualList } from "./hooks/useSftpPaneVirtualList";
import { useSftpDialogActionHandler } from "./hooks/useSftpDialogAction";
import { useSftpBookmarks } from "./hooks/useSftpBookmarks";
interface SftpPaneWrapperProps {
side: "left" | "right";
@@ -84,6 +87,28 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
});
const { sortField, sortOrder, columnWidths, handleSort, handleResizeStart } = useSftpPaneSorting();
// Bookmark support
const updateHosts = useSftpUpdateHosts();
const currentHost = useMemo(
() => hosts.find((h) => h.id === pane.connection?.hostId),
[hosts, pane.connection?.hostId],
);
const onUpdateHost = useCallback(
(updated: Host) => updateHosts(hosts.map((h) => (h.id === updated.id ? updated : h))),
[hosts, updateHosts],
);
const {
bookmarks,
isCurrentPathBookmarked,
toggleBookmark,
deleteBookmark,
} = useSftpBookmarks({
host: currentHost,
currentPath: pane.connection?.currentPath,
onUpdateHost,
});
const { filteredFiles, sortedDisplayFiles } = useSftpPaneFiles({
files: pane.files,
filter: pane.filter,
@@ -293,6 +318,11 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
setFileNameError={setFileNameError}
setShowNewFileDialog={setShowNewFileDialog}
setShowNewFolderDialog={setShowNewFolderDialog}
bookmarks={bookmarks}
isCurrentPathBookmarked={isCurrentPathBookmarked}
onToggleBookmark={toggleBookmark}
onNavigateToBookmark={callbacks.onNavigateTo}
onDeleteBookmark={deleteBookmark}
/>
<SftpPaneFileList

View File

@@ -3,19 +3,19 @@
*/
import {
ArrowDown,
CheckCircle2,
FolderUp,
Loader2,
RefreshCw,
X,
XCircle,
ArrowDown,
CheckCircle2,
FolderUp,
Loader2,
RefreshCw,
X,
XCircle,
} from 'lucide-react';
import React,{ memo, useRef, useEffect } from 'react';
import React, { memo } from 'react';
import { cn } from '../../lib/utils';
import { TransferTask } from '../../types';
import { Button } from '../ui/button';
import { formatSpeed,formatTransferBytes } from './utils';
import { formatSpeed, formatTransferBytes } from './utils';
interface SftpTransferItemProps {
task: TransferTask;
@@ -27,49 +27,13 @@ interface SftpTransferItemProps {
const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({ task, onCancel, onRetry, onDismiss }) => {
const progress = task.totalBytes > 0 ? Math.min((task.transferredBytes / task.totalBytes) * 100, 100) : 0;
// Use refs to store stable display values and prevent flickering
const lastSpeedRef = useRef<number>(0);
const lastSpeedTimeRef = useRef<number>(Date.now());
const displaySpeedRef = useRef<string>('');
// Update speed display with smoothing - only update if speed is stable for a moment
useEffect(() => {
if (task.status !== 'transferring') {
displaySpeedRef.current = '';
lastSpeedRef.current = 0;
return;
}
const now = Date.now();
const timeSinceLastUpdate = now - lastSpeedTimeRef.current;
// Only update speed display if:
// 1. Speed is above threshold (100 bytes/s)
// 2. Either it's been at least 500ms since last update, or speed changed significantly (>50%)
if (task.speed > 100) {
const speedChange = Math.abs(task.speed - lastSpeedRef.current);
const significantChange = lastSpeedRef.current > 0 && speedChange / lastSpeedRef.current > 0.5;
if (timeSinceLastUpdate >= 500 || significantChange || lastSpeedRef.current === 0) {
lastSpeedRef.current = task.speed;
lastSpeedTimeRef.current = now;
displaySpeedRef.current = formatSpeed(task.speed);
}
} else if (task.speed === 0 && lastSpeedRef.current > 0) {
// Don't immediately clear speed when it drops to 0
// Keep showing last speed for a short period
if (timeSinceLastUpdate >= 1000) {
lastSpeedRef.current = 0;
displaySpeedRef.current = '';
}
}
}, [task.speed, task.status]);
// Calculate remaining time based on stable speed
// Calculate remaining time from backend-reported sliding-window speed
const remainingBytes = task.totalBytes - task.transferredBytes;
const stableSpeed = lastSpeedRef.current > 0 ? lastSpeedRef.current : task.speed;
const remainingTime = stableSpeed > 0
? Math.ceil(remainingBytes / stableSpeed)
const effectiveSpeed = task.status === 'transferring'
? (Number.isFinite(task.speed) && task.speed > 0 ? task.speed : 0)
: 0;
const remainingTime = effectiveSpeed > 0
? Math.ceil(remainingBytes / effectiveSpeed)
: 0;
const remainingFormatted = remainingTime > 60
? `~${Math.ceil(remainingTime / 60)}m left`
@@ -84,8 +48,7 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({ task, onCancel
? formatTransferBytes(task.totalBytes)
: '';
// Use the stable display speed
const speedFormatted = displaySpeedRef.current;
const speedFormatted = effectiveSpeed > 0 ? formatSpeed(effectiveSpeed) : '';
return (
<div className="flex items-center gap-3 px-4 py-2.5 bg-background/60 border-t border-border/40 backdrop-blur-sm">
@@ -196,17 +159,15 @@ const arePropsEqual = (
// Always re-render on fileName change
if (prev.fileName !== next.fileName) return false;
// For transferring status, throttle updates based on progress
// For transferring status, allow frequent re-renders for smooth progress bar
if (next.status === 'transferring') {
// Re-render if progress changed by more than 0.5%
// Re-render on any meaningful progress change (0.1% for smooth bar animation)
const prevProgress = prev.totalBytes > 0 ? (prev.transferredBytes / prev.totalBytes) * 100 : 0;
const nextProgress = next.totalBytes > 0 ? (next.transferredBytes / next.totalBytes) * 100 : 0;
if (Math.abs(nextProgress - prevProgress) >= 0.5) return false;
if (Math.abs(nextProgress - prevProgress) >= 0.1) return false;
// Re-render periodically for speed updates (every ~500ms based on speed changes)
// The component uses refs to smooth speed display, so we allow more frequent renders
const speedDiff = Math.abs(next.speed - prev.speed);
if (speedDiff > 1000) return false; // Re-render if speed changed by more than 1KB/s
// Re-render on any speed change (backend already smooths via sliding window)
if (next.speed !== prev.speed) return false;
}
// For pending status, don't re-render unless status changes

View File

@@ -0,0 +1,69 @@
import { useCallback, useMemo } from "react";
import type { Host, SftpBookmark } from "../../../domain/models";
interface UseSftpBookmarksParams {
host: Host | undefined;
currentPath: string | undefined;
onUpdateHost: ((host: Host) => void) | undefined;
}
interface UseSftpBookmarksResult {
bookmarks: SftpBookmark[];
isCurrentPathBookmarked: boolean;
toggleBookmark: () => void;
deleteBookmark: (id: string) => void;
}
export const useSftpBookmarks = ({
host,
currentPath,
onUpdateHost,
}: UseSftpBookmarksParams): UseSftpBookmarksResult => {
const bookmarks = useMemo(() => host?.sftpBookmarks ?? [], [host]);
const isCurrentPathBookmarked = useMemo(
() =>
!!currentPath && bookmarks.some((b) => b.path === currentPath),
[currentPath, bookmarks],
);
const updateHostBookmarks = useCallback(
(newBookmarks: SftpBookmark[]) => {
if (!host || !onUpdateHost) return;
onUpdateHost({ ...host, sftpBookmarks: newBookmarks });
},
[host, onUpdateHost],
);
const toggleBookmark = useCallback(() => {
if (!currentPath || !host) return;
if (isCurrentPathBookmarked) {
updateHostBookmarks(bookmarks.filter((b) => b.path !== currentPath));
} else {
const label =
currentPath === "/"
? "/"
: currentPath.split("/").filter(Boolean).pop() || currentPath;
const newBookmark: SftpBookmark = {
id: `bm-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
path: currentPath,
label,
};
updateHostBookmarks([...bookmarks, newBookmark]);
}
}, [currentPath, host, isCurrentPathBookmarked, bookmarks, updateHostBookmarks]);
const deleteBookmark = useCallback(
(id: string) => {
updateHostBookmarks(bookmarks.filter((b) => b.id !== id));
},
[bookmarks, updateHostBookmarks],
);
return {
bookmarks,
isCurrentPathBookmarked,
toggleBookmark,
deleteBookmark,
};
};

View File

@@ -238,7 +238,7 @@ export const useSftpKeyboardShortcuts = ({
case "sftpSelectAll": {
// Select all files in the current pane
const term = pane.filter.trim().toLowerCase();
let visibleFiles = filterHiddenFiles(pane.files, showHiddenFiles);
let visibleFiles = filterHiddenFiles(pane.files, showHiddenFiles, pane.connection.isLocal);
if (term) {
visibleFiles = visibleFiles.filter(
(f) => f.name === ".." || f.name.toLowerCase().includes(term),

View File

@@ -29,12 +29,12 @@ export const useSftpPaneFiles = ({
}: UseSftpPaneFilesParams): UseSftpPaneFilesResult => {
const filteredFiles = useMemo(() => {
const term = filter.trim().toLowerCase();
let nextFiles = filterHiddenFiles(files, showHiddenFiles);
let nextFiles = filterHiddenFiles(files, showHiddenFiles, connection?.isLocal);
if (!term) return nextFiles;
return nextFiles.filter(
(f) => f.name === ".." || f.name.toLowerCase().includes(term),
);
}, [files, filter, showHiddenFiles]);
}, [files, filter, showHiddenFiles, connection?.isLocal]);
const displayFiles = useMemo(() => {
if (!connection) return [];
@@ -50,7 +50,7 @@ export const useSftpPaneFiles = ({
lastModified: 0,
lastModifiedFormatted: "--",
};
return [parentEntry, ...filteredFiles.filter((f) => f.name !== "..")] ;
return [parentEntry, ...filteredFiles.filter((f) => f.name !== "..")];
}, [connection, filteredFiles]);
const sortedDisplayFiles = useMemo(() => {

View File

@@ -6,9 +6,9 @@
// Utilities
export {
formatBytes,formatDate,
formatSpeed,formatTransferBytes,getFileIcon,isNavigableDirectory,isWindowsHiddenFile,filterHiddenFiles,type ColumnWidths,type SortField,
type SortOrder
formatBytes, formatDate,
formatSpeed, formatTransferBytes, getFileIcon, isNavigableDirectory, isHiddenFile, isWindowsHiddenFile, filterHiddenFiles, type ColumnWidths, type SortField,
type SortOrder
} from './utils';
// Context
@@ -18,6 +18,7 @@ export {
useSftpPaneCallbacks,
useSftpDrag,
useSftpHosts,
useSftpUpdateHosts,
useSftpShowHiddenFiles,
useActiveTabId,
useIsPaneActive,

View File

@@ -3,23 +3,23 @@
*/
import {
Database,
ExternalLink,
File,
FileArchive,
FileAudio,
FileCode,
FileImage,
FileSpreadsheet,
FileText,
FileType,
FileVideo,
Folder,
Globe,
Key,
Lock,
Settings,
Terminal,
Database,
ExternalLink,
File,
FileArchive,
FileAudio,
FileCode,
FileImage,
FileSpreadsheet,
FileText,
FileType,
FileVideo,
Folder,
Globe,
Key,
Lock,
Settings,
Terminal,
} from 'lucide-react';
import React from 'react';
import { SftpFileEntry } from '../../types';
@@ -74,7 +74,7 @@ export const formatSpeed = (bytesPerSecond: number): string => {
*/
export const getFileIcon = (entry: SftpFileEntry): React.ReactElement => {
if (entry.type === 'directory') return React.createElement(Folder, { size: 14 });
// For symlink files (not directories), show a special symlink icon
if (entry.type === 'symlink' && entry.linkTarget !== 'directory') {
return React.createElement(ExternalLink, { size: 14, className: "text-cyan-500" });
@@ -189,31 +189,42 @@ export const isNavigableDirectory = (entry: SftpFileEntry): boolean => {
};
/**
* Check if a file is hidden on Windows
* Only applies to local Windows filesystem where the hidden attribute is set
* The ".." parent directory entry is never considered hidden
*
* Note: On Unix/Linux, there's no system-level hidden file concept.
* Dotfiles are just a convention, not actual hidden files, so we don't filter them.
* Check if a file is hidden
* - Windows: checks the `hidden` attribute (set by localFsBridge)
* - Unix/Linux (remote): also treats dotfiles (names starting with '.') as hidden
* The ".." parent directory entry is never considered hidden.
*
* @param isLocal When true, only the Windows hidden attribute is checked.
* This prevents `.gitignore` etc. from disappearing on local Windows panes.
*/
export const isWindowsHiddenFile = <T extends { name: string; hidden?: boolean }>(file: T): boolean => {
export const isHiddenFile = <T extends { name: string; hidden?: boolean }>(
file: T,
isLocal?: boolean
): boolean => {
if (file.name === "..") return false;
return file.hidden === true;
// Windows hidden attribute — always checked
if (file.hidden === true) return true;
// Unix/Linux dotfile convention — only on remote/non-local connections
if (!isLocal && file.name.startsWith(".")) return true;
return false;
};
/** @deprecated Use isHiddenFile instead */
export const isWindowsHiddenFile = <T extends { name: string; hidden?: boolean }>(file: T): boolean =>
isHiddenFile(file, true);
/**
* Filter files based on Windows hidden file visibility setting
* Only filters files with the Windows hidden attribute set
* Always preserves ".." parent directory entry
*
* This setting only affects local Windows filesystem browsing.
* On Unix/Linux systems and remote SFTP connections, all files are shown
* because there's no system-level hidden file concept (dotfiles are just a convention).
* Filter files based on hidden file visibility setting.
* Filters Windows hidden files and, on remote connections, Unix/Linux dotfiles.
* Always preserves ".." parent directory entry.
*
* @param isLocal Pass true for local filesystem panes to skip dotfile filtering.
*/
export const filterHiddenFiles = <T extends { name: string; hidden?: boolean }>(
files: T[],
showHiddenFiles: boolean
showHiddenFiles: boolean,
isLocal?: boolean
): T[] => {
if (showHiddenFiles) return files;
return files.filter((f) => !isWindowsHiddenFile(f));
return files.filter((f) => !isHiddenFile(f, isLocal));
};

View File

@@ -0,0 +1,187 @@
/**
* Custom Theme Editor Panel
* Inline color editor for creating/editing custom terminal themes.
* Uses native <input type="color"> for zero-dependency color picking.
*/
import React, { useCallback, memo } from 'react';
import { TerminalTheme } from '../../domain/models';
import { useI18n } from '../../application/i18n/I18nProvider';
interface ColorFieldDef {
key: keyof TerminalTheme['colors'];
labelKey: string;
}
const GENERAL_COLORS: ColorFieldDef[] = [
{ key: 'background', labelKey: 'terminal.customTheme.color.background' },
{ key: 'foreground', labelKey: 'terminal.customTheme.color.foreground' },
{ key: 'cursor', labelKey: 'terminal.customTheme.color.cursor' },
{ key: 'selection', labelKey: 'terminal.customTheme.color.selection' },
];
const NORMAL_COLORS: ColorFieldDef[] = [
{ key: 'black', labelKey: 'terminal.customTheme.color.black' },
{ key: 'red', labelKey: 'terminal.customTheme.color.red' },
{ key: 'green', labelKey: 'terminal.customTheme.color.green' },
{ key: 'yellow', labelKey: 'terminal.customTheme.color.yellow' },
{ key: 'blue', labelKey: 'terminal.customTheme.color.blue' },
{ key: 'magenta', labelKey: 'terminal.customTheme.color.magenta' },
{ key: 'cyan', labelKey: 'terminal.customTheme.color.cyan' },
{ key: 'white', labelKey: 'terminal.customTheme.color.white' },
];
const BRIGHT_COLORS: ColorFieldDef[] = [
{ key: 'brightBlack', labelKey: 'terminal.customTheme.color.brightBlack' },
{ key: 'brightRed', labelKey: 'terminal.customTheme.color.brightRed' },
{ key: 'brightGreen', labelKey: 'terminal.customTheme.color.brightGreen' },
{ key: 'brightYellow', labelKey: 'terminal.customTheme.color.brightYellow' },
{ key: 'brightBlue', labelKey: 'terminal.customTheme.color.brightBlue' },
{ key: 'brightMagenta', labelKey: 'terminal.customTheme.color.brightMagenta' },
{ key: 'brightCyan', labelKey: 'terminal.customTheme.color.brightCyan' },
{ key: 'brightWhite', labelKey: 'terminal.customTheme.color.brightWhite' },
];
const ColorInput = memo(({
label,
value,
onChange,
}: {
label: string;
value: string;
onChange: (value: string) => void;
}) => {
// Local state for text input — allows partial hex while typing
const [textValue, setTextValue] = React.useState(value);
// Sync external value changes into local state
React.useEffect(() => { setTextValue(value); }, [value]);
const handleTextChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = e.target.value;
if (!/^#[0-9a-fA-F]{0,6}$/.test(v)) return;
setTextValue(v);
// Only commit complete hex values (#rgb or #rrggbb)
if (/^#[0-9a-fA-F]{3}$/.test(v) || /^#[0-9a-fA-F]{6}$/.test(v)) {
// Normalize #rgb to #rrggbb
const normalized = v.length === 4
? `#${v[1]}${v[1]}${v[2]}${v[2]}${v[3]}${v[3]}`
: v;
onChange(normalized);
}
};
// On blur, revert to the last committed value if incomplete
const handleBlur = () => { setTextValue(value); };
return (
<div className="flex items-center gap-2">
<div className="relative">
<input
type="color"
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-6 h-6 rounded cursor-pointer border border-border/50 p-0"
style={{ appearance: 'none', WebkitAppearance: 'none', background: value }}
/>
</div>
<span className="text-[10px] text-muted-foreground flex-1 truncate">{label}</span>
<input
type="text"
value={textValue}
onChange={handleTextChange}
onBlur={handleBlur}
className="w-[68px] text-[10px] font-mono px-1.5 py-0.5 rounded border border-border bg-background text-foreground uppercase"
spellCheck={false}
/>
</div>
);
});
ColorInput.displayName = 'ColorInput';
interface CustomThemeEditorProps {
theme: TerminalTheme;
onChange: (theme: TerminalTheme) => void;
onBack?: () => void; // kept for API compat but no longer rendered
isNew?: boolean;
}
export const CustomThemeEditor: React.FC<CustomThemeEditorProps> = ({
theme,
onChange,
onBack: _onBack,
isNew: _isNew,
}) => {
const { t } = useI18n();
const updateColor = useCallback((key: keyof TerminalTheme['colors'], value: string) => {
onChange({
...theme,
colors: { ...theme.colors, [key]: value },
});
}, [theme, onChange]);
const updateName = useCallback((name: string) => {
onChange({ ...theme, name });
}, [theme, onChange]);
const toggleType = useCallback(() => {
onChange({ ...theme, type: theme.type === 'dark' ? 'light' : 'dark' });
}, [theme, onChange]);
const renderColorGroup = (title: string, fields: ColorFieldDef[]) => (
<div>
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mb-1.5 font-semibold">
{title}
</div>
<div className="space-y-1">
{fields.map(({ key, labelKey }) => (
<ColorInput
key={key}
label={t(labelKey)}
value={theme.colors[key]}
onChange={(v) => updateColor(key, v)}
/>
))}
</div>
</div>
);
return (
<div className="flex flex-col h-full">
{/* Name + Type */}
<div className="p-2 space-y-2 border-b border-border shrink-0">
<div>
<label className="text-[9px] uppercase tracking-wider text-muted-foreground font-semibold">
{t('terminal.customTheme.name')}
</label>
<input
type="text"
value={theme.name}
onChange={(e) => updateName(e.target.value)}
className="w-full mt-1 text-xs px-2 py-1.5 rounded border border-border bg-background text-foreground"
placeholder={t('terminal.customTheme.namePlaceholder')}
/>
</div>
<div className="flex items-center gap-2">
<label className="text-[9px] uppercase tracking-wider text-muted-foreground font-semibold flex-1">
{t('terminal.customTheme.type')}
</label>
<button
onClick={toggleType}
className="text-[10px] px-2 py-0.5 rounded border border-border bg-muted/30 text-foreground hover:bg-muted transition-colors capitalize"
>
{theme.type}
</button>
</div>
</div>
{/* Color Groups */}
<div className="flex-1 overflow-y-auto p-2 space-y-3">
{renderColorGroup(t('terminal.customTheme.group.general'), GENERAL_COLORS)}
{renderColorGroup(t('terminal.customTheme.group.normal'), NORMAL_COLORS)}
{renderColorGroup(t('terminal.customTheme.group.bright'), BRIGHT_COLORS)}
</div>
</div>
);
};

View File

@@ -0,0 +1,230 @@
/**
* Dedicated Custom Theme Editor Modal
* Standalone modal with two-column layout: editor (left) + preview (right)
* Opens on top of ThemeCustomizeModal for creating/editing custom themes.
*/
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { Trash2, X } from 'lucide-react';
import { TerminalTheme } from '../../domain/models';
import { useI18n } from '../../application/i18n/I18nProvider';
import { CustomThemeEditor } from './CustomThemeEditor';
import { Button } from '../ui/button';
interface CustomThemeModalProps {
open: boolean;
theme: TerminalTheme;
isNew: boolean;
onSave: (theme: TerminalTheme) => void;
onDelete?: (themeId: string) => void;
onCancel: () => void;
}
// Minimal terminal preview for the right panel
const MiniPreview: React.FC<{ theme: TerminalTheme }> = ({ theme }) => (
<div
className="rounded-lg border border-border/50 overflow-hidden font-mono text-[11px] leading-relaxed flex-1"
style={{ backgroundColor: theme.colors.background, color: theme.colors.foreground }}
>
{/* Title bar */}
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-black/20">
<div className="w-2.5 h-2.5 rounded-full bg-red-500/80" />
<div className="w-2.5 h-2.5 rounded-full bg-yellow-500/80" />
<div className="w-2.5 h-2.5 rounded-full bg-green-500/80" />
<span className="flex-1 text-center text-[10px] opacity-50">Terminal Preview</span>
</div>
<div className="p-3 space-y-0.5">
<div>
<span style={{ color: theme.colors.green }}>user@server</span>
<span style={{ color: theme.colors.foreground }}>:</span>
<span style={{ color: theme.colors.blue }}>~</span>
<span style={{ color: theme.colors.foreground }}>$ neofetch</span>
</div>
<div style={{ color: theme.colors.cyan }}>{' ,g$$P" """Y$$."". '}</div>
<div>
<span style={{ color: theme.colors.cyan }}>{` ,$$P' `}</span>
<span style={{ color: theme.colors.blue }}>OS</span>
<span>: Ubuntu 22.04 LTS</span>
</div>
<div>
<span style={{ color: theme.colors.cyan }}>{` '',$$P `}</span>
<span style={{ color: theme.colors.blue }}>Kernel</span>
<span>: 5.15.0-generic</span>
</div>
<div>
<span style={{ color: theme.colors.cyan }}>{` d$$' `}</span>
<span style={{ color: theme.colors.blue }}>Shell</span>
<span>: bash 5.1.16</span>
</div>
<div>
<span style={{ color: theme.colors.cyan }}>{` $$P `}</span>
<span style={{ color: theme.colors.blue }}>Memory</span>
<span>: 4.2G / 16G (26%)</span>
</div>
<div>&nbsp;</div>
{/* ANSI color palette */}
<div className="flex gap-0.5">
{[theme.colors.black, theme.colors.red, theme.colors.green, theme.colors.yellow,
theme.colors.blue, theme.colors.magenta, theme.colors.cyan, theme.colors.white].map((c, i) => (
<div key={i} className="w-3.5 h-2.5 rounded-sm" style={{ backgroundColor: c }} />
))}
</div>
<div className="flex gap-0.5">
{[theme.colors.brightBlack, theme.colors.brightRed, theme.colors.brightGreen, theme.colors.brightYellow,
theme.colors.brightBlue, theme.colors.brightMagenta, theme.colors.brightCyan, theme.colors.brightWhite].map((c, i) => (
<div key={i} className="w-3.5 h-2.5 rounded-sm" style={{ backgroundColor: c }} />
))}
</div>
<div>&nbsp;</div>
<div>
<span style={{ color: theme.colors.green }}>user@server</span>
<span>:</span>
<span style={{ color: theme.colors.blue }}>~</span>
<span>$ </span>
<span style={{ backgroundColor: theme.colors.cursor, color: theme.colors.background }}>&nbsp;</span>
</div>
</div>
</div>
);
export const CustomThemeModal: React.FC<CustomThemeModalProps> = ({
open,
theme: initialTheme,
isNew,
onSave,
onDelete,
onCancel,
}) => {
const { t } = useI18n();
const [editingTheme, setEditingTheme] = useState<TerminalTheme>(initialTheme);
// Reset when opened with a new theme
React.useEffect(() => {
if (open) {
setEditingTheme({ ...initialTheme, colors: { ...initialTheme.colors } });
}
}, [open, initialTheme]);
const handleChange = useCallback((theme: TerminalTheme) => {
setEditingTheme(theme);
}, []);
const handleSave = useCallback(() => {
onSave(editingTheme);
}, [editingTheme, onSave]);
const handleDelete = useCallback(() => {
onDelete?.(editingTheme.id);
}, [editingTheme.id, onDelete]);
// Dummy back handler — in the standalone modal, back = cancel
const handleBack = useCallback(() => {
onCancel();
}, [onCancel]);
const themeInfo = useMemo(() => {
return `${editingTheme.name}${editingTheme.type.toUpperCase()}`;
}, [editingTheme.name, editingTheme.type]);
// Handle Escape key — close child editor
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
onCancel();
}
};
document.addEventListener('keydown', handleKeyDown, true); // capture phase
return () => document.removeEventListener('keydown', handleKeyDown, true);
}, [open, onCancel]);
if (!open) return null;
const modalContent = (
<div
className="fixed inset-0 z-[300] flex items-center justify-center"
>
{/* Backdrop — clicking it dismisses the modal */}
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onCancel} />
{/* Modal */}
<div className="relative z-10 bg-popover/95 backdrop-blur-xl rounded-xl shadow-2xl border border-border/50 flex flex-col"
style={{ width: 'min(820px, 90vw)', height: 'min(600px, 85vh)' }}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-3 shrink-0 border-b border-border">
<h2 className="text-sm font-semibold text-foreground">
{isNew ? t('terminal.customTheme.newTitle') : t('terminal.customTheme.editTitle')}
</h2>
<button
onClick={onCancel}
className="w-7 h-7 rounded-md flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
<X size={16} />
</button>
</div>
{/* Body: Editor (left) + Preview (right) */}
<div className="flex flex-1 min-h-0">
{/* Left: Editor */}
<div className="w-[300px] shrink-0 border-r border-border flex flex-col min-h-0">
<CustomThemeEditor
theme={editingTheme}
onChange={handleChange}
onBack={handleBack}
isNew={isNew}
/>
</div>
{/* Right: Preview */}
<div className="flex-1 flex flex-col p-4 min-w-0">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-3 font-semibold">
{t('terminal.themeModal.livePreview')}
</div>
<MiniPreview theme={editingTheme} />
<div className="mt-2 text-xs text-muted-foreground text-center">
{themeInfo}
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center gap-3 px-5 py-3 shrink-0 border-t border-border bg-muted/20">
{/* Delete button (only for existing themes) */}
{!isNew && onDelete && (
<Button
variant="ghost"
onClick={handleDelete}
className="h-9 text-destructive hover:text-destructive hover:bg-destructive/10 gap-1.5"
>
<Trash2 size={14} />
{t('terminal.customTheme.delete')}
</Button>
)}
<div className="flex-1" />
<Button
variant="ghost"
onClick={onCancel}
className="h-9 px-5"
>
{t('common.cancel')}
</Button>
<Button
onClick={handleSave}
className="h-9 px-6"
>
{t('common.save')}
</Button>
</div>
</div>
</div>
);
return createPortal(modalContent, document.body);
};
export default CustomThemeModal;

View File

@@ -0,0 +1,167 @@
/**
* Terminal Compose Bar
* A modern text input bar for composing commands before sending them.
* Supports pre-reviewing passwords/commands and broadcasting to multiple sessions.
*/
import { Radio, Send, X } from 'lucide-react';
import React, { useCallback, useEffect, useRef } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
export interface TerminalComposeBarProps {
onSend: (text: string) => void;
onClose: () => void;
isBroadcastEnabled?: boolean;
themeColors?: {
background: string;
foreground: string;
};
}
export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
onSend,
onClose,
isBroadcastEnabled,
themeColors,
}) => {
const { t } = useI18n();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const isComposingRef = useRef(false);
// Auto-focus on mount
useEffect(() => {
// Small delay to ensure the element is rendered
const timer = setTimeout(() => textareaRef.current?.focus(), 50);
return () => clearTimeout(timer);
}, []);
// Auto-resize textarea
const handleInput = useCallback(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = `${Math.min(el.scrollHeight, 120)}px`;
}, []);
const handleSend = useCallback(() => {
const el = textareaRef.current;
if (!el) return;
const text = el.value;
if (!text) return;
onSend(text);
el.value = '';
el.style.height = 'auto';
el.focus();
}, [onSend]);
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey && !isComposingRef.current) {
e.preventDefault();
handleSend();
} else if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
}, [handleSend, onClose]);
const bg = themeColors?.background ?? '#0a0a0a';
const fg = themeColors?.foreground ?? '#d4d4d4';
return (
<div
className="flex-shrink-0"
style={{
background: `linear-gradient(to top, ${bg}, color-mix(in srgb, ${fg} 4%, ${bg} 96%))`,
borderTop: `1px solid color-mix(in srgb, ${fg} 10%, ${bg} 90%)`,
borderRadius: '0 0 8px 8px',
padding: '6px 10px',
}}
>
<div className="flex items-center gap-2">
{/* Broadcast indicator */}
{isBroadcastEnabled && (
<div
className="flex items-center"
title={t("terminal.composeBar.broadcasting")}
>
<Radio size={14} className="text-amber-400 animate-pulse" />
</div>
)}
{/* Input field */}
<textarea
ref={textareaRef}
className={cn(
"flex-1 min-w-0 resize-none rounded-md px-3 py-1.5 text-xs font-mono leading-relaxed",
"outline-none transition-all duration-200",
"placeholder:opacity-40",
)}
style={{
backgroundColor: `color-mix(in srgb, ${fg} 6%, ${bg} 94%)`,
color: fg,
border: `1px solid color-mix(in srgb, ${fg} 25%, ${bg} 75%)`,
minHeight: '28px',
maxHeight: '120px',
boxShadow: `inset 0 1px 3px color-mix(in srgb, ${bg} 80%, transparent)`,
}}
rows={1}
placeholder={t("terminal.composeBar.placeholder")}
onInput={handleInput}
onKeyDown={handleKeyDown}
onFocus={(e) => {
e.currentTarget.style.borderColor = `color-mix(in srgb, ${fg} 40%, ${bg} 60%)`;
e.currentTarget.style.boxShadow = `inset 0 1px 3px color-mix(in srgb, ${bg} 80%, transparent), 0 0 0 1px color-mix(in srgb, ${fg} 8%, transparent)`;
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = `color-mix(in srgb, ${fg} 25%, ${bg} 75%)`;
e.currentTarget.style.boxShadow = `inset 0 1px 3px color-mix(in srgb, ${bg} 80%, transparent)`;
}}
onCompositionStart={() => { isComposingRef.current = true; }}
onCompositionEnd={() => { isComposingRef.current = false; }}
/>
{/* Action buttons */}
<div className="flex items-center gap-0.5">
<button
className="h-7 w-7 flex items-center justify-center rounded-md transition-colors duration-150"
style={{
color: fg,
background: `color-mix(in srgb, ${fg} 20%, ${bg} 80%)`,
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = `color-mix(in srgb, ${fg} 30%, ${bg} 70%)`;
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = `color-mix(in srgb, ${fg} 20%, ${bg} 80%)`;
}}
onClick={handleSend}
title={t("terminal.composeBar.send")}
>
<Send size={13} />
</button>
<button
className="h-7 w-7 flex items-center justify-center rounded-md transition-colors duration-150"
style={{
color: `color-mix(in srgb, ${fg} 60%, ${bg} 40%)`,
background: `color-mix(in srgb, ${fg} 12%, ${bg} 88%)`,
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = `color-mix(in srgb, ${fg} 22%, ${bg} 78%)`;
e.currentTarget.style.color = fg;
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = `color-mix(in srgb, ${fg} 12%, ${bg} 88%)`;
e.currentTarget.style.color = `color-mix(in srgb, ${fg} 60%, ${bg} 40%)`;
}}
onClick={onClose}
title={t("terminal.composeBar.close")}
>
<X size={13} />
</button>
</div>
</div>
</div>
);
};
export default TerminalComposeBar;

View File

@@ -2,7 +2,7 @@
* Terminal Toolbar
* Displays SFTP, Scripts, Theme, Highlight, Search buttons and close button in terminal status bar
*/
import { FolderInput, X, Zap, Palette, Search } from 'lucide-react';
import { FolderInput, X, Zap, Palette, Search, TextCursorInput } from 'lucide-react';
import React, { useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Snippet, Host } from '../../types';
@@ -32,6 +32,9 @@ export interface TerminalToolbarProps {
// Search functionality
isSearchOpen?: boolean;
onToggleSearch?: () => void;
// Compose bar
isComposeBarOpen?: boolean;
onToggleComposeBar?: () => void;
}
export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
@@ -53,6 +56,8 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
onClose,
isSearchOpen,
onToggleSearch,
isComposeBarOpen,
onToggleComposeBar,
}) => {
const { t } = useI18n();
const [themeModalOpen, setThemeModalOpen] = useState(false);
@@ -173,6 +178,18 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
buttonClassName={buttonBase}
/>
<Button
variant="secondary"
size="icon"
className={buttonBase}
title={t("terminal.toolbar.composeBar")}
aria-label={t("terminal.toolbar.composeBar")}
aria-pressed={isComposeBarOpen}
onClick={onToggleComposeBar}
>
<TextCursorInput size={12} />
</Button>
<Button
variant="secondary"
size="icon"

View File

@@ -7,34 +7,44 @@
* - Real-time preview: changes are applied immediately to the terminal
* - Save: persists the current settings
* - Cancel: reverts to the original settings when modal was opened
* - Custom themes: create, edit, delete, import .itermcolors
*/
import React, { useEffect, useMemo, useState, useCallback, useRef, memo } from 'react';
import { createPortal } from 'react-dom';
import { Check, Minus, Palette, Plus, Type, X } from 'lucide-react';
import { Check, Download, Minus, Palette, Pencil, Plus, Sparkles, Type, X } from 'lucide-react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { useAvailableFonts } from '../../application/state/fontStore';
import { TERMINAL_THEMES, TerminalThemeConfig } from '../../infrastructure/config/terminalThemes';
import { DEFAULT_FONT_SIZE, MIN_FONT_SIZE, MAX_FONT_SIZE, TerminalFont } from '../../infrastructure/config/fonts';
import { useCustomThemes, useCustomThemeActions } from '../../application/state/customThemeStore';
import { parseItermcolors } from '../../infrastructure/parsers/itermcolorsParser';
import { CustomThemeModal } from './CustomThemeModal';
import { Button } from '../ui/button';
import { cn } from '../../lib/utils';
import { TerminalTheme } from '../../domain/models';
type TabType = 'theme' | 'font';
type TabType = 'theme' | 'font' | 'custom';
// Memoized theme item component to prevent unnecessary re-renders
const ThemeItem = memo(({
theme,
isSelected,
onSelect
onSelect,
onEdit,
}: {
theme: TerminalThemeConfig;
isSelected: boolean;
onSelect: (id: string) => void;
onEdit?: (id: string) => void;
}) => (
<button
<div
role="button"
tabIndex={0}
onClick={() => onSelect(theme.id)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(theme.id); } }}
className={cn(
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-all',
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-all group cursor-pointer',
isSelected
? 'bg-primary/15 ring-1 ring-primary'
: 'hover:bg-muted'
@@ -53,12 +63,26 @@ const ThemeItem = memo(({
<div className={cn('text-xs font-medium truncate', isSelected ? 'text-primary' : 'text-foreground')}>
{theme.name}
</div>
<div className="text-[10px] text-muted-foreground capitalize">{theme.type}</div>
<div className="text-[10px] text-muted-foreground capitalize">
{theme.type}
{theme.isCustom && ' • custom'}
</div>
</div>
{isSelected && (
{onEdit && (
<div
role="button"
tabIndex={0}
onClick={(e) => { e.stopPropagation(); onEdit(theme.id); }}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.stopPropagation(); e.preventDefault(); onEdit(theme.id); } }}
className="w-6 h-6 rounded flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted/80 opacity-0 group-hover:opacity-100 transition-all"
>
<Pencil size={11} />
</div>
)}
{isSelected && !onEdit && (
<Check size={14} className="text-primary flex-shrink-0" />
)}
</button>
</div>
));
ThemeItem.displayName = 'ThemeItem';
@@ -176,64 +200,47 @@ const TerminalPreview = memo(({
<span style={{ color: theme.colors.foreground }}>server</span>
</div>
<div style={{ color: theme.colors.cyan }}>
{' ,g$$P" """Y$$.". '}
{' ,g$$P" """Y$$."". '}
<span style={{ color: theme.colors.foreground }}>-----------</span>
</div>
<div style={{ color: theme.colors.cyan }}>
{' ,$$P\' `$$$. '}
{` ,$$P' $$$. `}
<span style={{ color: theme.colors.blue }}>OS</span>
<span style={{ color: theme.colors.foreground }}>: Ubuntu 22.04 LTS</span>
</div>
<div style={{ color: theme.colors.cyan }}>
{'\',$$P ,ggs. `$$b: '}
{`'', $$P, ggs. $$b: `}
<span style={{ color: theme.colors.blue }}>Kernel</span>
<span style={{ color: theme.colors.foreground }}>: 5.15.0-generic</span>
</div>
<div style={{ color: theme.colors.cyan }}>
{'`d$$\' ,$P"\' . $$$ '}
{`d$$' ,$P"' . $$$ `}
<span style={{ color: theme.colors.blue }}>Uptime</span>
<span style={{ color: theme.colors.foreground }}>: 42 days, 3 hours</span>
</div>
<div style={{ color: theme.colors.cyan }}>
{' $$P d$\' , $$P '}
{` $$P d$' , $$P `}
<span style={{ color: theme.colors.blue }}>Shell</span>
<span style={{ color: theme.colors.foreground }}>: bash 5.1.16</span>
</div>
<div style={{ color: theme.colors.cyan }}>
{' $$: $$. - ,d$$\' '}
{` $$: $$. - ,d$$' `}
<span style={{ color: theme.colors.blue }}>Memory</span>
<span style={{ color: theme.colors.foreground }}>: 4.2G / 16G (26%)</span>
</div>
<div>&nbsp;</div>
<div>
<span style={{ color: theme.colors.green }}>user@server</span>
<span style={{ color: theme.colors.foreground }}>:</span>
<span style={{ color: theme.colors.blue }}>~</span>
<span style={{ color: theme.colors.foreground }}>$ </span>
<span>ls -la</span>
{/* ANSI color palette preview row */}
<div className="flex gap-0.5 mt-1">
{[theme.colors.black, theme.colors.red, theme.colors.green, theme.colors.yellow,
theme.colors.blue, theme.colors.magenta, theme.colors.cyan, theme.colors.white].map((c, i) => (
<div key={i} className="w-4 h-3 rounded-sm" style={{ backgroundColor: c }} />
))}
</div>
<div>
<span style={{ color: theme.colors.blue }}>drwxr-xr-x</span>
<span style={{ color: theme.colors.foreground }}> 5 user group </span>
<span style={{ color: theme.colors.yellow }}>4.0K</span>
<span style={{ color: theme.colors.foreground }}> Dec 12 10:30 </span>
<span style={{ color: theme.colors.blue }}>.config</span>
</div>
<div>
<span style={{ color: theme.colors.magenta }}>-rwxr-xr-x</span>
<span style={{ color: theme.colors.foreground }}> 1 user group </span>
<span style={{ color: theme.colors.yellow }}>2.1K</span>
<span style={{ color: theme.colors.foreground }}> Dec 11 15:22 </span>
<span style={{ color: theme.colors.green }}>deploy.sh</span>
</div>
<div>
<span style={{ color: theme.colors.cyan }}>lrwxrwxrwx</span>
<span style={{ color: theme.colors.foreground }}> 1 user group </span>
<span style={{ color: theme.colors.yellow }}> 24</span>
<span style={{ color: theme.colors.foreground }}> Dec 10 09:15 </span>
<span style={{ color: theme.colors.cyan }}>logs</span>
<span style={{ color: theme.colors.foreground }}> -{'>'} </span>
<span style={{ color: theme.colors.foreground }}>/var/log/app</span>
<div className="flex gap-0.5">
{[theme.colors.brightBlack, theme.colors.brightRed, theme.colors.brightGreen, theme.colors.brightYellow,
theme.colors.brightBlue, theme.colors.brightMagenta, theme.colors.brightCyan, theme.colors.brightWhite].map((c, i) => (
<div key={i} className="w-4 h-3 rounded-sm" style={{ backgroundColor: c }} />
))}
</div>
<div>&nbsp;</div>
<div>
@@ -267,11 +274,19 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
}) => {
const { t } = useI18n();
const availableFonts = useAvailableFonts();
const customThemes = useCustomThemes();
const { addTheme, updateTheme, deleteTheme } = useCustomThemeActions();
const [activeTab, setActiveTab] = useState<TabType>('theme');
const [selectedTheme, setSelectedTheme] = useState(currentThemeId);
const [selectedFont, setSelectedFont] = useState(currentFontFamilyId);
const [fontSize, setFontSize] = useState(currentFontSize);
// Custom theme editor state
const [editingTheme, setEditingTheme] = useState<TerminalTheme | null>(null);
const [isNewTheme, setIsNewTheme] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Store original values when modal opens (for cancel/revert)
const originalValuesRef = useRef({
theme: currentThemeId,
@@ -279,6 +294,12 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
fontSize: currentFontSize,
});
// Combine built-in + custom themes
const allThemes = useMemo(
() => [...TERMINAL_THEMES, ...customThemes],
[customThemes]
);
// Sync state when modal opens
useEffect(() => {
if (open) {
@@ -292,6 +313,8 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
setSelectedTheme(currentThemeId);
setSelectedFont(currentFontFamilyId);
setFontSize(currentFontSize);
setEditingTheme(null);
setIsNewTheme(false);
}
}, [open, currentThemeId, currentFontFamilyId, currentFontSize]);
@@ -300,13 +323,14 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
[selectedFont, availableFonts]
);
const currentTheme = useMemo(
() => TERMINAL_THEMES.find(t => t.id === selectedTheme) || TERMINAL_THEMES[0],
[selectedTheme]
() => editingTheme || allThemes.find(t => t.id === selectedTheme) || TERMINAL_THEMES[0],
[selectedTheme, allThemes, editingTheme]
);
// Handle theme selection - apply immediately for real-time preview
const handleThemeSelect = useCallback((themeId: string) => {
setSelectedTheme(themeId);
setEditingTheme(null);
onThemeChange?.(themeId); // Apply immediately
}, [onThemeChange]);
@@ -325,11 +349,93 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
});
}, [onFontSizeChange]);
// ---- Custom Theme Actions ----
const handleNewTheme = useCallback(() => {
// Clone current theme as starting point
const base = allThemes.find(t => t.id === selectedTheme) || TERMINAL_THEMES[0];
const newTheme: TerminalTheme = {
...base,
id: `custom-${Date.now()}`,
name: `${base.name} (Custom)`,
isCustom: true,
colors: { ...base.colors },
};
setEditingTheme(newTheme);
setIsNewTheme(true);
}, [selectedTheme, allThemes]);
const handleImportFile = useCallback(() => {
fileInputRef.current?.click();
}, []);
const handleFileSelected = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const name = file.name.replace(/\.(itermcolors|xml)$/i, '');
const reader = new FileReader();
reader.onload = () => {
const xml = reader.result as string;
const parsed = parseItermcolors(xml, name);
if (parsed) {
addTheme(parsed);
setSelectedTheme(parsed.id);
onThemeChange?.(parsed.id);
setActiveTab('theme');
} else {
console.error('[ThemeCustomize] 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('[ThemeCustomize] Failed to read file:', file.name, reader.error);
};
reader.readAsText(file);
// Reset file input so the same file can be re-imported
e.target.value = '';
}, [addTheme, onThemeChange, t]);
const handleEditTheme = useCallback((themeId: string) => {
const theme = customThemes.find(t => t.id === themeId);
if (theme) {
setEditingTheme({ ...theme, colors: { ...theme.colors } });
setIsNewTheme(false);
setActiveTab('custom');
}
}, [customThemes]);
const handleEditorBack = useCallback(() => {
setEditingTheme(null);
setIsNewTheme(false);
}, []);
const handleEditorDelete = useCallback((themeId: string) => {
deleteTheme(themeId);
if (selectedTheme === themeId) {
setSelectedTheme(TERMINAL_THEMES[0].id);
onThemeChange?.(TERMINAL_THEMES[0].id);
}
setEditingTheme(null);
setIsNewTheme(false);
}, [deleteTheme, selectedTheme, onThemeChange]);
// Save: just close (changes are already applied)
const handleSave = useCallback(() => {
// If editing a custom theme, save it first
if (editingTheme) {
if (isNewTheme) {
addTheme(editingTheme);
setSelectedTheme(editingTheme.id);
onThemeChange?.(editingTheme.id);
} else {
updateTheme(editingTheme.id, editingTheme);
}
}
onSave?.();
onClose();
}, [onSave, onClose]);
}, [editingTheme, isNewTheme, addTheme, updateTheme, onSave, onClose, onThemeChange]);
// Cancel: revert to original values
const handleCancel = useCallback(() => {
@@ -341,15 +447,15 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
onClose();
}, [onThemeChange, onFontFamilyChange, onFontSizeChange, onClose]);
// Handle ESC key - same as cancel
// Handle ESC key - same as cancel, but skip when child editor is open
useEffect(() => {
if (!open) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') handleCancel();
if (e.key === 'Escape' && !editingTheme) handleCancel();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [open, handleCancel]);
}, [open, handleCancel, editingTheme]);
// Handle backdrop click - same as cancel
const handleBackdropClick = useCallback((e: React.MouseEvent) => {
@@ -358,10 +464,12 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
if (!open) return null;
// Separate built-in and custom themes for display in the theme list
const builtinThemes = TERMINAL_THEMES;
const modalContent = (
<div
className="fixed inset-0 flex items-center justify-center bg-black/60"
style={{ zIndex: 99999 }}
className="fixed inset-0 z-[200] flex items-center justify-center bg-black/60"
onClick={handleBackdropClick}
>
<div
@@ -371,14 +479,14 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
{/* Header */}
<div className="flex items-center justify-between px-5 py-3 shrink-0 border-b border-border">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg flex items-center justify-center bg-primary/10">
<Palette size={16} className="text-primary" />
</div>
<div className="w-8 h-8 rounded-lg flex items-center justify-center bg-primary/10">
<Palette size={16} className="text-primary" />
</div>
<h2 className="text-sm font-semibold text-foreground">{t('terminal.themeModal.title')}</h2>
</div>
<button
onClick={handleCancel}
className="w-8 h-8 rounded-lg flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
</div>
<button
onClick={handleCancel}
className="w-8 h-8 rounded-lg flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
<X size={16} />
</button>
@@ -391,130 +499,243 @@ export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
{/* Tab Bar */}
<div className="flex p-2 gap-1 shrink-0 border-b border-border">
<button
onClick={() => setActiveTab('theme')}
onClick={() => { setActiveTab('theme'); setEditingTheme(null); }}
className={cn(
'flex-1 flex items-center justify-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium transition-all',
'flex-1 flex items-center justify-center gap-1.5 px-2 py-2 rounded-lg text-xs font-medium transition-all',
activeTab === 'theme'
? 'bg-primary/15 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
)}
>
<Palette size={13} />
>
<Palette size={13} />
{t('terminal.themeModal.tab.theme')}
</button>
<button
onClick={() => setActiveTab('font')}
className={cn(
'flex-1 flex items-center justify-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium transition-all',
</button>
<button
onClick={() => setActiveTab('font')}
className={cn(
'flex-1 flex items-center justify-center gap-1.5 px-2 py-2 rounded-lg text-xs font-medium transition-all',
activeTab === 'font'
? 'bg-primary/15 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
)}
>
<Type size={13} />
>
<Type size={13} />
{t('terminal.themeModal.tab.font')}
</button>
</div>
{/* List Content */}
<div className="flex-1 min-h-0 overflow-y-auto p-2">
{activeTab === 'theme' && (
<div className="space-y-1">
{TERMINAL_THEMES.map(theme => (
<ThemeItem
key={theme.id}
theme={theme}
isSelected={selectedTheme === theme.id}
onSelect={handleThemeSelect}
/>
))}
</div>
)}
{activeTab === 'font' && (
<div className="space-y-1">
{availableFonts.map(font => (
<FontItem
key={font.id}
font={font}
isSelected={selectedFont === font.id}
onSelect={handleFontSelect}
/>
))}
</div>
)}
</button>
<button
onClick={() => setActiveTab('custom')}
className={cn(
'flex-1 flex items-center justify-center gap-1.5 px-2 py-2 rounded-lg text-xs font-medium transition-all',
activeTab === 'custom'
? 'bg-primary/15 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
)}
>
<Sparkles size={13} />
{t('terminal.themeModal.tab.custom')}
</button>
</div>
{/* Font Size Control (only in font tab) */}
{activeTab === 'font' && (
<div className="p-3 border-t border-border shrink-0">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold">
{t('terminal.themeModal.fontSize')}
</div>
<div className="flex items-center justify-between gap-2 bg-muted/30 rounded-lg p-2">
<button
onClick={() => handleFontSizeChange(-1)}
disabled={fontSize <= MIN_FONT_SIZE}
className="w-8 h-8 rounded-md flex items-center justify-center bg-background hover:bg-accent text-foreground disabled:opacity-30 disabled:cursor-not-allowed transition-colors border border-border"
>
<Minus size={14} />
</button>
<div className="flex items-baseline gap-1">
<span className="text-xl font-bold text-foreground tabular-nums">{fontSize}</span>
<span className="text-[10px] text-muted-foreground">px</span>
{/* List Content */}
<>
<div className="flex-1 min-h-0 overflow-y-auto p-2">
{activeTab === 'theme' && (
<div className="space-y-1">
{/* Built-in themes */}
{builtinThemes.map(theme => (
<ThemeItem
key={theme.id}
theme={theme}
isSelected={selectedTheme === theme.id && !editingTheme}
onSelect={handleThemeSelect}
/>
))}
{/* Custom themes section */}
{customThemes.length > 0 && (
<>
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mt-3 mb-1.5 px-1 font-semibold">
{t('terminal.customTheme.section')}
</div>
{customThemes.map(theme => (
<ThemeItem
key={theme.id}
theme={theme}
isSelected={selectedTheme === theme.id && !editingTheme}
onSelect={handleThemeSelect}
onEdit={handleEditTheme}
/>
))}
</>
)}
</div>
<button
onClick={() => handleFontSizeChange(1)}
disabled={fontSize >= MAX_FONT_SIZE}
className="w-8 h-8 rounded-md flex items-center justify-center bg-background hover:bg-accent text-foreground disabled:opacity-30 disabled:cursor-not-allowed transition-colors border border-border"
>
<Plus size={14} />
</button>
</div>
)}
{activeTab === 'font' && (
<div className="space-y-1">
{availableFonts.map(font => (
<FontItem
key={font.id}
font={font}
isSelected={selectedFont === font.id}
onSelect={handleFontSelect}
/>
))}
</div>
)}
{activeTab === 'custom' && !editingTheme && (
<div className="space-y-2">
{/* Actions */}
<button
onClick={handleNewTheme}
className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-left hover:bg-muted transition-colors"
>
<div className="w-8 h-8 rounded-md flex items-center justify-center bg-primary/10 text-primary">
<Plus size={16} />
</div>
<div>
<div className="text-xs font-medium text-foreground">{t('terminal.customTheme.new')}</div>
<div className="text-[10px] text-muted-foreground">{t('terminal.customTheme.newDesc')}</div>
</div>
</button>
<button
onClick={handleImportFile}
className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-left hover:bg-muted transition-colors"
>
<div className="w-8 h-8 rounded-md flex items-center justify-center bg-blue-500/10 text-blue-500">
<Download size={16} />
</div>
<div>
<div className="text-xs font-medium text-foreground">{t('terminal.customTheme.import')}</div>
<div className="text-[10px] text-muted-foreground">{t('terminal.customTheme.importDesc')}</div>
</div>
</button>
<input
ref={fileInputRef}
type="file"
accept=".itermcolors"
onChange={handleFileSelected}
className="hidden"
/>
{/* Custom themes list */}
{customThemes.length > 0 && (
<>
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mt-3 mb-1 px-1 font-semibold">
{t('terminal.customTheme.yourThemes')}
</div>
{customThemes.map(theme => (
<ThemeItem
key={theme.id}
theme={theme}
isSelected={selectedTheme === theme.id}
onSelect={handleThemeSelect}
onEdit={handleEditTheme}
/>
))}
</>
)}
</div>
)}
</div>
)}
{/* Font Size Control (only in font tab) */}
{activeTab === 'font' && (
<div className="p-3 border-t border-border shrink-0">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold">
{t('terminal.themeModal.fontSize')}
</div>
<div className="flex items-center justify-between gap-2 bg-muted/30 rounded-lg p-2">
<button
onClick={() => handleFontSizeChange(-1)}
disabled={fontSize <= MIN_FONT_SIZE}
className="w-8 h-8 rounded-md flex items-center justify-center bg-background hover:bg-accent text-foreground disabled:opacity-30 disabled:cursor-not-allowed transition-colors border border-border"
>
<Minus size={14} />
</button>
<div className="flex items-baseline gap-1">
<span className="text-xl font-bold text-foreground tabular-nums">{fontSize}</span>
<span className="text-[10px] text-muted-foreground">px</span>
</div>
<button
onClick={() => handleFontSizeChange(1)}
disabled={fontSize >= MAX_FONT_SIZE}
className="w-8 h-8 rounded-md flex items-center justify-center bg-background hover:bg-accent text-foreground disabled:opacity-30 disabled:cursor-not-allowed transition-colors border border-border"
>
<Plus size={14} />
</button>
</div>
</div>
)}
</>
</div>
{/* Right Panel - Large Preview */}
<div className="flex-1 flex flex-col min-w-0 p-4">
{/* Right Panel - Large Preview */}
<div className="flex-1 flex flex-col min-w-0 p-4">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-3 font-semibold">
{t('terminal.themeModal.livePreview')}
</div>
<TerminalPreview theme={currentTheme} font={currentFont} fontSize={fontSize} />
<TerminalPreview theme={currentTheme} font={currentFont} fontSize={fontSize} />
{/* Info line */}
<div className="mt-3 text-xs text-muted-foreground flex items-center justify-between">
<span>
{currentTheme.name} {currentFont.name} {fontSize}px
</span>
<span className="text-[10px] uppercase">
</span>
<span className="text-[10px] uppercase">
{t('terminal.themeModal.themeType', { type: currentTheme.type })}
</span>
</div>
</div>
</span>
</div>
</div>
</div>
{/* Footer */}
<div className="flex gap-3 px-5 py-3 shrink-0 border-t border-border bg-muted/20">
<Button
variant="ghost"
onClick={handleCancel}
className="flex-1 h-10"
>
<Button
variant="ghost"
onClick={handleCancel}
className="flex-1 h-10"
>
{t('common.cancel')}
</Button>
<Button
onClick={handleSave}
className="flex-1 h-10"
>
</Button>
<Button
onClick={handleSave}
className="flex-1 h-10"
>
{t('common.save')}
</Button>
</div>
</div>
</div>
</Button>
</div>
</div>
</div>
);
// Use Portal to render at document root
return createPortal(modalContent, document.body);
return (
<>
{createPortal(modalContent, document.body)}
{editingTheme && (
<CustomThemeModal
open={!!editingTheme}
theme={editingTheme}
isNew={isNewTheme}
onSave={(theme) => {
if (isNewTheme) {
addTheme(theme);
setSelectedTheme(theme.id);
onThemeChange?.(theme.id);
} else {
updateTheme(theme.id, theme);
if (selectedTheme === theme.id) {
onThemeChange?.(theme.id);
}
}
setEditingTheme(null);
setIsNewTheme(false);
}}
onDelete={isNewTheme ? undefined : handleEditorDelete}
onCancel={handleEditorBack}
/>
)}
</>
);
};
export default ThemeCustomizeModal;

View File

@@ -31,6 +31,8 @@ export interface ServerStats {
memFree: number | null; // Free memory in MB
memBuffers: number | null; // Buffers in MB
memCached: number | null; // Cached in MB
swapTotal: number | null; // Total swap in MB
swapUsed: number | null; // Used swap in MB
topProcesses: ProcessInfo[]; // Top 10 processes by memory
diskPercent: number | null; // Disk usage percentage for root partition
diskUsed: number | null; // Disk used in GB
@@ -66,6 +68,8 @@ export function useServerStats({
memFree: null,
memBuffers: null,
memCached: null,
swapTotal: null,
swapUsed: null,
topProcesses: [],
diskPercent: null,
diskUsed: null,
@@ -109,6 +113,8 @@ export function useServerStats({
memFree: result.stats.memFree,
memBuffers: result.stats.memBuffers,
memCached: result.stats.memCached,
swapTotal: result.stats.swapTotal ?? null,
swapUsed: result.stats.swapUsed ?? null,
topProcesses: result.stats.topProcesses || [],
diskPercent: result.stats.diskPercent,
diskUsed: result.stats.diskUsed,
@@ -155,6 +161,8 @@ export function useServerStats({
memFree: null,
memBuffers: null,
memCached: null,
swapTotal: null,
swapUsed: null,
topProcesses: [],
diskPercent: null,
diskUsed: null,

View File

@@ -13,11 +13,13 @@ export const useTerminalContextActions = ({
sessionRef,
terminalBackend,
onHasSelectionChange,
disableBracketedPasteRef,
}: {
termRef: RefObject<XTerm | null>;
sessionRef: RefObject<string | null>;
terminalBackend: TerminalBackendWriteApi;
onHasSelectionChange?: (hasSelection: boolean) => void;
disableBracketedPasteRef?: RefObject<boolean>;
}) => {
const onCopy = useCallback(() => {
const term = termRef.current;
@@ -35,13 +37,13 @@ export const useTerminalContextActions = ({
const text = await navigator.clipboard.readText();
if (text && sessionRef.current) {
let data = normalizeLineEndings(text);
if (term.modes.bracketedPasteMode) data = wrapBracketedPaste(data);
if (term.modes.bracketedPasteMode && !disableBracketedPasteRef?.current) data = wrapBracketedPaste(data);
terminalBackend.writeToSession(sessionRef.current, data);
}
} catch (err) {
logger.warn("Failed to paste from clipboard", err);
}
}, [sessionRef, termRef, terminalBackend]);
}, [sessionRef, termRef, terminalBackend, disableBracketedPasteRef]);
const onSelectAll = useCallback(() => {
const term = termRef.current;

View File

@@ -4,6 +4,10 @@ import type { Terminal as XTerm } from "@xterm/xterm";
import type { Dispatch, RefObject, SetStateAction } from "react";
import { logger } from "../../../lib/logger";
import type { Host, Identity, SerialConfig, SSHKey, TerminalSession, TerminalSettings } from "../../../types";
import {
isEncryptedCredentialPlaceholder,
sanitizeCredentialValue,
} from "../../../domain/credentials";
import { resolveHostAuth } from "../../../domain/sshAuth";
type TerminalBackendApi = {
@@ -85,6 +89,7 @@ export type TerminalSessionStartersContext = {
setProgressLogs: Dispatch<SetStateAction<string[]>>;
setProgressValue: Dispatch<SetStateAction<number>>;
setChainProgress: Dispatch<SetStateAction<ChainProgressState>>;
t?: (key: string) => string;
onSessionExit?: (sessionId: string) => void;
onTerminalDataCapture?: (sessionId: string, data: string) => void;
@@ -194,6 +199,12 @@ const runDistroDetection = async (
};
export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContext) => {
const tr = (key: string, fallback: string): string => {
const translated = ctx.t?.(key);
if (!translated || translated === key) return fallback;
return translated;
};
const startSSH = async (term: XTerm) => {
try {
term.clear?.();
@@ -227,9 +238,11 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
});
const effectiveUsername = resolvedAuth.username || "root";
const effectivePassword = resolvedAuth.password;
const key = resolvedAuth.key;
const effectivePassphrase = resolvedAuth.passphrase;
const effectivePassword = sanitizeCredentialValue(resolvedAuth.password);
const effectivePassphrase = sanitizeCredentialValue(resolvedAuth.passphrase);
const hasEncryptedPrimaryPassword = isEncryptedCredentialPlaceholder(resolvedAuth.password);
const hasEncryptedPrimaryKey = isEncryptedCredentialPlaceholder(key?.privateKey);
let usedKey: SSHKey | undefined;
let usedPassword: string | undefined;
@@ -244,16 +257,19 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
);
};
const rawProxyPassword = ctx.host.proxyConfig?.password;
const hasEncryptedProxyPassword = isEncryptedCredentialPlaceholder(rawProxyPassword);
const proxyConfig = ctx.host.proxyConfig
? {
type: ctx.host.proxyConfig.type,
host: ctx.host.proxyConfig.host,
port: ctx.host.proxyConfig.port,
username: ctx.host.proxyConfig.username,
password: ctx.host.proxyConfig.password,
password: sanitizeCredentialValue(rawProxyPassword),
}
: undefined;
const jumpHostsWithUnavailableCredentials: string[] = [];
const jumpHosts = ctx.resolvedChainHosts.map<NetcattyJumpHost>((jumpHost) => {
const jumpAuth = resolveHostAuth({
host: jumpHost,
@@ -261,14 +277,30 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
identities: ctx.identities,
});
const jumpKey = jumpAuth.key;
const rawJumpPassword = jumpAuth.password;
const rawJumpPrivateKey = jumpKey?.privateKey;
const rawJumpPassphrase = jumpAuth.passphrase || jumpKey?.passphrase;
const jumpPassword = sanitizeCredentialValue(rawJumpPassword);
const jumpPrivateKey = sanitizeCredentialValue(rawJumpPrivateKey);
const jumpPassphrase = sanitizeCredentialValue(rawJumpPassphrase);
const hasEncryptedJumpCredential =
isEncryptedCredentialPlaceholder(rawJumpPassword) ||
isEncryptedCredentialPlaceholder(rawJumpPrivateKey) ||
isEncryptedCredentialPlaceholder(rawJumpPassphrase);
if (hasEncryptedJumpCredential && !jumpPassword && !jumpPrivateKey) {
jumpHostsWithUnavailableCredentials.push(jumpHost.label || jumpHost.hostname);
}
return {
hostname: jumpHost.hostname,
port: jumpHost.port || 22,
username: jumpAuth.username || "root",
password: jumpAuth.password,
privateKey: jumpKey?.privateKey,
password: jumpPassword,
privateKey: jumpPrivateKey,
certificate: jumpKey?.certificate,
passphrase: jumpAuth.passphrase || jumpKey?.passphrase,
passphrase: jumpPassphrase,
publicKey: jumpKey?.publicKey,
keyId: jumpAuth.keyId,
keySource: jumpKey?.source,
@@ -276,6 +308,38 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
};
});
if (hasEncryptedProxyPassword && !proxyConfig?.password && proxyConfig?.username) {
const message = tr(
"terminal.auth.proxyCredentialsUnavailable",
"Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.",
);
ctx.setNeedsAuth(false);
ctx.setAuthRetryMessage(null);
ctx.setError(message);
term.writeln(`\r\n[${message}]`);
ctx.updateStatus("disconnected");
return;
}
if (jumpHostsWithUnavailableCredentials.length > 0) {
const jumpList = jumpHostsWithUnavailableCredentials.slice(0, 2).join(", ");
const suffix =
jumpHostsWithUnavailableCredentials.length > 2
? ` +${jumpHostsWithUnavailableCredentials.length - 2}`
: "";
const base = tr(
"terminal.auth.jumpCredentialsUnavailable",
"A jump host has saved credentials that cannot be decrypted on this device. Open host settings and re-enter them.",
);
const message = `${base} (${jumpList}${suffix})`;
ctx.setNeedsAuth(false);
ctx.setAuthRetryMessage(null);
ctx.setError(message);
term.writeln(`\r\n[${message}]`);
ctx.updateStatus("disconnected");
return;
}
const totalHops = jumpHosts.length + 1;
let unsubscribeChainProgress: (() => void) | undefined;
@@ -334,8 +398,11 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
publicKey: attempt.key?.publicKey,
keyId: attempt.key?.id,
keySource: attempt.key?.source,
passphrase: attempt.key ? (effectivePassphrase || attempt.key.passphrase) : undefined,
passphrase: attempt.key
? (effectivePassphrase || sanitizeCredentialValue(attempt.key.passphrase))
: undefined,
agentForwarding: ctx.host.agentForwarding,
legacyAlgorithms: ctx.host.legacyAlgorithms,
cols: term.cols,
rows: term.rows,
charset: ctx.host.charset,
@@ -349,9 +416,46 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
let id: string;
// Respect explicit auth method selection - don't use key if password auth was explicitly selected
const authMethod = resolvedAuth.authMethod;
const hasKeyMaterial = !!key?.privateKey && authMethod !== 'password';
const hasKeyMaterial = !!sanitizeCredentialValue(key?.privateKey) && authMethod !== 'password';
const hasPassword = !!effectivePassword;
const needsCredentialReentry =
(authMethod === "password" && hasEncryptedPrimaryPassword && !hasPassword) ||
(authMethod !== "password" && hasEncryptedPrimaryKey && !hasKeyMaterial && !hasPassword);
if (needsCredentialReentry) {
if (unsubscribeChainProgress) unsubscribeChainProgress();
ctx.setError(null);
ctx.setNeedsAuth(true);
ctx.setAuthRetryMessage(
tr(
"terminal.auth.credentialsUnavailable",
"Saved credentials cannot be decrypted on this device. Please re-enter and save them again.",
),
);
ctx.setAuthPassword("");
ctx.setProgressLogs((prev) => [
...prev,
tr(
"terminal.auth.credentialsUnavailable",
"Saved credentials cannot be decrypted on this device. Please re-enter and save them again.",
),
]);
ctx.setStatus("connecting");
ctx.setChainProgress(null);
return;
}
if (!hasKeyMaterial && authMethod !== "password" && hasEncryptedPrimaryKey && hasPassword) {
ctx.setProgressLogs((prev) => [
...prev,
tr(
"terminal.auth.keyUnavailableFallbackPassword",
"Saved SSH key is unavailable on this device. Falling back to password authentication.",
),
]);
}
if (hasKeyMaterial) {
try {

View File

@@ -157,6 +157,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
const term = new XTerm({
...performanceConfig.options,
// Override ignoreBracketedPasteMode if user explicitly disables bracketed paste
ignoreBracketedPasteMode: settings?.disableBracketedPaste ?? performanceConfig.options.ignoreBracketedPasteMode,
fontSize: effectiveFontSize,
fontFamily,
fontWeight: fontWeight as
@@ -262,15 +264,10 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
if (performanceConfig.useWebGLAddon) {
try {
webglAddon = (() => {
const webglOptions: Record<string, unknown> = { useCustomGlyphHandler: true };
try {
const WebglCtor = WebglAddon as unknown as new (options?: unknown) => WebglAddon;
return new WebglCtor(webglOptions);
} catch {
return new WebglAddon();
}
})();
// WebglAddon constructor only accepts `preserveDrawingBuffer?: boolean`.
// Passing an object here (legacy API assumption) unintentionally enables
// preserveDrawingBuffer and can cause sporadic glyph artifacts/ghosting.
webglAddon = new WebglAddon();
webglAddon.onContextLoss(() => {
logger.warn("[XTerm] WebGL context loss detected, disposing addon");
webglAddon?.dispose();
@@ -316,7 +313,12 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
if (ctx.terminalBackend.openExternalAvailable()) {
void ctx.terminalBackend.openExternal(uri);
} else {
window.open(uri, "_blank");
const safeUri = String(uri || "");
if (/^https?:\/\//i.test(safeUri)) {
window.open(safeUri, "_blank", "noopener,noreferrer");
} else {
logger.warn("[XTerm] Refusing to open non-http(s) link:", safeUri);
}
}
});
term.loadAddon(webLinksAddon);
@@ -409,7 +411,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
const id = ctx.sessionRef.current;
if (id) {
let data = normalizeLineEndings(text);
if (term.modes.bracketedPasteMode) data = wrapBracketedPaste(data);
if (term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste) data = wrapBracketedPaste(data);
ctx.terminalBackend.writeToSession(id, data);
}
});
@@ -444,7 +446,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
const text = await navigator.clipboard.readText();
if (text && ctx.sessionRef.current) {
let data = normalizeLineEndings(text);
if (term.modes.bracketedPasteMode) data = wrapBracketedPaste(data);
if (term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste) data = wrapBracketedPaste(data);
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, data);
}
} catch (err) {

View File

@@ -1,7 +1,7 @@
import { ArrowLeft,MoreVertical,X } from 'lucide-react';
import React,{ createContext,ReactNode,useCallback,useContext,useState } from 'react';
import { ArrowLeft, MoreVertical, X } from 'lucide-react';
import React, { createContext, ReactNode, useCallback, useContext, useState } from 'react';
import { cn } from '../../lib/utils';
import { Popover,PopoverContent,PopoverTrigger } from './popover';
import { Popover, PopoverContent, PopoverTrigger } from './popover';
import { ScrollArea } from './scroll-area';
// Types
@@ -102,7 +102,7 @@ export const AsidePanelContent: React.FC<{ children: ReactNode; className?: stri
}) => {
return (
<ScrollArea className={cn("flex-1", className)}>
<div className="p-4 space-y-4">
<div className="p-4 space-y-4 overflow-hidden">
{children}
</div>
</ScrollArea>

92
domain/credentials.ts Normal file
View File

@@ -0,0 +1,92 @@
import type { SyncPayload } from "./sync";
export const CREDENTIAL_ENCRYPTION_PREFIX = "enc:v1:";
/**
* Base64 pattern: only allows A-Z, a-z, 0-9, +, / and trailing = padding.
*/
const BASE64_RE = /^[A-Za-z0-9+/]+=*$/;
/**
* Chromium/Electron safeStorage ciphertext carries known platform headers:
* - macOS/Linux: plaintext bytes start with "v10" or "v11"
* - Windows (legacy DPAPI blob): leading bytes are 0x01 0x00 0x00 0x00
*
* We validate the base64 payload starts with one of these header signatures
* instead of relying only on prefix+length heuristics. This greatly reduces
* false positives for plaintext credentials that happen to start with "enc:v1:".
*
* References:
* - components/os_crypt/sync/os_crypt_mac.mm (kObfuscationPrefixV10 = "v10")
* - components/os_crypt/sync/os_crypt_linux.cc (kObfuscationPrefixV10/V11)
* - components/os_crypt/sync/os_crypt_win.cc (DPAPI legacy path)
*/
const SAFE_STORAGE_BASE64_HEADER_PREFIXES = [
"djEw", // "v10"
"djEx", // "v11"
"AQAAAA", // 0x01 0x00 0x00 0x00 (DPAPI blob header)
] as const;
export const isEncryptedCredentialPlaceholder = (
value: string | undefined | null,
): value is string => {
if (typeof value !== "string" || !value.startsWith(CREDENTIAL_ENCRYPTION_PREFIX)) {
return false;
}
const payload = value.slice(CREDENTIAL_ENCRYPTION_PREFIX.length);
if (!payload || !BASE64_RE.test(payload)) return false;
return SAFE_STORAGE_BASE64_HEADER_PREFIXES.some((prefix) => payload.startsWith(prefix));
};
/**
* Strip enc:v1: placeholders from a single credential value.
* Used at the terminal connection boundary to avoid sending encrypted
* placeholders as actual passwords to SSH/Telnet servers.
*/
export const sanitizeCredentialValue = (
value: string | undefined,
): string | undefined => {
if (isEncryptedCredentialPlaceholder(value)) return undefined;
return value;
};
/**
* Scan a sync payload for any fields that still carry device-bound
* enc:v1: ciphertext. Returns the dotted paths of offending fields.
* Used as a pre-upload guard to prevent pushing un-decryptable data.
*/
export const findSyncPayloadEncryptedCredentialPaths = (
payload: SyncPayload,
): string[] => {
const issues: string[] = [];
payload.hosts.forEach((host, index) => {
if (isEncryptedCredentialPlaceholder(host.password)) {
issues.push(`hosts[${index}].password`);
}
if (isEncryptedCredentialPlaceholder(host.telnetPassword)) {
issues.push(`hosts[${index}].telnetPassword`);
}
if (isEncryptedCredentialPlaceholder(host.proxyConfig?.password)) {
issues.push(`hosts[${index}].proxyConfig.password`);
}
});
payload.keys.forEach((key, index) => {
if (isEncryptedCredentialPlaceholder(key.privateKey)) {
issues.push(`keys[${index}].privateKey`);
}
if (isEncryptedCredentialPlaceholder(key.passphrase)) {
issues.push(`keys[${index}].passphrase`);
}
});
payload.identities?.forEach((identity, index) => {
if (isEncryptedCredentialPlaceholder(identity.password)) {
issues.push(`identities[${index}].password`);
}
});
return issues;
};

View File

@@ -51,6 +51,12 @@ export interface ProtocolConfig {
theme?: string;
}
export interface SftpBookmark {
id: string;
path: string;
label: string;
}
export interface Host {
id: string;
label: string;
@@ -94,11 +100,14 @@ export interface Host {
// SFTP specific configuration
sftpSudo?: boolean; // Use sudo for SFTP operations (requires password)
sftpEncoding?: SftpFilenameEncoding; // Filename encoding for SFTP operations
sftpBookmarks?: SftpBookmark[]; // Bookmarked SFTP paths for quick navigation
// Managed source: if this host is managed by an external file (e.g., ~/.ssh/config)
managedSourceId?: string; // Reference to ManagedSource.id
// Host-level keyword highlighting (overrides/extends global settings)
keywordHighlightRules?: KeywordHighlightRule[];
keywordHighlightEnabled?: boolean;
// Legacy SSH algorithm support for older network equipment (switches, routers)
legacyAlgorithms?: boolean;
}
export type KeyType = 'RSA' | 'ECDSA' | 'ED25519';
@@ -194,7 +203,7 @@ export const parseKeyCombo = (keyStr: string): { modifiers: string[]; key: strin
// Convert keyboard event to a key string
export const keyEventToString = (e: KeyboardEvent, isMac: boolean): string => {
const parts: string[] = [];
if (isMac) {
if (e.metaKey) parts.push('⌘');
if (e.ctrlKey) parts.push('⌃');
@@ -206,7 +215,7 @@ export const keyEventToString = (e: KeyboardEvent, isMac: boolean): string => {
if (e.shiftKey) parts.push('Shift');
if (e.metaKey) parts.push('Win');
}
// Get the key name
let keyName = e.key;
// Normalize special keys
@@ -221,12 +230,12 @@ export const keyEventToString = (e: KeyboardEvent, isMac: boolean): string => {
else if (keyName === 'Enter') keyName = '↵';
else if (keyName === 'Tab') keyName = '⇥';
else if (keyName.length === 1) keyName = keyName.toUpperCase();
// Don't include modifier keys themselves
if (['Meta', 'Control', 'Alt', 'Shift'].includes(e.key)) {
return parts.join(' + ');
}
parts.push(keyName);
return parts.join(' + ');
};
@@ -234,7 +243,7 @@ export const keyEventToString = (e: KeyboardEvent, isMac: boolean): string => {
// Check if a keyboard event matches a key binding string
export const matchesKeyBinding = (e: KeyboardEvent, keyStr: string, isMac: boolean): boolean => {
if (!keyStr || keyStr === 'Disabled') return false;
// Handle range patterns like "[1...9]"
if (keyStr.includes('[1...9]')) {
const basePattern = keyStr.replace('[1...9]', '');
@@ -244,7 +253,7 @@ export const matchesKeyBinding = (e: KeyboardEvent, keyStr: string, isMac: boole
const testStr = basePattern + key;
return matchesKeyBinding(e, testStr.trim(), isMac);
}
// Handle arrow key patterns like "arrows"
if (keyStr.includes('arrows')) {
const basePattern = keyStr.replace('arrows', '');
@@ -252,18 +261,18 @@ export const matchesKeyBinding = (e: KeyboardEvent, keyStr: string, isMac: boole
// Check if it's an arrow key
if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(key)) return false;
// Map arrow key to symbol for matching
const arrowSymbol = key === 'ArrowUp' ? '↑'
const arrowSymbol = key === 'ArrowUp' ? '↑'
: key === 'ArrowDown' ? '↓'
: key === 'ArrowLeft' ? '←'
: '→';
: key === 'ArrowLeft' ? '←'
: '→';
// Check modifiers match the base pattern
const testStr = basePattern + arrowSymbol;
return matchesKeyBinding(e, testStr.trim(), isMac);
}
const parsed = parseKeyCombo(keyStr);
if (!parsed) return false;
const { modifiers, key } = parsed;
const hasMacModifiers = modifiers.some((modifier) => ['⌘', '⌃', '⌥'].includes(modifier));
@@ -271,14 +280,14 @@ export const matchesKeyBinding = (e: KeyboardEvent, keyStr: string, isMac: boole
if ((!isMac && hasMacModifiers) || (isMac && hasPcModifiers)) {
return false;
}
// Check modifiers
if (isMac) {
const needMeta = modifiers.includes('⌘');
const needCtrl = modifiers.includes('⌃');
const needAlt = modifiers.includes('⌥');
const needShift = modifiers.includes('Shift');
if (e.metaKey !== needMeta) return false;
if (e.ctrlKey !== needCtrl) return false;
if (e.altKey !== needAlt) return false;
@@ -288,13 +297,13 @@ export const matchesKeyBinding = (e: KeyboardEvent, keyStr: string, isMac: boole
const needAlt = modifiers.includes('Alt');
const needShift = modifiers.includes('Shift');
const needMeta = modifiers.includes('Win');
if (e.ctrlKey !== needCtrl) return false;
if (e.altKey !== needAlt) return false;
if (e.shiftKey !== needShift) return false;
if (e.metaKey !== needMeta) return false;
}
const normalizeKey = (rawKey: string): string => {
let normalizedKey = rawKey;
if (normalizedKey === ' ') normalizedKey = 'Space';
@@ -422,6 +431,9 @@ export interface TerminalSettings {
showServerStats: boolean; // Show CPU/Memory/Disk in terminal statusbar
serverStatsRefreshInterval: number; // Seconds between stats refresh (default: 30)
// Paste
disableBracketedPaste: boolean; // Disable bracketed paste mode (avoid ^[[200~ artifacts)
// Rendering
rendererType: 'auto' | 'webgl' | 'canvas'; // Terminal renderer: auto (detect based on hardware), webgl, or canvas
}
@@ -464,6 +476,7 @@ export const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
keepaliveInterval: 0, // 0 = disabled (use SSH library defaults)
showServerStats: true, // Show server stats by default
serverStatsRefreshInterval: 5, // Refresh every 5 seconds
disableBracketedPaste: false, // Bracketed paste enabled by default
rendererType: 'auto', // Auto-detect best renderer based on hardware
};
@@ -471,6 +484,7 @@ export interface TerminalTheme {
id: string;
name: string;
type: 'dark' | 'light';
isCustom?: boolean;
colors: {
background: string;
foreground: string;
@@ -524,17 +538,17 @@ export interface RemoteFile {
export type WorkspaceNode =
| {
id: string;
type: 'pane';
sessionId: string;
}
id: string;
type: 'pane';
sessionId: string;
}
| {
id: string;
type: 'split';
direction: 'horizontal' | 'vertical';
children: WorkspaceNode[];
sizes?: number[]; // relative sizes for children
};
id: string;
type: 'split';
direction: 'horizontal' | 'vertical';
children: WorkspaceNode[];
sizes?: number[]; // relative sizes for children
};
export type WorkspaceViewMode = 'split' | 'focus';

View File

@@ -10,35 +10,136 @@ const path = require("node:path");
const { BaseAgent } = require("ssh2/lib/agent.js");
const { parseKey } = require("ssh2/lib/protocol/keyParser.js");
// Simple file logger for debugging
const logFile = path.join(require("os").tmpdir(), "netcatty-agent.log");
const DEBUG_SSH = process.env.NETCATTY_SSH_DEBUG === "1";
// Debug logger (disabled by default)
const logFile = DEBUG_SSH
? path.join(require("os").tmpdir(), "netcatty-agent.log")
: null;
const log = (msg, data) => {
if (!DEBUG_SSH) return;
const line = `[${new Date().toISOString()}] ${msg} ${data ? JSON.stringify(data) : ""}\n`;
try { fs.appendFileSync(logFile, line); } catch {}
try { fs.appendFileSync(logFile, line); } catch { }
console.log("[Agent]", msg, data || "");
};
const DUMMY_ED25519_PUB =
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEB netcatty-agent-dummy";
function parseOpenSshKeyLine(line) {
if (typeof line !== "string" || !line.trim()) throw new Error("Empty OpenSSH key line");
const firstLine = line.split(/\r?\n/).find((l) => l.trim());
if (!firstLine) throw new Error("Empty OpenSSH key line");
const m = /^\s*(\S+)\s+([A-Za-z0-9+/=]+)(?:\s+(.*))?\s*$/.exec(firstLine);
if (!m) throw new Error("Invalid OpenSSH key line");
// Normalize input: remove extra whitespace and join into single line
// This handles cases where long certificates are wrapped across multiple lines
const normalized = line.split(/\r?\n/)
.map(l => l.trim())
.filter(l => l)
.join(" ");
if (!normalized) throw new Error("Empty OpenSSH key line");
// Match format: <type> <base64-blob> [comment]
// Base64 blob may be very long (certificates can be 2000+ chars)
// Allow spaces within base64 for cases where it was wrapped
const m = /^\s*(\S+)\s+((?:[A-Za-z0-9+/=]\s*)+?)(?:\s+(.+?))?\s*$/.exec(normalized);
if (!m) {
// Fallback: try simpler pattern for single-line format
const parts = normalized.split(/\s+/);
if (parts.length >= 2) {
const type = parts[0];
// Determine if last part is comment or base64
// Comments usually don't start with base64-valid chars at boundaries
const lastPart = parts[parts.length - 1];
const isLastBase64 = /^[A-Za-z0-9+/=]+$/.test(lastPart) && lastPart.length > 20;
// If last part is base64, it's part of the blob; otherwise it's a comment
const blobParts = isLastBase64 ? parts.slice(1) : parts.slice(1, -1);
const comment = isLastBase64 ? "" : lastPart;
if (blobParts.length === 0) {
throw new Error("No base64 data found in OpenSSH key line");
}
try {
const base64Str = blobParts.join("");
const blob = Buffer.from(base64Str, "base64");
log("Fallback parse success", { type, blobLength: blob.length, comment });
return { type, blob, comment };
} catch (e) {
throw new Error(`Invalid base64 in OpenSSH key line: ${e.message}`);
}
}
throw new Error("Invalid OpenSSH key line format");
}
const type = m[1];
const blob = Buffer.from(m[2], "base64");
const base64Str = m[2].replace(/\s+/g, ""); // Remove any spaces from base64
const blob = Buffer.from(base64Str, "base64");
const comment = m[3] || "";
return { type, blob, comment };
}
function buildCertificateIdentityKey({ certType, certBlob, comment }) {
const key = parseKey(DUMMY_ED25519_PUB);
if (key instanceof Error) throw key;
key.type = certType;
function buildCertificateIdentityKey({ certType, certBlob, comment, privateKey, passphrase }) {
// Parse the actual private key to get the correct public key object
if (!privateKey) throw new Error("privateKey required to build certificate identity");
const key = parseKey(privateKey, passphrase);
if (key instanceof Error) throw new Error(`Failed to parse private key: ${key.message}`);
// Extract base key type from certificate type (e.g., ssh-rsa-cert-v01@openssh.com -> ssh-rsa)
const baseType = certType.replace(/-cert-v0[01]@openssh\.com$/i, '');
// CRITICAL: Determine modern certificate type for algorithm negotiation
// OpenSSH servers require explicit signature algorithms (SHA-256/SHA-512, not generic SHA-1)
// But we MUST NOT modify the certificate blob (would break CA signature)
let modernCertType = certType;
if (certType === 'ssh-rsa-cert-v01@openssh.com' && baseType === 'ssh-rsa') {
// Prefer SHA-512 for RSA certificates (matches OpenSSH client default)
modernCertType = 'rsa-sha2-512-cert-v01@openssh.com';
}
log("Private key parsed for certificate identity", {
originalKeyType: key.type,
originalCertType: certType,
modernCertType: modernCertType,
baseType: baseType,
hasGetPublicSSH: !!key.getPublicSSH,
originalPublicSSHLength: key.getPublicSSH ? key.getPublicSSH().length : 0,
certBlobLength: certBlob.length,
certBlobPreview: certBlob.slice(0, 40).toString('hex')
});
// STRATEGY: Set key.type to MODERN certificate type for algorithm name in USERAUTH_REQUEST
// but return ORIGINAL unmodified certificate blob (to preserve CA signature)
// Server will accept this because:
// - Algorithm name in USERAUTH_REQUEST: rsa-sha2-512-cert-v01@openssh.com (what we claim to support)
// - Certificate blob type field: ssh-rsa-cert-v01@openssh.com (original, CA-signed)
// - Server knows these are compatible (both are RSA certs, just different hash algorithms)
key.type = modernCertType; // Use modern cert type as algorithm name
key._baseType = baseType;
key._originalCertType = certType;
key._certType = modernCertType;
key._signatureAlgo = modernCertType.includes('512') ? 'rsa-sha2-512' : 'rsa-sha2-256';
key.comment = comment || key.comment;
key.getPublicSSH = () => certBlob;
key.getPublicSSH = () => certBlob; // Return ORIGINAL unmodified certificate blob
// CRITICAL: Override sign() to ensure it returns signature algorithm, not cert type
// ssh2's authPK needs the signature algorithm for constructing the signature blob
// but key.type is the cert type. We need to provide the signature algorithm separately.
const originalSign = key.sign.bind(key);
key.sign = function (data, hash) {
const sig = originalSign(data, hash);
// Return signature with metadata for ssh2
if (sig instanceof Error) return sig;
// Attach signature algorithm as property for ssh2 to use
const sigBuffer = Buffer.from(sig);
sigBuffer._signatureAlgorithm = key._signatureAlgo;
return sigBuffer;
};
log("Built certificate identity key", {
finalType: key.type,
finalBaseType: key._baseType,
finalCertType: key._certType,
finalPublicSSHLength: key.getPublicSSH().length,
});
return key;
}
@@ -57,15 +158,40 @@ class NetcattyAgent extends BaseAgent {
this._advertisedType = null;
if (this._mode === "certificate") {
const { certificate, label } = opts.meta || {};
const { certificate, privateKey, passphrase, label } = opts.meta || {};
if (!certificate) throw new Error("Missing certificate");
const { type: certType, blob: certBlob } = parseOpenSshKeyLine(certificate);
this._key = buildCertificateIdentityKey({
certType,
certBlob,
comment: label || "",
});
this._advertisedType = certType;
if (!privateKey) throw new Error("Missing privateKey for certificate auth");
log("Parsing certificate", { certLength: certificate.length, label, hasPrivateKey: !!privateKey });
try {
const { type: certType, blob: certBlob } = parseOpenSshKeyLine(certificate);
log("Certificate parsed successfully", {
certType,
blobLength: certBlob.length,
blobPreview: certBlob.slice(0, 32).toString('hex')
});
this._key = buildCertificateIdentityKey({
certType,
certBlob,
comment: label || "",
privateKey,
passphrase,
});
this._advertisedType = certType; // Store original cert type for debugging
// Cache parsed private key to avoid re-parsing on every sign() call
const parsed = parseKey(privateKey, passphrase);
if (parsed instanceof Error) throw parsed;
this._parsedPrivateKey = Array.isArray(parsed) ? parsed[0] : parsed;
log("Agent initialized successfully", {
keyType: this._key.type,
certType: certType,
baseType: this._key._baseType,
});
} catch (err) {
log("Certificate parse error", { error: err.message, stack: err.stack });
throw err;
}
} else {
throw new Error(`Unknown agent mode: ${opts.mode}`);
}
@@ -73,46 +199,62 @@ class NetcattyAgent extends BaseAgent {
getIdentities(cb) {
log("getIdentities called", { mode: this._mode });
// Debug: log key structure
if (this._key) {
const publicSSH = this._key.getPublicSSH ? this._key.getPublicSSH() : null;
log("Returning key identity", {
keyType: this._key.type,
hasGetPublicSSH: !!this._key.getPublicSSH,
publicSSHLength: publicSSH?.length,
publicSSHPreview: publicSSH?.slice(0, 32).toString('hex'),
keyComment: this._key.comment,
});
}
cb(null, [this._key]);
}
sign(_pubKey, data, options, cb) {
log("sign called", {
mode: this._mode,
log("sign called", {
mode: this._mode,
dataLength: data?.length,
advertisedType: this._advertisedType,
options: options,
hasPrivateKeyInMeta: !!this._meta?.privateKey,
privateKeyLength: this._meta?.privateKey?.length,
});
if (typeof options === "function") {
cb = options;
options = undefined;
}
if (typeof cb !== "function") cb = () => {};
if (typeof cb !== "function") cb = () => { };
(async () => {
if (this._mode === "certificate") {
const { privateKey, passphrase } = this._meta || {};
if (!privateKey) throw new Error("Missing privateKey for certificate auth");
const parsed = parseKey(privateKey, passphrase);
if (parsed instanceof Error) throw parsed;
const key = Array.isArray(parsed) ? parsed[0] : parsed;
// Use cached parsed private key (parsed once during construction)
const key = this._parsedPrivateKey;
if (!key) {
throw new Error("Missing parsed private key — agent not properly initialized");
}
log("Using cached private key", { keyType: key.type });
// For certificates, key.type is now the base type (e.g., 'ssh-rsa')
// ssh2's getKeyAlgos() will negotiate the proper hash algorithm
const baseType = normalizeBaseTypeForConversion(key.type);
let hash = options && options.hash ? options.hash : undefined;
// ssh2 does not currently infer hash algorithms for certificate types.
// For RSA cert algorithms, select the hash based on the *advertised* algorithm
// (e.g. rsa-sha2-256-cert-v01@openssh.com), not the private key type (ssh-rsa).
if (!hash) {
const advertisedBaseType = normalizeBaseTypeForConversion(
this._advertisedType || this._key?.type
);
if (advertisedBaseType === "rsa-sha2-256") hash = "sha256";
else if (advertisedBaseType === "rsa-sha2-512") hash = "sha512";
else if (advertisedBaseType === "ssh-rsa") hash = "sha1";
// If hash not provided by ssh2, default to SHA-512 for RSA keys
// (matches OpenSSH client behavior, modern servers disable SHA-1)
if (!hash && baseType === 'ssh-rsa') {
hash = 'sha512'; // Use SHA-512 like OpenSSH client
}
log("Signing with parameters", {
privateKeyType: key.type,
baseType: baseType,
advertisedType: this._advertisedType,
hash: hash,
});
let sig = key.sign(data, hash);
if (sig instanceof Error) throw sig;
@@ -121,11 +263,19 @@ class NetcattyAgent extends BaseAgent {
baseType,
advertisedType: this._advertisedType,
hash,
sigLength: sig?.length,
sigLength: sig.length,
});
// Return raw signature. ssh2 will handle signature field construction.
return Buffer.from(sig);
// CRITICAL: ssh2's authPK() expects RAW signature (without algorithm name wrapper)
// authPK will construct the signature blob itself: algo_name + raw_signature
// If we return pre-wrapped blob, authPK will wrap it again causing double-wrapping
// which server will reject. So we must return ONLY the raw signature bytes.
log("Returning raw signature to ssh2", {
signatureLength: sig.length,
signaturePreview: sig.slice(0, 32).toString('hex')
});
return Buffer.from(sig); // Return RAW signature only
}
throw new Error("Unsupported agent mode");

View File

@@ -23,9 +23,9 @@ const { NetcattyAgent } = require("./netcattyAgent.cjs");
const fileWatcherBridge = require("./fileWatcherBridge.cjs");
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
const { createProxySocket } = require("./proxyUtils.cjs");
const {
buildAuthHandler,
createKeyboardInteractiveHandler,
const {
buildAuthHandler,
createKeyboardInteractiveHandler,
applyAuthToConnOpts,
safeSend: authSafeSend,
findAllDefaultPrivateKeys: findAllDefaultPrivateKeysFromHelper,
@@ -254,6 +254,44 @@ const ensureRemoteDirForSession = async (sftpId, dirPath, requestedEncoding) =>
return true;
};
/**
* Build SSH algorithm configuration for SFTP connections.
* When legacyEnabled is true, legacy algorithms are appended for older device compatibility.
*/
function buildSftpAlgorithms(legacyEnabled) {
const algorithms = {
cipher: [
'aes128-gcm@openssh.com', 'aes256-gcm@openssh.com',
'aes128-ctr', 'aes192-ctr', 'aes256-ctr',
],
kex: [
'curve25519-sha256', 'curve25519-sha256@libssh.org',
'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521',
'diffie-hellman-group14-sha256',
'diffie-hellman-group16-sha512', 'diffie-hellman-group18-sha512',
'diffie-hellman-group-exchange-sha256',
],
compress: ['none'],
};
if (legacyEnabled) {
algorithms.kex.push(
'diffie-hellman-group14-sha1',
'diffie-hellman-group1-sha1',
);
algorithms.cipher.push(
'aes128-cbc', 'aes256-cbc', '3des-cbc',
);
algorithms.serverHostKey = [
'ssh-ed25519', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521',
'rsa-sha2-512', 'rsa-sha2-256',
'ssh-rsa', 'ssh-dss',
];
}
return algorithms;
}
/**
* Send message to renderer safely
*/
@@ -307,22 +345,7 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
keepaliveCountMax: 3,
// Enable keyboard-interactive authentication (required for 2FA/MFA)
tryKeyboard: true,
algorithms: {
// Prioritize fastest ciphers (GCM modes are hardware-accelerated)
cipher: [
'aes128-gcm@openssh.com', 'aes256-gcm@openssh.com',
'aes128-ctr', 'aes192-ctr', 'aes256-ctr',
],
// Prioritize modern key exchange algorithms for broad compatibility
kex: [
'curve25519-sha256', 'curve25519-sha256@libssh.org',
'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521',
'diffie-hellman-group14-sha256',
'diffie-hellman-group16-sha512', 'diffie-hellman-group18-sha512',
'diffie-hellman-group-exchange-sha256',
],
compress: ['none'],
},
algorithms: buildSftpAlgorithms(options.legacyAlgorithms),
};
// Auth - support agent (certificate), key, and password fallback
@@ -726,6 +749,7 @@ async function openSftp(event, options) {
// Enable keyboard-interactive authentication (required for 2FA/MFA)
tryKeyboard: true,
readyTimeout: 120000, // 2 minutes for 2FA input
algorithms: buildSftpAlgorithms(options.legacyAlgorithms),
};
// Use the tunneled socket if we have one
@@ -831,6 +855,8 @@ async function openSftp(event, options) {
client.client.setMaxListeners(0); // 0 means unlimited
}
// Used by transferBridge to decide whether isolated fast-transfer channels are safe.
client.__netcattySudoMode = !!options.sudo;
sftpClients.set(connId, client);
// Store jump connections for cleanup when SFTP is closed

View File

@@ -165,14 +165,58 @@ function checkWindowsSshAgent() {
});
}
// Simple file logger for debugging
const logFile = path.join(require("os").tmpdir(), "netcatty-ssh.log");
const DEBUG_SSH = process.env.NETCATTY_SSH_DEBUG === "1";
// Debug logger (disabled by default)
const logFile = DEBUG_SSH
? path.join(require("os").tmpdir(), "netcatty-ssh.log")
: null;
const log = (msg, data) => {
if (!DEBUG_SSH) return;
const line = `[${new Date().toISOString()}] ${msg} ${data ? JSON.stringify(data) : ""}\n`;
try { fs.appendFileSync(logFile, line); } catch { }
console.log("[SSH]", msg, data ? JSON.stringify(data, null, 2) : "");
};
/**
* Build SSH algorithm configuration.
* When legacyEnabled is true, legacy algorithms are appended to each list
* (lower priority than modern ones) for compatibility with older network equipment.
*/
function buildAlgorithms(legacyEnabled) {
const algorithms = {
cipher: [
'aes128-gcm@openssh.com', 'aes256-gcm@openssh.com',
'aes128-ctr', 'aes192-ctr', 'aes256-ctr',
],
kex: [
'curve25519-sha256', 'curve25519-sha256@libssh.org',
'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521',
'diffie-hellman-group14-sha256',
'diffie-hellman-group16-sha512', 'diffie-hellman-group18-sha512',
'diffie-hellman-group-exchange-sha256',
],
compress: ['none'],
};
if (legacyEnabled) {
algorithms.kex.push(
'diffie-hellman-group14-sha1',
'diffie-hellman-group1-sha1',
);
algorithms.cipher.push(
'aes128-cbc', 'aes256-cbc', '3des-cbc',
);
algorithms.serverHostKey = [
'ssh-ed25519', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521',
'rsa-sha2-512', 'rsa-sha2-256',
'ssh-rsa', 'ssh-dss',
];
}
return algorithms;
}
// Session storage - shared reference passed from main
let sessions = null;
let electronModule = null;
@@ -277,22 +321,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
keepaliveCountMax: 3,
// Enable keyboard-interactive authentication (required for 2FA/MFA)
tryKeyboard: true,
algorithms: {
// Prioritize fastest ciphers (GCM modes are hardware-accelerated)
cipher: [
'aes128-gcm@openssh.com', 'aes256-gcm@openssh.com',
'aes128-ctr', 'aes192-ctr', 'aes256-ctr',
],
// Prioritize modern key exchange algorithms for broad compatibility
kex: [
'curve25519-sha256', 'curve25519-sha256@libssh.org',
'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521',
'diffie-hellman-group14-sha256',
'diffie-hellman-group16-sha512', 'diffie-hellman-group18-sha512',
'diffie-hellman-group-exchange-sha256',
],
compress: ['none'],
},
algorithms: buildAlgorithms(options.legacyAlgorithms),
};
// Auth - support agent (certificate), key, password, and default key fallback
@@ -465,22 +494,7 @@ async function startSSHSession(event, options) {
keepaliveCountMax: 3,
// Enable keyboard-interactive authentication (required for 2FA/MFA)
tryKeyboard: true,
algorithms: {
// Prioritize fastest ciphers (GCM modes are hardware-accelerated)
cipher: [
'aes128-gcm@openssh.com', 'aes256-gcm@openssh.com',
'aes128-ctr', 'aes192-ctr', 'aes256-ctr',
],
// Prioritize modern key exchange algorithms for broad compatibility
kex: [
'curve25519-sha256', 'curve25519-sha256@libssh.org',
'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521',
'diffie-hellman-group14-sha256',
'diffie-hellman-group16-sha512', 'diffie-hellman-group18-sha512',
'diffie-hellman-group-exchange-sha256',
],
compress: ['none'],
},
algorithms: buildAlgorithms(options.legacyAlgorithms),
};
// Authentication for final target
@@ -1470,8 +1484,8 @@ async function getServerStats(event, payload) {
`cpuraw=$(awk '/^cpu / {total=0; for(i=2;i<=NF;i++) total+=$i; printf "%d %d", total, $5}' /proc/stat 2>/dev/null || echo "")`,
// Get raw per-core CPU values from /proc/stat: "total:idle,total:idle,..."
`percoreraw=$(awk '/^cpu[0-9]/ {total=0; for(i=2;i<=NF;i++) total+=$i; printf "%d:%d,", total, $5}' /proc/stat 2>/dev/null | sed 's/,$//' || echo "")`,
// Get memory details from /proc/meminfo (total, free, buffers, cached in KB)
`meminfo=$(awk '/^MemTotal:/{t=$2} /^MemFree:/{f=$2} /^Buffers:/{b=$2} /^Cached:/{c=$2} /^SReclaimable:/{s=$2} END{printf "%d %d %d %d", t/1024, f/1024, b/1024, (c+s)/1024}' /proc/meminfo 2>/dev/null || echo "")`,
// Get memory details from /proc/meminfo (total, free, buffers, cached, swapTotal, swapFree in KB)
`meminfo=$(awk '/^MemTotal:/{t=$2} /^MemFree:/{f=$2} /^Buffers:/{b=$2} /^Cached:/{c=$2} /^SReclaimable:/{s=$2} /^SwapTotal:/{st=$2} /^SwapFree:/{sf=$2} END{printf "%d %d %d %d %d %d", t/1024, f/1024, b/1024, (c+s)/1024, st/1024, sf/1024}' /proc/meminfo 2>/dev/null || echo "")`,
// Get top 10 processes by memory - with BusyBox fallback
// GNU ps: ps -eo pid,%mem,comm --sort=-%mem
// BusyBox fallback: ps -o pid,vsz,comm and sort manually (BusyBox ps doesn't have %mem, use vsz instead)
@@ -1525,6 +1539,8 @@ async function getServerStats(event, payload) {
let memBuffers = null;
let memCached = null;
let memUsed = null;
let swapTotal = null;
let swapUsed = null;
let topProcesses = []; // Array of { pid, memPercent, command }
let disks = []; // Array of { mountPoint, used, total, percent }
let networkInterfaces = []; // Array of { name, rxBytes, txBytes }
@@ -1571,6 +1587,16 @@ async function getServerStats(event, payload) {
memUsed = memTotal - memFree - memBuffers - memCached;
if (memUsed < 0) memUsed = 0;
}
// Parse swap info (fields 5 and 6)
if (memParts.length >= 6) {
const st = parseInt(memParts[4], 10);
const sf = parseInt(memParts[5], 10);
if (!isNaN(st)) swapTotal = st;
if (!isNaN(sf)) {
swapUsed = (swapTotal !== null) ? swapTotal - sf : null;
if (swapUsed !== null && swapUsed < 0) swapUsed = 0;
}
}
}
} else if (part.startsWith('PROCS:')) {
const procsStr = part.substring(6).trim();
@@ -1743,6 +1769,8 @@ async function getServerStats(event, payload) {
memFree, // Free memory in MB
memBuffers, // Buffers in MB
memCached, // Cached in MB
swapTotal, // Swap total in MB
swapUsed, // Swap used in MB
topProcesses, // Top 10 processes by memory
diskPercent, // Disk usage percentage for root partition (backward compat)
diskUsed, // Disk used in GB for root partition (backward compat)

View File

@@ -8,9 +8,22 @@ const path = require("node:path");
const os = require("node:os");
const { encodePathForSession, ensureRemoteDirForSession } = require("./sftpBridge.cjs");
// ── Transfer performance tuning ──────────────────────────────────────────────
// ssh2's fastPut/fastGet send multiple SFTP read/write requests in parallel,
// dramatically improving throughput over sequential stream piping.
const TRANSFER_CHUNK_SIZE = 512 * 1024; // 512KB per SFTP request
const TRANSFER_CONCURRENCY = 64; // 64 parallel SFTP requests
// Progress IPC throttle: sending too many IPC messages bogs down the event loop
const PROGRESS_THROTTLE_MS = 100; // Send IPC at most every 100ms
const PROGRESS_THROTTLE_BYTES = 256 * 1024; // Or every 256KB of progress
// Speed calculation uses strict sliding-window average:
// speed = bytes_delta_in_window / time_delta_in_window
const SPEED_WINDOW_MS = 3000; // Keep 3s of samples
const SPEED_MIN_ELAPSED_MS = 50; // Minimum elapsed time to avoid divide-by-near-zero spikes
// Shared references
let sftpClients = null;
let electronModule = null;
// Active transfers storage
const activeTransfers = new Map();
@@ -20,43 +33,106 @@ const activeTransfers = new Map();
*/
function init(deps) {
sftpClients = deps.sftpClients;
electronModule = deps.electronModule;
}
async function openIsolatedSftpChannel(client) {
const sshClient = client?.client;
if (!sshClient || typeof sshClient.sftp !== "function") return null;
return new Promise((resolve, reject) => {
sshClient.sftp((err, sftp) => {
if (err) reject(err);
else resolve(sftp);
});
});
}
/**
* Upload a local file to SFTP using streams (supports cancellation)
* Upload a local file to SFTP using ssh2's fastPut (parallel SFTP requests).
* Falls back to sequential stream piping if fastPut is unavailable.
*/
async function uploadWithStreams(localPath, remotePath, client, fileSize, transfer, sendProgress) {
return new Promise((resolve, reject) => {
const readStream = fs.createReadStream(localPath);
async function uploadFile(localPath, remotePath, client, fileSize, transfer, sendProgress) {
const sftp = client.sftp;
if (!sftp) throw new Error("SFTP client not ready");
// Get the underlying sftp object from ssh2-sftp-client
const sftp = client.sftp;
if (!sftp) {
reject(new Error("SFTP client not ready"));
return;
// Prefer fastPut on an isolated SFTP channel so cancellation can abort just this transfer.
if (!client.__netcattySudoMode) {
let fastSftp = null;
try {
fastSftp = await openIsolatedSftpChannel(client);
} catch (err) {
console.warn("[transferBridge] Failed to open isolated SFTP channel for fastPut, falling back to streams:", err.message || String(err));
}
const writeStream = sftp.createWriteStream(remotePath);
if (fastSftp && typeof fastSftp.fastPut === "function") {
return new Promise((resolve, reject) => {
let settled = false;
let onFastSftpError = null;
const finish = (err) => {
if (settled) return;
settled = true;
if (transfer.abort === abortFastTransfer) {
transfer.abort = null;
}
if (onFastSftpError) {
try { fastSftp.removeListener("error", onFastSftpError); } catch { }
onFastSftpError = null;
}
try { fastSftp.end(); } catch { }
if (transfer.cancelled) reject(new Error("Transfer cancelled"));
else if (err) reject(err);
else resolve();
};
const abortFastTransfer = () => {
if (settled) return;
transfer.cancelled = true;
try { fastSftp.end(); } catch { }
finish(new Error("Transfer cancelled"));
};
transfer.abort = abortFastTransfer;
onFastSftpError = (err) => finish(err);
fastSftp.once("error", onFastSftpError);
if (transfer.cancelled) {
finish(new Error("Transfer cancelled"));
return;
}
fastSftp.fastPut(localPath, remotePath, {
chunkSize: TRANSFER_CHUNK_SIZE,
concurrency: TRANSFER_CONCURRENCY,
step: (transferred, _chunk, total) => {
if (transfer.cancelled) return;
sendProgress(transferred, total || fileSize);
},
}, finish);
});
}
if (fastSftp && typeof fastSftp.end === "function") {
try { fastSftp.end(); } catch { }
}
}
// Fallback: sequential stream piping
return new Promise((resolve, reject) => {
const readStream = fs.createReadStream(localPath, { highWaterMark: TRANSFER_CHUNK_SIZE });
const writeStream = sftp.createWriteStream(remotePath, { highWaterMark: TRANSFER_CHUNK_SIZE });
let transferred = 0;
let finished = false;
// Store streams for cancellation
transfer.readStream = readStream;
transfer.writeStream = writeStream;
const cleanup = (err) => {
if (finished) return;
finished = true;
// Remove listeners to prevent memory leaks
readStream.removeAllListeners();
writeStream.removeAllListeners();
if (err) {
// Destroy streams on error
try { readStream.destroy(); } catch {}
try { writeStream.destroy(); } catch {}
try { readStream.destroy(); } catch { }
try { writeStream.destroy(); } catch { }
reject(err);
} else {
resolve();
@@ -64,61 +140,106 @@ async function uploadWithStreams(localPath, remotePath, client, fileSize, transf
};
readStream.on('data', (chunk) => {
if (transfer.cancelled) {
cleanup(new Error('Transfer cancelled'));
return;
}
if (transfer.cancelled) { cleanup(new Error('Transfer cancelled')); return; }
transferred += chunk.length;
sendProgress(transferred, fileSize);
});
readStream.on('error', (err) => cleanup(err));
writeStream.on('error', (err) => cleanup(err));
readStream.on('error', cleanup);
writeStream.on('error', cleanup);
writeStream.on('close', () => {
if (transfer.cancelled) {
cleanup(new Error('Transfer cancelled'));
} else {
cleanup(null);
}
if (transfer.cancelled) cleanup(new Error('Transfer cancelled'));
else cleanup(null);
});
readStream.pipe(writeStream);
});
}
/**
* Download from SFTP to local file using streams (supports cancellation)
* Download from SFTP to local file using ssh2's fastGet (parallel SFTP requests).
* Falls back to sequential stream piping if fastGet is unavailable.
*/
async function downloadWithStreams(remotePath, localPath, client, fileSize, transfer, sendProgress) {
return new Promise((resolve, reject) => {
// Get the underlying sftp object from ssh2-sftp-client
const sftp = client.sftp;
if (!sftp) {
reject(new Error("SFTP client not ready"));
return;
async function downloadFile(remotePath, localPath, client, fileSize, transfer, sendProgress) {
const sftp = client.sftp;
if (!sftp) throw new Error("SFTP client not ready");
// Prefer fastGet on an isolated SFTP channel so cancellation can abort just this transfer.
if (!client.__netcattySudoMode) {
let fastSftp = null;
try {
fastSftp = await openIsolatedSftpChannel(client);
} catch (err) {
console.warn("[transferBridge] Failed to open isolated SFTP channel for fastGet, falling back to streams:", err.message || String(err));
}
const readStream = sftp.createReadStream(remotePath);
const writeStream = fs.createWriteStream(localPath);
if (fastSftp && typeof fastSftp.fastGet === "function") {
return new Promise((resolve, reject) => {
let settled = false;
let onFastSftpError = null;
const finish = (err) => {
if (settled) return;
settled = true;
if (transfer.abort === abortFastTransfer) {
transfer.abort = null;
}
if (onFastSftpError) {
try { fastSftp.removeListener("error", onFastSftpError); } catch { }
onFastSftpError = null;
}
try { fastSftp.end(); } catch { }
if (transfer.cancelled) reject(new Error("Transfer cancelled"));
else if (err) reject(err);
else resolve();
};
const abortFastTransfer = () => {
if (settled) return;
transfer.cancelled = true;
try { fastSftp.end(); } catch { }
finish(new Error("Transfer cancelled"));
};
transfer.abort = abortFastTransfer;
onFastSftpError = (err) => finish(err);
fastSftp.once("error", onFastSftpError);
if (transfer.cancelled) {
finish(new Error("Transfer cancelled"));
return;
}
fastSftp.fastGet(remotePath, localPath, {
chunkSize: TRANSFER_CHUNK_SIZE,
concurrency: TRANSFER_CONCURRENCY,
step: (transferred, _chunk, total) => {
if (transfer.cancelled) return;
sendProgress(transferred, total || fileSize);
},
}, finish);
});
}
if (fastSftp && typeof fastSftp.end === "function") {
try { fastSftp.end(); } catch { }
}
}
// Fallback: sequential stream piping
return new Promise((resolve, reject) => {
const readStream = sftp.createReadStream(remotePath, { highWaterMark: TRANSFER_CHUNK_SIZE });
const writeStream = fs.createWriteStream(localPath, { highWaterMark: TRANSFER_CHUNK_SIZE });
let transferred = 0;
let finished = false;
// Store streams for cancellation
transfer.readStream = readStream;
transfer.writeStream = writeStream;
const cleanup = (err) => {
if (finished) return;
finished = true;
// Remove listeners to prevent memory leaks
readStream.removeAllListeners();
writeStream.removeAllListeners();
if (err) {
// Destroy streams on error
try { readStream.destroy(); } catch {}
try { writeStream.destroy(); } catch {}
try { readStream.destroy(); } catch { }
try { writeStream.destroy(); } catch { }
reject(err);
} else {
resolve();
@@ -126,40 +247,25 @@ async function downloadWithStreams(remotePath, localPath, client, fileSize, tran
};
readStream.on('data', (chunk) => {
if (transfer.cancelled) {
cleanup(new Error('Transfer cancelled'));
return;
}
if (transfer.cancelled) { cleanup(new Error('Transfer cancelled')); return; }
transferred += chunk.length;
sendProgress(transferred, fileSize);
});
readStream.on('error', (err) => cleanup(err));
writeStream.on('error', (err) => cleanup(err));
// Handle normal completion
readStream.on('error', cleanup);
writeStream.on('error', cleanup);
writeStream.on('finish', () => {
if (transfer.cancelled) {
cleanup(new Error('Transfer cancelled'));
} else {
cleanup(null);
}
if (transfer.cancelled) cleanup(new Error('Transfer cancelled'));
else cleanup(null);
});
// Handle stream destruction (destroy() emits 'close' but not 'finish')
writeStream.on('close', () => {
if (transfer.cancelled) {
cleanup(new Error('Transfer cancelled'));
}
if (transfer.cancelled) cleanup(new Error('Transfer cancelled'));
});
readStream.pipe(writeStream);
});
}
/**
* Start a file transfer
* @param {object} event - IPC event
* @param {object} payload - Transfer configuration
* @param {function} [onProgress] - Optional progress callback (transferred, total, speed)
*/
async function startTransfer(event, payload, onProgress) {
const {
@@ -176,47 +282,121 @@ async function startTransfer(event, payload, onProgress) {
} = payload;
const sender = event.sender;
// Register transfer for cancellation
const transfer = { cancelled: false, readStream: null, writeStream: null };
const transfer = { cancelled: false, readStream: null, writeStream: null, abort: null };
activeTransfers.set(transferId, transfer);
const transferCreatedAt = Date.now();
let lastTime = Date.now();
let lastTransferred = 0;
let speed = 0;
// ── Progress/speed tracking ──────────────────────────────────────────────
// Keep progress monotonic and compute speed from a strict sliding window.
const speedSamples = [{ time: transferCreatedAt, bytes: 0 }]; // [{ time, bytes }]
let lastObservedTransferred = 0;
let lastObservedTotal = Math.max(0, totalBytes || 0);
let lastProgressSentTime = 0;
let lastProgressSentBytes = -1;
const computeWindowSpeed = (now, transferred) => {
const targetTime = now - SPEED_WINDOW_MS;
// Keep exactly one sample before targetTime for boundary interpolation.
while (speedSamples.length >= 2 && speedSamples[1].time <= targetTime) {
speedSamples.shift();
}
const first = speedSamples[0];
if (!first) return 0;
let boundaryTime = first.time;
let boundaryBytes = first.bytes;
if (speedSamples.length >= 2 && targetTime > first.time) {
const next = speedSamples[1];
const range = next.time - first.time;
if (range > 0) {
const ratio = (targetTime - first.time) / range;
boundaryBytes = first.bytes + (next.bytes - first.bytes) * ratio;
boundaryTime = targetTime;
}
}
const elapsedMs = now - boundaryTime;
if (elapsedMs < SPEED_MIN_ELAPSED_MS) return 0;
const deltaBytes = transferred - boundaryBytes;
if (deltaBytes <= 0) return 0;
const speed = (deltaBytes * 1000) / elapsedMs;
return Number.isFinite(speed) && speed > 0 ? Math.round(speed) : 0;
};
const emitProgress = (now, transferred, total, speed, force = false) => {
const isComplete = total > 0 && transferred >= total;
const transferredChanged = transferred !== lastProgressSentBytes;
const timeSinceLast = now - lastProgressSentTime;
const bytesSinceLast = transferred - lastProgressSentBytes;
if (
force ||
isComplete ||
(transferredChanged &&
(timeSinceLast >= PROGRESS_THROTTLE_MS || bytesSinceLast >= PROGRESS_THROTTLE_BYTES))
) {
lastProgressSentTime = now;
lastProgressSentBytes = transferred;
sender.send("netcatty:transfer:progress", { transferId, transferred, speed, totalBytes: total });
}
};
const cleanupTransfer = () => {
activeTransfers.delete(transferId);
};
const sendProgress = (transferred, total) => {
if (transfer.cancelled) return;
const now = Date.now();
const elapsed = now - lastTime;
if (elapsed >= 100) {
speed = Math.round((transferred - lastTransferred) / (elapsed / 1000));
lastTime = now;
lastTransferred = transferred;
let normalizedTotal = Number.isFinite(total) && total > 0 ? total : 0;
if (normalizedTotal === 0) {
normalizedTotal = lastObservedTotal || 0;
}
normalizedTotal = Math.max(normalizedTotal, lastObservedTotal, 0);
let normalizedTransferred = Number.isFinite(transferred) && transferred > 0 ? transferred : 0;
if (normalizedTotal > 0) {
normalizedTransferred = Math.min(normalizedTransferred, normalizedTotal);
}
normalizedTransferred = Math.max(normalizedTransferred, lastObservedTransferred);
lastObservedTransferred = normalizedTransferred;
lastObservedTotal = normalizedTotal;
const lastSample = speedSamples[speedSamples.length - 1];
if (!lastSample || lastSample.bytes !== normalizedTransferred || now - lastSample.time >= PROGRESS_THROTTLE_MS) {
speedSamples.push({ time: now, bytes: normalizedTransferred });
}
// Call optional progress callback if provided
const speed = computeWindowSpeed(now, normalizedTransferred);
if (onProgress) {
onProgress(transferred, total, speed);
onProgress(normalizedTransferred, normalizedTotal, speed);
}
sender.send("netcatty:transfer:progress", { transferId, transferred, speed, totalBytes: total });
emitProgress(now, normalizedTransferred, normalizedTotal, speed);
};
const sendComplete = () => {
activeTransfers.delete(transferId);
sender.send("netcatty:transfer:complete", { transferId });
cleanupTransfer();
};
const sendError = (error) => {
activeTransfers.delete(transferId);
cleanupTransfer();
sender.send("netcatty:transfer:error", { transferId, error: error.message || String(error) });
};
try {
let fileSize = totalBytes || 0;
// Get file size if not provided
if (!fileSize) {
if (sourceType === 'local') {
const stat = await fs.promises.stat(sourcePath);
@@ -230,23 +410,19 @@ async function startTransfer(event, payload, onProgress) {
}
}
// Send initial progress
sendProgress(0, fileSize);
// Handle different transfer scenarios
if (sourceType === 'local' && targetType === 'sftp') {
// Upload: Local -> SFTP using streams (supports cancellation)
const client = sftpClients.get(targetSftpId);
if (!client) throw new Error("Target SFTP session not found");
const dir = path.dirname(targetPath).replace(/\\/g, '/');
try { await ensureRemoteDirForSession(targetSftpId, dir, targetEncoding); } catch {}
try { await ensureRemoteDirForSession(targetSftpId, dir, targetEncoding); } catch { }
const encodedTargetPath = encodePathForSession(targetSftpId, targetPath, targetEncoding);
await uploadWithStreams(sourcePath, encodedTargetPath, client, fileSize, transfer, sendProgress);
await uploadFile(sourcePath, encodedTargetPath, client, fileSize, transfer, sendProgress);
} else if (sourceType === 'sftp' && targetType === 'local') {
// Download: SFTP -> Local using streams (supports cancellation)
const client = sftpClients.get(sourceSftpId);
if (!client) throw new Error("Source SFTP session not found");
@@ -254,16 +430,15 @@ async function startTransfer(event, payload, onProgress) {
await fs.promises.mkdir(dir, { recursive: true });
const encodedSourcePath = encodePathForSession(sourceSftpId, sourcePath, sourceEncoding);
await downloadWithStreams(encodedSourcePath, targetPath, client, fileSize, transfer, sendProgress);
await downloadFile(encodedSourcePath, targetPath, client, fileSize, transfer, sendProgress);
} else if (sourceType === 'local' && targetType === 'local') {
// Local copy: use streams
const dir = path.dirname(targetPath);
await fs.promises.mkdir(dir, { recursive: true });
await new Promise((resolve, reject) => {
const readStream = fs.createReadStream(sourcePath);
const writeStream = fs.createWriteStream(targetPath);
const readStream = fs.createReadStream(sourcePath, { highWaterMark: TRANSFER_CHUNK_SIZE });
const writeStream = fs.createWriteStream(targetPath, { highWaterMark: TRANSFER_CHUNK_SIZE });
let transferred = 0;
let finished = false;
@@ -276,8 +451,8 @@ async function startTransfer(event, payload, onProgress) {
readStream.removeAllListeners();
writeStream.removeAllListeners();
if (err) {
try { readStream.destroy(); } catch {}
try { writeStream.destroy(); } catch {}
try { readStream.destroy(); } catch { }
try { writeStream.destroy(); } catch { }
reject(err);
} else {
resolve();
@@ -285,36 +460,23 @@ async function startTransfer(event, payload, onProgress) {
};
readStream.on('data', (chunk) => {
if (transfer.cancelled) {
cleanup(new Error('Transfer cancelled'));
return;
}
if (transfer.cancelled) { cleanup(new Error('Transfer cancelled')); return; }
transferred += chunk.length;
sendProgress(transferred, fileSize);
});
readStream.on('error', cleanup);
writeStream.on('error', cleanup);
// Handle normal completion
writeStream.on('finish', () => {
if (transfer.cancelled) {
cleanup(new Error('Transfer cancelled'));
} else {
cleanup(null);
}
if (transfer.cancelled) cleanup(new Error('Transfer cancelled'));
else cleanup(null);
});
// Handle stream destruction (destroy() emits 'close' but not 'finish')
writeStream.on('close', () => {
if (transfer.cancelled) {
cleanup(new Error('Transfer cancelled'));
}
if (transfer.cancelled) cleanup(new Error('Transfer cancelled'));
});
readStream.pipe(writeStream);
});
} else if (sourceType === 'sftp' && targetType === 'sftp') {
// SFTP to SFTP: download to temp then upload using streams
const tempPath = path.join(os.tmpdir(), `netcatty-transfer-${transferId}`);
const sourceClient = sftpClients.get(sourceSftpId);
@@ -322,43 +484,39 @@ async function startTransfer(event, payload, onProgress) {
if (!sourceClient) throw new Error("Source SFTP session not found");
if (!targetClient) throw new Error("Target SFTP session not found");
// Download phase (0-50%) - wrap progress to show 0-50%
const encodedSourcePath = encodePathForSession(sourceSftpId, sourcePath, sourceEncoding);
const downloadProgress = (transferred, total) => {
const downloadProgress = (transferred) => {
sendProgress(Math.floor(transferred / 2), fileSize);
};
await downloadWithStreams(encodedSourcePath, tempPath, sourceClient, fileSize, transfer, downloadProgress);
await downloadFile(encodedSourcePath, tempPath, sourceClient, fileSize, transfer, downloadProgress);
if (transfer.cancelled) {
try { await fs.promises.unlink(tempPath); } catch {}
try { await fs.promises.unlink(tempPath); } catch { }
throw new Error('Transfer cancelled');
}
// Upload phase (50-100%) - wrap progress to show 50-100%
const dir = path.dirname(targetPath).replace(/\\/g, '/');
try { await ensureRemoteDirForSession(targetSftpId, dir, targetEncoding); } catch {}
try { await ensureRemoteDirForSession(targetSftpId, dir, targetEncoding); } catch { }
const encodedTargetPath = encodePathForSession(targetSftpId, targetPath, targetEncoding);
const uploadProgress = (transferred, total) => {
const uploadProgress = (transferred) => {
sendProgress(Math.floor(fileSize / 2) + Math.floor(transferred / 2), fileSize);
};
await uploadWithStreams(tempPath, encodedTargetPath, targetClient, fileSize, transfer, uploadProgress);
await uploadFile(tempPath, encodedTargetPath, targetClient, fileSize, transfer, uploadProgress);
// Cleanup temp file
try { await fs.promises.unlink(tempPath); } catch {}
try { await fs.promises.unlink(tempPath); } catch { }
} else {
throw new Error("Invalid transfer configuration");
}
// Send final 100% progress
sendProgress(fileSize, fileSize);
sendComplete();
return { transferId, totalBytes: fileSize };
} catch (err) {
if (err.message === 'Transfer cancelled') {
activeTransfers.delete(transferId);
cleanupTransfer();
sender.send("netcatty:transfer:cancelled", { transferId });
} else {
sendError(err);
@@ -372,24 +530,21 @@ async function startTransfer(event, payload, onProgress) {
*/
async function cancelTransfer(event, payload) {
const { transferId } = payload;
console.log('[transferBridge] cancelTransfer called for:', transferId);
const transfer = activeTransfers.get(transferId);
console.log('[transferBridge] Found transfer:', !!transfer, 'activeTransfers keys:', Array.from(activeTransfers.keys()));
if (transfer) {
transfer.cancelled = true;
console.log('[transferBridge] Set cancelled=true for transfer:', transferId);
// Destroy streams to immediately stop the transfer
if (typeof transfer.abort === "function") {
try { transfer.abort(); } catch { }
}
// Destroy streams for stream-based fallback transfers
if (transfer.readStream) {
console.log('[transferBridge] Destroying read stream');
try { transfer.readStream.destroy(); } catch (e) { console.log('[transferBridge] Error destroying readStream:', e); }
try { transfer.readStream.destroy(); } catch { }
}
if (transfer.writeStream) {
console.log('[transferBridge] Destroying write stream');
try { transfer.writeStream.destroy(); } catch (e) { console.log('[transferBridge] Error destroying writeStream:', e); }
try { transfer.writeStream.destroy(); } catch { }
}
console.log('[transferBridge] Transfer marked for cancellation');
}
return { success: true };
}

View File

@@ -35,6 +35,7 @@ const DEBUG_WINDOWS = process.env.NETCATTY_DEBUG_WINDOWS === "1";
const OAUTH_DEFAULT_WIDTH = 600;
const OAUTH_DEFAULT_HEIGHT = 700;
const OAUTH_OVERLAY_ID = "__netcatty_oauth_loading__";
const OAUTH_LOOPBACK_PORT = 45678; // must match electron/bridges/oauthBridge.cjs
const WINDOW_STATE_FILE = "window-state.json";
const DEFAULT_WINDOW_WIDTH = 1400;
const DEFAULT_WINDOW_HEIGHT = 900;
@@ -368,6 +369,86 @@ function parseWindowOpenFeatures(features) {
};
}
function createExternalOnlyWindowOpenHandler(shell) {
return (details) => {
const targetUrl = details?.url;
if (targetUrl && typeof targetUrl === "string" && /^https?:/i.test(targetUrl)) {
try {
void shell?.openExternal?.(targetUrl);
} catch {
// ignore
}
}
return { action: "deny" };
};
}
function createAppWindowOpenHandler(shell, { backgroundColor, appIcon }) {
const allowedPopupHosts = new Set([
// OAuth (PKCE loopback)
"accounts.google.com",
"login.microsoftonline.com",
"login.live.com",
]);
const isAllowedInAppPopupUrl = (rawUrl) => {
try {
const u = new URL(String(rawUrl));
if (u.protocol === "https:") {
return allowedPopupHosts.has(u.hostname);
}
if (u.protocol === "http:") {
// Allow ONLY the loopback OAuth callback page.
const isLoopback =
u.hostname === "127.0.0.1" || u.hostname === "localhost";
return isLoopback && u.port === String(OAUTH_LOOPBACK_PORT) && u.pathname === "/oauth/callback";
}
return false;
} catch {
return false;
}
};
return (details) => {
const targetUrl = details?.url;
if (!targetUrl || typeof targetUrl !== "string" || !/^https?:/i.test(targetUrl)) {
return { action: "deny" };
}
// Default: open in system browser to reduce remote-content attack surface.
if (!isAllowedInAppPopupUrl(targetUrl)) {
try {
void shell?.openExternal?.(targetUrl);
} catch {
// ignore
}
return { action: "deny" };
}
const size = parseWindowOpenFeatures(details?.features);
return {
action: "allow",
overrideBrowserWindowOptions: {
width: size.width || OAUTH_DEFAULT_WIDTH,
height: size.height || OAUTH_DEFAULT_HEIGHT,
minWidth: 420,
minHeight: 560,
backgroundColor,
icon: appIcon,
autoHideMenuBar: true,
menuBarVisible: false,
title: "Netcatty Authorization",
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
// Sandboxed because this window renders remote content and does not need a preload bridge.
sandbox: true,
},
},
};
};
}
function attachOAuthLoadingOverlay(win) {
if (!win || win.isDestroyed?.()) return;
@@ -543,7 +624,7 @@ function setupDeferredShow(win, { timeoutMs = 3000, waitForRendererReady = true
* Create the main application window
*/
async function createWindow(electronModule, options) {
const { BrowserWindow, nativeTheme, app, screen } = electronModule;
const { BrowserWindow, nativeTheme, app, screen, shell } = electronModule;
const { preload, devServerUrl, isDev, appIcon, isMac, onRegisterBridge, electronDir } = options;
// Store app reference for window state persistence
@@ -611,6 +692,35 @@ async function createWindow(electronModule, options) {
mainWindow = win;
// Prevent top-level navigation away from the app origin. If a remote origin ever
// loads in a privileged window (with preload), it can become an RCE vector.
const allowedOrigins = new Set(["app://netcatty"]);
if (isDev && devServerUrl) {
try {
allowedOrigins.add(new URL(getDevRendererBaseUrl(devServerUrl)).origin);
} catch {
// ignore invalid dev server URL
}
}
const isAllowedTopLevelUrl = (targetUrl) => {
try {
return allowedOrigins.has(new URL(String(targetUrl)).origin);
} catch {
return false;
}
};
const blockUntrustedNavigation = (event, targetUrl) => {
if (isAllowedTopLevelUrl(targetUrl)) return;
try {
event.preventDefault();
} catch {
// ignore
}
debugLog("Blocked navigation to untrusted origin", { targetUrl });
};
win.webContents.on("will-navigate", blockUntrustedNavigation);
win.webContents.on("will-redirect", blockUntrustedNavigation);
// Restore maximized state if it was saved
if (savedState?.isMaximized && !savedState?.isFullScreen) {
win.once("ready-to-show", () => {
@@ -732,36 +842,18 @@ async function createWindow(electronModule, options) {
} catch {
// ignore
}
// Never allow chained popups from remote content windows.
try {
childWindow.webContents?.setWindowOpenHandler?.(createExternalOnlyWindowOpenHandler(shell));
} catch {
// ignore
}
attachOAuthLoadingOverlay(childWindow);
});
win.webContents.setWindowOpenHandler((details) => {
const url = details?.url;
if (!url || !/^https?:/i.test(url)) {
return { action: "deny" };
}
const size = parseWindowOpenFeatures(details?.features);
return {
action: "allow",
overrideBrowserWindowOptions: {
width: size.width || OAUTH_DEFAULT_WIDTH,
height: size.height || OAUTH_DEFAULT_HEIGHT,
minWidth: 420,
minHeight: 560,
backgroundColor,
icon: appIcon,
autoHideMenuBar: true,
menuBarVisible: false,
title: "Netcatty Authorization",
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
},
},
};
});
win.webContents.setWindowOpenHandler(
createAppWindowOpenHandler(shell, { backgroundColor, appIcon })
);
// Register window control handlers
registerWindowHandlers(electronModule.ipcMain, nativeTheme);
@@ -788,7 +880,7 @@ async function createWindow(electronModule, options) {
* Create or focus the settings window
*/
async function openSettingsWindow(electronModule, options) {
const { BrowserWindow } = electronModule;
const { BrowserWindow, shell } = electronModule;
const { preload, devServerUrl, isDev, appIcon, isMac, electronDir } = options;
// If settings window already exists, just focus it
@@ -830,6 +922,52 @@ async function openSettingsWindow(electronModule, options) {
settingsWindow = win;
// Open external links in system browser by default, and allow only known OAuth hosts in-app.
try {
win.webContents?.setWindowOpenHandler?.(
createAppWindowOpenHandler(shell, { backgroundColor, appIcon })
);
} catch {
// ignore
}
// Never allow chained popups from remote content windows spawned from settings.
win.webContents?.on?.("did-create-window", (childWindow) => {
try {
childWindow.webContents?.setWindowOpenHandler?.(createExternalOnlyWindowOpenHandler(shell));
} catch {
// ignore
}
});
// Same navigation hardening as the main window (settings has preload access too).
const allowedOrigins = new Set(["app://netcatty"]);
if (isDev && devServerUrl) {
try {
allowedOrigins.add(new URL(getDevRendererBaseUrl(devServerUrl)).origin);
} catch {
// ignore invalid dev server URL
}
}
const isAllowedTopLevelUrl = (targetUrl) => {
try {
return allowedOrigins.has(new URL(String(targetUrl)).origin);
} catch {
return false;
}
};
const blockUntrustedNavigation = (event, targetUrl) => {
if (isAllowedTopLevelUrl(targetUrl)) return;
try {
event.preventDefault();
} catch {
// ignore
}
debugLog("Blocked navigation to untrusted origin (settings)", { targetUrl });
};
win.webContents.on("will-navigate", blockUntrustedNavigation);
win.webContents.on("will-redirect", blockUntrustedNavigation);
if (isMac) {
try {
win.setWindowButtonVisibility(true);

View File

@@ -289,7 +289,8 @@ const ensureKeyDir = async () => {
const writeKeyToDisk = async (keyId, privateKey) => {
if (!privateKey) return null;
await ensureKeyDir();
const filename = `${keyId || "temp"}.pem`;
const safeId = String(keyId || "temp").replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 120);
const filename = `${safeId}.pem`;
const target = path.join(keyRoot, filename);
const normalized = privateKey.endsWith("\n") ? privateKey : `${privateKey}\n`;
try {
@@ -644,9 +645,9 @@ const registerBridges = (win) => {
ipcMain.handle("netcatty:deleteTempFile", async (_event, { filePath }) => {
try {
// Only allow deleting files in Netcatty temp directory for security
const netcattyTempDir = tempDirBridge.getTempDir();
const resolvedPath = path.resolve(filePath);
if (!resolvedPath.startsWith(netcattyTempDir)) {
const netcattyTempDir = path.resolve(tempDirBridge.getTempDir());
const resolvedPath = path.resolve(String(filePath || ""));
if (!isPathInside(netcattyTempDir, resolvedPath)) {
console.warn(`[Main] Refused to delete file outside Netcatty temp dir: ${filePath}`);
return { success: false };
}
@@ -755,8 +756,24 @@ if (!gotLock) {
} catch {}
});
// Re-create window on macOS dock click
// Re-create or focus window on macOS dock click
app.on("activate", () => {
// If the main window was hidden (e.g. "close to tray"), clicking the Dock icon
// should bring it back. Fallback to creating a new window if none exists.
try {
const mainWin = windowManager.getMainWindow?.();
if (mainWin && !mainWin.isDestroyed?.()) {
if (mainWin.isMinimized?.()) mainWin.restore();
mainWin.show?.();
mainWin.focus?.();
try {
app.focus({ steal: true });
} catch {}
return;
}
} catch {}
if (focusMainWindow()) return;
if (BrowserWindow.getAllWindows().length === 0) {
void createWindow().catch((err) => {
console.error("[Main] Failed to create window on activate:", err);

View File

@@ -854,4 +854,50 @@ const api = {
// Merge with existing netcatty (if any) to avoid stale objects on hot reload
const existing = (typeof window !== "undefined" && window.netcatty) ? window.netcatty : {};
contextBridge.exposeInMainWorld("netcatty", { ...existing, ...api });
function getAllowedRendererOrigins() {
const origins = new Set(["app://netcatty"]);
const devServerUrl = process.env.VITE_DEV_SERVER_URL;
if (typeof devServerUrl === "string" && devServerUrl.length > 0) {
try {
const u = new URL(devServerUrl);
origins.add(u.origin);
// Vite often binds to 0.0.0.0, but Chromium navigates via localhost.
if (
u.hostname === "0.0.0.0" ||
u.hostname === "127.0.0.1" ||
u.hostname === "::1" ||
u.hostname === "[::1]" ||
u.hostname === "::" ||
u.hostname === "[::]"
) {
u.hostname = "localhost";
origins.add(u.origin);
}
} catch {
// ignore invalid dev URL
}
}
return origins;
}
function isTrustedRendererLocation(allowedOrigins) {
try {
const origin = window?.location?.origin;
return typeof origin === "string" && allowedOrigins.has(origin);
} catch {
return false;
}
}
const allowedOrigins = getAllowedRendererOrigins();
if (isTrustedRendererLocation(allowedOrigins)) {
contextBridge.exposeInMainWorld("netcatty", { ...existing, ...api });
} else {
// If a window navigates to an untrusted origin, do NOT expose the bridge.
try {
console.warn("[Preload] Refusing to expose netcatty bridge to untrusted origin:", window?.location?.origin);
} catch {
// ignore
}
}

4
global.d.ts vendored
View File

@@ -72,6 +72,8 @@ declare global {
jumpHosts?: NetcattyJumpHost[];
// SSH-level keepalive interval in seconds (0 = disabled)
keepaliveInterval?: number;
// Enable legacy SSH algorithms for older network equipment
legacyAlgorithms?: boolean;
// Use sudo for SFTP server
sudo?: boolean;
}
@@ -200,6 +202,8 @@ declare global {
memFree: number | null; // Free memory in MB
memBuffers: number | null; // Buffers in MB
memCached: number | null; // Cached in MB
swapTotal: number | null; // Total swap in MB
swapUsed: number | null; // Used swap in MB
topProcesses: Array<{ // Top 10 processes by memory
pid: string;
memPercent: number;

View File

@@ -64,3 +64,6 @@ export const STORAGE_KEY_MANAGED_SOURCES = 'netcatty_managed_sources_v1';
// Global Toggle Window Settings (Quake Mode)
export const STORAGE_KEY_TOGGLE_WINDOW_HOTKEY = 'netcatty_toggle_window_hotkey_v1';
export const STORAGE_KEY_CLOSE_TO_TRAY = 'netcatty_close_to_tray_v1';
// Custom Terminal Themes
export const STORAGE_KEY_CUSTOM_THEMES = 'netcatty_custom_themes_v1';

View File

@@ -0,0 +1,173 @@
import { TerminalTheme } from '../../domain/models';
/**
* Parse an .itermcolors XML plist file into a TerminalTheme.
*
* .itermcolors is Apple Plist XML with color keys like:
* "Ansi 0 Color", "Background Color", "Foreground Color", etc.
* Each color is a <dict> with "Red Component", "Green Component", "Blue Component" as <real> floats (0.01.0).
*/
/** Map .itermcolors key names to TerminalTheme color fields */
const COLOR_KEY_MAP: Record<string, keyof TerminalTheme['colors']> = {
'Ansi 0 Color': 'black',
'Ansi 1 Color': 'red',
'Ansi 2 Color': 'green',
'Ansi 3 Color': 'yellow',
'Ansi 4 Color': 'blue',
'Ansi 5 Color': 'magenta',
'Ansi 6 Color': 'cyan',
'Ansi 7 Color': 'white',
'Ansi 8 Color': 'brightBlack',
'Ansi 9 Color': 'brightRed',
'Ansi 10 Color': 'brightGreen',
'Ansi 11 Color': 'brightYellow',
'Ansi 12 Color': 'brightBlue',
'Ansi 13 Color': 'brightMagenta',
'Ansi 14 Color': 'brightCyan',
'Ansi 15 Color': 'brightWhite',
'Background Color': 'background',
'Foreground Color': 'foreground',
'Cursor Color': 'cursor',
'Selection Color': 'selection',
};
/**
* Convert a float (0.01.0) to a two-digit hex string.
*/
function floatToHex(value: number): string {
const clamped = Math.max(0, Math.min(1, value));
const byte = Math.round(clamped * 255);
return byte.toString(16).padStart(2, '0');
}
/**
* Detect if a background color is dark or light based on relative luminance.
*/
function isDarkBackground(hex: string): boolean {
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
// Relative luminance formula (ITU-R BT.709)
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
return luminance < 0.5;
}
/**
* Parse a single color <dict> element from the plist XML.
* Returns a hex color string like '#rrggbb'.
*/
function parseColorDict(dictElement: Element): string | null {
const children = dictElement.children;
let r = 0, g = 0, b = 0;
let found = 0;
for (let i = 0; i < children.length - 1; i++) {
const child = children[i];
if (child.tagName !== 'key') continue;
const key = child.textContent?.trim();
const valueEl = children[i + 1];
if (!key || !valueEl) continue;
// Accept <real> (float 0.01.0) and <integer> (0255) plist types
const tag = valueEl.tagName;
if (tag !== 'real' && tag !== 'integer') continue;
const raw = parseFloat(valueEl.textContent || '0');
if (isNaN(raw)) continue; // reject non-numeric content
// Normalize: <integer> values are 0-255, <real> values are 0.0-1.0
const value = tag === 'integer' ? raw / 255 : raw;
if (key === 'Red Component') { r = value; found++; }
else if (key === 'Green Component') { g = value; found++; }
else if (key === 'Blue Component') { b = value; found++; }
}
if (found < 3) return null;
return `#${floatToHex(r)}${floatToHex(g)}${floatToHex(b)}`;
}
/**
* Parse an .itermcolors XML string into a TerminalTheme.
*
* @param xml - The raw XML string from the .itermcolors file
* @param name - The theme name (usually derived from the filename)
* @returns - A TerminalTheme, or null if parsing fails
*/
export function parseItermcolors(xml: string, name: string): TerminalTheme | null {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(xml, 'text/xml');
// Check for parse errors
const parseError = doc.querySelector('parsererror');
if (parseError) return null;
// Get the root <dict> inside <plist>
const rootDict = doc.querySelector('plist > dict');
if (!rootDict) return null;
// Parse key-value pairs from the root dict
const colors: Partial<TerminalTheme['colors']> = {};
const children = rootDict.children;
for (let i = 0; i < children.length - 1; i++) {
const child = children[i];
if (child.tagName !== 'key') continue;
const keyName = child.textContent?.trim() || '';
const colorField = COLOR_KEY_MAP[keyName];
if (!colorField) continue;
// The next sibling should be a <dict> with color components
const nextSibling = children[i + 1];
if (!nextSibling || nextSibling.tagName !== 'dict') continue;
const hex = parseColorDict(nextSibling);
if (hex) {
colors[colorField] = hex;
}
}
// Validate we have at least the essential colors
if (!colors.background || !colors.foreground) return null;
// Fill any missing ANSI colors with sensible defaults
const defaults: TerminalTheme['colors'] = {
background: colors.background,
foreground: colors.foreground,
cursor: colors.cursor || colors.foreground,
selection: colors.selection || (isDarkBackground(colors.background) ? '#264f78' : '#add6ff'),
black: colors.black || '#000000',
red: colors.red || '#cc0000',
green: colors.green || '#00cc00',
yellow: colors.yellow || '#cccc00',
blue: colors.blue || '#0000cc',
magenta: colors.magenta || '#cc00cc',
cyan: colors.cyan || '#00cccc',
white: colors.white || '#cccccc',
brightBlack: colors.brightBlack || '#666666',
brightRed: colors.brightRed || '#ff0000',
brightGreen: colors.brightGreen || '#00ff00',
brightYellow: colors.brightYellow || '#ffff00',
brightBlue: colors.brightBlue || '#0000ff',
brightMagenta: colors.brightMagenta || '#ff00ff',
brightCyan: colors.brightCyan || '#00ffff',
brightWhite: colors.brightWhite || '#ffffff',
};
const id = `custom-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${Date.now()}`;
return {
id,
name,
type: isDarkBackground(defaults.background) ? 'dark' : 'light',
isCustom: true,
colors: defaults,
};
} catch {
return null;
}
}

View File

@@ -0,0 +1,12 @@
import { netcattyBridge } from "./netcattyBridge";
export const getCredentialProtectionAvailability = async (): Promise<boolean | null> => {
const bridge = netcattyBridge.get();
if (!bridge?.credentialsAvailable) return null;
try {
return await bridge.credentialsAvailable();
} catch {
return null;
}
};

10
package-lock.json generated
View File

@@ -6378,14 +6378,14 @@
}
},
"node_modules/axios": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz",
"integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==",
"version": "1.13.5",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
}
},

View File

@@ -20,6 +20,8 @@
"pack:win": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --win --publish=never",
"pack:mac": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --mac --publish=never",
"pack:linux": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --publish=never",
"pack:linux-x64": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --x64 --publish=never",
"pack:linux-arm64": "npm run build && cross-env NODE_OPTIONS=--disable-warning=DEP0190 electron-builder --config electron-builder.config.cjs --linux --arm64 --publish=never",
"postinstall": "electron-builder install-app-deps && patch-package",
"rebuild": "electron-builder install-app-deps",
"lint": "eslint .",
@@ -82,6 +84,7 @@
"wait-on": "^9.0.3"
},
"overrides": {
"cpu-features": "npm:empty-npm-package@1.0.0"
"cpu-features": "npm:empty-npm-package@1.0.0",
"axios": "1.13.5"
}
}

View File

@@ -1,3 +1,112 @@
diff --git a/node_modules/ssh2/lib/client.js b/node_modules/ssh2/lib/client.js
index 7291c2c..8943c9a 100644
--- a/node_modules/ssh2/lib/client.js
+++ b/node_modules/ssh2/lib/client.js
@@ -2170,6 +2170,29 @@ function getKeyAlgos(client, key, serverSigAlgs) {
return [];
}
return [['ssh-rsa', 'sha1']];
+
+ // OpenSSH RSA user certificates
+ // - publickey algorithm name must be the *cert* variant
+ // - signature algorithm uses the non-cert variant (handled in Protocol.js)
+ case 'ssh-rsa-cert-v01@openssh.com':
+ case 'rsa-sha2-256-cert-v01@openssh.com':
+ case 'rsa-sha2-512-cert-v01@openssh.com':
+ if (client._protocol._compatFlags & COMPAT.IMPLY_RSA_SHA2_SIGALGS) {
+ if (!Array.isArray(serverSigAlgs))
+ serverSigAlgs = ['rsa-sha2-256', 'rsa-sha2-512'];
+ else
+ serverSigAlgs = ['rsa-sha2-256', 'rsa-sha2-512', ...serverSigAlgs];
+ }
+ if (Array.isArray(serverSigAlgs)) {
+ // Prefer SHA-512 when available (matches modern OpenSSH default)
+ if (serverSigAlgs.indexOf('rsa-sha2-512') !== -1)
+ return [['rsa-sha2-512-cert-v01@openssh.com', 'sha512']];
+ if (serverSigAlgs.indexOf('rsa-sha2-256') !== -1)
+ return [['rsa-sha2-256-cert-v01@openssh.com', 'sha256']];
+ if (serverSigAlgs.indexOf('ssh-rsa') === -1)
+ return [];
+ }
+ return [['ssh-rsa-cert-v01@openssh.com', 'sha1']];
}
}
diff --git a/node_modules/ssh2/lib/protocol/Protocol.js b/node_modules/ssh2/lib/protocol/Protocol.js
index 7302488..95584c5 100644
--- a/node_modules/ssh2/lib/protocol/Protocol.js
+++ b/node_modules/ssh2/lib/protocol/Protocol.js
@@ -701,11 +701,19 @@ class Protocol {
if (signature === false)
throw new Error('Error while converting handshake signature');
+ let sigAlgo = keyAlgo;
+ if (signature && typeof signature === 'object' && signature._signatureAlgorithm)
+ sigAlgo = signature._signatureAlgorithm;
+ else if (typeof keyAlgo === 'string'
+ && keyAlgo.endsWith('-cert-v01@openssh.com'))
+ sigAlgo = keyAlgo.slice(0, -'-cert-v01@openssh.com'.length);
+
const sigLen = signature.length;
+ const sigAlgoLen = Buffer.byteLength(sigAlgo);
p = this._packetRW.write.allocStart;
packet = this._packetRW.write.alloc(
1 + 4 + userLen + 4 + 14 + 4 + 9 + 1 + 4 + algoLen + 4 + pubKeyLen + 4
- + 4 + algoLen + 4 + sigLen
+ + 4 + sigAlgoLen + 4 + sigLen
);
// TODO: simply copy from original "packet" to new `packet` to avoid
@@ -729,12 +737,12 @@ class Protocol {
writeUInt32BE(packet, pubKeyLen, p += algoLen);
packet.set(pubKey, p += 4);
- writeUInt32BE(packet, 4 + algoLen + 4 + sigLen, p += pubKeyLen);
+ writeUInt32BE(packet, 4 + sigAlgoLen + 4 + sigLen, p += pubKeyLen);
- writeUInt32BE(packet, algoLen, p += 4);
- packet.utf8Write(keyAlgo, p += 4, algoLen);
+ writeUInt32BE(packet, sigAlgoLen, p += 4);
+ packet.utf8Write(sigAlgo, p += 4, sigAlgoLen);
- writeUInt32BE(packet, sigLen, p += algoLen);
+ writeUInt32BE(packet, sigLen, p += sigAlgoLen);
packet.set(signature, p += 4);
// Servers shouldn't send packet type 60 in response to signed publickey
@@ -810,19 +818,27 @@ class Protocol {
if (!signature)
throw new Error('Error while converting handshake signature');
+ let sigAlgo = keyAlgo;
+ if (signature && typeof signature === 'object' && signature._signatureAlgorithm)
+ sigAlgo = signature._signatureAlgorithm;
+ else if (typeof keyAlgo === 'string'
+ && keyAlgo.endsWith('-cert-v01@openssh.com'))
+ sigAlgo = keyAlgo.slice(0, -'-cert-v01@openssh.com'.length);
+
const sigLen = signature.length;
+ const sigAlgoLen = Buffer.byteLength(sigAlgo);
const reqDataLen = (data.length - sesLen - 4);
p = this._packetRW.write.allocStart;
const packet = this._packetRW.write.alloc(
- reqDataLen + 4 + 4 + algoLen + 4 + sigLen
+ reqDataLen + 4 + 4 + sigAlgoLen + 4 + sigLen
);
bufferCopy(data, packet, 4 + sesLen, data.length, p);
- writeUInt32BE(packet, 4 + algoLen + 4 + sigLen, p += reqDataLen);
- writeUInt32BE(packet, algoLen, p += 4);
- packet.utf8Write(keyAlgo, p += 4, algoLen);
- writeUInt32BE(packet, sigLen, p += algoLen);
+ writeUInt32BE(packet, 4 + sigAlgoLen + 4 + sigLen, p += reqDataLen);
+ writeUInt32BE(packet, sigAlgoLen, p += 4);
+ packet.utf8Write(sigAlgo, p += 4, sigAlgoLen);
+ writeUInt32BE(packet, sigLen, p += sigAlgoLen);
packet.set(signature, p += 4);
this._authsQueue.push('hostbased');
diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/protocol/SFTP.js
index 9f33c02..9751164 100644
--- a/node_modules/ssh2/lib/protocol/SFTP.js