Compare commits

...

137 Commits

Author SHA1 Message Date
陈大猫
a451fd8811 Merge pull request #308 from binaricat/fix/issue-307-display-upload-path
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
fix(sftp): display upload destination path on completed task items (#307)
2026-03-10 21:26:06 +08:00
bincxz
49cef792a8 fix(sftp): display upload destination path on completed task items (#307)
Show the remote target path inline on completed upload task items
(e.g. "Completed - 1.2 MB → /home/user/dir") so users know exactly
where their files were uploaded after drag-and-drop to terminal.

- Add `targetPath` field to modal's TransferTask type
- Populate targetPath from currentPath in onTaskCreated callback
- Display targetPath on completed upload items in SftpModalUploadTasks
- Add i18n key `sftp.upload.completedToPath` (en/zh-CN)
2026-03-10 21:14:25 +08:00
陈大猫
62511ceb21 Merge pull request #305 from binaricat/fix/sftp-mfa-auth-304
fix(sftp): handle non-fatal agent auth errors for MFA/keyboard-interactive (#304)
2026-03-10 10:54:37 +08:00
bincxz
00cbb05d71 fix(sftp): handle end/close events during SSH connect phase
Address code review feedback: the direct ssh2.Client connect path
was missing end/close event handlers. If the server closes the
connection before 'ready' (e.g. rejected handshake, hop drops),
the promise now properly rejects instead of hanging forever.

Uses a settle/cleanup pattern to ensure listeners are removed and
the promise is resolved/rejected exactly once.
2026-03-10 10:40:47 +08:00
bincxz
3497614165 fix(sftp): fallback to standard SFTP when sudo sftp-server not found
When sudo SFTP fails with exit code 127 (sftp-server binary not found,
e.g. on ESXi), automatically fall back to the standard SFTP subsystem
channel instead of failing the entire connection. This avoids requiring
users to manually disable sudo mode for hosts that lack sftp-server.
2026-03-10 10:37:47 +08:00
bincxz
b652b836a7 fix(sftp): handle non-fatal agent auth errors for MFA/keyboard-interactive (#304)
Two compounding issues caused SFTP connections to fail when
keyboard-interactive (MFA) authentication was required:

1. ssh2-sftp-client's connect() installs error listeners that reject
   the entire connection on ANY error, including non-fatal agent auth
   failures. This prevents ssh2 from falling through to
   keyboard-interactive. Fix: bypass ssh2-sftp-client's connect() and
   use direct ssh2.Client with err.level === 'agent' filtering.

2. getSshAgentSocket() on Windows unconditionally returned the agent
   pipe path without checking if the SSH Agent service is running.
   Fix: added async getAvailableAgentSocket() that runs
   'sc query ssh-agent' before returning the pipe path.
2026-03-10 10:12:37 +08:00
陈大猫
cd604107ee Merge pull request #303 from binaricat/fix/unify-sync-payload
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
fix: unify sync payload logic & harden port forwarding lifecycle
2026-03-10 03:10:30 +08:00
bincxz
adc4c25dc9 fix: set cancelled flag in stopPortForward(tunnelId) IPC handler\n\nThe legacy stopPortForward(tunnelId) path also needs to mark\ntunnel.cancelled = true before conn.end() and skip the immediate\ndelete. Otherwise, when a user clicks Stop on a connecting rule,\nconn.on('close') sees no cancelled flag and rejects the Promise,\ncausing a false error toast. 2026-03-10 03:04:29 +08:00
bincxz
eaaf0265f8 fix: preserve cancelled markers in stopAllPortForwards\n\nMark all tunnel entries as cancelled before calling conn.end()\nand remove the .clear() call. Let each conn.on('close') handler\ndelete its own entry so it can read the cancelled flag first.\n\nPreviously, .clear() removed all entries before the async close\nevents fired, so the close handler saw no entry and treated the\nshutdown as an unexpected failure — surfacing error toasts or\ntriggering auto-reconnect for rules the user just cleared. 2026-03-10 02:57:56 +08:00
bincxz
f4d833497d fix: ref-count singleton effects and use only stopPortForwardByRuleId for cleanup\n\n1. Replace boolean guard flags (reconnectCancelListenerActive,\n heartbeatActive) with ref-counting. Resources are created when\n count goes 0→1 and destroyed when count goes 1→0. The previous\n boolean approach broke when React ran child effects before parent\n ones: opening the Port Forwarding page let the child register\n the listener/heartbeat, but navigating away tore them down even\n though the App instance was still mounted.\n\n2. stopAndCleanupRule now uses only stopPortForwardByRuleId (which\n sets tunnel.cancelled = true before conn.end()). The old code\n called stopPortForward(tunnelId) first, which deletes the\n main-process tunnel entry immediately — making the cancelled\n flag invisible to conn.on('close') and causing intentional\n deletions to surface as error toasts. 2026-03-10 02:50:58 +08:00
bincxz
75871717a9 fix: capture cancelled flag before close handler cleanup deletes the entry\n\nstopPortForwardByRuleId previously deleted the tunnel entry from\nportForwardingTunnels before conn.end() fired the async close\nevent. By the time conn.on('close') ran, the entry was gone and\nthe cancelled flag was invisible. The fallback check\n!portForwardingTunnels.has(tunnelId) was ambiguous — it was also\ntrue when conn.on('error') deleted the entry for a real failure.\n\nFix:\n1. Capture tunnel.cancelled BEFORE cleanup deletes the entry.\n2. Don't delete in stopPortForwardByRuleId — let conn.on('close')\n handle deletion so it can read the flag first.\n3. Remove the ambiguous !has() fallback check entirely. 2026-03-10 02:42:08 +08:00
bincxz
f6619c28ed fix: strip lastUsedAt from SettingsSyncTab localStorage fallback\n\nConsistent with the useAutoSync fallback, also clear lastUsedAt\nfrom rules read from localStorage before building the sync payload.\nPreviously, device-local usage timestamps leaked into the cloud\nsnapshot and were replicated to other devices on import. 2026-03-10 02:33:43 +08:00
bincxz
ca77315257 fix: handle cancelled handshakes gracefully and evict stale connecting entries\n\n1. startPortForward now checks result.cancelled for intentional\n cancellations (rule deleted/replaced during SSH handshake).\n Instead of triggering error state or reconnect, it transitions\n to 'inactive' and returns cleanly. Previously, success:false\n from a cancelled handshake would schedule another reconnect,\n resurrecting the tunnel a few seconds later.\n\n2. reconcileWithBackend now evicts 'connecting' entries seeded by\n a previous reconcile (observed from another window's handshake)\n when the backend no longer reports them. Only locally-initiated\n connecting entries (which have an unsubscribe callback from\n their startPortForward call) are preserved. Previously, stale\n connecting entries from failed/cancelled handshakes stayed\n forever, with the rule stuck showing 'connecting' in the UI. 2026-03-10 02:25:53 +08:00
bincxz
3ab681e63b fix: update heartbeat entries on status change and graceful intentional cancellation\n\n1. reconcileWithBackend Case 3: when a tunnel already exists in\n activeConnections but the backend reports a different status\n (e.g. connecting→active after handshake completed in another\n window), update the entry and include it in the 'appeared' set.\n Previously, existing entries were never updated, leaving\n secondary windows stuck on 'connecting' permanently.\n\n2. stopPortForwardByRuleId now marks tunnel.cancelled = true\n before calling conn.end(). The close handler checks this flag:\n intentional cancellations resolve with { cancelled: true }\n instead of rejecting with an Error. This prevents the renderer\n from showing a bogus error toast when a rule is deleted or\n replaced while its SSH handshake is still in progress. 2026-03-10 02:16:44 +08:00
bincxz
2ee7781b82 fix: reconnect stuck state, side-effect guards, and syncWithBackend status\n\n1. scheduleReconnectIfNeeded now returns false when the\n activeConnections entry is missing (deleted by stopAndCleanupRule\n while handshake was in-flight). Previously it returned true\n but never set the timeout, leaving reconnect-enabled rules\n stuck in 'connecting' permanently.\n\n2. Module-level guards (reconnectCancelListenerActive,\n heartbeatActive) prevent duplicate initReconnectCancelListener\n and reconcile heartbeat instances. The hook mounts from both\n App.tsx and PortForwardingNew.tsx, so without guards each\n window gets double listeners and double backend polling.\n\n3. syncWithBackend now uses tunnel.status from the backend\n (connecting or active) instead of hardcoding 'active',\n matching the reconcileWithBackend fix from the previous commit. 2026-03-10 02:09:03 +08:00
bincxz
95780a29dc fix: strip lastUsedAt from sync fallback and use real tunnel status in reconciliation\n\n1. useAutoSync localStorage fallback now also strips lastUsedAt\n (alongside status/error). Without this, the hash computed\n before async init (with lastUsedAt) differs from the hash\n after init (App.tsx strips it), causing a needless sync upload\n on every launch.\n\n2. reconcileWithBackend now uses tunnel.status from the backend\n (connecting or active) instead of hardcoding 'active' when\n seeding activeConnections. This prevents falsely marking a\n handshaking tunnel as active in the renderer. 2026-03-10 02:01:05 +08:00
bincxz
060c35f66a fix: auto-sync localStorage fallback for PF rules and settled Promise in bridge\n\n1. useAutoSync buildPayload/getDataHash now fall back to localStorage\n when portForwardingRules is empty (async init not complete).\n Previously, clicking sync immediately after launch would upload\n portForwardingRules: [] and overwrite the cloud snapshot.\n\n2. SettingsSyncTab localStorage fallback strips transient per-device\n fields (status, error) before building the sync payload.\n\n3. startPortForward Promise now tracks a settled flag across all\n resolve/reject paths. conn.on('close') rejects the Promise when\n it hasn't been settled yet (tunnel killed during SSH handshake\n by stopPortForwardByRuleId), preventing callers from hanging\n indefinitely in pendingOperations. 2026-03-10 01:52:48 +08:00
bincxz
ee5d3827d5 fix: reconnect cancel on clear-all, strip transient sync fields, tunnel connecting status\n\n1. importRules([]) now iterates stored rules calling\n stopAndCleanupRule() for each one, broadcasting per-rule reconnect\n cancellation to other windows. Previously only called\n stopAllPortForwards() which doesn't signal reconnect cancel.\n\n2. SettingsSyncTab localStorage fallback strips transient per-device\n fields (status, error) before feeding rules to buildSyncPayload.\n This prevents uploading stale connection state to the cloud.\n\n3. portForwardingBridge tunnel entries now track status explicitly:\n 'connecting' on early registration, 'active' after server.listen\n or forwardIn succeeds. listPortForwards and getPortForwardStatus\n report the actual status instead of hardcoding 'active'. 2026-03-10 01:42:01 +08:00
bincxz
f06333b95e fix: register tunnel in portForwardingTunnels before SSH handshake\n\nThe previous stopPortForwardByRuleId couldn't catch tunnels during\nSSH handshake because they were only added to portForwardingTunnels\nafter conn.on('ready') + server.listen/forwardIn succeeded.\n\nNow the connection is registered immediately before conn.connect()\nwith server: null. The conn.on('ready') handler updates the entry\nwith the real server object. Error/close handlers already delete\nthe entry, so cleanup is unchanged.\n\nThis closes the last timing window where a deleted rule's tunnel\ncould become orphaned. 2026-03-10 01:33:59 +08:00
bincxz
a07c644ec8 fix: add stopPortForwardByRuleId IPC and fix uninitialized diff baseline\n\nTwo issues:\n\n1. Cross-window cleanup couldn't stop tunnels still in SSH handshake\n because listPortForwards doesn't list them. New approach:\n stopPortForwardByRuleId IPC directly iterates the main process\n portForwardingTunnels map matching by rule ID in the tunnel ID\n string, catching tunnels in ANY state.\n\n - portForwardingBridge.cjs: new stopPortForwardByRuleId function\n - preload.cjs + global.d.ts: expose the new IPC\n - portForwardingService.ts: stopAndCleanupRule and\n initReconnectCancelListener now use stopPortForwardByRuleId\n instead of fragile listPortForwards + match\n\n2. importRules diff loop missed removed/changed rules in a freshly\n opened settings window where globalRules was still empty (async\n initializeStore hadn't finished). Now falls back to reading from\n localStorage as the diff baseline. 2026-03-10 01:28:06 +08:00
bincxz
1d5c40c665 fix: expose stopAllPortForwards via IPC for cross-window tunnel cleanup\n\nThe renderer's stopAllPortForwards only iterated activeConnections\nwhich is empty in a freshly opened settings window. The backend's\nstopAllPortForwards (which iterates portForwardingTunnels in the\nmain process) was only called from will-quit, never via IPC.\n\nChanges:\n- portForwardingBridge.cjs: register netcatty:portforward:stopAll\n- preload.cjs: expose stopAllPortForwards in the bridge API\n- global.d.ts: add type for stopAllPortForwards\n- portForwardingService.ts: after clearing local activeConnections,\n also call bridge.stopAllPortForwards() to stop any backend\n tunnels this renderer doesn't know about 2026-03-10 01:17:55 +08:00
bincxz
ab0c4ede7e fix: handle settings window initialization timing for sync and cleanup\n\nTwo race conditions in the settings window when hooks haven't finished\nasync initialization:\n\n1. clearAllLocalData calls importRules([]) but globalRules is still\n empty, so no stopAndCleanupRule calls are made. Fix: when\n importRules receives an empty array, call stopAllPortForwards()\n on the backend as a safety net.\n\n2. onBuildPayload reads portForwardingRules from hook state which\n starts as [] until initializeStore finishes. Fix: fall back to\n reading directly from localStorage when hook state is empty,\n preventing empty-array upload that would overwrite remote data. 2026-03-10 01:10:07 +08:00
bincxz
cf86c166cf fix: Prevent xterm.js right-click behavior from interfering with tmux/vim popups when mouse tracking is active. 2026-03-10 00:59:44 +08:00
bincxz
686a707fef fix: address Codex round-3 reviews (legacy payload, heartbeat, cross-window reconnect)\n\n1. Preserve local state for legacy payloads: use !== undefined\n checks instead of ?? [] so older cloud snapshots that omit\n knownHosts/portForwardingRules don't wipe local data.\n\n2. Skip connecting tunnels during heartbeat eviction: the backend\n only lists tunnels after SSH handshake completes, so slow\n connections would be falsely evicted.\n\n3. Cross-window reconnect cancellation: stopAndCleanupRule now\n broadcasts via localStorage so other windows cancel pending\n reconnect timers. initReconnectCancelListener listens for\n these events and clears timers + activeConnections entries. 2026-03-10 00:55:07 +08:00
bincxz
159a5eccd2 fix: address Codex round-2 reviews (legacy payload, heartbeat, cross-window)\n\n1. Preserve omitted sync fields for legacy payloads: revert ?? []\n to !== undefined checks so older cloud snapshots that lack\n knownHosts/portForwardingRules don't destructively wipe local data.\n\n2. Exclude connecting tunnels from heartbeat eviction: backend\n doesn't report a tunnel until SSH handshake completes, so slow\n connections (MFA, network latency) were being falsely evicted\n every 4 seconds.\n\n3. Cross-window tunnel cleanup: stopAndCleanupRule now queries\n the backend for the tunnel ID when no local activeConnections\n entry exists (settings window stopping a tunnel started by\n the main window). 2026-03-10 00:46:45 +08:00
bincxz
8a6e915dd7 fix: address Codex review P1 (stale tunnel on config change) and P2 (additive-only sync)\n\nP1: importRules now compares 6 connection-relevant fields\n(type, localPort, remoteHost, remotePort, bindAddress, hostId)\nbetween existing and incoming rules. If any differ, the old\ntunnel is torn down so the UI no longer shows 'active' for\na tunnel pointing at stale parameters.\n\nP2: applySyncPayload now uses ?? [] fallback for\nportForwardingRules and knownHosts. This ensures 'download\nand replace' truly replaces all data, even when the payload\nwas created by an older client that didn't emit these fields. 2026-03-10 00:36:49 +08:00
bincxz
474a8bae87 chore: reduce heartbeat interval from 30s to 4s 2026-03-10 00:23:55 +08:00
bincxz
6c2e902007 feat: add periodic heartbeat to reconcile port forwarding state\n\nAdd a 30-second heartbeat that queries the main process for actual\nactive tunnels and reconciles with the renderer's state. This\nprevents state drift caused by:\n- Tunnel dying without IPC notification reaching renderer\n- Status callbacks being unsubscribed after page navigation\n- Any other edge case where renderer and backend disagree\n\nChanges:\n- Add reconcileWithBackend() to portForwardingService that detects\n gone (renderer has it, backend doesn't) and appeared (backend\n has it, renderer doesn't) tunnels\n- Add 30s heartbeat useEffect in usePortForwardingState that\n auto-corrects rule statuses when drift is detected 2026-03-10 00:18:17 +08:00
bincxz
0e61262bc0 fix: stop active tunnels when rules are deleted or replaced\n\nPreviously, deleteRule() and importRules() only removed port\nforwarding rules from state/UI without stopping the backend SSH\ntunnels. This left orphaned tunnels listening on ports with no\nUI control to shut them down.\n\nChanges:\n- Add stopAndCleanupRule() to portForwardingService for fire-and-\n forget tunnel teardown (clears reconnect timers, unsubscribes\n status events, sends IPC stop to main process)\n- deleteRule() now calls stopAndCleanupRule() before removing\n- importRules() now diffs old vs new rule IDs and stops tunnels\n for any rules being removed (covers cloud sync download and\n Clear Local Data scenarios) 2026-03-10 00:12:18 +08:00
bincxz
200d710cc9 fix: clear port forwarding rules when clearing local data
Address Codex review: since the sync payload now includes
portForwardingRules, "Clear Local Data" must also reset them
to prevent stale rules from being re-uploaded on the next sync.
2026-03-09 23:55:04 +08:00
bincxz
a7873fc457 fix: unify sync payload build/apply logic to prevent data loss\n\nThe settings window was building sync payloads with customGroups\nhardcoded to [] and missing portForwardingRules/knownHosts entirely.\nThis caused data loss when syncing from the settings window.\n\nChanges:\n- Add domain/syncPayload.ts with buildSyncPayload/applySyncPayload\n pure functions as the single source of truth\n- Update App.tsx to use applySyncPayload instead of inline logic\n- Rewrite SettingsSyncTab.tsx to use unified domain functions\n- Wire portForwardingRules through SettingsPage.tsx to the sync tab\n- Fix useAutoSync getDataHash to include customGroups and knownHosts\n so their changes trigger auto-sync 2026-03-09 23:40:07 +08:00
陈大猫
1286975a4b fix: improve URL highlighting precision (#302)
* fix: improve URL highlighting precision

* fix: tighten ipv4 highlight boundaries

* fix: narrow version prefix exclusion

* fix: trim trailing URL delimiters

* fix: preserve bracketed ipv6 urls
2026-03-09 23:07:10 +08:00
陈大猫
2933e108bc feat: support system theme auto-switching (#301)
* feat: support system theme auto-switching\n\nAdd 'system' as a third theme option alongside 'light' and 'dark'.\nWhen set to 'system', the UI theme automatically follows the OS\ncolor scheme preference and switches in real-time when the system\nappearance changes.\n\nChanges:\n- useSettingsState.ts: Add resolvedTheme state derived from\n  matchMedia('prefers-color-scheme: dark'), add listener for\n  system preference changes, update applyThemeTokens to use\n  resolvedTheme instead of theme directly\n- SettingsAppearanceTab.tsx: Replace dark mode Toggle with\n  3-segment selector (Light / System / Dark) using Sun/Monitor/Moon\n  icons\n- en.ts/zh-CN.ts: Replace darkMode i18n keys with new theme keys\n  including 'system' option\n- Default theme changed from 'light' to 'system' for new users\n\nPartially addresses #294

* fix: derive resolvedTheme synchronously and guard matchMedia\n\nAddress Codex review feedback:\n1. Replace resolvedTheme useState+useEffect with synchronous\n   derivation from systemPreference state. This eliminates the\n   one-frame stale render where useLayoutEffect could apply\n   tokens from the old palette before useEffect updated\n   resolvedTheme.\n2. Add window.matchMedia guard in the system preference listener\n   to prevent crashes in jsdom tests or constrained webviews.\n3. Make the matchMedia listener unconditional (always tracks OS\n   preference) to avoid setup/teardown churn when toggling modes.

* fix: resolve 'system' theme in pre-hydration bootstrap to prevent flash

The index.html bootstrap script only handled 'dark'/'light' stored
values. Since DEFAULT_THEME is now 'system', new users (or users who
chose system mode) would get a wrong-theme first paint until React
mounted. Now resolve 'system' via matchMedia('(prefers-color-scheme:
dark)') before applying the CSS class, eliminating the visible flash.

Also use the resolved theme (not raw stored value) for accent foreground
calculation to ensure correct contrast on first paint.

Addresses Codex review on PR #301.

* fix: use resolvedTheme for top-bar toggle to avoid no-op in system mode

When theme preference is 'system' and the OS is dark, the toggle button
showed a moon icon and clicking it just switched from 'system' to 'dark'
— visually a no-op. Now we:

1. Pass resolvedTheme (always 'light'|'dark') to TopTabs for icon display
2. Toggle based on resolvedTheme so the first click always produces a
   visible change (e.g. system+dark → light, system+light → dark)

Addresses Codex review on PR #301.
2026-03-09 21:49:00 +08:00
陈大猫
8278bfde0f feat: show hidden files (dotfiles) on local filesystem browser\n\nPreviously, the showHiddenFiles setting only hid dotfiles on remote\nconnections. Local filesystem panes always showed dotfiles like\n.gitignore, .env, etc. regardless of the setting.\n\nNow the setting consistently hides/shows dotfiles on both local and\nremote connections. Also updated the i18n descriptions in EN and\nzh-CN to remove outdated Windows-only references.\n\nChanges:\n- utils.ts: Remove isLocal bypass from isHiddenFile/filterHiddenFiles\n- useSftpPaneFiles.ts: Remove isLocal from filterHiddenFiles call\n- useSftpKeyboardShortcuts.ts: Remove isLocal from filterHiddenFiles\n- SFTPModal.tsx: Remove isLocalSession from filterHiddenFiles call\n- en.ts/zh-CN.ts: Update descriptions to be platform-agnostic\n\nPartially addresses #294 (#299) 2026-03-09 19:12:43 +08:00
陈大猫
d0b941eabf docs: add Shift+Drag hint for tmux/vim in copy-on-select setting\n\nUpdate the copy-on-select setting description in both EN and zh-CN\nlocales to guide users on how to select text when tmux or vim has\nmouse mode enabled: hold Shift while dragging.\n\nPartially addresses #294 (#298) 2026-03-09 19:00:52 +08:00
陈大猫
a98821acb7 fix: re-run startup command on Start Over after SSH disconnect (#297)
* fix: re-run startup command on Start Over after SSH disconnect\n\nThe hasRunStartupCommandRef was set to true on first connection but\nnever reset when the user clicked Start Over (handleRetry). This\ncaused the startup command to be skipped on all subsequent retries.\n\nReset the ref to false in handleRetry so the startup command\nexecutes again on reconnection.\n\nPartially addresses #294

* fix: guard startup-command timer against stale sessions\n\nCapture the session ID when scheduling the startup command timer\nand verify it still matches sessionRef.current when the timer fires.\n\nThis prevents double execution when the user clicks Start Over\nquickly: the old timer detects the session ID mismatch and bails\nout, so only the new connection's timer runs the startup command.\n\nApplied to both SSH and Mosh startup command paths.
2026-03-09 18:20:03 +08:00
陈大猫
4edc28113e fix: scroll terminal to bottom on paste when scrollOnPaste is enabled\n\nThe scrollOnPaste setting only affected xterm.js native paste events.\nWhen pasting via Netcatty's context menu or keyboard shortcut, the\nterminal did not scroll to bottom because the custom paste path uses\nwriteToSession() which bypasses xterm's built-in scroll-on-paste.\n\nNow explicitly calls term.scrollToBottom() after writing paste data\nwhen the scrollOnPaste setting is enabled (default: true).\n\nPartially addresses #294 (#296) 2026-03-09 18:07:42 +08:00
陈大猫
adc712e121 fix: disable context menu in alternate screen to prevent tmux double menu (#295)
* fix: disable context menu in alternate screen to prevent tmux double menu\n\nWhen applications like tmux enable mouse mode in xterm's alternate\nscreen buffer, right-clicking would show both tmux's context menu\nand Netcatty's context menu simultaneously.\n\nThis fix detects alternate screen mode via xterm.js buffer.onBufferChange\nand disables Netcatty's context menu, letting the terminal application\nhandle mouse events natively.\n\nFixes #294 (Bug 1: Tmux duplicate context menus)

* refactor: use mouse tracking mode detection instead of alternate screen\n\nReplace alternate screen detection with mouseTrackingMode check.\nThis is more precise: context menu is only disabled when the terminal\napplication is actively capturing mouse events (e.g. tmux with\n`set -g mouse on`, vim with `set mouse=a`).\n\nPrograms that use alternate screen without mouse tracking (e.g.\nless, man, vim without mouse) will still show Netcatty's context menu.
2026-03-09 17:50:19 +08:00
陈大猫
81d1b4602d feat: add auto-update support via electron-updater (#289) (#293)
* feat: add auto-update support via electron-updater (#289)

- Add autoUpdateBridge.cjs wrapping electron-updater for check/download/install
- Register bridge in main.cjs, expose IPC in preload.cjs
- Add auto-update methods to NetcattyBridge type in global.d.ts
- Extend updateService.ts with electron-updater bridge functions
- Add Software Update section in Settings > System tab with state machine UI
- Add i18n keys for update UI (en + zh-CN)
- Add publish config for GitHub Releases in electron-builder.config.cjs
- Update CI workflow to upload update metadata (*.yml, *.blockmap, *.zip)
- Fallback to manual GitHub download for unsupported platforms or errors

* fix: address Codex review - guard bridge call and pin sender window

- Guard optional bridge call in SettingsSystemTab to prevent TypeError
  when getAppInfo is unavailable (e.g. browser/dev/test rendering)
- Capture senderWindow at download initiation in autoUpdateBridge so
  progress/downloaded/error events always go to the requesting renderer,
  even if focus changes during download

* fix: use semver ordering for version check and clean up listeners on rejection

- Replace strict equality (===) with localeCompare for version comparison
  to avoid false positives on pre-release/nightly builds
- Clean up download-progress/update-downloaded/error listeners in the
  catch path when downloadUpdate() rejects before emitting events

* feat: redirect update toast to Settings window for in-app update

- Update toast notification now opens Settings window instead of
  GitHub Releases page, enabling the in-app download/install flow
- Add 'update.viewInSettings' i18n key (en + zh-CN)
- Remove unused openReleasePage from App.tsx destructuring
- Move useWindowControls() before the update effect to fix declaration order
2026-03-09 13:34:05 +08:00
陈大猫
540aabb676 fix: skip invalid ssh agent sockets (#292) 2026-03-09 11:59:42 +08:00
陈大猫
8d014193ca Remove dead code and unused components (#288) 2026-03-08 10:55:17 +08:00
陈大猫
892c6da44d fix: cloud sync 401 Unauthorized on first app launch (#287)
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
* fix: cloud sync 401 Unauthorized on first app launch

Root cause: CloudSyncManager.initProviderDecryption() runs before the
Electron bridge (window.netcatty) is available. decryptField() silently
returns encrypted ciphertext as-is (no-op fallback), so tokens remain
encrypted. When checkRemoteVersion() fires, the adapter sends encrypted
ciphertext as the Bearer token → 401 Unauthorized.

Fix: Add a decryptionEffective flag to detect when decryption was a
no-op. In getConnectedAdapter(), retry decryption for the requested
provider if startup decryption failed due to bridge unavailability.

* fix: track actual decryption success instead of bridge function existence

The preload script sets up bridge functions before the main process
registers IPC handlers. Checking function existence is unreliable —
the function exists but the actual IPC call throws. Now we track
whether any decryption threw an error and only mark decryptionEffective
when decryption actually succeeds.

* fix: use per-provider decryption state instead of global flag

Address P1 review: with a single global decryptionEffective flag,
the first provider's successful retry would prevent retries for
other providers. Changed to providerDecrypted record so each
provider independently tracks its decryption status.

* fix: evict stale adapter after successful deferred decryption

Address P1 review: after deferred decryption succeeds, the old adapter
(built with encrypted ciphertext) was still cached. isAuthenticated
returns true for it because the ciphertext is a non-empty string, so
it kept being reused and returning 401. Now the stale adapter is signed
out and evicted, forcing a fresh one with decrypted credentials.
2026-03-08 01:09:05 +08:00
陈大猫
0ff6273882 fix: enable Windows PTY compatibility for local terminals (#286)
* fix: enable Windows PTY compatibility for local terminals

* fix: detect localhost local terminal sessions

* fix: improve Windows local shell defaults

* fix: align detected local shell with launcher

* fix: limit windows pty handling to local terminals

* fix: skip pwsh app execution alias shims
2026-03-08 00:20:20 +08:00
陈大猫
92556d824e fix: normalize persisted redhat distro alias (#285) 2026-03-07 11:48:49 +08:00
midas
f3676734a7 feat(sftp): show download progress for "Open With" temp file downloads (#283)
* feat(sftp): show download progress for "Open With" temp file downloads

When opening remote files via "Open With" or double-click, the download
to a temp directory now displays real-time progress (bar, speed, ETA) in
the transfer overlay instead of silently blocking until completion.

Reuses the existing transferBridge infrastructure (fastGet with throttled
IPC progress events) and the SftpTransferItem UI. Cancellation is handled
gracefully — the task transitions to "cancelled" status, the partial temp
file is cleaned up, and the file is not opened in the external application.
The original downloadSftpToTemp path is preserved as a fallback for
contexts without a transfer queue.

* fix(sftp): harden temp download transfer state

---------

Co-authored-by: midasgao <midasgao@distinctclinic.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-03-07 10:14:30 +08:00
陈大猫
3d1db751ca Remove legacy macOS quarantine workaround (#284) 2026-03-06 17:08:52 +08:00
陈大猫
35f531bb55 Fix SFTP folder copy into symlinked directories (#282)
* Fix SFTP directory copy into symlinked folders

* Honor SFTP directory drop targets

* Limit SFTP drop targeting to symlink directories

* Bind SFTP drops to the visible target pane

* Revert "Bind SFTP drops to the visible target pane"

This reverts commit d1bad223ffafd89d15217add8fbe4a24dda60433.

* Revert "Limit SFTP drop targeting to symlink directories"

This reverts commit edc67ed4a21c0c510854b5479592f4451d9b4cb7.

* Revert "Honor SFTP directory drop targets"

This reverts commit fed0d7bdd0f28fa6d4e9335f3964467b62921d7c.

* Stabilize SFTP directory transfer progress

* Enable compressed uploads in SFTP view

* Fix directory transfer cancellation and total growth

* Keep prescan cancellation in transfer cleanup

* Sync compressed uploads and persistent cancellation

* Tighten SFTP cancellation cleanup

* Handle Windows SFTP directory paths
2026-03-06 17:07:18 +08:00
陈大猫
71ff9953bd Fix issue #278 identity refresh and session log autosave (#281)
* Fix issue #278 identity refresh and session log autosave

* Sync session log settings across windows
2026-03-06 15:12:38 +08:00
bincxz
72635eeaeb fix(ci): upgrade Node.js from 20 to 22 for @electron/rebuild compat
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
@electron/rebuild@4.0.3 requires Node >= 22.12.0
2026-03-06 02:34:24 +08:00
bincxz
ec17abb507 Merge pull request: feat: enable macOS code signing and notarization
- Enable hardenedRuntime and notarize in electron-builder config
- Remove FixQuarantine.app workaround and DMG background image
- Pass signing and notarization secrets in CI build step
2026-03-06 02:07:10 +08:00
bincxz
fe7f760a47 chore: remove DMG background image 2026-03-06 02:06:50 +08:00
bincxz
ab70a406c9 feat: enable macOS code signing and notarization
- Enable hardenedRuntime and notarize in electron-builder config
- Remove FixQuarantine.app workaround from DMG (no longer needed
  with proper code signing)
- Pass signing and notarization secrets in CI build step
- Shrink DMG window to fit the simpler two-icon layout
2026-03-06 01:48:49 +08:00
bincxz
7e73da5557 Merge pull request #277 from binaricat/fix/issue-264-linux-x64-revert-container
fix(ci): revert Linux x64 build to ubuntu-latest without container

Closes #264
2026-03-06 01:45:47 +08:00
bincxz
97474acb89 fix(ci): revert Linux x64 build to ubuntu-latest without container
The debian:bullseye container introduced in v1.0.39 broke native module
packaging — node-pty's .node binary was missing from app.asar.unpacked,
causing 'No such file or directory' on ArchLinux and other x64 distros.

Revert to the v1.0.38 approach: build x64 directly on ubuntu-latest
with setup-node. ARM64 keeps the Debian container for GLIBC compat.

Closes #264
2026-03-06 01:44:08 +08:00
陈大猫
f59c83be2a fix: await provider token decryption before creating sync adapters (#276)
* fix: await provider token decryption before creating sync adapters

On cold start, initProviderDecryption() runs async in the constructor
but getConnectedAdapter() could be called before it finished, causing
adapter creation with still-encrypted tokens to fail silently.

Store the decryption promise and await it in getConnectedAdapter() so
tokens are guaranteed to be decrypted before use.

* fix: auto-recover sync providers stuck in error status

When syncAllProviders runs, providers with status 'error' that still
have tokens/config are now reset to 'connected' and their cached
adapter is invalidated, allowing a fresh retry with current (decrypted)
tokens. This prevents the permanent 'not configured' state that
previously required opening Settings to clear.
2026-03-06 01:38:18 +08:00
陈大猫
cba1803230 fix: install Linux icons in standard hicolor sizes (#274)\n\nGenerate 16x16 through 512x512 icon PNGs in build/icons/ so\nelectron-builder installs them to the correct hicolor directories\ninstead of only 1024x1024.\n\nUpdate .gitignore to track build/icons/ while keeping other\nbuild artifacts ignored.\n\nCloses #274 (#275) 2026-03-06 01:10:22 +08:00
陈大猫
e50a087a07 Merge pull request #272 from binaricat/feat/issue-261-terminal-encoding-switcher
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
feat: add terminal encoding switcher for SSH sessions (#261)
2026-03-05 02:23:31 +08:00
bincxz
5839c00b67 fix: validate SSH session type and exclude localhost from encoding UI
- Check session.stream in setSessionEncoding to reject non-SSH sessions
  that share the sessions map (local/telnet/serial)
- Add hostname !== 'localhost' guard to isSSHSession in toolbar and
  onSessionAttached, since localhost routes through startLocal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 02:17:59 +08:00
bincxz
f5cb590e0c fix: reject encoding updates for inactive SSH sessions
Check that sessionId exists in the sessions map before writing to
sessionEncodings/sessionDecoders, preventing stale map entries and
misleading ok:true responses for disconnected sessions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 02:11:03 +08:00
bincxz
237b4404dc fix: sync encoding before first data chunk arrives
Move encoding sync from updateStatus("connected") to a new
onSessionAttached callback in attachSessionToTerminal, which fires
right after sessionRef is set but before the data listener is
registered. This ensures the first data chunk is decoded correctly
even if the user changed encoding during connection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 02:03:27 +08:00
bincxz
1c10076866 fix: revert localhost guard and scope encoding sync to SSH sessions
- Remove hostname==='localhost' check since SSH to localhost is valid
  and local protocol sessions are already filtered by isLocalTerminal
- Restrict updateStatus encoding sync to SSH sessions only, preventing
  stale decoder entries from accumulating for non-SSH session types

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 01:54:24 +08:00
bincxz
eb80b8f60c fix: always sync encoding on connect and hide for localhost sessions
- Remove utf-8 guard from connect-time sync so GB-preseeded hosts that
  get switched to UTF-8 during connect are synced correctly
- Exclude hostname==='localhost' sessions from encoding popover since
  they route through startLocal, not the SSH bridge

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 01:46:47 +08:00
bincxz
f38515d383 fix: sync encoding to backend when session connects
If the user changes encoding while still connecting, sessionRef is null
so the IPC call is skipped. Now updateStatus syncs the encoding to the
backend when status transitions to 'connected' and encoding is non-default.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 01:35:42 +08:00
bincxz
64a1b8de3e fix: exclude Mosh sessions from encoding switcher
Mosh sessions keep host.protocol as 'ssh' but set host.moshEnabled,
so also gate encoding popover on !host?.moshEnabled.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 01:29:36 +08:00
bincxz
c1eb19a739 fix: use stateful iconv decoder and restrict encoding to SSH sessions
- Replace per-chunk iconv.decode() with stateful iconv.getDecoder() to
  handle multibyte characters split across packet boundaries (P1)
- Reset decoders when encoding is switched mid-session
- Gate encoding popover to SSH sessions only, excluding Telnet/Mosh (P2)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 01:23:45 +08:00
bincxz
7342b4a872 feat: add terminal encoding switcher for SSH sessions (#261)
Allow users to switch between UTF-8 and GB18030 encoding mid-session
via a toolbar popover, fixing garbled output when viewing mixed-encoding
logs on remote servers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 01:17:05 +08:00
陈大猫
db682d7857 Merge pull request #271 from binaricat/fix/issue-258-windows-ssh-agent-check
fix: check Windows SSH Agent before connecting to agent pipe
2026-03-05 01:00:05 +08:00
bincxz
c6491b71c9 fix: only enable agentForward when agent is actually available
ssh2 throws when agentForward=true but no agent path is set. Move the
agentForward assignment after the agent availability check so forwarding
is silently skipped when the agent is unavailable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 00:56:28 +08:00
bincxz
8667d0d535 fix: check Windows SSH Agent before connecting to agent pipe
On Windows, the agent socket path was set unconditionally to
\\.\pipe\openssh-ssh-agent even when the ssh-agent service is not
running. This caused "Failed to connect to agent" errors and prevented
fallback to keyboard-interactive auth (password prompt).

Now uses the existing checkWindowsSshAgent() to verify the service is
running before setting the agent path, allowing auth to fall through to
keyboard-interactive when no keys or password are configured.

Closes #258

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 00:52:05 +08:00
陈大猫
2bcb081486 Merge pull request #270 from binaricat/feat/issue-260-local-sftp-bookmarks
feat: add bookmark support for local SFTP directories
2026-03-05 00:44:54 +08:00
bincxz
fefda0015e fix: use shared external store for local bookmarks
Replace per-instance useState with useSyncExternalStore backed by a
module-level singleton so all mounted local SFTP panes share the same
bookmark state and writes never overwrite each other.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 00:38:50 +08:00
bincxz
5fc5471685 fix: handle Windows backslash paths in local bookmark labels
Split on both / and \ so the label extracts correctly for paths
like C:\Users\damao\Documents → "Documents".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 00:37:26 +08:00
bincxz
4601372ce6 feat: add bookmark support for local SFTP directories (#260)
Local SFTP panes now support directory bookmarks, stored in localStorage
since there is no Host object for local connections.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 00:32:40 +08:00
陈大猫
6491ab38bc Merge pull request #269 from binaricat/fix/issue-266-password-only-passphrase
fix: skip SSH key passphrase prompt for password-only connections
2026-03-05 00:23:50 +08:00
bincxz
6476bc95df fix: include agentForwarding in password-only guard
When agent forwarding is enabled, the session uses an SSH agent which
may hold encrypted keys. Don't classify such sessions as password-only
to preserve the encrypted key retry path.

Addresses P2 review feedback on #269.
2026-03-05 00:04:45 +08:00
bincxz
7ef1059f7b fix: preserve encrypted key retry for jump host connections
When jump hosts are configured, the auth error could originate from a
key-based bastion rather than the password-only final target. Skip the
passphrase prompt bypass when jump hosts are present to ensure encrypted
default keys can still be offered for the chain.

Addresses review feedback on #269.
2026-03-04 23:57:54 +08:00
bincxz
fd78fc7baa fix: skip SSH key passphrase prompt for password-only connections
When a host is configured with username+password (no SSH key), the app
incorrectly prompted for local SSH key passphrases because:

1. buildAuthHandler added default ~/.ssh/ keys and ssh-agent as fallback
   methods for password-only connections, causing unnecessary key probing
2. startSSHSessionWrapper unconditionally scanned for encrypted default
   keys on auth failure and showed passphrase modal

Fix by:
- Removing default key/agent fallback from password-only auth handler
- Skipping encrypted key passphrase prompt in retry logic when the user
  explicitly configured password authentication

Fixes #266
2026-03-04 23:48:11 +08:00
陈大猫
5787a6ac6a Merge pull request #268 from binaricat/fix/issue-264-linux-x64-build
fix(ci): build Linux x64 in debian:bullseye container for native modules
2026-03-04 23:44:16 +08:00
bincxz
787760d02c fix(ci): build Linux x64 in debian:bullseye container for native modules
The Linux x64 AppImage was missing the compiled node-pty native module
(pty.node), causing the app to crash on launch. This happened because
the bare ubuntu-latest runner lacked build-essential/python3 needed by
node-gyp to compile native addons.

Move the Linux x64 build into a dedicated job using debian:bullseye
container (matching the ARM64 job) which:
- Installs build-essential, python3 and other native build deps
- Ensures node-pty, ssh2, cpu-features compile correctly
- Pins GLIBC to 2.31 for broader distro compatibility

Fixes #264
2026-03-04 23:37:42 +08:00
陈大猫
1b2c3e30a2 Merge pull request #267 from binaricat/fix/issue-263-rhel-distro-detection
fix: handle quoted ID values in /etc/os-release for RHEL distro detection
2026-03-04 23:32:49 +08:00
bincxz
ae7495baf9 fix: handle quoted ID values in /etc/os-release for distro detection
The regex for parsing the distro ID from /etc/os-release only matched
unquoted values like `ID=ubuntu`, but RHEL uses `ID="rhel"` with
double quotes. The new regex `/^ID="?([\w-]+)"?$/im` handles both
quoted and unquoted forms.

Fixes #263
2026-03-04 23:30:05 +08:00
陈大猫
2bcea8386f Merge pull request #265 from RoryChou-flux/codex/issue-259-sftp-reconnect-pr
fix(sftp): recover stale channel after network reconnect
2026-03-04 23:26:39 +08:00
bincxz
be7d29f45e fix(sftp): address reconnect selection and channel timeout edge cases 2026-03-04 23:18:36 +08:00
bincxz
4a762097ee fix(sftp): avoid sudo channel downgrade during channel recovery 2026-03-04 23:06:56 +08:00
bincxz
c91cf1d2f8 fix(sftp): guard reconnect reload against stale navigation state 2026-03-04 22:57:31 +08:00
bincxz
0a43220057 Merge remote-tracking branch 'origin/main' into fix/sftp-stale-channel-recovery
# Conflicts:
#	components/sftp-modal/hooks/useSftpModalSession.ts
#	electron/bridges/transferBridge.cjs
2026-03-04 22:47:05 +08:00
bincxz
288ea06c04 fix(sftp): add channel recovery to transferBridge stream operations
- Export requireSftpChannel from sftpBridge for cross-module use
- Add channel recovery to uploadWithStreams, downloadWithStreams,
  and startTransfer stat call in transferBridge
- Clean up verbose debug console.logs in cancelTransfer
2026-03-04 22:16:28 +08:00
bincxz
9ca7e39748 chore(sftp): remove dead isFatalUploadError function
The function was exported but never imported anywhere in the codebase.
2026-03-04 22:13:07 +08:00
bincxz
1cbbb61afa fix(sftp): add channel recovery to ensureRemoteDirForSession UTF-8 branch
The mkdirSftp handler delegates to ensureRemoteDirForSession, which
had the same issue as deleteSftp — the UTF-8 branch called
client.mkdir() directly without validating the channel first.
2026-03-04 22:11:33 +08:00
bincxz
cf352502f8 fix(sftp): deep review fixes for channel recovery
- Fix per-client dedup: store _reopeningPromise on client object
  instead of module-level global to prevent cross-session confusion
- Narrow isSessionError patterns: replace overly broad "not found"
  and "closed" with specific "channel closed"/"connection closed",
  add "timed out" for channel open timeout errors
- Prevent channel leak on timeout: close orphaned SFTP channel
  when tryOpenSftpChannel callback fires after timeout
- Auto-reload directory listing after successful reconnect in
  SFTP modal to avoid stale UI state
2026-03-04 22:07:51 +08:00
bincxz
72d270580f fix(sftp): harden channel recovery across all operations
P1 fixes:
- Add requireSftpChannel() to all SFTP operations: readSftp,
  readSftpBinary, writeSftp, writeSftpBinary,
  writeSftpBinaryWithProgress, renameSftp, statSftp, chmodSftp,
  and deleteSftp UTF-8 branch
- Add 10s timeout to tryOpenSftpChannel to prevent hang when
  SSH connection is half-dead

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: update bracketed-paste option on live terminals

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

* fix: respect disableBracketedPaste in all manual paste paths

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

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

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

* fix: limit dotfile filtering to remote connections only

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

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

* fix: preserve isWindowsHiddenFile backward compatibility

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

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

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

Closes #193

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

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

* feat: add bookmark support to SFTPModal with shared hook

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

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

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

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

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

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

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

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

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

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

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

* fix: validate base64 format in encrypted credential detection

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

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

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

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

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

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

* fix: reduce enc:v1 placeholder false positives

* fix: require syncable payload before master key rotation

---------

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

Co-authored-by: rorychou <roryechou@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 10:49:11 +08:00
Rory Chou
4445bf578c security: harden external navigation / window.open (#209) 2026-02-14 16:21:11 +08:00
Rory Chou
f719350507 fix(macos): restore main window on Dock activate (#208) 2026-02-14 12:20:48 +08:00
125 changed files with 7918 additions and 3904 deletions

View File

@@ -13,12 +13,18 @@ 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
env:
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
@@ -31,7 +37,7 @@ jobs:
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
cache: npm
- name: Install deps
@@ -50,43 +56,147 @@ 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
- name: Build package (Windows)
if: matrix.os == 'windows-latest'
- name: Build package
env:
ELECTRON_BUILDER_PUBLISH: "never"
run: npm run pack:win
- name: Build package (Linux)
if: matrix.os == 'ubuntu-latest'
env:
ELECTRON_BUILDER_PUBLISH: "never"
run: npm run pack:linux
# macOS code signing & notarization (ignored on other platforms)
CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: npm run ${{ matrix.pack_script }}
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: netcatty-${{ matrix.os }}
name: netcatty-${{ matrix.name }}
path: |
release/*.dmg
release/*.zip
release/*.exe
release/*.msi
release/*.AppImage
release/*.deb
release/*.rpm
release/*.tar.gz
release/*.yml
release/*.blockmap
if-no-files-found: ignore
# Linux x64 — builds directly on ubuntu-latest (no container).
# v1.0.39 used a debian:bullseye container which broke native module
# packaging (node-pty .node file missing from asar.unpacked). Reverted
# to the v1.0.38 approach. See #264.
build-linux-x64:
name: build-linux-x64
runs-on: ubuntu-latest
env:
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_SECRET: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_SECRET }}
VITE_SYNC_ONEDRIVE_CLIENT_ID: ${{ secrets.VITE_SYNC_ONEDRIVE_CLIENT_ID }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install deps
run: npm ci
- name: Set version
shell: bash
run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
VERSION="${GITHUB_REF_NAME#v}"
else
VERSION="${GITHUB_SHA:0:7}"
fi
echo "Setting version to ${VERSION}"
npm pkg set version="${VERSION}"
- name: Build package
env:
ELECTRON_BUILDER_PUBLISH: "never"
run: npm run pack:linux-x64
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: netcatty-linux-x64
path: |
release/*.AppImage
release/*.deb
release/*.rpm
release/*.yml
release/*.blockmap
if-no-files-found: ignore
# Dedicated job for Linux ARM64 — builds inside Debian Bullseye (GLIBC 2.31)
# to ensure compatibility with older distros like UOS/Deepin (GLIBC 2.28).
# Key: GLIBC < 2.34 avoids the libpthread-merge symbol requirement.
build-linux-arm64:
name: build-linux-arm64
runs-on: ubuntu-24.04-arm
container:
image: debian:bullseye
env:
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
VITE_SYNC_GOOGLE_CLIENT_SECRET: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_SECRET }}
VITE_SYNC_ONEDRIVE_CLIENT_ID: ${{ secrets.VITE_SYNC_ONEDRIVE_CLIENT_ID }}
steps:
- name: Install build dependencies
run: |
apt-get update
apt-get install -y curl build-essential python3 git libfuse2 file rpm
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
apt-get install -y nodejs
- name: Checkout
uses: actions/checkout@v4
- name: Install deps
run: npm ci
- name: Set version
shell: bash
run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
VERSION="${GITHUB_REF_NAME#v}"
else
VERSION="${GITHUB_SHA:0:7}"
fi
echo "Setting version to ${VERSION}"
npm pkg set version="${VERSION}"
- name: Build package
env:
npm_config_arch: arm64
ELECTRON_BUILDER_PUBLISH: "never"
run: npm run pack:linux-arm64
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: netcatty-linux-arm64
path: |
release/*.AppImage
release/*.deb
release/*.rpm
release/*.yml
release/*.blockmap
if-no-files-found: ignore
release:
name: release
runs-on: ubuntu-latest
needs: build
needs: [build, build-linux-x64, build-linux-arm64]
if: startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.publish_release)
permissions:
contents: write
@@ -116,10 +226,13 @@ jobs:
body_path: release_notes.md
files: |
artifacts/*.dmg
artifacts/*.zip
artifacts/*.exe
artifacts/*.AppImage
artifacts/*.deb
artifacts/*.rpm
artifacts/*.yml
artifacts/*.blockmap
generate_release_notes: true
fail_on_unmatched_files: false
token: ${{ secrets.RELEASE_TOKEN }}

3
.gitignore vendored
View File

@@ -17,7 +17,8 @@ dist-ssr
*.tsbuildinfo
coverage
/.vite
/build
/build/*
!/build/icons
/electron/native/**/build
/release
/out

135
App.tsx
View File

@@ -14,6 +14,8 @@ import { initializeUIFonts } from './application/state/uiFontStore';
import { I18nProvider, useI18n } from './application/i18n/I18nProvider';
import { matchesKeyBinding } from './domain/models';
import { resolveHostAuth } from './domain/sshAuth';
import { applySyncPayload } from './domain/syncPayload';
import { getCredentialProtectionAvailability } from './infrastructure/services/credentialProtection';
import { netcattyBridge } from './infrastructure/services/netcattyBridge';
import { TopTabs } from './components/TopTabs';
import { Button } from './components/ui/button';
@@ -166,8 +168,8 @@ function App({ settings }: { settings: SettingsState }) {
const [passphraseQueue, setPassphraseQueue] = useState<PassphraseRequest[]>([]);
const {
theme,
setTheme,
resolvedTheme,
setTerminalThemeId,
currentTerminalTheme,
terminalFontFamilyId,
@@ -284,17 +286,10 @@ function App({ settings }: { settings: SettingsState }) {
portForwardingRules: portForwardingRulesForSync,
knownHosts,
onApplyPayload: (payload) => {
importDataFromString(JSON.stringify({
hosts: payload.hosts,
keys: payload.keys,
identities: payload.identities,
snippets: payload.snippets,
customGroups: payload.customGroups,
}));
if (payload.portForwardingRules) {
importPortForwardingRules(payload.portForwardingRules);
}
applySyncPayload(payload, {
importVaultData: importDataFromString,
importPortForwardingRules,
});
},
});
@@ -309,7 +304,10 @@ function App({ settings }: { settings: SettingsState }) {
}, [handleSyncNow]);
// Update check hook - checks for new versions on startup
const { updateState, openReleasePage, dismissUpdate } = useUpdateCheck();
const { updateState, dismissUpdate } = useUpdateCheck();
// Window controls - must be before update toast effect which uses openSettingsWindow
const { openSettingsWindow } = useWindowControls();
// Show toast notification when update is available
useEffect(() => {
@@ -321,14 +319,14 @@ function App({ settings }: { settings: SettingsState }) {
title: t('update.available.title'),
duration: 8000, // Show longer for update notifications
onClick: () => {
openReleasePage();
void openSettingsWindow();
dismissUpdate();
},
actionLabel: t('update.downloadNow'),
actionLabel: t('update.viewInSettings'),
}
);
}
}, [updateState.hasUpdate, updateState.latestRelease, t, openReleasePage, dismissUpdate]);
}, [updateState.hasUpdate, updateState.latestRelease, t, openSettingsWindow, dismissUpdate]);
// Memoize keys for port forwarding to prevent unnecessary re-renders
const portForwardingKeys = useMemo(
@@ -439,16 +437,40 @@ function App({ settings }: { settings: SettingsState }) {
return;
}
const { username, hostname: localHost } = systemInfoRef.current;
if (host.protocol === 'serial') {
const portName = host.hostname.split('/').pop() || host.hostname;
const sessionId = connectToHost(host);
addConnectionLog({
sessionId,
hostId: host.id,
hostLabel: host.label || `Serial: ${portName}`,
hostname: host.hostname,
username,
protocol: 'serial',
startTime: Date.now(),
localUsername: username,
localHostname: localHost,
saved: false,
});
return;
}
const protocol = host.moshEnabled ? 'mosh' : (host.protocol || 'ssh');
const resolvedAuth = resolveHostAuth({ host, keys, identities });
const sessionId = connectToHost(host);
addConnectionLog({
sessionId,
hostId: host.id,
hostLabel: host.label,
hostname: host.hostname,
username: host.username,
localHostname: "",
username: resolvedAuth.username || 'root',
protocol: protocol as 'ssh' | 'telnet' | 'local' | 'mosh',
startTime: Date.now(),
localUsername: username,
localHostname: localHost,
saved: false,
});
connectToHost(host);
};
const bridge = netcattyBridge.get();
@@ -460,7 +482,7 @@ function App({ settings }: { settings: SettingsState }) {
unsubscribeJump?.();
unsubscribeConnect?.();
};
}, [addConnectionLog, connectToHost, hosts, sessions, setActiveTabId, setWorkspaceFocusedSession, t]);
}, [addConnectionLog, connectToHost, hosts, identities, keys, sessions, setActiveTabId, setWorkspaceFocusedSession, t]);
// Keyboard-interactive authentication (2FA/MFA) event listener
useEffect(() => {
@@ -894,7 +916,9 @@ function App({ settings }: { settings: SettingsState }) {
// Wrapper to create local terminal with logging
const handleCreateLocalTerminal = useCallback(() => {
const { username, hostname } = systemInfoRef.current;
const sessionId = createLocalTerminal();
addConnectionLog({
sessionId,
hostId: '',
hostLabel: 'Local Terminal',
hostname: 'localhost',
@@ -905,7 +929,6 @@ function App({ settings }: { settings: SettingsState }) {
localHostname: hostname,
saved: false,
});
createLocalTerminal();
}, [addConnectionLog, createLocalTerminal]);
// Wrapper to connect to host with logging
@@ -915,7 +938,9 @@ function App({ settings }: { settings: SettingsState }) {
// Handle serial hosts separately
if (host.protocol === 'serial') {
const portName = host.hostname.split('/').pop() || host.hostname;
const sessionId = connectToHost(host);
addConnectionLog({
sessionId,
hostId: host.id,
hostLabel: host.label || `Serial: ${portName}`,
hostname: host.hostname,
@@ -926,13 +951,14 @@ function App({ settings }: { settings: SettingsState }) {
localHostname: localHost,
saved: false,
});
connectToHost(host);
return;
}
const protocol = host.moshEnabled ? 'mosh' : (host.protocol || 'ssh');
const resolvedAuth = resolveHostAuth({ host, keys, identities });
const sessionId = connectToHost(host);
addConnectionLog({
sessionId,
hostId: host.id,
hostLabel: host.label,
hostname: host.hostname,
@@ -943,14 +969,15 @@ function App({ settings }: { settings: SettingsState }) {
localHostname: localHost,
saved: false,
});
connectToHost(host);
}, [addConnectionLog, connectToHost, identities, keys]);
// Wrapper to create serial session with logging
const handleConnectSerial = useCallback((config: SerialConfig) => {
const { username, hostname } = systemInfoRef.current;
const portName = config.path.split('/').pop() || config.path;
const sessionId = createSerialSession(config);
addConnectionLog({
sessionId,
hostId: '',
hostLabel: `Serial: ${portName}`,
hostname: config.path,
@@ -961,32 +988,23 @@ function App({ settings }: { settings: SettingsState }) {
localHostname: hostname,
saved: false,
});
createSerialSession(config);
}, [addConnectionLog, createSerialSession]);
// Handle terminal data capture when session exits
const handleTerminalDataCapture = useCallback((sessionId: string, data: string) => {
if (IS_DEV) console.log('[handleTerminalDataCapture] Called', { sessionId, dataLength: data.length });
// Find the connection log for this session
const session = sessions.find(s => s.id === sessionId);
if (IS_DEV) console.log('[handleTerminalDataCapture] Session', session);
if (!session) {
if (IS_DEV) console.log('[handleTerminalDataCapture] No session found');
return;
}
if (IS_DEV) console.log('[handleTerminalDataCapture] All logs:', connectionLogs.map(l => ({ id: l.id, sessionId: l.sessionId, hostname: l.hostname, endTime: l.endTime, hasTerminalData: !!l.terminalData })));
if (IS_DEV) console.log('[handleTerminalDataCapture] Looking for logs with hostname:', session.hostname);
if (IS_DEV) console.log('[handleTerminalDataCapture] All logs:', connectionLogs.map(l => ({ id: l.id, hostname: l.hostname, endTime: l.endTime, hasTerminalData: !!l.terminalData })));
// Find the most recent log matching this session's hostname and doesn't have terminalData yet
// For local terminal, hostname is 'localhost'
// Sort by startTime descending to find the most recent matching log
// Prefer the persisted sessionId because the session may already have been
// removed from state by the time the terminal unmount cleanup runs.
const matchingLog = connectionLogs
.filter(log =>
log.hostname === session.hostname &&
!log.endTime &&
!log.terminalData
)
.filter((log) => {
if (log.endTime || log.terminalData) return false;
if (log.sessionId) return log.sessionId === sessionId;
return !!session && log.hostname === session.hostname;
})
.sort((a, b) => b.startTime - a.startTime)[0];
if (IS_DEV) console.log('[handleTerminalDataCapture] Matching log', matchingLog);
@@ -1066,14 +1084,15 @@ function App({ settings }: { settings: SettingsState }) {
}, [protocolSelectHost, handleConnectToHost]);
const handleToggleTheme = useCallback(() => {
setTheme(prev => prev === 'dark' ? 'light' : 'dark');
}, [setTheme]);
// Toggle based on the actual rendered theme so clicking always produces a visible change,
// even when the stored preference is 'system'.
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
}, [resolvedTheme, setTheme]);
const handleOpenQuickSwitcher = useCallback(() => {
setIsQuickSwitcherOpen(true);
}, []);
const { openSettingsWindow } = useWindowControls();
const handleOpenSettings = useCallback(() => {
void (async () => {
@@ -1082,6 +1101,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]);
@@ -1117,7 +1160,7 @@ function App({ settings }: { settings: SettingsState }) {
return (
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" onContextMenu={handleRootContextMenu}>
<TopTabs
theme={theme}
theme={resolvedTheme}
sessions={sessions}
orphanSessions={orphanSessions}
workspaces={workspaces}
@@ -1184,7 +1227,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

@@ -215,11 +215,7 @@ Netcatty は接続したホストの OS を検出し、ホスト一覧でアイ
または [GitHub Releases](https://github.com/binaricat/Netcatty/releases) ですべてのリリースを参照してください。
> **⚠️ macOS ユーザーへ:** アプリはコード署名されていないため、macOS Gatekeeper によってブロックされます。ダウンロード後、以下のコマンドを実行して隔離属性を削除してください
> ```bash
> xattr -cr /Applications/Netcatty.app
> ```
> または、アプリを右クリック → 開く → ダイアログで「開く」をクリックしてください。
> **macOS ユーザーへ:** 現在のリリースはコード署名と notarization が行われている想定です。Gatekeeper の警告が出る場合は、GitHub Releases から最新版の公式ビルドを取得しているか確認してください
### 前提条件
- Node.js 18+ と npm

View File

@@ -214,11 +214,7 @@ Download the latest release for your platform from [GitHub Releases](https://git
Or browse all releases at [GitHub Releases](https://github.com/binaricat/Netcatty/releases).
> **⚠️ macOS Users:** Since the app is not code-signed, macOS Gatekeeper will block it. After downloading, run this command to remove the quarantine attribute:
> ```bash
> xattr -cr /Applications/Netcatty.app
> ```
> Or right-click the app → Open → Click "Open" in the dialog.
> **macOS Users:** Current releases are expected to be code-signed and notarized. If Gatekeeper still warns, make sure you downloaded the latest official build from GitHub Releases.
### Prerequisites
- Node.js 18+ and npm

View File

@@ -214,11 +214,7 @@ Netcatty 会自动识别并在主机列表中展示对应的系统图标:
或在 [GitHub Releases](https://github.com/binaricat/Netcatty/releases) 浏览所有版本。
> **⚠️ macOS 用户注意:** 由于应用未经代码签名macOS Gatekeeper 会阻止运行。下载后,请在终端运行以下命令移除隔离属性:
> ```bash
> xattr -cr /Applications/Netcatty.app
> ```
> 或者右键点击应用 → 打开 → 在弹出的对话框中点击"打开"。
> **macOS 用户注意:** 当前发布版本应已完成代码签名和公证。如果 Gatekeeper 仍然提示风险,请确认您下载的是 GitHub Releases 中的最新官方构建。
### 前置条件
- Node.js 18+ 和 npm

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,31 @@ 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 > System > Software Update
'settings.update.title': 'Software Update',
'settings.update.currentVersion': 'Current version',
'settings.update.checkForUpdates': 'Check for Updates',
'settings.update.checking': 'Checking...',
'settings.update.upToDate': 'You are using the latest version.',
'settings.update.available': 'New version {version} is available.',
'settings.update.download': 'Download Update',
'settings.update.downloading': 'Downloading... {percent}%',
'settings.update.readyToInstall': 'Update downloaded and ready to install.',
'settings.update.restartNow': 'Restart to Update',
'settings.update.error': 'Failed to check for updates.',
'settings.update.downloadError': 'Download failed.',
'settings.update.manualDownload': 'Download from GitHub',
'settings.update.manualDownloadHint': 'Auto-update is not available on this platform. Download the latest version from GitHub.',
'settings.update.hint': 'Netcatty checks for updates from GitHub Releases.',
// Settings > Session Logs
'settings.sessionLogs.title': 'Session Logs',
@@ -147,13 +176,17 @@ const en: Messages = {
'update.upToDate.message': 'You are running the latest version ({version}).',
'update.error': 'Failed to check for updates',
'update.downloadNow': 'Download Now',
'update.viewInSettings': 'View in Settings',
'update.remindLater': 'Remind Later',
'update.skipVersion': 'Skip This Version',
// Settings > Appearance
'settings.appearance.uiTheme': 'UI Theme',
'settings.appearance.darkMode': 'Dark Mode',
'settings.appearance.darkMode.desc': 'Toggle between light and dark theme',
'settings.appearance.theme': 'Theme',
'settings.appearance.theme.desc': 'Choose light, dark, or follow system preference',
'settings.appearance.theme.light': 'Light',
'settings.appearance.theme.dark': 'Dark',
'settings.appearance.theme.system': 'System',
'settings.appearance.accentColor': 'Accent Color',
'settings.appearance.customColor': 'Custom color',
'settings.appearance.accentColor.mode': 'Use custom accent',
@@ -214,10 +247,13 @@ const en: Messages = {
'settings.terminal.behavior.rightClick.paste': 'Paste',
'settings.terminal.behavior.rightClick.selectWord': 'Select word',
'settings.terminal.behavior.copyOnSelect': 'Copy on select',
'settings.terminal.behavior.copyOnSelect.desc': 'Automatically copy selected text',
'settings.terminal.behavior.copyOnSelect.desc': 'Automatically copy selected text. In tmux/vim with mouse mode, hold Shift to select',
'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 +360,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 +550,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',
@@ -688,6 +728,7 @@ const en: Messages = {
'sftp.upload.currentFile': 'Current: {fileName}',
'sftp.upload.cancelled': 'Upload cancelled',
'sftp.upload.cancel': 'Cancel',
'sftp.upload.completedToPath': 'Uploaded to {path}',
// SFTP Download
'sftp.download.completed': 'Downloaded',
@@ -704,9 +745,9 @@ const en: Messages = {
// Settings > SFTP Show Hidden Files
'settings.sftp.showHiddenFiles': 'Show hidden files',
'settings.sftp.showHiddenFiles.desc': 'Display files with the Windows hidden attribute in the SFTP file browser when browsing local Windows filesystem.',
'settings.sftp.showHiddenFiles.desc': 'Display hidden files (dotfiles on Unix/macOS and files with the hidden attribute on Windows) in the SFTP file browser.',
'settings.sftp.showHiddenFiles.enable': 'Show hidden files',
'settings.sftp.showHiddenFiles.enableDesc': 'Display Windows hidden files when browsing local filesystem',
'settings.sftp.showHiddenFiles.enableDesc': 'Display hidden files when browsing both local and remote filesystems',
// Settings > SFTP Compressed Upload
'settings.sftp.compressedUpload': 'Folder Compression Transfer',
@@ -781,6 +822,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,8 +941,16 @@ 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.encoding': 'Terminal Encoding',
'terminal.toolbar.encoding.utf8': 'UTF-8',
'terminal.toolbar.encoding.gb18030': 'GB18030',
'terminal.toolbar.closeSession': 'Close session',
'terminal.toolbar.hostHighlight.title': 'Host Keyword Highlighting',
'terminal.toolbar.hostHighlight.noRules': 'No custom highlight rules defined for this host',
@@ -916,6 +969,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 +1009,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 +1028,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,31 @@ 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 > System > Software Update
'settings.update.title': '软件更新',
'settings.update.currentVersion': '当前版本',
'settings.update.checkForUpdates': '检查更新',
'settings.update.checking': '检查中...',
'settings.update.upToDate': '当前已是最新版本。',
'settings.update.available': '新版本 {version} 已发布。',
'settings.update.download': '下载更新',
'settings.update.downloading': '正在下载... {percent}%',
'settings.update.readyToInstall': '更新已下载,准备安装。',
'settings.update.restartNow': '重启并更新',
'settings.update.error': '检查更新失败。',
'settings.update.downloadError': '下载失败。',
'settings.update.manualDownload': '前往 GitHub 下载',
'settings.update.manualDownloadHint': '当前平台不支持自动更新,请前往 GitHub 下载最新版本。',
'settings.update.hint': 'Netcatty 从 GitHub Releases 检查更新。',
// Settings > Session Logs
'settings.sessionLogs.title': '会话日志',
@@ -132,13 +160,17 @@ const zhCN: Messages = {
'update.upToDate.message': '当前版本 ({version}) 已是最新。',
'update.error': '检查更新失败',
'update.downloadNow': '立即下载',
'update.viewInSettings': '在设置中查看',
'update.remindLater': '稍后提醒',
'update.skipVersion': '跳过此版本',
// Settings > Appearance
'settings.appearance.uiTheme': '界面主题',
'settings.appearance.darkMode': '深色模式',
'settings.appearance.darkMode.desc': '浅色深色主题之间切换',
'settings.appearance.theme': '主题',
'settings.appearance.theme.desc': '选择浅色深色或跟随系统设置',
'settings.appearance.theme.light': '浅色',
'settings.appearance.theme.dark': '深色',
'settings.appearance.theme.system': '系统',
'settings.appearance.accentColor': '强调色',
'settings.appearance.customColor': '自定义颜色',
'settings.appearance.accentColor.mode': '使用自定义强调色',
@@ -191,6 +223,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 +388,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 +532,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,8 +622,16 @@ 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.encoding': '终端编码',
'terminal.toolbar.encoding.utf8': 'UTF-8',
'terminal.toolbar.encoding.gb18030': 'GB18030',
'terminal.toolbar.closeSession': '关闭会话',
'terminal.toolbar.hostHighlight.title': '主机关键字高亮',
'terminal.toolbar.hostHighlight.noRules': '此主机未定义自定义高亮规则',
@@ -602,6 +650,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 +690,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 +710,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 +912,7 @@ const zhCN: Messages = {
'common.import': '导入',
'common.generate': '生成',
'common.delete': '删除',
'common.edit': '编辑',
'common.clear': '清除',
'common.optional': '可选',
'common.selectPlaceholder': '请选择...',
@@ -956,6 +1053,7 @@ const zhCN: Messages = {
'sftp.upload.currentFile': '当前: {fileName}',
'sftp.upload.cancelled': '上传已取消',
'sftp.upload.cancel': '取消',
'sftp.upload.completedToPath': '已上传至 {path}',
// SFTP Download
'sftp.download.completed': '已下载',
@@ -972,9 +1070,9 @@ const zhCN: Messages = {
// Settings > SFTP Show Hidden Files
'settings.sftp.showHiddenFiles': '显示隐藏文件',
'settings.sftp.showHiddenFiles.desc': '在浏览本地 Windows 文件系统时,显示具有 Windows 隐藏属性文件。',
'settings.sftp.showHiddenFiles.desc': '在 SFTP 文件浏览器中显示隐藏文件Unix/macOS 点文件和 Windows 隐藏属性文件。',
'settings.sftp.showHiddenFiles.enable': '显示隐藏文件',
'settings.sftp.showHiddenFiles.enableDesc': '浏览本地文件系统时显示 Windows 隐藏文件',
'settings.sftp.showHiddenFiles.enableDesc': '浏览本地和远程文件系统时显示隐藏文件',
// Settings > SFTP Compressed Upload
'settings.sftp.compressedUpload': '文件夹压缩传输',
@@ -1021,9 +1119,12 @@ const zhCN: Messages = {
'settings.terminal.behavior.rightClick.paste': '粘贴',
'settings.terminal.behavior.rightClick.selectWord': '选择单词',
'settings.terminal.behavior.copyOnSelect': '选择即复制',
'settings.terminal.behavior.copyOnSelect.desc': '自动复制选中的文本',
'settings.terminal.behavior.copyOnSelect.desc': '自动复制选中的文本。在 tmux/vim 鼠标模式下,按住 Shift 拖选即可选中文本',
'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 +1187,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,175 @@
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>();
/** 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.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

@@ -4,31 +4,16 @@ export const isSessionError = (err: unknown): boolean => {
return (
msg.includes("session not found") ||
msg.includes("sftp session") ||
msg.includes("not found") ||
msg.includes("closed") ||
msg.includes("connection reset")
);
};
/**
* Check if an error message indicates a fatal error that should stop the entire upload.
* This includes session errors AND target directory deletion errors.
*/
export const isFatalUploadError = (errorMessage: string): boolean => {
const msg = errorMessage.toLowerCase();
return (
// Session-related errors
msg.includes("session not found") ||
msg.includes("sftp session") ||
msg.includes("connection") ||
msg.includes("disconnected") ||
// Target directory was deleted during upload
msg.includes("no such file") ||
msg.includes("enoent") ||
msg.includes("does not exist") ||
msg.includes("write stream error") ||
// Directory was removed
msg.includes("directory not found") ||
msg.includes("not a directory")
msg.includes("session lost") ||
msg.includes("channel not ready") ||
msg.includes("readdir is not a function") ||
msg.includes("channel closed") ||
msg.includes("connection closed") ||
msg.includes("connection reset") ||
msg.includes("write after end") ||
msg.includes("no response") ||
msg.includes("not connected") ||
msg.includes("client disconnected") ||
msg.includes("timed out")
);
};

View File

@@ -52,4 +52,5 @@ export interface FileWatchErrorEvent {
export interface SftpStateOptions {
onFileWatchSynced?: (event: FileWatchSyncedEvent) => void;
onFileWatchError?: (event: FileWatchErrorEvent) => void;
useCompressedUpload?: boolean;
}

View File

@@ -20,8 +20,10 @@ interface UseSftpExternalOperationsParams {
getActivePane: (side: "left" | "right") => SftpPane | null;
refresh: (side: "left" | "right") => Promise<void>;
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
useCompressedUpload?: boolean;
addExternalUpload?: (task: TransferTask) => void;
updateExternalUpload?: (taskId: string, updates: Partial<TransferTask>) => void;
isTransferCancelled?: (taskId: string) => boolean;
dismissExternalUpload?: (taskId: string) => void;
}
@@ -47,7 +49,16 @@ interface SftpExternalOperationsResult {
export const useSftpExternalOperations = (
params: UseSftpExternalOperationsParams
): SftpExternalOperationsResult => {
const { getActivePane, refresh, sftpSessionsRef, addExternalUpload, updateExternalUpload, dismissExternalUpload } = params;
const {
getActivePane,
refresh,
sftpSessionsRef,
useCompressedUpload = false,
addExternalUpload,
updateExternalUpload,
isTransferCancelled,
dismissExternalUpload,
} = params;
// Upload controller for cancellation support
const uploadControllerRef = useRef<UploadController | null>(null);
@@ -173,14 +184,113 @@ export const useSftpExternalOperations = (
throw new Error("SFTP session not found");
}
console.log("[SFTP] Downloading file to temp", { sftpId, remotePath, fileName });
const localTempPath = await bridge.downloadSftpToTemp(
sftpId,
remotePath,
fileName,
pane.filenameEncoding,
);
console.log("[SFTP] File downloaded to temp", { localTempPath });
let localTempPath: string;
let wasCancelled = false;
let externalTransferId: string | undefined;
const isLocalTempDownloadCancelled = () =>
!!externalTransferId && !!isTransferCancelled?.(externalTransferId);
const cleanupTempDownload = async (filePath: string) => {
if (!bridge.deleteTempFile) return;
try {
await bridge.deleteTempFile(filePath);
} catch (err) {
console.warn("[SFTP] Failed to delete cancelled temp download:", err);
}
};
if (bridge.downloadSftpToTempWithProgress && addExternalUpload && updateExternalUpload) {
externalTransferId = `download-temp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
addExternalUpload({
id: externalTransferId,
fileName,
sourcePath: remotePath,
targetPath: "(temp)",
sourceConnectionId: pane.connection.id,
targetConnectionId: "local",
direction: "download",
status: "transferring" as TransferStatus,
totalBytes: 0,
transferredBytes: 0,
speed: 0,
startTime: Date.now(),
isDirectory: false,
retryable: false,
});
try {
const result = await bridge.downloadSftpToTempWithProgress(
sftpId,
remotePath,
fileName,
pane.filenameEncoding,
externalTransferId,
(transferred, total, speed) => {
updateExternalUpload(externalTransferId, {
transferredBytes: transferred,
totalBytes: total,
speed,
});
},
undefined,
(error) => {
updateExternalUpload(externalTransferId, {
status: "failed" as TransferStatus,
endTime: Date.now(),
error,
speed: 0,
});
},
() => {
updateExternalUpload(externalTransferId, {
status: "cancelled" as TransferStatus,
endTime: Date.now(),
speed: 0,
});
},
);
wasCancelled = result.cancelled;
localTempPath = result.localPath;
} catch (err) {
updateExternalUpload(externalTransferId, {
status: "failed" as TransferStatus,
endTime: Date.now(),
error: err instanceof Error ? err.message : String(err),
speed: 0,
});
throw err;
}
if (wasCancelled) {
if (localTempPath && bridge.deleteTempFile) {
bridge.deleteTempFile(localTempPath).catch(() => {});
}
return { localTempPath: "" };
}
if (isLocalTempDownloadCancelled()) {
await cleanupTempDownload(localTempPath);
return { localTempPath: "" };
}
updateExternalUpload(externalTransferId, {
status: "completed" as TransferStatus,
endTime: Date.now(),
speed: 0,
});
} else {
localTempPath = await bridge.downloadSftpToTemp(
sftpId,
remotePath,
fileName,
pane.filenameEncoding,
);
}
if (isLocalTempDownloadCancelled()) {
await cleanupTempDownload(localTempPath);
return { localTempPath: "" };
}
if (bridge.registerTempFile) {
try {
@@ -190,15 +300,23 @@ export const useSftpExternalOperations = (
}
}
console.log("[SFTP] Opening with application", { localTempPath, appPath });
await bridge.openWithApplication(localTempPath, appPath);
console.log("[SFTP] Application launched");
try {
await bridge.openWithApplication(localTempPath, appPath);
} catch (err) {
if (externalTransferId) {
updateExternalUpload(externalTransferId, {
status: "failed" as TransferStatus,
endTime: Date.now(),
error: err instanceof Error ? err.message : String(err),
speed: 0,
});
}
throw err;
}
let watchId: string | undefined;
console.log("[SFTP] Auto-sync enabled check", { enableWatch: options?.enableWatch, hasStartFileWatch: !!bridge.startFileWatch });
if (options?.enableWatch && bridge.startFileWatch) {
try {
console.log("[SFTP] Starting file watch", { localTempPath, remotePath, sftpId });
const result = await bridge.startFileWatch(
localTempPath,
remotePath,
@@ -206,17 +324,14 @@ export const useSftpExternalOperations = (
pane.filenameEncoding,
);
watchId = result.watchId;
console.log("[SFTP] File watch started successfully", { watchId, localTempPath, remotePath });
} catch (err) {
console.warn("[SFTP] Failed to start file watch:", err);
}
} else {
console.log("[SFTP] File watching not enabled or not available");
}
return { localTempPath, watchId };
},
[getActivePane, sftpSessionsRef],
[getActivePane, sftpSessionsRef, addExternalUpload, updateExternalUpload, isTransferCancelled],
);
// Create upload callbacks that translate to TransferTask updates
@@ -402,6 +517,7 @@ export const useSftpExternalOperations = (
bridge: createUploadBridge,
joinPath,
callbacks,
useCompressedUpload,
},
controller
);
@@ -415,7 +531,14 @@ export const useSftpExternalOperations = (
uploadControllerRef.current = null;
}
},
[getActivePane, refresh, sftpSessionsRef, createUploadCallbacks, createUploadBridge],
[
getActivePane,
refresh,
sftpSessionsRef,
createUploadCallbacks,
createUploadBridge,
useCompressedUpload,
],
);
const cancelExternalUpload = useCallback(async () => {

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

@@ -39,6 +39,7 @@ interface UseSftpTransfersResult {
addExternalUpload: (task: TransferTask) => void;
updateExternalUpload: (taskId: string, updates: Partial<TransferTask>) => void;
cancelTransfer: (transferId: string) => Promise<void>;
isTransferCancelled: (transferId: string) => boolean;
retryTransfer: (transferId: string) => Promise<void>;
clearCompletedTransfers: () => void;
dismissTransfer: (transferId: string) => void;
@@ -123,6 +124,73 @@ export const useSftpTransfers = ({
}
}, []);
const clearCancelledTask = useCallback((taskId: string) => {
cancelledTasksRef.current.delete(taskId);
}, []);
const isTransferCancelledError = useCallback(
(error: unknown): boolean =>
error instanceof Error && error.message === "Transfer cancelled",
[],
);
const getEntrySize = useCallback((entry: SftpFileEntry): number => {
if (typeof entry.size === "string") {
const parsed = parseInt(entry.size, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
}
return typeof entry.size === "number" && entry.size > 0 ? entry.size : 0;
}, []);
const estimateDirectoryBytes = useCallback(
async (
sourcePath: string,
sourceSftpId: string | null,
sourceIsLocal: boolean,
sourceEncoding: SftpFilenameEncoding,
rootTaskId: string,
): Promise<number> => {
if (cancelledTasksRef.current.has(rootTaskId)) {
throw new Error("Transfer cancelled");
}
const files = sourceIsLocal
? await listLocalFiles(sourcePath)
: sourceSftpId
? await listRemoteFiles(sourceSftpId, sourcePath, sourceEncoding)
: null;
if (!files) {
throw new Error("No source connection");
}
let totalBytes = 0;
for (const file of files) {
if (file.name === "..") continue;
if (cancelledTasksRef.current.has(rootTaskId)) {
throw new Error("Transfer cancelled");
}
if (file.type === "directory") {
totalBytes += await estimateDirectoryBytes(
joinPath(sourcePath, file.name),
sourceSftpId,
sourceIsLocal,
sourceEncoding,
rootTaskId,
);
} else {
totalBytes += getEntrySize(file);
}
}
return totalBytes;
},
[getEntrySize, listLocalFiles, listRemoteFiles],
);
const transferFile = async (
task: TransferTask,
sourceSftpId: string | null,
@@ -132,6 +200,7 @@ export const useSftpTransfers = ({
sourceEncoding: SftpFilenameEncoding,
targetEncoding: SftpFilenameEncoding,
rootTaskId: string, // The original top-level task ID for cancellation checking
onStreamProgress?: (transferred: number, total: number, speed: number) => void,
): Promise<void> => {
// Check if task or root task was cancelled before starting
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
@@ -158,15 +227,23 @@ export const useSftpTransfers = ({
total: number,
speed: number,
) => {
// Bubble up streaming progress to parent (for directory transfers)
onStreamProgress?.(transferred, total, speed);
setTransfers((prev) =>
prev.map((t) => {
if (t.id !== task.id) return t;
if (t.status === "cancelled") return t;
const normalizedTotal = total > 0 ? total : t.totalBytes;
const normalizedTransferred = Math.max(
t.transferredBytes,
Math.min(transferred, normalizedTotal > 0 ? normalizedTotal : transferred),
);
return {
...t,
transferredBytes: transferred,
totalBytes: total || t.totalBytes,
speed,
transferredBytes: normalizedTransferred,
totalBytes: normalizedTotal,
speed: Number.isFinite(speed) && speed > 0 ? speed : 0,
};
}),
);
@@ -249,6 +326,7 @@ export const useSftpTransfers = ({
sourceEncoding: SftpFilenameEncoding,
targetEncoding: SftpFilenameEncoding,
rootTaskId: string, // The original top-level task ID for cancellation checking
onChildProgress?: (completedBytes: number, currentFileTransferred: number, currentFileTotal: number, speed: number) => void,
) => {
// Check if task or root task was cancelled before starting
if (cancelledTasksRef.current.has(task.id) || cancelledTasksRef.current.has(rootTaskId)) {
@@ -270,6 +348,9 @@ export const useSftpTransfers = ({
throw new Error("No source connection");
}
// Track bytes completed so far in this directory (including subdirectories)
let completedBytesInDir = 0;
for (const file of files) {
if (file.name === "..") continue;
@@ -290,6 +371,13 @@ export const useSftpTransfers = ({
};
if (file.type === "directory") {
// For subdirectories, create a nested progress tracker
let subDirCompletedBytes = 0;
const onSubDirChildProgress = (subCompleted: number, currentTransferred: number, currentTotal: number, speed: number) => {
subDirCompletedBytes = subCompleted;
// Report to parent: our completed + subdirectory's (completed + in-progress)
onChildProgress?.(completedBytesInDir + subCompleted, currentTransferred, currentTotal, speed);
};
await transferDirectory(
childTask,
sourceSftpId,
@@ -299,8 +387,14 @@ export const useSftpTransfers = ({
sourceEncoding,
targetEncoding,
rootTaskId,
onSubDirChildProgress,
);
completedBytesInDir += subDirCompletedBytes;
} else {
// For files, report streaming progress
const onFileStreamProgress = (transferred: number, total: number, speed: number) => {
onChildProgress?.(completedBytesInDir, transferred, total, speed);
};
await transferFile(
childTask,
sourceSftpId,
@@ -310,7 +404,12 @@ export const useSftpTransfers = ({
sourceEncoding,
targetEncoding,
rootTaskId,
onFileStreamProgress,
);
// After file completes, add its bytes to completed total
const childSize = typeof file.size === 'string' ? parseInt(file.size, 10) || 0 : (file.size || 0);
completedBytesInDir += childSize;
onChildProgress?.(completedBytesInDir, 0, 0, 0);
}
}
};
@@ -336,7 +435,27 @@ export const useSftpTransfers = ({
: targetPane.filenameEncoding || "auto";
let actualFileSize = task.totalBytes;
if (!task.isDirectory && actualFileSize === 0) {
let prescanCancelled = false;
if (task.isDirectory) {
try {
const sourceSftpId = sourcePane.connection?.isLocal
? null
: sftpSessionsRef.current.get(sourcePane.connection!.id);
actualFileSize = await estimateDirectoryBytes(
task.sourcePath,
sourceSftpId,
sourcePane.connection!.isLocal,
sourceEncoding,
task.id,
);
} catch (err) {
if (isTransferCancelledError(err)) {
prescanCancelled = true;
}
// Fall back to the existing estimate below if size discovery fails.
}
} else if (actualFileSize === 0) {
try {
const sourceSftpId = sourcePane.connection?.isLocal
? null
@@ -367,13 +486,6 @@ export const useSftpTransfers = ({
const hasStreamingTransfer = !!netcattyBridge.get()?.startStreamTransfer;
updateTask({
status: "transferring",
totalBytes: estimatedSize,
transferredBytes: 0,
startTime: Date.now(),
});
const sourceSftpId = sourcePane.connection?.isLocal
? null
: sftpSessionsRef.current.get(sourcePane.connection!.id);
@@ -393,12 +505,24 @@ export const useSftpTransfers = ({
}
let useSimulatedProgress = false;
if (!hasStreamingTransfer || task.isDirectory) {
useSimulatedProgress = true;
startProgressSimulation(task.id, estimatedSize);
}
try {
if (prescanCancelled) {
throw new Error("Transfer cancelled");
}
updateTask({
status: "transferring",
totalBytes: estimatedSize,
transferredBytes: 0,
startTime: Date.now(),
});
if (!hasStreamingTransfer && !task.isDirectory) {
useSimulatedProgress = true;
startProgressSimulation(task.id, estimatedSize);
}
if (!task.skipConflictCheck && !task.isDirectory && targetPane.connection) {
let targetExists = false;
let existingStat: { size: number; mtime: number } | null = null;
@@ -481,6 +605,31 @@ export const useSftpTransfers = ({
}
if (task.isDirectory) {
// Track real progress for directory transfers:
// completedBytes = sum of all finished child files
// + currentFileTransferred = in-progress bytes of the currently transferring file
const onChildProgress = (completedBytes: number, currentFileTransferred: number, currentFileTotal: number, speed: number) => {
const totalProgress = completedBytes + currentFileTransferred;
setTransfers((prev) =>
prev.map((t) => {
if (t.id !== task.id || t.status === "cancelled") return t;
const newTotal = Math.max(
t.totalBytes,
totalProgress,
completedBytes + currentFileTotal,
);
return {
...t,
transferredBytes: Math.max(
t.transferredBytes,
Math.min(totalProgress, newTotal),
),
totalBytes: newTotal,
speed: Number.isFinite(speed) && speed > 0 ? speed : t.speed,
};
}),
);
};
await transferDirectory(
task,
sourceSftpId,
@@ -490,6 +639,7 @@ export const useSftpTransfers = ({
sourceEncoding,
targetEncoding,
task.id, // rootTaskId - this is the top-level task
onChildProgress,
);
} else {
await transferFile(
@@ -560,6 +710,7 @@ export const useSftpTransfers = ({
completionHandlersRef.current.delete(task.id);
}
}
clearCancelledTask(task.id);
return "cancelled";
}
@@ -590,14 +741,14 @@ export const useSftpTransfers = ({
async (
sourceFiles: { name: string; isDirectory: boolean }[],
sourceSide: "left" | "right",
targetSide: "left" | "right",
options?: {
sourcePane?: SftpPane;
sourcePath?: string;
sourceConnectionId?: string;
onTransferComplete?: (result: TransferResult) => void | Promise<void>;
},
) => {
targetSide: "left" | "right",
options?: {
sourcePane?: SftpPane;
sourcePath?: string;
sourceConnectionId?: string;
onTransferComplete?: (result: TransferResult) => void | Promise<void>;
},
) => {
const sourcePane = options?.sourcePane ?? getActivePane(sourceSide);
const targetPane = getActivePane(targetSide);
@@ -633,11 +784,11 @@ export const useSftpTransfers = ({
const stat = await netcattyBridge.get()?.statLocal?.(fullPath);
if (stat) fileSize = stat.size;
} else if (sourceSftpId) {
const stat = await netcattyBridge.get()?.statSftp?.(
sourceSftpId,
fullPath,
sourceEncoding,
);
const stat = await netcattyBridge.get()?.statSftp?.(
sourceSftpId,
fullPath,
sourceEncoding,
);
if (stat) fileSize = stat.size;
}
} catch {
@@ -718,10 +869,6 @@ export const useSftpTransfers = ({
}
}
// Clean up cancelled task ID after a delay to ensure all async ops see it
setTimeout(() => {
cancelledTasksRef.current.delete(transferId);
}, 5000);
},
[stopProgressSimulation],
);
@@ -729,7 +876,18 @@ export const useSftpTransfers = ({
const retryTransfer = useCallback(
async (transferId: string) => {
const task = transfers.find((t) => t.id === transferId);
if (!task) return;
if (!task || task.retryable === false) return;
const retriedTask: TransferTask = {
...task,
id: crypto.randomUUID(),
status: "pending" as TransferStatus,
error: undefined,
transferredBytes: 0,
speed: 0,
startTime: Date.now(),
endTime: undefined,
};
const sourceSide = task.sourceConnectionId.startsWith("left") ? "left" : "right";
const targetSide = task.targetConnectionId.startsWith("left") ? "left" : "right";
@@ -737,14 +895,20 @@ export const useSftpTransfers = ({
const targetPane = getActivePane(targetSide as "left" | "right");
if (sourcePane?.connection && targetPane?.connection) {
const completionHandler = completionHandlersRef.current.get(transferId);
if (completionHandler) {
completionHandlersRef.current.set(retriedTask.id, completionHandler);
completionHandlersRef.current.delete(transferId);
}
setTransfers((prev) =>
prev.map((t) =>
t.id === transferId
? { ...t, status: "pending" as TransferStatus, error: undefined }
? retriedTask
: t,
),
);
await processTransfer(task, sourcePane, targetPane, targetSide);
await processTransfer(retriedTask, sourcePane, targetPane, targetSide);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- processTransfer is defined inline
@@ -761,6 +925,10 @@ export const useSftpTransfers = ({
setTransfers((prev) => prev.filter((t) => t.id !== transferId));
}, []);
const isTransferCancelled = useCallback((transferId: string) => {
return cancelledTasksRef.current.has(transferId);
}, []);
const addExternalUpload = useCallback((task: TransferTask) => {
// Filter out any pending scanning tasks before adding the new task.
// This ensures that even if dismissExternalUpload's state update hasn't been applied yet
@@ -773,7 +941,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;
}),
);
}, []);
@@ -870,6 +1058,7 @@ export const useSftpTransfers = ({
addExternalUpload,
updateExternalUpload,
cancelTransfer,
isTransferCancelled,
retryTransfer,
clearCompletedTransfers,
dismissTransfer,

View File

@@ -12,7 +12,12 @@ 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 { STORAGE_KEY_PORT_FORWARDING } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { toast } from '../../components/ui/toast';
interface AutoSyncConfig {
@@ -48,13 +53,30 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// Build sync payload
const buildPayload = useCallback((): SyncPayload => {
// If port-forwarding hook state is still [] (async init in progress),
// fall back to localStorage to avoid uploading an empty array that
// overwrites the cloud snapshot.
let effectivePFRules = config.portForwardingRules;
if (!effectivePFRules || effectivePFRules.length === 0) {
const stored = localStorageAdapter.read<SyncPayload['portForwardingRules']>(
STORAGE_KEY_PORT_FORWARDING,
);
if (stored && Array.isArray(stored) && stored.length > 0) {
effectivePFRules = stored.map((rule) => ({
...rule,
status: 'inactive' as const,
error: undefined,
lastUsedAt: undefined,
}));
}
}
return {
hosts: config.hosts,
keys: config.keys,
identities: config.identities,
snippets: config.snippets,
customGroups: config.customGroups,
portForwardingRules: config.portForwardingRules,
portForwardingRules: effectivePFRules,
knownHosts: config.knownHosts,
syncedAt: Date.now(),
};
@@ -62,15 +84,32 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// Create a hash of current data for comparison
const getDataHash = useCallback(() => {
// Same fallback as buildPayload
let effectivePFRules = config.portForwardingRules;
if (!effectivePFRules || effectivePFRules.length === 0) {
const stored = localStorageAdapter.read<SyncPayload['portForwardingRules']>(
STORAGE_KEY_PORT_FORWARDING,
);
if (stored && Array.isArray(stored) && stored.length > 0) {
effectivePFRules = stored.map((rule) => ({
...rule,
status: 'inactive' as const,
error: undefined,
lastUsedAt: undefined,
}));
}
}
const data = {
hosts: config.hosts,
keys: config.keys,
identities: config.identities,
snippets: config.snippets,
portForwardingRules: config.portForwardingRules,
customGroups: config.customGroups,
portForwardingRules: effectivePFRules,
knownHosts: config.knownHosts,
};
return JSON.stringify(data);
}, [config.hosts, config.keys, config.identities, config.snippets, config.portForwardingRules]);
}, [config.hosts, config.keys, config.identities, config.snippets, config.customGroups, config.portForwardingRules, config.knownHosts]);
// Sync now handler - get fresh state directly from manager
const syncNow = useCallback(async (options?: SyncNowOptions) => {
@@ -109,6 +148,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

@@ -135,8 +135,6 @@ export const useGlobalHotkeys = ({
e.stopPropagation();
const currentActions = actionsRef.current;
const _tabs = orderedTabsRef.current;
switch (action) {
case 'switchToTab': {
const num = parseInt(e.key, 10);

View File

@@ -9,12 +9,25 @@ import { localStorageAdapter } from "../../infrastructure/persistence/localStora
import {
clearReconnectTimer,
getActiveConnection,
initReconnectCancelListener,
reconcileWithBackend,
startPortForward,
stopAllPortForwards,
stopAndCleanupRule,
stopPortForward,
syncWithBackend,
} from "../../infrastructure/services/portForwardingService";
import { useStoredViewMode, ViewMode } from "./useStoredViewMode";
// Module-level ref-counts: these side effects must run at most once per
// window, not per hook instance (the hook mounts from both App.tsx
// and PortForwardingNew.tsx). Ref-counting ensures the resources
// stay alive as long as ANY instance is mounted.
let reconnectCancelListenerRefs = 0;
let reconnectCancelCleanup: (() => void) | undefined;
let heartbeatRefs = 0;
let heartbeatIntervalId: ReturnType<typeof setInterval> | undefined;
export type { ViewMode };
export type SortMode = "az" | "za" | "newest" | "oldest";
@@ -177,6 +190,53 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
return () => window.removeEventListener("storage", handleStorageChange);
}, []);
// Listen for cross-window reconnect cancellation events.
// Ref-counted so the listener stays alive as long as ANY hook
// instance is mounted (App.tsx outlives PortForwardingNew.tsx).
useEffect(() => {
reconnectCancelListenerRefs++;
let cleanup: (() => void) | undefined;
if (reconnectCancelListenerRefs === 1) {
cleanup = initReconnectCancelListener();
reconnectCancelCleanup = cleanup;
}
return () => {
reconnectCancelListenerRefs--;
if (reconnectCancelListenerRefs === 0 && reconnectCancelCleanup) {
reconnectCancelCleanup();
reconnectCancelCleanup = undefined;
}
};
}, []);
// Periodic heartbeat: reconcile renderer state with the backend every 4s.
// Ref-counted — same pattern as the reconnect cancel listener.
useEffect(() => {
heartbeatRefs++;
let intervalId: ReturnType<typeof setInterval> | undefined;
if (heartbeatRefs === 1) {
const HEARTBEAT_INTERVAL_MS = 4_000;
const tick = async () => {
const { gone, appeared } = await reconcileWithBackend();
if (gone.length === 0 && appeared.length === 0) return;
// Re-derive statuses from the now-updated activeConnections map
setGlobalRules(normalizeRulesWithConnections(globalRules));
};
intervalId = setInterval(tick, HEARTBEAT_INTERVAL_MS);
heartbeatIntervalId = intervalId;
}
return () => {
heartbeatRefs--;
if (heartbeatRefs === 0 && heartbeatIntervalId !== undefined) {
clearInterval(heartbeatIntervalId);
heartbeatIntervalId = undefined;
}
};
}, []);
const addRule = useCallback(
(
rule: Omit<PortForwardingRule, "id" | "createdAt" | "status">,
@@ -207,6 +267,8 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
const deleteRule = useCallback(
(id: string) => {
// Stop any active tunnel before removing the rule
stopAndCleanupRule(id);
const updated = globalRules.filter((r) => r.id !== id);
setGlobalRules(updated);
if (selectedRuleId === id) {
@@ -238,6 +300,60 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
);
const importRules = useCallback((newRules: PortForwardingRule[]) => {
// When clearing all rules (e.g. "Clear local data"), stop ALL tunnels
// and broadcast per-rule reconnect cancellation. stopAllPortForwards
// handles the backend, but we also need per-rule broadcasts so other
// windows cancel their pending reconnect timers.
if (newRules.length === 0) {
// Read from localStorage since globalRules may be empty (uninitialized)
const storedRules = localStorageAdapter.read<PortForwardingRule[]>(
STORAGE_KEY_PORT_FORWARDING,
);
const rulesToCancel = globalRules.length > 0
? globalRules
: (storedRules && Array.isArray(storedRules) ? storedRules : []);
for (const rule of rulesToCancel) {
stopAndCleanupRule(rule.id);
}
// Safety net: also stop anything the renderer doesn't know about
void stopAllPortForwards();
}
// Stop tunnels for rules that are being removed or whose connection
// config has changed (same ID but different host/port/type means the
// old tunnel is pointing at stale parameters and must be torn down).
//
// Use globalRules as the diff baseline. In a freshly opened settings
// window, globalRules may still be empty because initializeStore is
// async. Fall back to reading directly from localStorage to avoid
// missing tunnels that need to be stopped.
let diffBaseline = globalRules;
if (diffBaseline.length === 0 && newRules.length > 0) {
const stored = localStorageAdapter.read<PortForwardingRule[]>(
STORAGE_KEY_PORT_FORWARDING,
);
if (stored && Array.isArray(stored) && stored.length > 0) {
diffBaseline = stored;
}
}
const newRulesById = new Map(newRules.map((r) => [r.id, r]));
for (const existing of diffBaseline) {
const incoming = newRulesById.get(existing.id);
if (!incoming) {
// Rule removed entirely
stopAndCleanupRule(existing.id);
} else if (
existing.type !== incoming.type ||
existing.localPort !== incoming.localPort ||
existing.remoteHost !== incoming.remoteHost ||
existing.remotePort !== incoming.remotePort ||
existing.bindAddress !== incoming.bindAddress ||
existing.hostId !== incoming.hostId
) {
// Connection-relevant config changed — tear down the old tunnel
stopAndCleanupRule(existing.id);
}
}
setGlobalRules(normalizeRulesWithConnections(newRules));
}, []);

View File

@@ -49,9 +49,10 @@ export const useSessionState = () => {
username: 'local',
status: 'connecting',
};
setSessions(prev => [...prev, newSession]);
setActiveTabId(sessionId);
}, [setActiveTabId]);
setSessions(prev => [...prev, newSession]);
setActiveTabId(sessionId);
return sessionId;
}, [setActiveTabId]);
const createSerialSession = useCallback((config: SerialConfig) => {
const sessionId = crypto.randomUUID();
@@ -69,6 +70,7 @@ export const useSessionState = () => {
};
setSessions(prev => [...prev, newSession]);
setActiveTabId(sessionId);
return sessionId;
}, [setActiveTabId]);
const connectToHost = useCallback((host: Host) => {
@@ -100,7 +102,7 @@ export const useSessionState = () => {
};
setSessions(prev => [...prev, newSession]);
setActiveTabId(sessionId);
return;
return sessionId;
}
const newSession: TerminalSession = {
@@ -115,9 +117,10 @@ export const useSessionState = () => {
port: host.port,
moshEnabled: host.moshEnabled,
};
setSessions(prev => [...prev, newSession]);
setActiveTabId(newSession.id);
}, [setActiveTabId]);
setSessions(prev => [...prev, newSession]);
setActiveTabId(newSession.id);
return newSession.id;
}, [setActiveTabId]);
const updateSessionStatus = useCallback((sessionId: string, status: TerminalSession['status']) => {
setSessions(prev => prev.map(s => s.id === sessionId ? { ...s, status } : s));

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
import { SyncConfig, TerminalSettings, DEFAULT_TERMINAL_SETTINGS, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat } from '../../domain/models';
import { SyncConfig, TerminalSettings, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat, normalizeTerminalSettings } from '../../domain/models';
import {
STORAGE_KEY_COLOR,
STORAGE_KEY_SYNC,
@@ -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';
@@ -38,7 +39,13 @@ import { useAvailableFonts } from './fontStore';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
const DEFAULT_THEME: 'light' | 'dark' = 'light';
const DEFAULT_THEME: 'light' | 'dark' | 'system' = 'system';
/** Resolve the current OS color scheme preference. */
const getSystemPreference = (): 'light' | 'dark' =>
typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
const DEFAULT_LIGHT_UI_THEME = 'snow';
const DEFAULT_DARK_UI_THEME = 'midnight';
const DEFAULT_ACCENT_MODE: 'theme' | 'custom' = 'theme';
@@ -76,7 +83,7 @@ const readStoredString = (key: string): string | null => {
}
};
const isValidTheme = (value: unknown): value is 'light' | 'dark' => value === 'light' || value === 'dark';
const isValidTheme = (value: unknown): value is 'light' | 'dark' | 'system' => value === 'light' || value === 'dark' || value === 'system';
const isValidHslToken = (value: string): boolean => {
// Expect: "<h> <s>% <l>%", e.g. "221.2 83.2% 53.3%"
@@ -145,10 +152,14 @@ const applyThemeTokens = (
export const useSettingsState = () => {
const availableFonts = useAvailableFonts();
const uiFontsLoaded = useUIFontsLoaded();
const [theme, setTheme] = useState<'dark' | 'light'>(() => {
const [theme, setTheme] = useState<'dark' | 'light' | 'system'>(() => {
const stored = readStoredString(STORAGE_KEY_THEME);
return stored && isValidTheme(stored) ? stored : DEFAULT_THEME;
});
// Track the OS color scheme preference (updated by matchMedia listener)
const [systemPreference, setSystemPreference] = useState<'light' | 'dark'>(getSystemPreference);
// resolvedTheme is always 'light' or 'dark' — derived synchronously from theme + OS preference
const resolvedTheme: 'light' | 'dark' = theme === 'system' ? systemPreference : theme;
const [lightUiThemeId, setLightUiThemeId] = useState<string>(() => {
const stored = readStoredString(STORAGE_KEY_UI_THEME_LIGHT);
return stored && isValidUiThemeId('light', stored) ? stored : DEFAULT_LIGHT_UI_THEME;
@@ -181,7 +192,7 @@ export const useSettingsState = () => {
});
const [terminalSettings, setTerminalSettingsState] = useState<TerminalSettings>(() => {
const stored = localStorageAdapter.read<TerminalSettings>(STORAGE_KEY_TERM_SETTINGS);
return stored ? { ...DEFAULT_TERMINAL_SETTINGS, ...stored } : DEFAULT_TERMINAL_SETTINGS;
return normalizeTerminalSettings(stored);
});
const [hotkeyScheme, setHotkeyScheme] = useState<HotkeyScheme>(() => {
const stored = localStorageAdapter.readString(STORAGE_KEY_HOTKEY_SCHEME);
@@ -259,9 +270,10 @@ export const useSettingsState = () => {
const setTerminalSettings = useCallback((nextValue: SetStateAction<TerminalSettings>) => {
setTerminalSettingsState((prev) => {
const next = typeof nextValue === 'function'
const candidate = typeof nextValue === 'function'
? (nextValue as (prevState: TerminalSettings) => TerminalSettings)(prev)
: nextValue;
const next = normalizeTerminalSettings(candidate);
if (areTerminalSettingsEqual(prev, next)) {
return prev;
}
@@ -272,7 +284,7 @@ export const useSettingsState = () => {
const mergeIncomingTerminalSettings = useCallback((incoming: Partial<TerminalSettings>) => {
setTerminalSettingsState((prev) => {
const next = { ...prev, ...incoming };
const next = normalizeTerminalSettings({ ...prev, ...incoming });
if (areTerminalSettingsEqual(prev, next)) {
return prev;
}
@@ -309,8 +321,9 @@ export const useSettingsState = () => {
setAccentMode(nextAccentMode);
setCustomAccent(nextAccent);
const tokens = getUiThemeById(nextTheme, nextTheme === 'dark' ? nextDarkId : nextLightId).tokens;
applyThemeTokens(nextTheme, tokens, nextAccentMode, nextAccent);
const effective = nextTheme === 'system' ? getSystemPreference() : nextTheme;
const tokens = getUiThemeById(effective, effective === 'dark' ? nextDarkId : nextLightId).tokens;
applyThemeTokens(effective, tokens, nextAccentMode, nextAccent);
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
const syncCustomCssFromStorage = useCallback(() => {
@@ -319,8 +332,8 @@ export const useSettingsState = () => {
}, []);
useLayoutEffect(() => {
const tokens = getUiThemeById(theme, theme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
applyThemeTokens(theme, tokens, accentMode, customAccent);
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
applyThemeTokens(resolvedTheme, tokens, accentMode, customAccent);
localStorageAdapter.writeString(STORAGE_KEY_THEME, theme);
localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_LIGHT, lightUiThemeId);
localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId);
@@ -332,7 +345,18 @@ export const useSettingsState = () => {
notifySettingsChanged(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId);
notifySettingsChanged(STORAGE_KEY_ACCENT_MODE, accentMode);
notifySettingsChanged(STORAGE_KEY_COLOR, customAccent);
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, notifySettingsChanged]);
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, notifySettingsChanged]);
// Listen for OS color scheme changes to keep systemPreference in sync
useEffect(() => {
if (typeof window === 'undefined' || !window.matchMedia) return;
const mql = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e: MediaQueryListEvent) => {
setSystemPreference(e.matches ? 'dark' : 'light');
};
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}, []);
useLayoutEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_UI_LANGUAGE, uiLanguage);
@@ -403,6 +427,18 @@ export const useSettingsState = () => {
if (key === STORAGE_KEY_EDITOR_WORD_WRAP && typeof value === 'boolean') {
setEditorWordWrapState((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_SESSION_LOGS_ENABLED && typeof value === 'boolean') {
setSessionLogsEnabled((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_SESSION_LOGS_DIR && typeof value === 'string') {
setSessionLogsDir((prev) => (prev === value ? prev : value));
}
if (
key === STORAGE_KEY_SESSION_LOGS_FORMAT &&
(value === 'txt' || value === 'raw' || value === 'html')
) {
setSessionLogsFormat((prev) => (prev === value ? prev : value));
}
if (key === STORAGE_KEY_HOTKEY_SCHEME && (value === 'disabled' || value === 'mac' || value === 'pc')) {
setHotkeyScheme(value);
}
@@ -509,7 +545,7 @@ export const useSettingsState = () => {
if (e.key === STORAGE_KEY_TERM_SETTINGS && e.newValue) {
try {
const newSettings = JSON.parse(e.newValue) as TerminalSettings;
mergeIncomingTerminalSettings({ ...DEFAULT_TERMINAL_SETTINGS, ...newSettings });
mergeIncomingTerminalSettings(newSettings);
} catch {
// ignore parse errors
}
@@ -559,6 +595,25 @@ export const useSettingsState = () => {
setEditorWordWrapState(newValue);
}
}
if (e.key === STORAGE_KEY_SESSION_LOGS_ENABLED && e.newValue !== null) {
const newValue = e.newValue === 'true';
if (newValue !== sessionLogsEnabled) {
setSessionLogsEnabled(newValue);
}
}
if (e.key === STORAGE_KEY_SESSION_LOGS_DIR && e.newValue !== null) {
if (e.newValue !== sessionLogsDir) {
setSessionLogsDir(e.newValue);
}
}
if (e.key === STORAGE_KEY_SESSION_LOGS_FORMAT && e.newValue) {
if (
(e.newValue === 'txt' || e.newValue === 'raw' || e.newValue === 'html') &&
e.newValue !== sessionLogsFormat
) {
setSessionLogsFormat(e.newValue);
}
}
// Sync SFTP compressed upload setting from other windows
if (e.key === STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD && e.newValue !== null) {
const newValue = e.newValue === 'true' || e.newValue === 'enabled';
@@ -570,7 +625,7 @@ export const useSettingsState = () => {
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, editorWordWrap, mergeIncomingTerminalSettings]);
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, mergeIncomingTerminalSettings]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
@@ -766,9 +821,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(
@@ -786,6 +846,7 @@ export const useSettingsState = () => {
return {
theme,
setTheme,
resolvedTheme,
lightUiThemeId,
setLightUiThemeId,
darkUiThemeId,

View File

@@ -1,184 +0,0 @@
/**
* useSftpFileOperations - Shared file operations for SFTP components
*
* This hook provides common file operations like open, edit, preview
* that can be shared between SFTPModal and SftpView components.
*/
import { useCallback, useState } from "react";
import { getFileExtension, isTextFile, FileOpenerType } from "../../lib/sftpFileUtils";
import { toast } from "../../components/ui/toast";
import { useI18n } from "../i18n/I18nProvider";
import { useSftpFileAssociations } from "./useSftpFileAssociations";
export interface FileOperationsState {
// Text editor state
showTextEditor: boolean;
textEditorTarget: { name: string; fullPath: string } | null;
textEditorContent: string;
loadingTextContent: boolean;
// File opener dialog state
showFileOpenerDialog: boolean;
fileOpenerTarget: { name: string; fullPath: string } | null;
}
export interface FileOperationsActions {
// Open file based on type/association
openFile: (fileName: string, fullPath: string) => void;
// Edit text file
editFile: (
fileName: string,
fullPath: string,
readContent: () => Promise<string>
) => Promise<void>;
// Save text file
saveTextFile: (
content: string,
writeContent: (path: string, content: string) => Promise<void>
) => Promise<void>;
// Handle file opener selection
handleFileOpenerSelect: (
openerType: FileOpenerType,
setAsDefault: boolean,
readTextContent: () => Promise<string>,
readImageData: () => Promise<ArrayBuffer>
) => Promise<void>;
// Close modals
closeTextEditor: () => void;
closeFileOpenerDialog: () => void;
// Check if file can be edited
canEditFile: (fileName: string) => boolean;
}
export interface UseSftpFileOperationsResult {
state: FileOperationsState;
actions: FileOperationsActions;
}
export function useSftpFileOperations(): UseSftpFileOperationsResult {
const { t } = useI18n();
const { getOpenerForFile, setOpenerForExtension } = useSftpFileAssociations();
// Text editor state
const [showTextEditor, setShowTextEditor] = useState(false);
const [textEditorTarget, setTextEditorTarget] = useState<{ name: string; fullPath: string } | null>(null);
const [textEditorContent, setTextEditorContent] = useState("");
const [loadingTextContent, setLoadingTextContent] = useState(false);
// File opener dialog state
const [showFileOpenerDialog, setShowFileOpenerDialog] = useState(false);
const [fileOpenerTarget, setFileOpenerTarget] = useState<{ name: string; fullPath: string } | null>(null);
const canEditFile = useCallback((fileName: string) => {
return isTextFile(fileName);
}, []);
const closeTextEditor = useCallback(() => {
setShowTextEditor(false);
setTextEditorTarget(null);
setTextEditorContent("");
}, []);
const closeFileOpenerDialog = useCallback(() => {
setShowFileOpenerDialog(false);
setFileOpenerTarget(null);
}, []);
const editFile = useCallback(async (
fileName: string,
fullPath: string,
readContent: () => Promise<string>
) => {
try {
setLoadingTextContent(true);
setTextEditorTarget({ name: fileName, fullPath });
const content = await readContent();
setTextEditorContent(content);
setShowTextEditor(true);
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.loadFailed"),
"SFTP",
);
} finally {
setLoadingTextContent(false);
}
}, [t]);
const saveTextFile = useCallback(async (
content: string,
writeContent: (path: string, content: string) => Promise<void>
) => {
if (!textEditorTarget) return;
await writeContent(textEditorTarget.fullPath, content);
}, [textEditorTarget]);
const openFile = useCallback((fileName: string, fullPath: string) => {
const savedOpener = getOpenerForFile(fileName);
if (savedOpener) {
// User has saved an opener for this file type
// We'll just set the target and let the caller handle it
setFileOpenerTarget({ name: fileName, fullPath });
// Return the opener type so caller knows which operation to perform
if (savedOpener === 'builtin-editor' && canEditFile(fileName)) {
// Don't show dialog, caller should call editFile
return 'edit' as const;
}
}
// No saved opener, show the dialog
setFileOpenerTarget({ name: fileName, fullPath });
setShowFileOpenerDialog(true);
return 'dialog' as const;
}, [getOpenerForFile, canEditFile]);
const handleFileOpenerSelect = useCallback(async (
openerType: FileOpenerType,
setAsDefault: boolean,
readTextContent: () => Promise<string>,
_readImageData: () => Promise<ArrayBuffer>
) => {
if (!fileOpenerTarget) return;
if (setAsDefault) {
const ext = getFileExtension(fileOpenerTarget.name);
if (ext !== 'file') {
setOpenerForExtension(ext, openerType);
}
}
setShowFileOpenerDialog(false);
if (openerType === 'builtin-editor') {
await editFile(fileOpenerTarget.name, fileOpenerTarget.fullPath, readTextContent);
}
}, [fileOpenerTarget, setOpenerForExtension, editFile]);
return {
state: {
showTextEditor,
textEditorTarget,
textEditorContent,
loadingTextContent,
showFileOpenerDialog,
fileOpenerTarget,
},
actions: {
openFile,
editFile,
saveTextFile,
handleFileOpenerSelect,
closeTextEditor,
closeFileOpenerDialog,
canEditFile,
},
};
}

View File

@@ -213,6 +213,7 @@ export const useSftpState = (
addExternalUpload,
updateExternalUpload,
cancelTransfer,
isTransferCancelled,
retryTransfer,
clearCompletedTransfers,
dismissTransfer,
@@ -238,8 +239,10 @@ export const useSftpState = (
getActivePane,
refresh,
sftpSessionsRef,
useCompressedUpload: options?.useCompressedUpload,
addExternalUpload,
updateExternalUpload,
isTransferCancelled,
dismissExternalUpload: dismissTransfer,
});

View File

@@ -1,68 +0,0 @@
import { useCallback, useState } from "react";
import { loadFromGist, syncToGist } from "../../infrastructure/services/syncService";
export type SyncStatus = "idle" | "success" | "error";
export const useSyncState = () => {
const [isSyncing, setIsSyncing] = useState(false);
const [syncStatus, setSyncStatus] = useState<SyncStatus>("idle");
const resetSyncStatus = useCallback(() => {
setSyncStatus("idle");
}, []);
const verify = useCallback(async (token: string, gistId?: string) => {
setIsSyncing(true);
setSyncStatus("idle");
try {
if (gistId) {
await loadFromGist(token, gistId);
}
setSyncStatus("success");
} catch (err) {
setSyncStatus("error");
throw err;
} finally {
setIsSyncing(false);
}
}, []);
const upload = useCallback(
async (
token: string,
gistId: string | undefined,
data: Parameters<typeof syncToGist>[2],
) => {
setIsSyncing(true);
setSyncStatus("idle");
try {
const newGistId = await syncToGist(token, gistId, data);
setSyncStatus("success");
return newGistId;
} catch (err) {
setSyncStatus("error");
throw err;
} finally {
setIsSyncing(false);
}
},
[],
);
const download = useCallback(async (token: string, gistId: string) => {
setIsSyncing(true);
setSyncStatus("idle");
try {
const data = await loadFromGist(token, gistId);
setSyncStatus("success");
return data;
} catch (err) {
setSyncStatus("error");
throw err;
} finally {
setIsSyncing(false);
}
}, []);
return { isSyncing, syncStatus, resetSyncStatus, verify, upload, download };
};

View File

@@ -78,6 +78,12 @@ export const useTerminalBackend = () => {
bridge?.closeSession?.(sessionId);
}, []);
const setSessionEncoding = useCallback(async (sessionId: string, encoding: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.setSessionEncoding) return { ok: false, encoding };
return bridge.setSessionEncoding(sessionId, encoding);
}, []);
const onSessionData = useCallback((sessionId: string, cb: (data: string) => void) => {
const bridge = netcattyBridge.get();
if (!bridge?.onSessionData) throw new Error("onSessionData unavailable");
@@ -148,6 +154,7 @@ export const useTerminalBackend = () => {
writeToSession,
resizeSession,
closeSession,
setSessionEncoding,
onSessionData,
onSessionExit,
onChainProgress,

BIN
build/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

BIN
build/icons/16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 645 B

BIN
build/icons/256x256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

BIN
build/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
build/icons/48x48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
build/icons/512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
build/icons/64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -1,309 +0,0 @@
import { ChevronDown, Eye, EyeOff, Key, Lock, User } from "lucide-react";
import React, { useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { cn } from "../lib/utils";
import { Host, SSHKey } from "../types";
import { DistroAvatar } from "./DistroAvatar";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { ScrollArea } from "./ui/scroll-area";
interface AuthDialogProps {
host: Host;
keys: SSHKey[];
onSubmit: (auth: {
username: string;
authMethod: "password" | "key";
password?: string;
keyId?: string;
saveCredentials: boolean;
}) => void;
onCancel: () => void;
}
const AuthDialog: React.FC<AuthDialogProps> = ({
host,
keys,
onSubmit,
onCancel,
}) => {
const { t } = useI18n();
const [username, setUsername] = useState(host.username || "root");
const [authMethod, setAuthMethod] = useState<"password" | "key">("password");
const [password, setPassword] = useState("");
const [selectedKeyId, setSelectedKeyId] = useState<string | null>(null);
const [showPassword, setShowPassword] = useState(false);
const [saveCredentials, setSaveCredentials] = useState(true);
const [isKeySelectOpen, setIsKeySelectOpen] = useState(false);
const _selectedKey = keys.find((k) => k.id === selectedKeyId);
const handleSubmit = () => {
onSubmit({
username,
authMethod,
password: authMethod === "password" ? password : undefined,
keyId: authMethod === "key" ? (selectedKeyId ?? undefined) : undefined,
saveCredentials,
});
};
const isValid =
username.trim() &&
((authMethod === "password" && password.trim()) ||
(authMethod === "key" && selectedKeyId));
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="w-[420px] max-w-[90vw] bg-background border border-border/60 rounded-2xl shadow-2xl animate-in fade-in-0 zoom-in-95 duration-200">
{/* Header */}
<div className="px-6 py-5 border-b border-border/50">
<div className="flex items-center gap-3">
<DistroAvatar
host={host}
fallback={host.label.slice(0, 2).toUpperCase()}
className="h-12 w-12"
/>
<div>
<h2 className="text-base font-semibold">{host.label}</h2>
<p className="text-xs text-muted-foreground font-mono">
SSH {host.hostname}:{host.port || 22}
</p>
</div>
</div>
</div>
{/* Progress indicator */}
<div className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-primary text-primary-foreground flex items-center justify-center">
<User size={14} />
</div>
<div className="flex-1 h-0.5 bg-muted" />
<div
className={cn(
"h-8 w-8 rounded-full flex items-center justify-center transition-colors",
username.trim()
? "bg-primary/20 text-primary"
: "bg-muted text-muted-foreground",
)}
>
{authMethod === "password" ? (
<Lock size={14} />
) : (
<Key size={14} />
)}
</div>
<div className="flex-1 h-0.5 bg-muted" />
<div className="h-8 w-8 rounded-full bg-muted text-muted-foreground flex items-center justify-center text-xs font-mono">
{">_"}
</div>
</div>
</div>
{/* Auth method tabs */}
<div className="px-6">
<div className="flex gap-1 p-1 bg-secondary/80 rounded-lg border border-border/60">
<button
className={cn(
"flex-1 flex items-center justify-center gap-2 py-2 text-sm font-medium rounded-md transition-all",
authMethod === "password"
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-secondary",
)}
onClick={() => setAuthMethod("password")}
>
<Lock size={14} />
{t("terminal.auth.password")}
</button>
<button
className={cn(
"flex-1 flex items-center justify-center gap-2 py-2 text-sm font-medium rounded-md transition-all",
authMethod === "key"
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground hover:bg-secondary",
)}
onClick={() => setAuthMethod("key")}
>
<Key size={14} />
{t("terminal.auth.sshKey")}
</button>
</div>
</div>
{/* Form */}
<div className="px-6 py-4 space-y-4">
{/* Username field (shown when no username on host) */}
{!host.username && (
<div className="space-y-2">
<Label htmlFor="auth-username">{t("terminal.auth.username")}</Label>
<Input
id="auth-username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={t("terminal.auth.username.placeholder")}
autoFocus
/>
</div>
)}
{/* Password field */}
{authMethod === "password" && (
<div className="space-y-2">
<Label htmlFor="auth-password">
{t("terminal.auth.passwordLabel")}
</Label>
<div className="relative">
<Input
id="auth-password"
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("terminal.auth.password.placeholder")}
className="pr-10"
autoFocus={!!host.username}
onKeyDown={(e) => {
if (e.key === "Enter" && isValid) {
handleSubmit();
}
}}
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
)}
{/* Key selection */}
{authMethod === "key" && (
<div className="space-y-2">
<Label>{t("terminal.auth.selectKey")}</Label>
{keys.length === 0 ? (
<div className="text-sm text-muted-foreground p-3 border border-dashed border-border/60 rounded-lg text-center">
{t("terminal.auth.noKeysHint")}
</div>
) : (
<div className="space-y-2">
{keys
.filter((k) => k.category === "key")
.slice(0, 5)
.map((key) => (
<button
key={key.id}
className={cn(
"w-full flex items-center gap-3 px-3 py-2.5 rounded-lg border transition-colors text-left",
selectedKeyId === key.id
? "border-primary bg-primary/5"
: "border-border/50 hover:bg-secondary/50",
)}
onClick={() => setSelectedKeyId(key.id)}
>
<div
className={cn(
"h-8 w-8 rounded-lg flex items-center justify-center",
"bg-primary/20 text-primary",
)}
>
<Key size={14} />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">
{key.label}
</div>
<div className="text-xs text-muted-foreground">
{t("auth.keyType", { type: key.type })}
</div>
</div>
</button>
))}
{keys.filter((k) => k.category === "key").length > 5 && (
<Popover
open={isKeySelectOpen}
onOpenChange={setIsKeySelectOpen}
>
<PopoverTrigger asChild>
<Button variant="outline" className="w-full">
{t("auth.showAllKeys")}
<ChevronDown size={14} className="ml-2" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-0">
<ScrollArea className="h-64">
<div className="p-2 space-y-1">
{keys
.filter((k) => k.category === "key")
.map((key) => (
<button
key={key.id}
className={cn(
"w-full flex items-center gap-2 px-2 py-2 rounded-md text-left transition-colors",
selectedKeyId === key.id
? "bg-primary/10"
: "hover:bg-secondary",
)}
onClick={() => {
setSelectedKeyId(key.id);
setIsKeySelectOpen(false);
}}
>
<Key size={14} className="text-primary" />
<span className="text-sm truncate">
{key.label}
</span>
<span className="text-xs text-muted-foreground ml-auto">
{key.type}
</span>
</button>
))}
</div>
</ScrollArea>
</PopoverContent>
</Popover>
)}
</div>
)}
</div>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-border/50 flex items-center justify-between">
<Button variant="secondary" onClick={onCancel}>
{t("common.close")}
</Button>
<div className="flex items-center gap-2">
<Popover>
<PopoverTrigger asChild>
<Button disabled={!isValid} onClick={handleSubmit}>
{t("terminal.auth.continueSave")}
<ChevronDown size={14} className="ml-2" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-40 p-1" align="end">
<button
className="w-full px-3 py-2 text-sm text-left hover:bg-secondary rounded-md"
onClick={() => {
setSaveCredentials(false);
handleSubmit();
}}
disabled={!isValid}
>
{t("common.continue")}
</button>
</PopoverContent>
</Popover>
</div>
</div>
</div>
</div>
);
};
export default AuthDialog;

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

@@ -1,107 +0,0 @@
import { ChevronRight,Folder,FolderOpen,FolderPlus,Plus } from 'lucide-react';
import React,{ useMemo } from 'react';
import { useI18n } from '../application/i18n/I18nProvider';
import { cn } from '../lib/utils';
import { GroupNode } from '../types';
import { Collapsible,CollapsibleContent,CollapsibleTrigger } from './ui/collapsible';
import { ContextMenu,ContextMenuContent,ContextMenuItem,ContextMenuTrigger } from './ui/context-menu';
interface GroupTreeItemProps {
node: GroupNode;
depth: number;
expandedPaths: Set<string>;
onToggle: (path: string) => void;
onSelectGroup: (path: string) => void;
selectedGroup: string | null;
onEditGroup: (path: string) => void;
onNewHost: (path: string) => void;
onNewSubfolder: (path: string) => void;
isManagedGroup?: (path: string) => boolean;
}
export const GroupTreeItem: React.FC<GroupTreeItemProps> = ({
node,
depth,
expandedPaths,
onToggle,
onSelectGroup,
selectedGroup,
onEditGroup,
onNewHost,
onNewSubfolder,
}) => {
const { t } = useI18n();
const isExpanded = expandedPaths.has(node.path);
const hasChildren = node.children && Object.keys(node.children).length > 0;
const paddingLeft = `${depth * 12 + 12}px`;
const isSelected = selectedGroup === node.path;
const childNodes = useMemo(() => {
return node.children
? (Object.values(node.children) as unknown as GroupNode[]).sort((a, b) => a.name.localeCompare(b.name))
: [];
}, [node.children]);
return (
<Collapsible open={isExpanded} onOpenChange={() => onToggle(node.path)}>
<ContextMenu>
<ContextMenuTrigger>
<CollapsibleTrigger asChild>
<div
className={cn(
"flex items-center py-1.5 pr-2 text-sm font-medium cursor-pointer transition-colors select-none group relative rounded-r-md",
isSelected ? "bg-primary/10 text-primary border-l-2 border-primary" : "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
)}
style={{ paddingLeft }}
onClick={() => onSelectGroup(node.path)}
>
<div className="mr-1.5 flex-shrink-0 w-4 h-4 flex items-center justify-center">
{hasChildren && (
<div className={cn("transition-transform duration-200", isExpanded ? "rotate-90" : "")}>
<ChevronRight size={12} />
</div>
)}
</div>
<div className="mr-2 text-primary/80 group-hover:text-primary transition-colors">
{isExpanded ? <FolderOpen size={16} /> : <Folder size={16} />}
</div>
<span className="truncate flex-1">{node.name}</span>
{node.hosts.length > 0 && (
<span className="text-[10px] opacity-70 bg-background/50 px-1.5 rounded-full border border-border">
{node.hosts.length}
</span>
)}
</div>
</CollapsibleTrigger>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => onNewHost(node.path)}>
<Plus className="mr-2 h-4 w-4" /> {t("action.newHost")}
</ContextMenuItem>
<ContextMenuItem onClick={() => onNewSubfolder(node.path)}>
<FolderPlus className="mr-2 h-4 w-4" /> {t("action.newSubfolder")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
{hasChildren && (
<CollapsibleContent>
{childNodes.map((child) => (
<GroupTreeItem
key={child.path}
node={child}
depth={depth + 1}
expandedPaths={expandedPaths}
onToggle={onToggle}
onSelectGroup={onSelectGroup}
selectedGroup={selectedGroup}
onEditGroup={onEditGroup}
onNewHost={onNewHost}
onNewSubfolder={onNewSubfolder}
/>
))}
</CollapsibleContent>
)}
</Collapsible>
);
};

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";
@@ -154,13 +156,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
// Group input state for inline creation suggestion
const [groupInputValue, setGroupInputValue] = useState(form.group || "");
// Check if the entered group is new (doesn't exist)
// Reserved for future use: showing inline "create new group" suggestion
const _isNewGroup = useMemo(() => {
const trimmed = groupInputValue.trim();
return trimmed.length > 0 && !groups.includes(trimmed);
}, [groupInputValue, groups]);
useEffect(() => {
if (initialData) {
// Ensure telnetEnabled is set when protocol is telnet
@@ -1115,21 +1110,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 +1126,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 +1217,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 +1317,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 +1491,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 +1513,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

@@ -1,382 +0,0 @@
import { Key, Lock, Plus, Save, Server, X } from "lucide-react";
import React, { useEffect, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { cn } from "../lib/utils";
import { Host, SSHKey } from "../types";
import { Button } from "./ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "./ui/dialog";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { Switch } from "./ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "./ui/select";
interface HostFormProps {
initialData?: Host | null;
availableKeys: SSHKey[];
groups: string[];
onSave: (host: Host) => void;
onCancel: () => void;
}
const HostForm: React.FC<HostFormProps> = ({
initialData,
availableKeys,
groups,
onSave,
onCancel,
}) => {
const { t } = useI18n();
const [formData, setFormData] = useState<Partial<Host>>(
initialData || {
label: "",
hostname: "",
port: 22,
username: "root",
tags: [],
os: "linux",
group: "General",
identityFileId: "",
},
);
const [authType, setAuthType] = useState<"password" | "key">(
initialData?.identityFileId ? "key" : "password",
);
const [tagInput, setTagInput] = useState("");
const handleAddTag = () => {
const tag = tagInput.trim();
if (tag && !formData.tags?.includes(tag)) {
setFormData((prev) => ({ ...prev, tags: [...(prev.tags || []), tag] }));
setTagInput("");
}
};
const handleRemoveTag = (tagToRemove: string) => {
setFormData((prev) => ({
...prev,
tags: (prev.tags || []).filter((t) => t !== tagToRemove),
}));
};
const handleTagKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
handleAddTag();
}
};
// Effect to ensure we have a valid auth state if switching back and forth
useEffect(() => {
if (authType === "password") {
setFormData((prev) => ({ ...prev, identityFileId: "" }));
} else if (
authType === "key" &&
!formData.identityFileId &&
availableKeys.length > 0
) {
// Default to first key if none selected
setFormData((prev) => ({ ...prev, identityFileId: availableKeys[0].id }));
}
}, [authType, availableKeys, formData.identityFileId]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (formData.label && formData.hostname && formData.username) {
onSave({
...formData,
id: initialData?.id || crypto.randomUUID(),
tags: formData.tags || [],
port: formData.port || 22,
group: formData.group || "General",
identityFileId:
authType === "key" ? formData.identityFileId : undefined,
createdAt: initialData?.createdAt || Date.now(),
} as Host);
}
};
return (
<Dialog open={true} onOpenChange={() => onCancel()}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Server className="h-5 w-5 text-primary" />
{initialData ? t("hostForm.title.edit") : t("hostForm.title.new")}
</DialogTitle>
<DialogDescription className="sr-only">
{initialData ? t("hostForm.desc.edit") : t("hostForm.desc.new")}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="label">{t("hostForm.field.label")}</Label>
<Input
id="label"
placeholder={t("hostForm.placeholder.label")}
value={formData.label}
onChange={(e) =>
setFormData({ ...formData, label: e.target.value })
}
required
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2 grid gap-2">
<Label htmlFor="hostname">{t("hostForm.field.hostname")}</Label>
<Input
id="hostname"
placeholder={t("hostForm.placeholder.hostname")}
value={formData.hostname}
onChange={(e) =>
setFormData({ ...formData, hostname: e.target.value })
}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="port">{t("hostForm.field.port")}</Label>
<Input
id="port"
type="number"
value={formData.port}
onChange={(e) =>
setFormData({ ...formData, port: parseInt(e.target.value) })
}
required
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="username">{t("hostForm.field.username")}</Label>
<Input
id="username"
value={formData.username}
onChange={(e) =>
setFormData({ ...formData, username: e.target.value })
}
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="os">{t("hostForm.field.osType")}</Label>
<Select
value={formData.os}
onValueChange={(val: "linux" | "windows" | "macos") =>
setFormData({ ...formData, os: val })
}
>
<SelectTrigger>
<SelectValue placeholder={t("hostForm.placeholder.selectOs")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="linux">Linux</SelectItem>
<SelectItem value="windows">Windows</SelectItem>
<SelectItem value="macos">macOS</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="group">{t("hostForm.field.group")}</Label>
<Input
id="group"
placeholder={t("hostForm.placeholder.group")}
value={formData.group}
onChange={(e) =>
setFormData({ ...formData, group: e.target.value })
}
list="group-suggestions"
autoComplete="off"
/>
<datalist id="group-suggestions">
{groups.map((g) => (
<option key={g} value={g} />
))}
</datalist>
</div>
<div className="grid gap-2">
<Label htmlFor="tags">{t("hostForm.field.tags")}</Label>
<div className="flex gap-2">
<Input
id="tags"
placeholder={t("hostForm.placeholder.addTag")}
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={handleTagKeyDown}
className="flex-1"
/>
<Button
type="button"
variant="secondary"
size="icon"
onClick={handleAddTag}
disabled={!tagInput.trim()}
>
<Plus size={16} />
</Button>
</div>
{formData.tags && formData.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-1">
{formData.tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-primary text-xs"
>
{tag}
<button
type="button"
onClick={() => handleRemoveTag(tag)}
className="hover:bg-primary/20 rounded-full p-0.5"
>
<X size={12} />
</button>
</span>
))}
</div>
)}
</div>
<div className="space-y-3 pt-2">
<div className="flex items-center justify-between space-x-2 border rounded-md p-3">
<div className="space-y-0.5">
<Label htmlFor="sftp-sudo" className="text-base">
{t("hostDetails.sftp.sudo")}
</Label>
<p className="text-xs text-muted-foreground">
{t("hostDetails.sftp.sudo.desc")}
</p>
{formData.sftpSudo && authType === "key" && (
<p className="text-xs text-amber-500 mt-1">
{t("hostDetails.sftp.sudo.passwordWarning")}
</p>
)}
</div>
<Switch
id="sftp-sudo"
checked={formData.sftpSudo || false}
onCheckedChange={(checked) =>
setFormData({ ...formData, sftpSudo: checked })
}
/>
</div>
<div className="space-y-1">
<Label htmlFor="sftp-encoding">
{t("hostDetails.sftp.encoding")}
</Label>
<Select
value={formData.sftpEncoding || "auto"}
onValueChange={(val) =>
setFormData({ ...formData, sftpEncoding: val as Host["sftpEncoding"] })
}
>
<SelectTrigger id="sftp-encoding">
<SelectValue placeholder={t("sftp.encoding.label")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{t("sftp.encoding.auto")}</SelectItem>
<SelectItem value="utf-8">{t("sftp.encoding.utf8")}</SelectItem>
<SelectItem value="gb18030">{t("sftp.encoding.gb18030")}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{t("hostDetails.sftp.encoding.desc")}
</p>
</div>
<Label>{t("hostForm.auth.method")}</Label>
<div className="grid grid-cols-2 gap-4">
<div
className={cn(
"border rounded-md p-3 flex flex-col items-center justify-center gap-2 cursor-pointer transition-all hover:bg-accent/50",
authType === "password"
? "border-primary bg-primary/5 text-primary ring-1 ring-primary"
: "text-muted-foreground",
)}
onClick={() => setAuthType("password")}
>
<Lock size={20} />
<span className="text-xs font-medium">{t("hostForm.auth.password")}</span>
</div>
<div
className={cn(
"border rounded-md p-3 flex flex-col items-center justify-center gap-2 cursor-pointer transition-all hover:bg-accent/50",
authType === "key"
? "border-primary bg-primary/5 text-primary ring-1 ring-primary"
: "text-muted-foreground",
)}
onClick={() => setAuthType("key")}
>
<Key size={20} />
<span className="text-xs font-medium">{t("hostForm.auth.sshKey")}</span>
</div>
</div>
{authType === "key" && (
<div className="animate-in fade-in zoom-in-95 duration-200">
<Select
value={formData.identityFileId || ""}
onValueChange={(val) =>
setFormData({ ...formData, identityFileId: val })
}
>
<SelectTrigger>
<SelectValue placeholder={t("hostForm.auth.selectKey")} />
</SelectTrigger>
<SelectContent>
{availableKeys.map((key) => (
<SelectItem key={key.id} value={key.id}>
{key.label} ({key.type})
</SelectItem>
))}
{availableKeys.length === 0 && (
<SelectItem value="none" disabled>
{t("hostForm.auth.noKeys")}
</SelectItem>
)}
</SelectContent>
</Select>
{availableKeys.length === 0 && (
<p className="text-[10px] text-destructive mt-1">
{t("hostForm.auth.noKeysHint")}
</p>
)}
</div>
)}
</div>
<DialogFooter>
<Button type="button" variant="ghost" onClick={onCancel}>
{t("common.cancel")}
</Button>
<Button type="submit">
<Save className="mr-2 h-4 w-4" /> {t("hostForm.saveHost")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};
export default HostForm;

View File

@@ -1,411 +0,0 @@
import {
Key,
LayoutGrid,
List as ListIcon,
Pencil,
Plus,
Search,
Shield,
Trash2,
} from "lucide-react";
import React, { useMemo, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { KeyType } from "../domain/models";
import { cn } from "../lib/utils";
import { SSHKey } from "../types";
import { Button } from "./ui/button";
import { Card, CardDescription, CardTitle } from "./ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "./ui/dialog";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { Textarea } from "./ui/textarea";
interface KeyManagerProps {
keys: SSHKey[];
onSave: (key: SSHKey) => void;
onDelete: (id: string) => void;
}
const KeyManager: React.FC<KeyManagerProps> = ({ keys, onSave, onDelete }) => {
const { t } = useI18n();
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [panelMode, setPanelMode] = useState<"new" | "edit">("new");
const [draftKey, setDraftKey] = useState<Partial<SSHKey>>({
id: "",
label: "",
type: "RSA",
privateKey: "",
publicKey: "",
created: Date.now(),
});
const [generateMode, setGenerateMode] = useState(false);
const [search, setSearch] = useState("");
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
const handleGenerate = () => {
// Simulate Key Generation
const mockKey =
`-----BEGIN ${draftKey.type} PRIVATE KEY-----\n` +
`MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC${Math.random().toString(36).substring(7)}\n` +
`... (simulated generated content) ...\n` +
`-----END ${draftKey.type} PRIVATE KEY-----`;
setDraftKey({ ...draftKey, privateKey: mockKey });
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!draftKey.label || !draftKey.privateKey) return;
const payload: SSHKey = {
id: draftKey.id || crypto.randomUUID(),
label: draftKey.label,
type: (draftKey.type as KeyType) || "RSA",
privateKey: draftKey.privateKey,
publicKey: draftKey.publicKey?.trim() || undefined,
created: draftKey.created || Date.now(),
source: draftKey.source || (generateMode ? "generated" : "imported"),
category: draftKey.category || "key",
};
onSave(payload);
setIsDialogOpen(false);
setGenerateMode(false);
};
const openPanelForKey = (key: SSHKey) => {
setPanelMode("edit");
setDraftKey({ ...key });
setIsDialogOpen(true);
setGenerateMode(false);
};
const openPanelNew = (isGenerate = false) => {
setPanelMode("new");
setGenerateMode(isGenerate);
setDraftKey({
id: "",
label: "",
type: "RSA",
privateKey: isGenerate
? "Click generate to create a new key pair..."
: "",
publicKey: "",
created: Date.now(),
});
setIsDialogOpen(true);
};
const handleDelete = (id: string) => {
onDelete(id);
if (draftKey.id === id) {
setIsDialogOpen(false);
setDraftKey({
id: "",
label: "",
type: "RSA",
privateKey: "",
publicKey: "",
created: Date.now(),
});
}
};
const filteredKeys = useMemo(() => {
const term = search.trim().toLowerCase();
return keys.filter((k) => {
if (!term) return true;
return (
k.label.toLowerCase().includes(term) ||
(k.type || "").toString().toLowerCase().includes(term)
);
});
}, [keys, search]);
const derivedPublicKey = useMemo(() => {
if (draftKey.publicKey) return draftKey.publicKey;
if (!draftKey.label) return "Generated By netcatty";
return `ssh-${(draftKey.type || "ed25519").toLowerCase()} AAAAC3NzaC1lZDI1NTE5AAAA${(
draftKey.label || "netcatty"
)
.replace(/\s+/g, "")
.slice(0, 8)} Generated By netcatty`;
}, [draftKey.label, draftKey.type, draftKey.publicKey]);
return (
<div className="px-2.5 py-2.5 lg:px-3 lg:py-3 h-full overflow-y-auto space-y-3.5 relative">
<div className="flex flex-wrap items-center gap-3 bg-secondary/60 border border-border/70 rounded-xl px-2 py-1.5 shadow-sm">
<Button
size="sm"
variant="secondary"
className="h-8 px-3 gap-2"
disabled
>
Key
<span className="text-[10px] px-2 rounded-full h-5 min-w-[22px] flex items-center justify-center bg-primary/10 text-primary border border-border/70">
{keys.length}
</span>
</Button>
<div className="ml-auto flex items-center gap-2">
<div className="relative">
<Search
size={14}
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search keys..."
className="h-9 pl-8 w-44 md:w-56"
/>
</div>
<Button
size="icon"
variant={viewMode === "grid" ? "secondary" : "ghost"}
className="h-9 w-9"
onClick={() => setViewMode("grid")}
>
<LayoutGrid size={16} />
</Button>
<Button
size="icon"
variant={viewMode === "list" ? "secondary" : "ghost"}
className="h-9 w-9"
onClick={() => setViewMode("list")}
>
<ListIcon size={16} />
</Button>
<Button size="sm" onClick={() => openPanelNew(false)}>
<Plus size={14} className="mr-2" /> Import
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => openPanelNew(true)}
>
Generate
</Button>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between px-1">
<h2 className="text-base font-semibold text-muted-foreground">
Keys
</h2>
</div>
<div className="space-y-3">
{filteredKeys.length === 0 && (
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground">
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
<Shield size={32} className="opacity-60" />
</div>
<h3 className="text-lg font-semibold text-foreground mb-2">
Set up your keys
</h3>
<p className="text-sm text-center max-w-sm">
Import or generate SSH keys for secure authentication.
</p>
</div>
)}
<div
className={
viewMode === "grid"
? "grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
: "flex flex-col gap-0"
}
>
{filteredKeys.map((key) => (
<Card
key={key.id}
className={cn(
"group cursor-pointer soft-card elevate rounded-xl",
viewMode === "grid"
? "h-[68px] px-3 py-2"
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors",
)}
onClick={() => openPanelForKey(key)}
>
<div className="flex items-center gap-3 h-full">
<div className="h-9 w-9 rounded-md bg-primary/15 text-primary flex items-center justify-center">
<Key size={16} />
</div>
<div className="min-w-0 flex-1">
<CardTitle className="text-sm font-semibold truncate">
{key.label}
</CardTitle>
<CardDescription className="text-[11px] font-mono text-muted-foreground truncate">
Type {key.type}
</CardDescription>
<div className="text-[10px] text-muted-foreground/80 font-mono truncate">
SHA256:{key.id.substring(0, 16)}...
</div>
</div>
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
openPanelForKey(key);
}}
>
<Pencil size={14} />
</Button>
<Button
size="icon"
variant="ghost"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleDelete(key.id);
}}
>
<Trash2 size={14} />
</Button>
</div>
</div>
</Card>
))}
</div>
</div>
</div>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
{panelMode === "new"
? t("keychain.keyDialog.newTitle")
: t("keychain.keyDialog.editTitle")}
</DialogTitle>
<DialogDescription className="sr-only">
{panelMode === "new"
? t("keychain.keyDialog.newDesc")
: t("keychain.keyDialog.editDesc")}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label>{t("keychain.field.label")}</Label>
<Input
value={draftKey.label}
onChange={(e) =>
setDraftKey({ ...draftKey, label: e.target.value })
}
placeholder={t("keychain.field.labelPlaceholder")}
required
/>
</div>
<div className="space-y-2">
<Label>{t("keychain.field.privateKeyRequired")}</Label>
<Textarea
value={draftKey.privateKey}
onChange={(e) =>
setDraftKey({ ...draftKey, privateKey: e.target.value })
}
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
className="min-h-[160px] font-mono text-xs"
required
/>
{generateMode && (
<Button
type="button"
size="sm"
variant="secondary"
onClick={handleGenerate}
>
{t("keychain.generate.generate")}
</Button>
)}
</div>
<div className="space-y-2">
<Label>{t("keychain.field.publicKey")}</Label>
<Textarea
value={derivedPublicKey}
onChange={(e) =>
setDraftKey({ ...draftKey, publicKey: e.target.value })
}
placeholder="ssh-ed25519 AAAAC3... user@host"
className="min-h-[90px] font-mono text-xs"
/>
</div>
<div className="space-y-2">
<Label className="flex items-center gap-2">
{t("terminal.auth.certificate")}{" "}
<span className="text-[10px] px-2 py-0.5 rounded-full bg-muted text-muted-foreground">
{t("common.optional")}
</span>
</Label>
<Textarea
placeholder={t("keychain.field.certificatePlaceholder")}
className="min-h-[80px] text-xs"
/>
</div>
<div className="border border-dashed border-border/80 rounded-xl p-4 text-center space-y-2 bg-background/60">
<div className="text-sm text-muted-foreground">
{t("keychain.import.dropHint")}
</div>
<Button
type="button"
variant="secondary"
onClick={() => {
// mock file import
setDraftKey({
...draftKey,
label: draftKey.label || t("keychain.import.importedKeyLabel"),
privateKey:
draftKey.privateKey ||
"-----BEGIN OPENSSH PRIVATE KEY-----\nAAAAC3NzaC1lZDI1NTE5AAAA\n-----END OPENSSH PRIVATE KEY-----",
});
}}
>
{t("keychain.import.importFromFile")}
</Button>
</div>
<DialogFooter>
{panelMode === "edit" && draftKey.id && (
<Button
type="button"
variant="ghost"
className="text-destructive mr-auto"
onClick={() => handleDelete(draftKey.id!)}
>
{t("common.delete")}
</Button>
)}
<Button
type="button"
variant="ghost"
onClick={() => setIsDialogOpen(false)}
>
{t("common.cancel")}
</Button>
<Button type="submit">
{panelMode === "new"
? t("keychain.import.saveKey")
: t("keychain.keyDialog.updateKey")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
);
};
export default KeyManager;

View File

@@ -450,11 +450,6 @@ echo $3 >> "$FILE"`);
[onDeleteIdentity, panel, closePanel],
);
// Copy to clipboard
const _copyToClipboard = useCallback((_text: string) => {
navigator.clipboard.writeText(_text);
}, []);
// Get icon for key source
const getKeyIcon = (key: SSHKey) => {
if (key.certificate) return <BadgeCheck size={16} />;
@@ -506,46 +501,6 @@ echo $3 >> "$FILE"`);
[],
);
// Handle drag and drop
const _handleDrop = useCallback((event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
const file = event.dataTransfer.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target?.result as string;
if (content) {
let detectedType: KeyType = "ED25519";
const lc = content.toLowerCase();
if (lc.includes("rsa")) detectedType = "RSA";
else if (lc.includes("ecdsa") || lc.includes("ec private"))
detectedType = "ECDSA";
else if (lc.includes("ed25519")) detectedType = "ED25519";
const label = file.name.replace(/\.(pem|key|pub|ppk)$/i, "");
setDraftKey((prev) => ({
...prev,
privateKey: content,
label: prev.label || label,
type: detectedType,
}));
}
};
reader.readAsText(file);
}, []);
const _handleDragOver = useCallback(
(event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
},
[],
);
return (
<div className="h-full flex relative">
{/* Hidden file input */}

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

@@ -307,8 +307,6 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
const label =
newFormDraft.label?.trim() ||
(() => {
// Host lookup reserved for future label enhancement (e.g., "Local:8080 → api.example.com:80 via server1")
const _host = hosts.find((h) => h.id === newFormDraft.hostId);
switch (newFormDraft.type) {
case "local":
return `Local:${newFormDraft.localPort}${newFormDraft.remoteHost}:${newFormDraft.remotePort}`;
@@ -546,12 +544,6 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
);
};
// Handle skip wizard (just save with defaults)
const _skipWizard = () => {
setShowWizard(false);
resetWizard();
};
// Render wizard panel content
const hasRules = filteredRules.length > 0;

View File

@@ -14,7 +14,7 @@ import React, { useMemo, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import type { QuickConnectTarget } from "../domain/quickConnect";
import { cn } from "../lib/utils";
import { Host, KnownHost, SSHKey } from "../types";
import { Host, SSHKey } from "../types";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
@@ -30,7 +30,6 @@ interface QuickConnectWizardProps {
open: boolean;
target: QuickConnectTarget;
keys: SSHKey[];
knownHosts: KnownHost[];
warnings?: string[];
onConnect: (host: Host) => void;
onSaveHost?: (host: Host) => void;
@@ -42,7 +41,6 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
open,
target,
keys,
knownHosts,
warnings,
onConnect,
onSaveHost,
@@ -69,16 +67,7 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
const [password, setPassword] = useState("");
const [selectedKeyId, setSelectedKeyId] = useState<string | null>(null);
const [showPassword, setShowPassword] = useState(false);
const [saveCredentials, _setSaveCredentials] = useState(true);
// Check if host is in known hosts
const _existingKnownHost = useMemo(() => {
return knownHosts.find(
(kh) =>
kh.hostname === target.hostname &&
(kh.port === port || (!kh.port && port === 22)),
);
}, [knownHosts, target.hostname, port]);
const [saveCredentials] = useState(true);
// Reset state when target changes
React.useEffect(() => {

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,
@@ -391,9 +403,40 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
setLoading,
t,
useCompressedUpload: sftpUseCompressedUpload,
listSftp: listSftpWithEncoding,
deleteLocalFile,
});
const hasEverOpenedRef = useRef(false);
const hasActiveTransferTasks = useMemo(
() =>
uploadTasks.some(
(task) =>
task.status === "pending" ||
task.status === "uploading" ||
task.status === "downloading",
),
[uploadTasks],
);
useEffect(() => {
if (open) {
hasEverOpenedRef.current = true;
return;
}
if (!hasEverOpenedRef.current) return;
if (uploading || hasActiveTransferTasks) return;
void closeSftpSession();
}, [closeSftpSession, hasActiveTransferTasks, open, sessionVersion, uploading]);
const handleClose = async () => {
if (uploading || hasActiveTransferTasks) {
onClose();
return;
}
await closeSftpSession();
onClose();
};
@@ -526,7 +569,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 +685,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
onCreateFile={handleCreateFile}
onFileSelect={handleFileSelect}
onFolderSelect={handleFolderSelect}
onUpdateHost={onUpdateHost}
onNavigateToBookmark={(path) => setCurrentPath(path)}
/>
<SftpModalFileList
@@ -718,6 +763,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

@@ -197,19 +197,6 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
}));
}, [currentPath]);
const _handleBack = () => {
if (currentPath) {
const parts = currentPath.split("/");
if (parts.length > 1) {
setCurrentPath(parts.slice(0, -1).join("/"));
} else {
setCurrentPath(null);
}
} else {
onBack();
}
};
return (
<div
className={cn(

View File

@@ -3,8 +3,9 @@
* This component is rendered in a separate Electron window
*/
import { AppWindow, Cloud, FileType, HardDrive, Keyboard, Palette, TerminalSquare, X } from "lucide-react";
import React, { useCallback, useEffect, useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useSettingsState } from "../application/state/useSettingsState";
import { usePortForwardingState } from "../application/state/usePortForwardingState";
import { useVaultState } from "../application/state/useVaultState";
import { useWindowControls } from "../application/state/useWindowControls";
import { I18nProvider, useI18n } from "../application/i18n/I18nProvider";
@@ -31,17 +32,37 @@ const SettingsSyncTabWithVault: React.FC = () => {
keys,
identities,
snippets,
customGroups,
knownHosts,
importDataFromString,
clearVaultData,
} = useVaultState();
const { rules: portForwardingRules, importRules: importPortForwardingRules } = usePortForwardingState();
// Strip transient runtime fields before passing to sync
const portForwardingRulesForSync = useMemo(
() =>
portForwardingRules.map((rule) => ({
...rule,
status: "inactive" as const,
error: undefined,
lastUsedAt: undefined,
})),
[portForwardingRules],
);
const vault = useMemo(
() => ({ hosts, keys, identities, snippets, customGroups, knownHosts }),
[hosts, keys, identities, snippets, customGroups, knownHosts],
);
return (
<SettingsSyncTab
hosts={hosts}
keys={keys}
identities={identities}
snippets={snippets}
vault={vault}
portForwardingRules={portForwardingRulesForSync}
importDataFromString={importDataFromString}
importPortForwardingRules={importPortForwardingRules}
clearVaultData={clearVaultData}
/>
);

View File

@@ -48,15 +48,17 @@ 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 {
sftpDoubleClickBehavior,
sftpAutoSync,
sftpShowHiddenFiles,
sftpUseCompressedUpload,
hotkeyScheme,
keyBindings,
editorWordWrap,
@@ -76,7 +78,12 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
},
}), [t]);
const sftp = useSftpState(hosts, keys, identities, fileWatchHandlers);
const sftpOptions = useMemo(() => ({
...fileWatchHandlers,
useCompressedUpload: sftpUseCompressedUpload,
}), [fileWatchHandlers, sftpUseCompressedUpload]);
const sftp = useSftpState(hosts, keys, identities, sftpOptions);
// Get stream transfer functions for optimized downloads
const { showSaveDialog, startStreamTransfer } = useSftpBackend();
@@ -167,7 +174,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 +220,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
@@ -209,6 +215,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const hasConnectedRef = useRef(false);
const hasRunStartupCommandRef = useRef(false);
const commandBufferRef = useRef<string>("");
const [hasMouseTracking, setHasMouseTracking] = useState(false);
const mouseTrackingRef = useRef(false);
const serialLineBufferRef = useRef<string>("");
const terminalSettingsRef = useRef(terminalSettings);
@@ -260,7 +268,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
snippetsRef.current = snippets;
const terminalBackend = useTerminalBackend();
const { resizeSession } = terminalBackend;
const { resizeSession, setSessionEncoding } = terminalBackend;
@@ -290,6 +298,13 @@ 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 [terminalEncoding, setTerminalEncoding] = useState<'utf-8' | 'gb18030'>(() => {
if (host?.charset && /^gb/i.test(String(host.charset).trim())) return 'gb18030';
return 'utf-8';
});
const terminalEncodingRef = useRef(terminalEncoding);
terminalEncodingRef.current = terminalEncoding;
const terminalSearch = useTerminalSearch({ searchAddonRef, termRef });
const {
@@ -344,13 +359,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 +435,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
setProgressLogs,
setProgressValue,
setChainProgress,
t,
onSessionAttached: (id: string) => {
// Sync terminal encoding to SSH backend before first data arrives
const isSSH = host.protocol !== 'local' && host.protocol !== 'serial' && host.protocol !== 'telnet' && host.protocol !== 'mosh' && !host.moshEnabled && !host.id?.startsWith('local-') && !host.id?.startsWith('serial-') && host.hostname !== 'localhost';
if (isSSH) {
setSessionEncoding(id, terminalEncodingRef.current);
}
},
onSessionExit,
onTerminalDataCapture,
onOsDetected,
@@ -679,6 +706,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);
@@ -856,6 +884,61 @@ const TerminalComponent: React.FC<TerminalProps> = ({
term.onSelectionChange(onSelectionChange);
}, [terminalSettings?.copyOnSelect]);
// Track whether the terminal application has enabled mouse tracking
// (e.g. tmux with `set -g mouse on`, vim with `set mouse=a`).
// When mouse tracking is active, disable Netcatty's context menu to avoid
// conflicting with the application's own mouse handling.
useEffect(() => {
const term = termRef.current;
if (!term) return;
const disposable = term.onWriteParsed(() => {
const tracking = term.modes.mouseTrackingMode !== 'none';
if (tracking !== mouseTrackingRef.current) {
mouseTrackingRef.current = tracking;
setHasMouseTracking(tracking);
}
});
// Set initial state
const initial = term.modes.mouseTrackingMode !== 'none';
mouseTrackingRef.current = initial;
setHasMouseTracking(initial);
return () => disposable.dispose();
}, [sessionId]);
// Prevent xterm.js's built-in rightClickHandler and right-button mouseup
// from interfering with tmux/vim popup menus when mouse tracking is active.
// - contextmenu: xterm.js calls textarea.select() which steals focus
// - mouseup (button 2): tmux interprets the right-button release as a
// dismiss action, closing the popup menu immediately after it appears
// Both are intercepted at the capture phase before xterm.js's own listeners.
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const handleContextMenuCapture = (e: MouseEvent) => {
if (mouseTrackingRef.current) {
e.preventDefault();
e.stopImmediatePropagation();
}
};
const handleMouseUpCapture = (e: MouseEvent) => {
if (e.button === 2 && mouseTrackingRef.current) {
e.stopImmediatePropagation();
}
};
el.addEventListener('contextmenu', handleContextMenuCapture, true);
el.addEventListener('mouseup', handleMouseUpCapture, true);
return () => {
el.removeEventListener('contextmenu', handleContextMenuCapture, true);
el.removeEventListener('mouseup', handleMouseUpCapture, true);
};
}, [sessionId]);
useEffect(() => {
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
@@ -875,11 +958,19 @@ const TerminalComponent: React.FC<TerminalProps> = ({
};
}, []);
const disableBracketedPasteRef = useRef(terminalSettings?.disableBracketedPaste ?? false);
disableBracketedPasteRef.current = terminalSettings?.disableBracketedPaste ?? false;
const scrollOnPasteRef = useRef(terminalSettings?.scrollOnPaste ?? true);
scrollOnPasteRef.current = terminalSettings?.scrollOnPaste ?? true;
const terminalContextActions = useTerminalContextActions({
termRef,
sessionRef,
terminalBackend,
onHasSelectionChange: setHasSelection,
disableBracketedPasteRef,
scrollOnPasteRef,
});
const handleSnippetClick = (cmd: string) => {
@@ -892,6 +983,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
termRef.current?.writeln("\r\n[No active SSH session]");
};
const handleSetTerminalEncoding = (encoding: 'utf-8' | 'gb18030') => {
setTerminalEncoding(encoding);
if (sessionRef.current) {
setSessionEncoding(sessionRef.current, encoding);
}
};
const handleOpenSFTP = async () => {
// If SFTP is already open, toggle it off
if (showSFTP) {
@@ -974,6 +1072,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
if (!termRef.current) return;
cleanupSession();
auth.resetForRetry();
hasRunStartupCommandRef.current = false;
setStatus("connecting");
setError(null);
setProgressLogs(["Retrying secure channel..."]);
@@ -1094,6 +1193,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onClose={() => onCloseSession?.(sessionId)}
isSearchOpen={isSearchOpen}
onToggleSearch={handleToggleSearch}
isComposeBarOpen={inWorkspace ? isWorkspaceComposeBarOpen : isComposeBarOpen}
onToggleComposeBar={inWorkspace ? onToggleComposeBar : () => setIsComposeBarOpen(prev => !prev)}
terminalEncoding={terminalEncoding}
onSetTerminalEncoding={handleSetTerminalEncoding}
/>
);
@@ -1103,8 +1206,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
: status === "connecting"
? "bg-amber-400"
: "bg-rose-500";
const _isConnecting = status === "connecting";
const _hasError = Boolean(error);
return (
<TerminalContextMenu
@@ -1112,6 +1213,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
rightClickBehavior={terminalSettings?.rightClickBehavior}
isAlternateScreen={hasMouseTracking}
onCopy={terminalContextActions.onCopy}
onPaste={terminalContextActions.onPaste}
onSelectAll={terminalContextActions.onSelectAll}
@@ -1122,7 +1224,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 +1404,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 +1692,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 +1770,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 +1780,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,12 @@ const ThemeSelectPanel: React.FC<ThemeSelectPanelProps> = ({
// Reserved for future hover preview feature
const [_hoveredThemeId, setHoveredThemeId] = useState<string | null>(null);
// 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');
return { dark, light };
}, []);
const customThemes = useCustomThemes();
// Find selected theme info - reserved for displaying selection details
const _selectedTheme = useMemo(() => {
return TERMINAL_THEMES.find(t => t.id === selectedThemeId);
}, [selectedThemeId]);
// All themes combined
const allThemes = useMemo(() => {
return [...TERMINAL_THEMES, ...customThemes];
}, [customThemes]);
const renderThemeItem = (theme: TerminalTheme) => {
const isSelected = theme.id === selectedThemeId;
@@ -99,36 +94,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 +117,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

@@ -2495,7 +2495,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
open={isQuickConnectOpen}
target={quickConnectTarget}
keys={keys}
knownHosts={knownHosts}
onConnect={handleQuickConnect}
onSaveHost={handleQuickConnectSaveHost}
onClose={() => {
@@ -2543,6 +2542,7 @@ const vaultViewAreEqual = (
const isEqual =
prev.hosts === next.hosts &&
prev.keys === next.keys &&
prev.identities === next.identities &&
prev.snippets === next.snippets &&
prev.snippetPackages === next.snippetPackages &&
prev.customGroups === next.customGroups &&

View File

@@ -1,94 +0,0 @@
/**
* Edit Key Panel - Edit existing SSH key
*/
import { Info } from 'lucide-react';
import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { SSHKey } from '../../types';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Textarea } from '../ui/textarea';
interface EditKeyPanelProps {
draftKey: Partial<SSHKey>;
_originalKey: SSHKey; // Reserved for future diff/comparison feature
setDraftKey: (key: Partial<SSHKey>) => void;
onExport: () => void;
onSave: () => void;
}
export const EditKeyPanel: React.FC<EditKeyPanelProps> = ({
draftKey,
_originalKey, // Reserved for future diff/comparison feature
setDraftKey,
onExport,
onSave,
}) => {
const { t } = useI18n();
return (
<>
<div className="space-y-2">
<Label>{t('keychain.field.labelRequired')}</Label>
<Input
value={draftKey.label || ''}
onChange={e => setDraftKey({ ...draftKey, label: e.target.value })}
placeholder={t('keychain.field.labelPlaceholder')}
/>
</div>
<div className="space-y-2">
<Label className="text-destructive">{t('keychain.field.privateKeyRequired')}</Label>
<Textarea
value={draftKey.privateKey || ''}
onChange={e => setDraftKey({ ...draftKey, privateKey: e.target.value })}
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
className="min-h-[180px] font-mono text-xs"
/>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">{t('keychain.field.publicKey')}</Label>
<Textarea
value={draftKey.publicKey || ''}
onChange={e => setDraftKey({ ...draftKey, publicKey: e.target.value })}
placeholder="ssh-ed25519 AAAA..."
className="min-h-[80px] font-mono text-xs"
/>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">{t('terminal.auth.certificate')}</Label>
<Textarea
value={draftKey.certificate || ''}
onChange={e => setDraftKey({ ...draftKey, certificate: e.target.value })}
placeholder={t('keychain.field.certificatePlaceholder')}
className="min-h-[60px] font-mono text-xs"
/>
</div>
{/* Key Export section */}
<div className="pt-4 mt-4 border-t border-border/60">
<div className="flex items-center gap-2 mb-3">
<span className="text-sm font-medium">{t('keychain.export.title')}</span>
<div className="h-4 w-4 rounded-full bg-muted flex items-center justify-center">
<Info size={10} className="text-muted-foreground" />
</div>
</div>
<Button className="w-full h-11" onClick={onExport}>
{t('keychain.export.exportToHost')}
</Button>
</div>
{/* Save button */}
<Button
className="w-full h-11 mt-4"
disabled={!draftKey.label?.trim() || !draftKey.privateKey?.trim()}
onClick={onSave}
>
{t('common.saveChanges')}
</Button>
</>
);
};

View File

@@ -1,246 +0,0 @@
/**
* Export Key Panel - Export SSH key to remote host
*/
import { ChevronRight, Info } from 'lucide-react';
import React, { useState } from 'react';
import { useKeychainBackend } from '../../application/state/useKeychainBackend';
import { useI18n } from '../../application/i18n/I18nProvider';
import { resolveHostAuth } from '../../domain/sshAuth';
import { cn } from '../../lib/utils';
import { Host, Identity, SSHKey } from '../../types';
import { Button } from '../ui/button';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Textarea } from '../ui/textarea';
import { toast } from '../ui/toast';
import { getKeyIcon, getKeyTypeDisplay, isMacOS } from './utils';
interface ExportKeyPanelProps {
keyItem: SSHKey;
_hosts: Host[]; // Reserved for future inline host list/validation
keys: SSHKey[];
identities: Identity[];
exportHost: Host | null;
_setExportHost: (host: Host | null) => void; // Host selection handled by onShowHostSelector callback
onShowHostSelector: () => void;
onSaveHost?: (host: Host) => void;
onClose: () => void;
}
const DEFAULT_EXPORT_SCRIPT = `DIR="$HOME/$1"
FILE="$DIR/$2"
if [ ! -d "$DIR" ]; then
mkdir -p "$DIR"
chmod 700 "$DIR"
fi
if [ ! -f "$FILE" ]; then
touch "$FILE"
chmod 600 "$FILE"
fi
echo $3 >> "$FILE"`;
export const ExportKeyPanel: React.FC<ExportKeyPanelProps> = ({
keyItem,
_hosts, // Reserved for future inline host list/validation
keys,
identities,
exportHost,
_setExportHost, // Host selection handled by onShowHostSelector callback
onShowHostSelector,
onSaveHost,
onClose,
}) => {
const { t } = useI18n();
const { execCommand } = useKeychainBackend();
const [exportLocation, setExportLocation] = useState('.ssh');
const [exportFilename, setExportFilename] = useState('authorized_keys');
const [exportAdvancedOpen, setExportAdvancedOpen] = useState(false);
const [exportScript, setExportScript] = useState(DEFAULT_EXPORT_SCRIPT);
const [isExporting, setIsExporting] = useState(false);
const isMac = isMacOS();
const handleExport = async () => {
if (!exportHost || !keyItem.publicKey) return;
setIsExporting(true);
try {
const exportAuth = resolveHostAuth({ host: exportHost, keys, identities });
// Check for authentication method
if (!exportAuth.password && !exportAuth.key?.privateKey) {
throw new Error(t('keychain.export.missingCredentials'));
}
const hostPrivateKey = exportAuth.key?.privateKey;
// Escape the public key for shell
const escapedPublicKey = keyItem.publicKey.replace(/'/g, "'\\''");
// Build the command by replacing $1, $2, $3
const scriptWithVars = exportScript
.replace(/\$1/g, exportLocation)
.replace(/\$2/g, exportFilename)
.replace(/\$3/g, `'${escapedPublicKey}'`);
const command = scriptWithVars;
// Execute via SSH
const result = await execCommand({
hostname: exportHost.hostname,
username: exportAuth.username,
port: exportHost.port || 22,
password: exportAuth.password,
privateKey: hostPrivateKey,
command,
timeout: 30000,
enableKeyboardInteractive: true,
sessionId: `export-key:${exportHost.id}:${keyItem.id}`,
});
// Check result
const exitCode = result?.code;
const hasError = result?.stderr?.trim();
if (exitCode === 0 || (exitCode == null && !hasError)) {
// Update host to use this key for authentication
if (onSaveHost) {
const updatedHost: Host = {
...exportHost,
identityFileId: keyItem.id,
authMethod: 'key',
};
onSaveHost(updatedHost);
}
toast.success(
t('keychain.export.successMessage', { host: exportHost.label }),
t('keychain.export.successTitle'),
);
onClose();
} else {
const errorMsg = hasError || result?.stdout?.trim() || `Command exited with code ${exitCode}`;
toast.error(
t('keychain.export.failedMessage', { error: errorMsg }),
t('keychain.export.failedTitle'),
);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
toast.error(
t('keychain.export.failedGeneric', { message }),
t('keychain.export.failedTitle'),
);
} finally {
setIsExporting(false);
}
};
return (
<>
{/* Key info card */}
<div className="flex items-center gap-3 p-3 bg-card border border-border/80 rounded-lg">
<div className={cn(
"h-10 w-10 rounded-md flex items-center justify-center",
keyItem.certificate
? "bg-emerald-500/15 text-emerald-500"
: "bg-primary/15 text-primary"
)}>
{getKeyIcon(keyItem)}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold truncate">{keyItem.label}</p>
<p className="text-xs text-muted-foreground">
{t('auth.keyType', { type: getKeyTypeDisplay(keyItem, isMac) })}
</p>
</div>
</div>
{/* Export to field */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-muted-foreground">{t('keychain.export.exportToRequired')}</Label>
<Button
variant="link"
className="h-auto p-0 text-primary text-sm"
onClick={onShowHostSelector}
>
{t('keychain.export.selectHost')}
</Button>
</div>
<Input
value={exportHost?.label || ''}
readOnly
placeholder={t('keychain.export.selectHostPlaceholder')}
className="bg-muted/50 cursor-pointer"
onClick={onShowHostSelector}
/>
</div>
{/* Location field */}
<div className="space-y-2">
<Label className="text-muted-foreground">{t('keychain.export.locationLabel')}</Label>
<Input
value={exportLocation}
onChange={e => setExportLocation(e.target.value)}
placeholder=".ssh"
/>
</div>
{/* Filename field */}
<div className="space-y-2">
<Label className="text-muted-foreground">{t('keychain.export.filenameLabel')}</Label>
<Input
value={exportFilename}
onChange={e => setExportFilename(e.target.value)}
placeholder="authorized_keys"
/>
</div>
{/* Info note */}
<div className="flex items-start gap-2 p-3 bg-muted/50 border border-border/60 rounded-lg">
<Info size={14} className="mt-0.5 text-muted-foreground shrink-0" />
<p className="text-xs text-muted-foreground">
{t('keychain.export.note.supportsOnly')}{' '}
<span className="font-semibold text-foreground">UNIX</span>{' '}
{t('keychain.export.note.systems')}{' '}
{t('keychain.export.note.use')}{' '}
<span className="font-semibold text-foreground">{t('keychain.export.advanced')}</span>{' '}
{t('keychain.export.note.customize')}
</p>
</div>
{/* Advanced collapsible */}
<Collapsible open={exportAdvancedOpen} onOpenChange={setExportAdvancedOpen}>
<CollapsibleTrigger asChild>
<Button variant="ghost" className="w-full justify-between px-0 h-10 hover:bg-transparent hover:text-current">
<span className="font-medium">{t('keychain.export.advanced')}</span>
<ChevronRight size={16} className={cn(
"transition-transform",
exportAdvancedOpen && "rotate-90"
)} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2 pt-2">
<Label className="text-muted-foreground">{t('keychain.export.scriptRequired')}</Label>
<Textarea
value={exportScript}
onChange={e => setExportScript(e.target.value)}
className="min-h-[180px] font-mono text-xs"
placeholder={t('keychain.export.scriptPlaceholder')}
/>
</CollapsibleContent>
</Collapsible>
{/* Export button */}
<Button
className="w-full h-11"
disabled={!exportHost || !exportLocation || !exportFilename || isExporting}
onClick={handleExport}
>
{isExporting ? t('keychain.export.exporting') : t('keychain.export.exportAndAttach')}
</Button>
</>
);
};

View File

@@ -15,8 +15,6 @@ export { IdentityCard } from './IdentityCard';
export { KeyCard } from './KeyCard';
// Panel components
export { EditKeyPanel } from './EditKeyPanel';
export { ExportKeyPanel } from './ExportKeyPanel';
export { GenerateStandardPanel } from './GenerateStandardPanel';
export { IdentityPanel } from './IdentityPanel';
export { ImportKeyPanel } from './ImportKeyPanel';

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

@@ -1,5 +1,5 @@
import React, { useCallback } from "react";
import { Check, Moon, Palette, Sun } from "lucide-react";
import { Check, Monitor, Moon, Palette, Sun } from "lucide-react";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { DARK_UI_THEMES, LIGHT_UI_THEMES } from "../../../infrastructure/config/uiThemes";
import { useAvailableUIFonts } from "../../../application/state/uiFontStore";
@@ -9,8 +9,8 @@ import { SectionHeader, SettingsTabContent, SettingRow, Toggle, Select } from ".
import { FontSelect } from "../FontSelect";
export default function SettingsAppearanceTab(props: {
theme: "dark" | "light";
setTheme: (theme: "dark" | "light") => void;
theme: "dark" | "light" | "system";
setTheme: (theme: "dark" | "light" | "system") => void;
lightUiThemeId: string;
setLightUiThemeId: (themeId: string) => void;
darkUiThemeId: string;
@@ -97,6 +97,12 @@ export default function SettingsAppearanceTab(props: {
{ name: "Slate", value: "215 16% 47%" },
];
const THEME_OPTIONS: { value: "light" | "system" | "dark"; icon: React.ReactNode; label: string }[] = [
{ value: "light", icon: <Sun size={14} />, label: t("settings.appearance.theme.light") },
{ value: "system", icon: <Monitor size={14} />, label: t("settings.appearance.theme.system") },
{ value: "dark", icon: <Moon size={14} />, label: t("settings.appearance.theme.dark") },
];
const renderThemeSwatches = (
options: { id: string; name: string; tokens: { background: string } }[],
value: string,
@@ -153,13 +159,25 @@ export default function SettingsAppearanceTab(props: {
<SectionHeader title={t("settings.appearance.uiTheme")} />
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
<SettingRow
label={t("settings.appearance.darkMode")}
description={t("settings.appearance.darkMode.desc")}
label={t("settings.appearance.theme")}
description={t("settings.appearance.theme.desc")}
>
<div className="flex items-center gap-2">
<Sun size={14} className="text-muted-foreground" />
<Toggle checked={theme === "dark"} onChange={(v) => setTheme(v ? "dark" : "light")} />
<Moon size={14} className="text-muted-foreground" />
<div className="flex items-center rounded-lg border border-border bg-muted/50 p-0.5">
{THEME_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => setTheme(opt.value)}
className={cn(
"flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium transition-colors",
theme === opt.value
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
{opt.icon}
{opt.label}
</button>
))}
</div>
</SettingRow>
</div>

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

@@ -1,51 +1,73 @@
import React, { useCallback } from "react";
import type { Host, Identity, Snippet, SSHKey } from "../../../domain/models";
import type { PortForwardingRule } from "../../../domain/models";
import type { SyncPayload } from "../../../domain/sync";
import { buildSyncPayload, applySyncPayload } from "../../../domain/syncPayload";
import type { SyncableVaultData } from "../../../domain/syncPayload";
import { STORAGE_KEY_PORT_FORWARDING } from "../../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
import { CloudSyncSettings } from "../../CloudSyncSettings";
import { SettingsTabContent } from "../settings-ui";
export default function SettingsSyncTab(props: {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
snippets: Snippet[];
vault: SyncableVaultData;
portForwardingRules: PortForwardingRule[];
importDataFromString: (data: string) => void;
importPortForwardingRules: (rules: PortForwardingRule[]) => void;
clearVaultData: () => void;
}) {
const { hosts, keys, identities, snippets, importDataFromString, clearVaultData } = props;
const {
vault,
portForwardingRules,
importDataFromString,
importPortForwardingRules,
clearVaultData,
} = props;
const buildSyncPayload = useCallback((): SyncPayload => {
return {
hosts,
keys,
identities,
snippets,
customGroups: [],
syncedAt: Date.now(),
};
}, [hosts, keys, identities, snippets]);
const applySyncPayload = useCallback(
(payload: SyncPayload) => {
importDataFromString(
JSON.stringify({
hosts: payload.hosts,
keys: payload.keys,
identities: payload.identities,
snippets: payload.snippets,
customGroups: payload.customGroups,
}),
const onBuildPayload = useCallback((): SyncPayload => {
// If hook state is empty but localStorage has data, the async store
// initialization hasn't finished yet. Read from localStorage directly
// to avoid uploading empty arrays and overwriting the remote snapshot.
let effectiveRules = portForwardingRules;
if (effectiveRules.length === 0) {
const stored = localStorageAdapter.read<PortForwardingRule[]>(
STORAGE_KEY_PORT_FORWARDING,
);
if (stored && Array.isArray(stored) && stored.length > 0) {
// Strip transient per-device fields (status, error, lastUsedAt)
// that setGlobalRules persists to localStorage but shouldn't be
// included in the cloud sync snapshot.
effectiveRules = stored.map(({ status: _status, error: _error, ...rest }) => ({
...rest,
status: "inactive" as const,
error: undefined,
lastUsedAt: undefined,
}));
}
}
return buildSyncPayload(vault, effectiveRules);
}, [vault, portForwardingRules]);
const onApplyPayload = useCallback(
(payload: SyncPayload) => {
applySyncPayload(payload, {
importVaultData: importDataFromString,
importPortForwardingRules,
});
},
[importDataFromString],
[importDataFromString, importPortForwardingRules],
);
const clearAllLocalData = useCallback(() => {
clearVaultData();
importPortForwardingRules([]);
}, [clearVaultData, importPortForwardingRules]);
return (
<SettingsTabContent value="sync">
<CloudSyncSettings
onBuildPayload={buildSyncPayload}
onApplyPayload={applySyncPayload}
onClearLocalData={clearVaultData}
onBuildPayload={onBuildPayload}
onApplyPayload={onApplyPayload}
onClearLocalData={clearAllLocalData}
/>
</SettingsTabContent>
);

View File

@@ -1,10 +1,20 @@
/**
* Settings System Tab - System information, temp file management, session logs, and global hotkey
*/
import { FileText, FolderOpen, HardDrive, Keyboard, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
import { Download, ExternalLink, 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 {
checkForUpdate,
downloadUpdate,
installUpdate,
onDownloadProgress,
onDownloaded,
onError as onUpdateError,
getReleasesUrl,
} from "../../../infrastructure/services/updateService";
import { SessionLogFormat, keyEventToString } from "../../../domain/models";
import { TabsContent } from "../../ui/tabs";
import { Button } from "../../ui/button";
@@ -61,6 +71,84 @@ 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);
// Software Update state
type UpdateStatus = 'idle' | 'checking' | 'available' | 'up-to-date' | 'downloading' | 'ready' | 'error';
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>('idle');
const [updateVersion, setUpdateVersion] = useState('');
const [updatePercent, setUpdatePercent] = useState(0);
const [updateError, setUpdateError] = useState('');
const [updateSupported, setUpdateSupported] = useState(true);
const [appVersion, setAppVersion] = useState('');
// Load app version on mount
useEffect(() => {
const promise = netcattyBridge.get()?.getAppInfo?.();
if (promise) {
promise.then((info) => {
setAppVersion(info?.version ?? '');
}).catch(() => {});
}
}, []);
// Subscribe to auto-update events
useEffect(() => {
const cleanupProgress = onDownloadProgress((p) => {
setUpdatePercent(Math.round(p.percent));
});
const cleanupDownloaded = onDownloaded(() => {
setUpdateStatus('ready');
});
const cleanupError = onUpdateError((payload) => {
setUpdateError(payload.error);
setUpdateStatus('error');
});
return () => {
cleanupProgress?.();
cleanupDownloaded?.();
cleanupError?.();
};
}, []);
const handleCheckForUpdate = useCallback(async () => {
setUpdateStatus('checking');
setUpdateError('');
const result = await checkForUpdate();
if (result.error) {
setUpdateError(result.error);
setUpdateSupported(result.supported !== false);
setUpdateStatus('error');
} else if (result.available && result.version) {
setUpdateVersion(result.version);
setUpdateSupported(result.supported !== false);
setUpdateStatus('available');
} else {
setUpdateSupported(result.supported !== false);
setUpdateStatus('up-to-date');
}
}, []);
const handleDownloadUpdate = useCallback(async () => {
setUpdateStatus('downloading');
setUpdatePercent(0);
const result = await downloadUpdate();
if (!result.success) {
setUpdateError(result.error ?? t('settings.update.downloadError'));
setUpdateStatus('error');
}
// Success is handled by onDownloaded event
}, [t]);
const handleInstallUpdate = useCallback(() => {
installUpdate();
}, []);
const handleOpenReleases = useCallback(() => {
const url = updateVersion ? getReleasesUrl(updateVersion) : getReleasesUrl();
netcattyBridge.get()?.openExternal?.(url);
}, [updateVersion]);
const loadTempDirInfo = useCallback(async () => {
const bridge = netcattyBridge.get();
@@ -81,6 +169,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 +303,161 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
</p>
</div>
{/* Software Update Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Download size={18} className="text-muted-foreground" />
<h3 className="text-base font-medium">{t('settings.update.title')}</h3>
</div>
<div className="rounded-lg border border-border/60 p-4 space-y-3">
{/* Current version */}
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{t('settings.update.currentVersion')}
</span>
<span className="text-sm font-mono">{appVersion || '...'}</span>
</div>
{/* Status message */}
{updateStatus === 'up-to-date' && (
<p className="text-sm text-green-600 dark:text-green-400">
{t('settings.update.upToDate')}
</p>
)}
{updateStatus === 'available' && (
<p className="text-sm text-blue-600 dark:text-blue-400">
{t('settings.update.available').replace('{version}', updateVersion)}
</p>
)}
{updateStatus === 'downloading' && (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
{t('settings.update.downloading').replace('{percent}', String(updatePercent))}
</p>
<div className="h-2 w-full rounded-full bg-muted overflow-hidden">
<div
className="h-full rounded-full bg-primary transition-all duration-300"
style={{ width: `${updatePercent}%` }}
/>
</div>
</div>
)}
{updateStatus === 'ready' && (
<p className="text-sm text-green-600 dark:text-green-400">
{t('settings.update.readyToInstall')}
</p>
)}
{updateStatus === 'error' && (
<p className="text-sm text-destructive">
{updateError || t('settings.update.error')}
</p>
)}
{/* Manual fallback hint when auto-update not supported */}
{!updateSupported && updateStatus !== 'idle' && (
<p className="text-sm text-muted-foreground">
{t('settings.update.manualDownloadHint')}
</p>
)}
{/* Action buttons */}
<div className="flex items-center gap-2 pt-1">
{(updateStatus === 'idle' || updateStatus === 'up-to-date' || updateStatus === 'error') && (
<Button
variant="outline"
size="sm"
onClick={handleCheckForUpdate}
disabled={updateStatus === 'checking'}
>
<RefreshCw size={14} className={cn('mr-1.5', updateStatus === 'checking' && 'animate-spin')} />
{updateStatus === 'checking' ? t('settings.update.checking') : t('settings.update.checkForUpdates')}
</Button>
)}
{updateStatus === 'checking' && (
<Button variant="outline" size="sm" disabled>
<RefreshCw size={14} className="mr-1.5 animate-spin" />
{t('settings.update.checking')}
</Button>
)}
{updateStatus === 'available' && updateSupported && (
<Button variant="default" size="sm" onClick={handleDownloadUpdate}>
<Download size={14} className="mr-1.5" />
{t('settings.update.download')}
</Button>
)}
{updateStatus === 'ready' && (
<Button variant="default" size="sm" onClick={handleInstallUpdate}>
<RotateCcw size={14} className="mr-1.5" />
{t('settings.update.restartNow')}
</Button>
)}
{/* Manual fallback link — shown when unsupported, on error, or when update is available but unsupported */}
{((updateStatus === 'error') || (updateStatus === 'available' && !updateSupported)) && (
<Button variant="ghost" size="sm" onClick={handleOpenReleases}>
<ExternalLink size={14} className="mr-1.5" />
{t('settings.update.manualDownload')}
</Button>
)}
</div>
</div>
<p className="text-xs text-muted-foreground">
{t('settings.update.hint')}
</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

@@ -329,18 +329,26 @@ export const SftpModalFileList: React.FC<SftpModalFileListProps> = ({
) : (
<>
{isNavigableDirectory && (
<ContextMenuItem
onClick={() =>
handleNavigate(
currentPath === "/"
? `/${file.name}`
: `${currentPath}/${file.name}`,
)
}
>
<FolderOpen size={14} className="mr-2" />
{t("sftp.context.open")}
</ContextMenuItem>
<>
<ContextMenuItem
onClick={() =>
handleNavigate(
currentPath === "/"
? `/${file.name}`
: `${currentPath}/${file.name}`,
)
}
>
<FolderOpen size={14} className="mr-2" />
{t("sftp.context.open")}
</ContextMenuItem>
{!isLocalSession && (
<ContextMenuItem onClick={() => handleDownload(file)}>
<Download size={14} className="mr-2" />
{t("sftp.context.download")}
</ContextMenuItem>
)}
</>
)}
{isDownloadableFile && (
<>

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

@@ -13,6 +13,7 @@ interface TransferTask {
status: "pending" | "uploading" | "downloading" | "completed" | "failed" | "cancelled";
error?: string;
direction: "upload" | "download";
targetPath?: string;
}
interface SftpModalUploadTasksProps {
@@ -64,7 +65,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 +84,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 +125,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 && (
@@ -165,6 +167,9 @@ export const SftpModalUploadTasks: React.FC<SftpModalUploadTasksProps> = ({ task
{task.status === "completed" && (
<div className="text-[10px] text-green-600 mt-0.5">
{t(task.direction === "download" ? "sftp.download.completed" : "sftp.upload.completed")} - {formatBytes(task.totalBytes)}
{task.targetPath && (
<span className="text-muted-foreground ml-1"> {task.targetPath}</span>
)}
</div>
)}
{task.status === "cancelled" && (

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

@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import type { Host, RemoteFile } from "../../../types";
import { logger } from "../../../lib/logger";
import { isSessionError } from "../../../application/state/sftp/errors";
import { toast } from "../../ui/toast";
interface UseSftpModalSessionParams {
@@ -20,6 +21,7 @@ interface UseSftpModalSessionParams {
proxy?: NetcattyProxyConfig;
jumpHosts?: NetcattyJumpHost[];
sftpSudo?: boolean;
legacyAlgorithms?: boolean;
};
initialPath?: string;
isLocalSession: boolean;
@@ -39,6 +41,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 +58,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>;
@@ -75,11 +79,14 @@ export const useSftpModalSession = ({
getHomeDir,
onClearSelection,
}: UseSftpModalSessionParams): UseSftpModalSessionResult => {
const [currentPath, setCurrentPath] = useState("/");
const [currentPath, setCurrentPathState] = useState("/");
const [files, setFiles] = useState<RemoteFile[]>([]);
const [loading, setLoading] = useState(false);
const [reconnecting, setReconnecting] = useState(false);
const [sessionVersion, setSessionVersion] = useState(0);
const currentPathRef = useRef(currentPath);
const sftpIdRef = useRef<string | null>(null);
const closingPromiseRef = useRef<Promise<void> | null>(null);
const initializedRef = useRef(false);
const lastInitialPathRef = useRef<string | undefined>(undefined);
const localHomeRef = useRef<string | null>(null);
@@ -93,9 +100,19 @@ export const useSftpModalSession = ({
Map<string, { files: RemoteFile[]; timestamp: number }>
>(new Map());
const loadSeqRef = useRef(0);
const setCurrentPath = useCallback((path: string) => {
currentPathRef.current = path;
setCurrentPathState(path);
}, []);
const bumpSessionVersion = useCallback(() => {
setSessionVersion((prev) => prev + 1);
}, []);
const ensureSftp = useCallback(async () => {
if (isLocalSession) throw new Error("Local session does not use SFTP");
if (closingPromiseRef.current) {
await closingPromiseRef.current;
}
if (sftpIdRef.current) return sftpIdRef.current;
const sftpId = await openSftp({
sessionId: `sftp-modal-${host.id}`,
@@ -112,8 +129,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,34 +152,48 @@ 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]);
})();
const isSessionError = useCallback((err: unknown): boolean => {
if (!(err instanceof Error)) return false;
const msg = err.message.toLowerCase();
return (
msg.includes("session not found") ||
msg.includes("sftp session") ||
msg.includes("not found") ||
msg.includes("closed") ||
msg.includes("connection reset") ||
msg.includes("write after end") ||
msg.includes("no response") ||
msg.includes("client disconnected")
);
}, []);
closingPromiseRef.current = currentClosePromise;
await currentClosePromise;
}, [bumpSessionVersion, closeSftp, isLocalSession]);
// Use shared session-error classifier from errors.ts
const handleSessionError = useCallback(async () => {
if (reconnectingRef.current) return;
@@ -169,17 +204,31 @@ 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 ensureSftp();
await closeSftpSession();
const newSftpId = await ensureSftp();
reconnectingRef.current = false;
setReconnecting(false);
// Auto-reload current directory after successful reconnect
try {
const reloadPath = currentPathRef.current;
const reloadRequestId = loadSeqRef.current;
const list = await listSftp(newSftpId, reloadPath);
if (
reloadRequestId !== loadSeqRef.current ||
currentPathRef.current !== reloadPath
) {
return;
}
onClearSelection();
setFiles(list);
dirCacheRef.current.set(`${host.id}::${reloadPath}`, {
files: list,
timestamp: Date.now(),
});
} catch {
// Reload failed — UI still shows old data, user can manually refresh
}
return;
} catch (err) {
logger.warn(
@@ -195,7 +244,7 @@ export const useSftpModalSession = ({
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
}, [closeSftp, ensureSftp, t]);
}, [closeSftpSession, ensureSftp, listSftp, host.id, onClearSelection, t]);
const loadFiles = useCallback(
async (path: string, options?: { force?: boolean }) => {
@@ -248,7 +297,7 @@ export const useSftpModalSession = ({
}
}
},
[ensureSftp, host.id, isLocalSession, listLocalDir, listSftp, t, isSessionError, handleSessionError, files.length, onClearSelection],
[ensureSftp, host.id, isLocalSession, listLocalDir, listSftp, t, handleSessionError, files.length, onClearSelection],
);
useLayoutEffect(() => {
@@ -351,7 +400,6 @@ export const useSftpModalSession = ({
void loadFiles(currentPath);
} else {
loadSeqRef.current += 1;
void closeSftpSession();
initializedRef.current = false;
}
}, [
@@ -367,6 +415,7 @@ export const useSftpModalSession = ({
loadFiles,
onClearSelection,
open,
setCurrentPath,
t,
]);
@@ -384,6 +433,7 @@ export const useSftpModalSession = ({
loading,
setLoading,
reconnecting,
sessionVersion,
ensureSftp,
loadFiles,
closeSftpSession,

View File

@@ -27,6 +27,7 @@ interface TransferTask {
fileCount?: number;
completedCount?: number;
direction: "upload" | "download";
targetPath?: string;
}
// Keep UploadTask as alias for backwards compatibility
@@ -40,6 +41,8 @@ interface UseSftpModalTransfersParams {
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
readLocalFile: (path: string) => Promise<ArrayBuffer>;
readSftp: (sftpId: string, path: string) => Promise<string>;
listSftp?: (sftpId: string, path: string) => Promise<RemoteFile[]>;
deleteLocalFile?: (path: string) => Promise<void>;
writeLocalFile: (path: string, data: ArrayBuffer) => Promise<void>;
writeSftpBinaryWithProgress: (
sftpId: string,
@@ -113,6 +116,8 @@ export const useSftpModalTransfers = ({
setLoading,
t,
useCompressedUpload = false,
listSftp,
deleteLocalFile,
}: UseSftpModalTransfersParams): UseSftpModalTransfersResult => {
const [uploading, setUploading] = useState(false);
const [uploadTasks, setUploadTasks] = useState<UploadTask[]>([]);
@@ -127,6 +132,9 @@ export const useSftpModalTransfers = ({
// Track cancelled transfer IDs to detect cancellation in bridge wrapper
const cancelledTransferIdsRef = useRef<Set<string>>(new Set());
// Track active child transfer IDs for directory downloads (parentId -> childId)
const activeChildTransferIdsRef = useRef<Map<string, string>>(new Map());
// Create upload bridge that adapts the modal's functions to the service interface
const createUploadBridge = useMemo((): UploadBridge => {
return {
@@ -157,7 +165,7 @@ export const useSftpModalTransfers = ({
onComplete || (() => { }),
onError || (() => { })
);
// Check if this transfer was cancelled
const wasCancelled = cancelledTransferIdsRef.current.has(taskId);
if (wasCancelled) {
@@ -239,6 +247,7 @@ export const useSftpModalTransfers = ({
startTime: Date.now(),
isDirectory: task.isDirectory,
direction: "upload",
targetPath: currentPath,
};
setUploadTasks(prev => [...prev, uploadTask]);
},
@@ -251,13 +260,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,
};
})
);
@@ -311,8 +329,8 @@ export const useSftpModalTransfers = ({
const [folderName, phase] = newName.split('|');
const phaseLabel = phase === 'compressing' ? t('sftp.upload.phase.compressing')
: phase === 'extracting' ? t('sftp.upload.phase.extracting')
: phase === 'uploading' ? t('sftp.upload.phase.uploading')
: t('sftp.upload.phase.compressed');
: phase === 'uploading' ? t('sftp.upload.phase.uploading')
: t('sftp.upload.phase.compressed');
displayName = `${folderName} (${phaseLabel})`;
}
setUploadTasks(prev =>
@@ -327,7 +345,7 @@ export const useSftpModalTransfers = ({
);
},
};
}, [t]);
}, [t, currentPath]);
// Helper function to perform upload with compression setting from user preference
const performUpload = useCallback(async (
@@ -401,12 +419,236 @@ export const useSftpModalTransfers = ({
return;
}
// For remote SFTP files, use streaming download with save dialog
// For remote SFTP files/directories, use streaming download with save dialog
if (!showSaveDialog || !startStreamTransfer) {
toast.error(t("sftp.error.downloadFailed"), "SFTP");
return;
}
// Check if this is a directory download
const isDirectory = file.type === 'directory' || (file.type === 'symlink' && file.linkTarget === 'directory');
if (isDirectory) {
// For directories, download recursively
if (!listSftp) {
toast.error(t("sftp.error.downloadFailed"), "SFTP");
return;
}
// Show save dialog to get target path (the saved "file" becomes the folder path)
const targetPath = await showSaveDialog(file.name);
if (!targetPath) return;
const sftpId = await ensureSftp();
const transferId = `download-dir-${Date.now()}-${Math.random().toString(36).slice(2)}`;
// Track the currently active child transfer ID for cancellation
let activeChildTransferId: string | null = null;
const setActiveChild = (childId: string | null) => {
activeChildTransferId = childId;
if (childId) {
activeChildTransferIdsRef.current.set(transferId, childId);
} else {
activeChildTransferIdsRef.current.delete(transferId);
}
};
// Create download task for progress display
const downloadTask: TransferTask = {
id: transferId,
fileName: file.name,
status: "downloading",
progress: 0,
totalBytes: 0,
transferredBytes: 0,
speed: 0,
startTime: Date.now(),
direction: "download",
isDirectory: true,
};
setUploadTasks(prev => [...prev, downloadTask]);
try {
// Safely create target directory.
// showSaveDialog "Replace" may leave a file (not directory) at the path,
// so we remove it first — ONLY in this explicit overwrite context.
try {
await createUploadBridge.mkdirLocal(targetPath);
} catch (mkdirErr: unknown) {
const isEEXIST = mkdirErr instanceof Error && mkdirErr.message.includes('EEXIST');
if (isEEXIST && deleteLocalFile) {
// Path exists as a file (from save dialog replace), remove it and retry
await deleteLocalFile(targetPath);
await createUploadBridge.mkdirLocal(targetPath);
} else {
throw mkdirErr;
}
}
// Recursively download directory contents
let completedBytes = 0;
// Track visited remote paths to prevent symlink cycles
const visitedPaths = new Set<string>();
// Max symlink-directory nesting depth to prevent cycles (only applies to symlinks)
const MAX_SYMLINK_DEPTH = 32;
const downloadDir = async (remotePath: string, localPath: string, symlinkDepth = 0): Promise<void> => {
// Prevent revisiting the same path
if (visitedPaths.has(remotePath)) return;
visitedPaths.add(remotePath);
// Check if transfer was cancelled
if (cancelledTransferIdsRef.current.has(transferId)) {
throw new Error('Transfer cancelled');
}
const entries = await listSftp(sftpId, remotePath);
for (const entry of entries) {
if (entry.name === '..' || entry.name === '.') continue;
// Check cancellation between files
if (cancelledTransferIdsRef.current.has(transferId)) {
// Cancel the active child transfer if any
if (activeChildTransferId && cancelTransfer) {
try { await cancelTransfer(activeChildTransferId); } catch { /* ignore */ }
}
throw new Error('Transfer cancelled');
}
const remoteEntryPath = joinPath(remotePath, entry.name);
const localEntryPath = `${localPath}/${entry.name}`;
const isRealDir = entry.type === 'directory';
const isSymlinkDir = entry.type === 'symlink' && entry.linkTarget === 'directory';
if (isRealDir || isSymlinkDir) {
// Only symlink directories can form cycles; enforce depth limit for them
if (isSymlinkDir && symlinkDepth >= MAX_SYMLINK_DEPTH) {
throw new Error('Maximum symlink directory depth exceeded (possible symlink cycle)');
}
try {
await createUploadBridge.mkdirLocal(localEntryPath);
} catch (mkdirErr: unknown) {
// Only ignore EEXIST (directory already exists), propagate other errors
const isEEXIST = mkdirErr instanceof Error && mkdirErr.message.includes('EEXIST');
if (!isEEXIST) throw mkdirErr;
}
await downloadDir(remoteEntryPath, localEntryPath, isSymlinkDir ? symlinkDepth + 1 : symlinkDepth);
} else {
// Download individual file
const childTransferId = `download-${Date.now()}-${Math.random().toString(36).slice(2)}`;
activeChildTransferId = childTransferId;
setActiveChild(childTransferId);
const entrySize = typeof entry.size === 'number' ? entry.size : parseInt(String(entry.size), 10) || 0;
await new Promise<void>((resolve, reject) => {
startStreamTransfer(
{
transferId: childTransferId,
sourcePath: remoteEntryPath,
targetPath: localEntryPath,
sourceType: 'sftp',
targetType: 'local',
sourceSftpId: sftpId,
totalBytes: entrySize,
},
// onProgress - update parent task
(transferred, total, speed) => {
if (cancelledTransferIdsRef.current.has(transferId)) {
// Actively cancel the in-flight child transfer
if (cancelTransfer) {
cancelTransfer(childTransferId).catch(() => { /* ignore */ });
}
return;
}
const totalProgress = completedBytes + transferred;
setUploadTasks(prev =>
prev.map(task =>
task.id === transferId
? {
...task,
transferredBytes: Math.max(task.transferredBytes, totalProgress),
totalBytes: Math.max(task.totalBytes, totalProgress, completedBytes + total),
progress: (() => {
const effectiveTotal = Math.max(task.totalBytes, completedBytes + total);
if (effectiveTotal <= 0) return task.progress;
const percent = (totalProgress / effectiveTotal) * 100;
return Math.max(task.progress, Math.min(percent, 99));
})(),
speed: Number.isFinite(speed) && speed > 0 ? speed : 0,
}
: task
)
);
},
// onComplete
() => {
completedBytes += entrySize;
setActiveChild(null);
resolve();
},
// onError
(error) => {
setActiveChild(null);
reject(new Error(error));
}
).then((result) => {
// Handle resolved result with error (e.g. cancellation)
if (result === undefined) {
setActiveChild(null);
reject(new Error('Stream transfer unavailable'));
} else if (result?.error) {
setActiveChild(null);
reject(new Error(result.error));
}
}).catch(reject);
});
}
}
};
await downloadDir(fullPath, targetPath);
// Mark as completed
setUploadTasks(prev =>
prev.map(task =>
task.id === transferId
? {
...task,
status: "completed" as const,
progress: 100,
transferredBytes: completedBytes,
totalBytes: completedBytes,
speed: 0,
}
: task
)
);
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
} catch (e) {
const errorMsg = e instanceof Error ? e.message : t("sftp.error.downloadFailed");
const isCancelError = errorMsg.includes('cancelled') || errorMsg.includes('canceled')
|| cancelledTransferIdsRef.current.has(transferId);
setUploadTasks(prev =>
prev.map(task =>
task.id === transferId
? {
...task,
status: isCancelError ? "cancelled" as const : "failed" as const,
speed: 0,
error: isCancelError ? undefined : errorMsg,
}
: task
)
);
if (!isCancelError) {
toast.error(errorMsg, "SFTP");
}
} finally {
cancelledTransferIdsRef.current.delete(transferId);
}
return;
}
// Show save dialog to get target path
const targetPath = await showSaveDialog(file.name);
if (!targetPath) {
@@ -452,12 +694,20 @@ export const useSftpModalTransfers = ({
prev.map(task =>
task.id === transferId
? {
...task,
transferredBytes: transferred,
totalBytes: total,
progress: total > 0 ? Math.round((transferred / total) * 100) : 0,
speed,
}
...task,
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 +717,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
)
);
@@ -546,7 +802,7 @@ export const useSftpModalTransfers = ({
setLoading(false);
}
},
[currentPath, ensureSftp, isLocalSession, joinPath, readLocalFile, setLoading, showSaveDialog, startStreamTransfer, t],
[currentPath, ensureSftp, isLocalSession, joinPath, readLocalFile, setLoading, showSaveDialog, startStreamTransfer, t, listSftp, createUploadBridge, deleteLocalFile, cancelledTransferIdsRef, cancelTransfer],
);
@@ -763,13 +1019,27 @@ export const useSftpModalTransfers = ({
if (!task) return;
if (task.direction === "download") {
// For download tasks, cancel only this specific transfer
// For download tasks, cancel the specific transfer
// Add to cancelled set so recursive downloads can check
cancelledTransferIdsRef.current.add(taskId);
if (cancelTransfer) {
try {
// Cancel the parent task ID (works for single-file downloads)
await cancelTransfer(taskId);
} catch {
// Ignore cancellation errors
}
// Also cancel the active child transfer for directory downloads
const activeChildId = activeChildTransferIdsRef.current.get(taskId);
if (activeChildId) {
try {
await cancelTransfer(activeChildId);
} catch {
// Ignore cancellation errors
}
activeChildTransferIdsRef.current.delete(taskId);
}
}
// Mark task as cancelled
setUploadTasks(prev =>

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,13 @@ interface SftpPaneToolbarProps {
setFileNameError: (value: string | null) => void;
setShowNewFileDialog: (open: boolean) => void;
setShowNewFolderDialog: (open: boolean) => void;
setNewFolderName: (value: string) => 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 +80,12 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
setFileNameError,
setShowNewFileDialog,
setShowNewFolderDialog,
setNewFolderName,
bookmarks,
isCurrentPathBookmarked,
onToggleBookmark,
onNavigateToBookmark,
onDeleteBookmark,
}) => (
<>
{/* Toolbar - always visible when connected */}
@@ -154,27 +168,120 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
</div>
)}
{/* Bookmark button with dropdown */}
<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"
size="icon"
className="h-6 w-6"
onClick={() => setShowNewFolderDialog(true)}
onClick={() => {
setNewFolderName("");
setShowNewFolderDialog(true);
}}
title={t("sftp.newFolder")}
>
<FolderPlus size={14} />

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,8 @@ 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";
import { useLocalSftpBookmarks } from "./hooks/useLocalSftpBookmarks";
interface SftpPaneWrapperProps {
side: "left" | "right";
@@ -84,6 +88,32 @@ 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 remoteBookmarks = useSftpBookmarks({
host: currentHost,
currentPath: pane.connection?.currentPath,
onUpdateHost,
});
const localBookmarks = useLocalSftpBookmarks({
currentPath: pane.connection?.currentPath,
});
const {
bookmarks,
isCurrentPathBookmarked,
toggleBookmark,
deleteBookmark,
} = pane.connection?.isLocal ? localBookmarks : remoteBookmarks;
const { filteredFiles, sortedDisplayFiles } = useSftpPaneFiles({
files: pane.files,
filter: pane.filter,
@@ -201,7 +231,10 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
() => ({
onRename: (fileName: string) => openRenameDialog(fileName),
onDelete: (fileNames: string[]) => openDeleteConfirm(fileNames),
onNewFolder: () => setShowNewFolderDialog(true),
onNewFolder: () => {
setNewFolderName("");
setShowNewFolderDialog(true);
},
onNewFile: () => {
const defaultName = getNextUntitledName(pane.files.map(f => f.name));
setNewFileName(defaultName);
@@ -216,6 +249,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
pane.files,
setFileNameError,
setNewFileName,
setNewFolderName,
setShowNewFileDialog,
setShowNewFolderDialog,
],
@@ -293,6 +327,12 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
setFileNameError={setFileNameError}
setShowNewFileDialog={setShowNewFileDialog}
setShowNewFolderDialog={setShowNewFolderDialog}
setNewFolderName={setNewFolderName}
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">
@@ -158,7 +121,7 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({ task, onCancel
</div>
<div className="flex items-center gap-1 shrink-0">
{task.status === 'failed' && (
{task.status === 'failed' && task.retryable !== false && (
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onRetry} title="Retry">
<RefreshCw size={12} />
</Button>
@@ -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,73 @@
import { useCallback, useMemo, useSyncExternalStore } from "react";
import type { SftpBookmark } from "../../../domain/models";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
import { STORAGE_KEY_SFTP_LOCAL_BOOKMARKS } from "../../../infrastructure/config/storageKeys";
// ── Shared external store so every hook instance sees the same bookmarks ──
type Listener = () => void;
const listeners = new Set<Listener>();
let snapshot: SftpBookmark[] =
localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_LOCAL_BOOKMARKS) ?? [];
function subscribe(listener: Listener) {
listeners.add(listener);
return () => { listeners.delete(listener); };
}
function getSnapshot() {
return snapshot;
}
function setBookmarks(next: SftpBookmark[] | ((prev: SftpBookmark[]) => SftpBookmark[])) {
snapshot = typeof next === "function" ? next(snapshot) : next;
localStorageAdapter.write(STORAGE_KEY_SFTP_LOCAL_BOOKMARKS, snapshot);
for (const l of listeners) l();
}
// ── Hook ──
interface UseLocalSftpBookmarksParams {
currentPath: string | undefined;
}
export const useLocalSftpBookmarks = ({
currentPath,
}: UseLocalSftpBookmarksParams) => {
const bookmarks = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const isCurrentPathBookmarked = useMemo(
() => !!currentPath && bookmarks.some((b) => b.path === currentPath),
[currentPath, bookmarks],
);
const toggleBookmark = useCallback(() => {
if (!currentPath) return;
if (isCurrentPathBookmarked) {
setBookmarks((prev) => prev.filter((b) => b.path !== currentPath));
} else {
const isRoot = currentPath === "/" || /^[A-Za-z]:\\?$/.test(currentPath);
const label = isRoot
? currentPath
: currentPath.split(/[\\/]/).filter(Boolean).pop() || currentPath;
const newBookmark: SftpBookmark = {
id: `bm-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
path: currentPath,
label,
};
setBookmarks((prev) => [...prev, newBookmark]);
}
}, [currentPath, isCurrentPathBookmarked]);
const deleteBookmark = useCallback((id: string) => {
setBookmarks((prev) => prev.filter((b) => b.id !== id));
}, []);
return {
bookmarks,
isCurrentPathBookmarked,
toggleBookmark,
deleteBookmark,
};
};

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

@@ -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,40 @@ 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
/**
* A file is considered hidden if:
* - It has the Windows hidden attribute (`hidden === true`), OR
* - Its name starts with a dot (Unix/Linux dotfile convention)
*
* The ".." parent directory entry is never considered hidden.
*/
export const isWindowsHiddenFile = <T extends { name: string; hidden?: boolean }>(file: T): boolean => {
export const isHiddenFile = <T extends { name: string; hidden?: boolean }>(
file: T,
): boolean => {
if (file.name === "..") return false;
return file.hidden === true;
// Windows hidden attribute
if (file.hidden === true) return true;
// Unix/Linux dotfile convention
if (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);
/**
* 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 Unix/Linux dotfiles on all connections.
* Always preserves ".." parent directory entry.
*/
export const filterHiddenFiles = <T extends { name: string; hidden?: boolean }>(
files: T[],
showHiddenFiles: boolean
showHiddenFiles: boolean,
): T[] => {
if (showHiddenFiles) return files;
return files.filter((f) => !isWindowsHiddenFile(f));
return files.filter((f) => !isHiddenFile(f));
};

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

@@ -31,7 +31,7 @@ export interface TerminalConnectionDialogProps {
authProps: Omit<TerminalAuthDialogProps, 'keys'>;
keys: SSHKey[];
// Progress props
progressProps: Omit<TerminalConnectionProgressProps, 'status' | 'error' | 'showLogs' | '_setShowLogs'>;
progressProps: Omit<TerminalConnectionProgressProps, 'status' | 'error' | 'showLogs'>;
}
// Helper to get protocol display info
@@ -166,7 +166,6 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
status={status}
error={error}
showLogs={showLogs}
_setShowLogs={setShowLogs}
{...progressProps}
/>
)}

View File

@@ -14,7 +14,6 @@ export interface TerminalConnectionProgressProps {
timeLeft: number;
isCancelling: boolean;
showLogs: boolean;
_setShowLogs: (show: boolean) => void; // Reserved for future log toggle UI within this component
progressLogs: string[];
onCancel: () => void;
onRetry: () => void;
@@ -26,7 +25,6 @@ export const TerminalConnectionProgress: React.FC<TerminalConnectionProgressProp
timeLeft,
isCancelling,
showLogs,
_setShowLogs, // Reserved for future log toggle UI within this component
progressLogs,
onCancel,
onRetry,

View File

@@ -28,6 +28,7 @@ export interface TerminalContextMenuProps {
hotkeyScheme?: 'disabled' | 'mac' | 'pc';
keyBindings?: KeyBinding[];
rightClickBehavior?: RightClickBehavior;
isAlternateScreen?: boolean;
onCopy?: () => void;
onPaste?: () => void;
onSelectAll?: () => void;
@@ -44,6 +45,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
hotkeyScheme = 'mac',
keyBindings,
rightClickBehavior = 'context-menu',
isAlternateScreen = false,
onCopy,
onPaste,
onSelectAll,
@@ -73,10 +75,14 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
const splitVShortcut = getShortcut('split-vertical');
const clearShortcut = getShortcut('clear-buffer');
const showContextMenu = rightClickBehavior === 'context-menu';
const showContextMenu = rightClickBehavior === 'context-menu' && !isAlternateScreen;
const handleRightClick = useCallback(
(e: React.MouseEvent) => {
// In alternate screen (tmux, vim, etc.), let the terminal application
// handle right-click natively to avoid conflicting menus
if (isAlternateScreen) return;
if (rightClickBehavior === 'paste') {
e.preventDefault();
e.stopPropagation();
@@ -87,7 +93,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
onSelectWord?.();
}
},
[rightClickBehavior, onPaste, onSelectWord],
[rightClickBehavior, onPaste, onSelectWord, isAlternateScreen],
);
// Always use ContextMenu wrapper to maintain consistent React tree structure

View File

@@ -2,12 +2,13 @@
* 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 { Check, FolderInput, Languages, X, Zap, Palette, Search, TextCursorInput } from 'lucide-react';
import React, { useState } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Snippet, Host } from '../../types';
import { Button } from '../ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from '../ui/popover';
import { cn } from '../../lib/utils';
import { ScrollArea } from '../ui/scroll-area';
import ThemeCustomizeModal from './ThemeCustomizeModal';
import HostKeywordHighlightPopover from './HostKeywordHighlightPopover';
@@ -32,6 +33,12 @@ export interface TerminalToolbarProps {
// Search functionality
isSearchOpen?: boolean;
onToggleSearch?: () => void;
// Compose bar
isComposeBarOpen?: boolean;
onToggleComposeBar?: () => void;
// Terminal encoding
terminalEncoding?: 'utf-8' | 'gb18030';
onSetTerminalEncoding?: (encoding: 'utf-8' | 'gb18030') => void;
}
export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
@@ -53,6 +60,10 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
onClose,
isSearchOpen,
onToggleSearch,
isComposeBarOpen,
onToggleComposeBar,
terminalEncoding,
onSetTerminalEncoding,
}) => {
const { t } = useI18n();
const [themeModalOpen, setThemeModalOpen] = useState(false);
@@ -61,6 +72,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
const isLocalTerminal = host?.protocol === 'local' || host?.id?.startsWith('local-');
const isSerialTerminal = host?.protocol === 'serial' || host?.id?.startsWith('serial-');
const isSSHSession = !isLocalTerminal && !isSerialTerminal && host?.protocol !== 'telnet' && host?.protocol !== 'mosh' && !host?.moshEnabled && host?.hostname !== 'localhost';
const hidesSftp = isLocalTerminal || isSerialTerminal;
const currentThemeId = host?.theme || defaultThemeId;
@@ -113,6 +125,44 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
</Button>
)}
{isSSHSession && onSetTerminalEncoding && (
<Popover>
<PopoverTrigger asChild>
<Button
variant="secondary"
size="icon"
className={buttonBase}
title={t("terminal.toolbar.encoding")}
aria-label={t("terminal.toolbar.encoding")}
>
<Languages size={12} />
</Button>
</PopoverTrigger>
<PopoverContent className="w-36 p-1" align="start">
{(["utf-8", "gb18030"] as const).map((enc) => (
<PopoverClose asChild key={enc}>
<button
className={cn(
"w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors",
terminalEncoding === enc && "font-medium"
)}
onClick={() => onSetTerminalEncoding(enc)}
>
<Check
size={12}
className={cn(
"shrink-0",
terminalEncoding === enc ? "opacity-100" : "opacity-0"
)}
/>
{t(`terminal.toolbar.encoding.${enc === "utf-8" ? "utf8" : enc}`)}
</button>
</PopoverClose>
))}
</PopoverContent>
</Popover>
)}
<Popover open={isScriptsOpen} onOpenChange={setIsScriptsOpen}>
<PopoverTrigger asChild>
<Button
@@ -173,6 +223,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,15 @@ export const useTerminalContextActions = ({
sessionRef,
terminalBackend,
onHasSelectionChange,
disableBracketedPasteRef,
scrollOnPasteRef,
}: {
termRef: RefObject<XTerm | null>;
sessionRef: RefObject<string | null>;
terminalBackend: TerminalBackendWriteApi;
onHasSelectionChange?: (hasSelection: boolean) => void;
disableBracketedPasteRef?: RefObject<boolean>;
scrollOnPasteRef?: RefObject<boolean>;
}) => {
const onCopy = useCallback(() => {
const term = termRef.current;
@@ -35,13 +39,16 @@ 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);
if (scrollOnPasteRef?.current) {
term.scrollToBottom();
}
}
} catch (err) {
logger.warn("Failed to paste from clipboard", err);
}
}, [sessionRef, termRef, terminalBackend]);
}, [sessionRef, termRef, terminalBackend, disableBracketedPasteRef, scrollOnPasteRef]);
const onSelectAll = useCallback(() => {
const term = termRef.current;

View File

@@ -1,31 +0,0 @@
/**
* Terminal components module
* Re-exports all terminal sub-components
*/
export { TerminalAuthDialog } from './TerminalAuthDialog';
export type { TerminalAuthDialogProps } from './TerminalAuthDialog';
export { TerminalConnectionProgress } from './TerminalConnectionProgress';
export type { TerminalConnectionProgressProps } from './TerminalConnectionProgress';
export { TerminalToolbar } from './TerminalToolbar';
export type { TerminalToolbarProps } from './TerminalToolbar';
export { HostKeywordHighlightPopover } from './HostKeywordHighlightPopover';
export type { HostKeywordHighlightPopoverProps } from './HostKeywordHighlightPopover';
export { TerminalConnectionDialog } from './TerminalConnectionDialog';
export type { ChainProgress,TerminalConnectionDialogProps } from './TerminalConnectionDialog';
export { TerminalContextMenu } from './TerminalContextMenu';
export type { TerminalContextMenuProps } from './TerminalContextMenu';
export { TerminalSearchBar } from './TerminalSearchBar';
export type { TerminalSearchBarProps } from './TerminalSearchBar';
export { KeywordHighlighter } from './keywordHighlight';
export { useTerminalSearch } from './hooks/useTerminalSearch';
export { useTerminalContextActions } from './hooks/useTerminalContextActions';
export { useTerminalAuthState } from './hooks/useTerminalAuthState';

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,7 +89,9 @@ export type TerminalSessionStartersContext = {
setProgressLogs: Dispatch<SetStateAction<string[]>>;
setProgressValue: Dispatch<SetStateAction<number>>;
setChainProgress: Dispatch<SetStateAction<ChainProgressState>>;
t?: (key: string) => string;
onSessionAttached?: (sessionId: string) => void;
onSessionExit?: (sessionId: string) => void;
onTerminalDataCapture?: (sessionId: string, data: string) => void;
onOsDetected?: (hostId: string, distro: string) => void;
@@ -123,6 +129,7 @@ const attachSessionToTerminal = (
},
) => {
ctx.sessionRef.current = id;
ctx.onSessionAttached?.(id);
ctx.disposeDataRef.current = ctx.terminalBackend.onSessionData(id, (chunk) => {
let data = chunk;
@@ -183,9 +190,9 @@ const runDistroDetection = async (
timeout: 8000,
});
const data = `${res.stdout || ""}\n${res.stderr || ""}`;
const idMatch = data.match(/ID=([\\w\\-]+)/i);
const idMatch = data.match(/^ID="?([\w-]+)"?$/im);
const distro = idMatch
? idMatch[1].replace(/"/g, "")
? idMatch[1]
: (data.split(/\s+/)[0] || "").toLowerCase();
if (distro) ctx.onOsDetected?.(ctx.host.id, distro);
} catch (err) {
@@ -194,6 +201,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 +240,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 +259,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 +279,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 +310,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 +400,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 +418,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 {
@@ -385,8 +491,11 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
const commandToRun = ctx.startupCommand || ctx.host.startupCommand;
if (commandToRun && !ctx.hasRunStartupCommandRef.current) {
ctx.hasRunStartupCommandRef.current = true;
const scheduledSessionId = id;
setTimeout(() => {
if (!ctx.sessionRef.current) return;
// Guard against stale timers: if the session changed (e.g. user
// clicked Start Over quickly), skip to avoid double execution
if (!ctx.sessionRef.current || ctx.sessionRef.current !== scheduledSessionId) return;
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, `${commandToRun}\r`);
if (ctx.onCommandExecuted) {
ctx.onCommandExecuted(commandToRun, ctx.host.id, ctx.host.label, ctx.sessionId);
@@ -505,8 +614,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
const commandToRun = ctx.startupCommand || ctx.host.startupCommand;
if (commandToRun && !ctx.hasRunStartupCommandRef.current) {
ctx.hasRunStartupCommandRef.current = true;
const scheduledSessionId = id;
setTimeout(() => {
if (!ctx.sessionRef.current) return;
if (!ctx.sessionRef.current || ctx.sessionRef.current !== scheduledSessionId) return;
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, `${commandToRun}\r`);
if (ctx.onCommandExecuted) {
ctx.onCommandExecuted(commandToRun, ctx.host.id, ctx.host.label, ctx.sessionId);

View File

@@ -19,6 +19,7 @@ import {
} from "../../../infrastructure/config/xtermPerformance";
import { logger } from "../../../lib/logger";
import { isMacPlatform, normalizeLineEndings, wrapBracketedPaste } from "../../../lib/utils";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import type {
Host,
KeyBinding,
@@ -119,6 +120,12 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
const settings = ctx.terminalSettingsRef.current;
const rendererType = settings?.rendererType ?? "auto";
const bridge = netcattyBridge.get();
const isLocalTerminalHost = ctx.host.protocol === "local";
const windowsPty =
platform === "win32" && isLocalTerminalHost
? bridge?.getWindowsPtyInfo?.() ?? { backend: "conpty" as const }
: undefined;
const performanceConfig = resolveXTermPerformanceConfig({
platform,
@@ -157,6 +164,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
const term = new XTerm({
...performanceConfig.options,
...(windowsPty ? { windowsPty } : {}),
// Override ignoreBracketedPasteMode if user explicitly disables bracketed paste
ignoreBracketedPasteMode: settings?.disableBracketedPaste ?? performanceConfig.options.ignoreBracketedPasteMode,
fontSize: effectiveFontSize,
fontFamily,
fontWeight: fontWeight as
@@ -262,15 +272,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 +321,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 +419,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 +454,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

@@ -12,7 +12,7 @@ export const normalizeDistroId = (value?: string) => {
if (v.includes('alpine')) return 'alpine';
if (v.includes('amzn') || v.includes('amazon') || v.includes('aws')) return 'amazon';
if (v.includes('opensuse') || v.includes('suse') || v.includes('sles')) return 'opensuse';
if (v.includes('red hat') || v.includes('rhel')) return 'redhat';
if (v.includes('red hat') || v.includes('redhat') || v.includes('rhel')) return 'redhat';
if (v.includes('oracle')) return 'oracle';
if (v.includes('kali')) return 'kali';
return '';

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,19 +431,86 @@ 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
}
const STRICT_IPV4_OCTET_PATTERN = '(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)';
export const URL_HIGHLIGHT_PATTERN =
"(?:\\bhttps?:\\/\\/\\[[0-9A-Fa-f:.]+\\](?::\\d+)?(?:[/?#][^\\s<>\"'`]*)?(?<![.,;:!?\\)}])|\\b(?:https?:\\/\\/|www\\.)[^\\s<>\"'`]+(?<![.,;:!?\\])}]))";
export const IPV4_HIGHLIGHT_PATTERN =
`(?<![\\w.])(?<!\\bver\\s)(?<!\\bversion\\s)(?:${STRICT_IPV4_OCTET_PATTERN}\\.){3}${STRICT_IPV4_OCTET_PATTERN}(?![\\w.])`;
export const MAC_ADDRESS_HIGHLIGHT_PATTERN =
'\\b([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}\\b';
export const DEFAULT_KEYWORD_HIGHLIGHT_RULES: KeywordHighlightRule[] = [
{ id: 'error', label: 'Error', patterns: ['\\[error\\]', '\\[err\\]', '\\berror\\b', '\\bfail(ed)?\\b', '\\bfatal\\b', '\\bcritical\\b', '\\bexception\\b'], color: '#F87171', enabled: true },
{ id: 'warning', label: 'Warning', patterns: ['\\[warn(ing)?\\]', '\\bwarn(ing)?\\b', '\\bcaution\\b', '\\bdeprecated\\b'], color: '#FBBF24', enabled: true },
{ id: 'ok', label: 'OK', patterns: ['\\[ok\\]', '\\bok\\b', '\\bsuccess(ful)?\\b', '\\bpassed\\b', '\\bcompleted\\b', '\\bdone\\b'], color: '#34D399', enabled: true },
{ id: 'info', label: 'Info', patterns: ['\\[info\\]', '\\[notice\\]', '\\[note\\]', '\\bnotice\\b', '\\bnote\\b'], color: '#3B82F6', enabled: true },
{ id: 'debug', label: 'Debug', patterns: ['\\[debug\\]', '\\[trace\\]', '\\[verbose\\]', '\\bdebug\\b', '\\btrace\\b', '\\bverbose\\b'], color: '#A78BFA', enabled: true },
{ id: 'ip-mac', label: 'IP address & MAC', patterns: ['\\b(?:\\d{1,3}\\.){3}\\d{1,3}\\b', '\\b([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}\\b'], color: '#EC4899', enabled: true },
{ id: 'ip-mac', label: 'URL, IP & MAC', patterns: [URL_HIGHLIGHT_PATTERN, IPV4_HIGHLIGHT_PATTERN, MAC_ADDRESS_HIGHLIGHT_PATTERN], color: '#EC4899', enabled: true },
];
const cloneKeywordHighlightRule = (rule: KeywordHighlightRule): KeywordHighlightRule => ({
...rule,
patterns: [...rule.patterns],
});
export const normalizeKeywordHighlightRules = (
rules?: KeywordHighlightRule[],
): KeywordHighlightRule[] => {
if (!rules || rules.length === 0) {
return DEFAULT_KEYWORD_HIGHLIGHT_RULES.map(cloneKeywordHighlightRule);
}
const defaultRulesById = new Map(
DEFAULT_KEYWORD_HIGHLIGHT_RULES.map((rule) => [rule.id, rule] as const),
);
const normalizedRules = rules.map((rule) => {
const defaultRule = defaultRulesById.get(rule.id);
if (!defaultRule) {
return cloneKeywordHighlightRule(rule);
}
return {
...defaultRule,
color: rule.color,
enabled: rule.enabled,
};
});
const existingRuleIds = new Set(normalizedRules.map((rule) => rule.id));
for (const defaultRule of DEFAULT_KEYWORD_HIGHLIGHT_RULES) {
if (!existingRuleIds.has(defaultRule.id)) {
normalizedRules.push(cloneKeywordHighlightRule(defaultRule));
}
}
return normalizedRules;
};
export const normalizeTerminalSettings = (
settings?: Partial<TerminalSettings> | null,
): TerminalSettings => {
const mergedSettings = {
...DEFAULT_TERMINAL_SETTINGS,
...(settings ?? {}),
};
return {
...mergedSettings,
keywordHighlightRules: normalizeKeywordHighlightRules(
mergedSettings.keywordHighlightRules,
),
};
};
export const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
scrollback: 10000,
drawBoldInBrightColors: true,
@@ -464,6 +540,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 +548,7 @@ export interface TerminalTheme {
id: string;
name: string;
type: 'dark' | 'light';
isCustom?: boolean;
colors: {
background: string;
foreground: string;
@@ -524,17 +602,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';
@@ -598,6 +676,7 @@ export interface TransferTask {
childTasks?: string[]; // For directory transfers
parentTaskId?: string;
skipConflictCheck?: boolean; // Skip conflict check for replace operations
retryable?: boolean; // False for task types that cannot be safely replayed through generic retry
}
export interface FileConflict {

103
domain/syncPayload.ts Normal file
View File

@@ -0,0 +1,103 @@
/**
* Sync Payload Builders — Single source of truth for constructing and applying
* the encrypted cloud-sync payload.
*
* Both the main window (App.tsx) and the settings window (SettingsSyncTab.tsx)
* must use these helpers to guarantee every field is included and no data is
* silently dropped.
*/
import type {
Host,
Identity,
KnownHost,
PortForwardingRule,
Snippet,
SSHKey,
} from './models';
import type { SyncPayload } from './sync';
// ---------------------------------------------------------------------------
// Input types
// ---------------------------------------------------------------------------
/** All vault-owned data that participates in cloud sync. */
export interface SyncableVaultData {
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
snippets: Snippet[];
customGroups: string[];
knownHosts: KnownHost[];
}
/** Callbacks used by `applySyncPayload` to import data into local state. */
export interface SyncPayloadImporters {
/** Import vault data (hosts, keys, identities, snippets, customGroups, knownHosts). */
importVaultData: (jsonString: string) => void;
/** Import port-forwarding rules (lives outside the vault hook). */
importPortForwardingRules?: (rules: PortForwardingRule[]) => void;
}
// ---------------------------------------------------------------------------
// Builders
// ---------------------------------------------------------------------------
/**
* Build a complete `SyncPayload` from local data.
*
* Port-forwarding rules are optional because they are managed by a separate
* state hook (`usePortForwardingState`). Callers should strip transient
* runtime fields (status, error, lastUsedAt) before passing them in.
*/
export function buildSyncPayload(
vault: SyncableVaultData,
portForwardingRules?: PortForwardingRule[],
): SyncPayload {
return {
hosts: vault.hosts,
keys: vault.keys,
identities: vault.identities,
snippets: vault.snippets,
customGroups: vault.customGroups,
knownHosts: vault.knownHosts,
portForwardingRules,
syncedAt: Date.now(),
};
}
/**
* Apply a downloaded `SyncPayload` to local state via the provided importers.
*
* This ensures both vault data and port-forwarding rules are imported
* consistently across windows.
*/
export function applySyncPayload(
payload: SyncPayload,
importers: SyncPayloadImporters,
): void {
// Build the vault import object. knownHosts is only included when the
// payload explicitly carries the field (even if it's []). Legacy cloud
// snapshots may omit it entirely — in that case we leave the local
// known-hosts list untouched rather than destructively wiping it.
const vaultImport: Record<string, unknown> = {
hosts: payload.hosts,
keys: payload.keys,
identities: payload.identities,
snippets: payload.snippets,
customGroups: payload.customGroups,
};
if (payload.knownHosts !== undefined) {
vaultImport.knownHosts = payload.knownHosts;
}
importers.importVaultData(JSON.stringify(vaultImport));
// Only import port-forwarding rules when the payload explicitly carries
// them. Absent field = "payload was created before this feature existed",
// so local rules are preserved. Explicitly present [] = "remote has no
// rules, clear local state".
if (payload.portForwardingRules !== undefined && importers.importPortForwardingRules) {
importers.importPortForwardingRules(payload.portForwardingRules);
}
}

View File

@@ -1,6 +1,3 @@
/* global __dirname */
const path = require('path');
/**
* @type {import('electron-builder').Configuration}
*/
@@ -37,8 +34,8 @@ module.exports = {
}
],
category: 'public.app-category.developer-tools',
hardenedRuntime: false,
gatekeeperAssess: false,
hardenedRuntime: true,
notarize: true,
entitlements: 'electron/entitlements.mac.plist',
entitlementsInherit: 'electron/entitlements.mac.plist',
extendInfo: {
@@ -49,24 +46,15 @@ module.exports = {
},
dmg: {
title: '${productName}',
background: 'public/dmg-background.jpg',
iconSize: 100,
iconTextSize: 12,
window: {
width: 672,
height: 500
width: 540,
height: 380
},
contents: [
{ x: 150, y: 158 },
{ x: 550, y: 158, type: 'link', path: '/Applications' },
{
x: 350,
y: 330,
type: 'file',
// Use absolute path resolved at build time
path: path.resolve(__dirname, 'scripts/FixQuarantine.app'),
name: '已损坏修复.app'
}
{ x: 140, y: 158 },
{ x: 400, y: 158, type: 'link', path: '/Applications' }
]
},
win: {
@@ -102,5 +90,13 @@ module.exports = {
}
],
category: 'Development'
}
},
publish: [
{
provider: 'github',
owner: 'binaricat',
repo: 'Netcatty',
releaseType: 'release'
}
]
};

View File

@@ -0,0 +1,199 @@
/**
* Auto-Update Bridge
*
* Wraps electron-updater to provide IPC-driven update checks, downloads, and
* install-on-quit. Designed around a "prompt" model: the renderer asks to
* check, then explicitly triggers download and install.
*
* Platforms where auto-update is NOT supported (Linux deb/rpm/snap) get a
* graceful { available: false, error } response so the renderer can fall back
* to a manual "open GitHub releases" link.
*/
let _deps = null;
/**
* Returns true when the current packaging format supports electron-updater
* (macOS zip/dmg, Windows NSIS, Linux AppImage).
*/
function isAutoUpdateSupported() {
if (process.platform === "darwin" || process.platform === "win32") {
return true;
}
// Linux: only AppImage supports in-place update.
// The APPIMAGE env variable is set by the AppImage runtime.
if (process.platform === "linux" && process.env.APPIMAGE) {
return true;
}
return false;
}
/** Lazily resolved autoUpdater — avoids importing electron-updater in
* contexts where native modules might not be available. */
let _autoUpdater = null;
function getAutoUpdater() {
if (_autoUpdater) return _autoUpdater;
try {
const { autoUpdater } = require("electron-updater");
autoUpdater.autoDownload = false;
autoUpdater.autoInstallOnAppQuit = false;
// Silence the default electron-log transport (we log ourselves).
autoUpdater.logger = null;
_autoUpdater = autoUpdater;
return autoUpdater;
} catch (err) {
console.error("[AutoUpdate] Failed to load electron-updater:", err?.message || err);
return null;
}
}
function init(deps) {
_deps = deps;
}
/** Get the focused or first available BrowserWindow to send events to. */
function getSenderWindow() {
try {
const { BrowserWindow } = _deps?.electronModule || {};
if (!BrowserWindow) return null;
const focused = BrowserWindow.getFocusedWindow();
if (focused && !focused.isDestroyed()) return focused;
const all = BrowserWindow.getAllWindows();
for (const win of all) {
if (!win.isDestroyed()) return win;
}
} catch {}
return null;
}
function registerHandlers(ipcMain) {
// ---- Check for updates ------------------------------------------------
ipcMain.handle("netcatty:update:check", async () => {
if (!isAutoUpdateSupported()) {
return {
available: false,
supported: false,
error: "Auto-update is not supported on this platform/package format.",
};
}
const updater = getAutoUpdater();
if (!updater) {
return {
available: false,
supported: false,
error: "Update module failed to load.",
};
}
try {
const result = await updater.checkForUpdates();
if (!result || !result.updateInfo) {
return { available: false, supported: true };
}
const { version, releaseNotes, releaseDate } = result.updateInfo;
// Compare with current version using semver ordering.
// Only report an update when the feed version is strictly newer,
// avoiding false positives for pre-release or nightly builds.
const { app } = _deps?.electronModule || {};
const currentVersion = app?.getVersion?.() || "0.0.0";
const isNewer = currentVersion.localeCompare(version, undefined, { numeric: true, sensitivity: 'base' }) < 0;
if (!isNewer) {
return { available: false, supported: true };
}
return {
available: true,
supported: true,
version,
releaseNotes: typeof releaseNotes === "string" ? releaseNotes : "",
releaseDate: releaseDate || null,
};
} catch (err) {
console.warn("[AutoUpdate] Check failed:", err?.message || err);
return {
available: false,
supported: true,
error: err?.message || "Unknown update check error",
};
}
});
// ---- Download update ---------------------------------------------------
ipcMain.handle("netcatty:update:download", async () => {
const updater = getAutoUpdater();
if (!updater) {
return { success: false, error: "Update module not available." };
}
try {
// Capture the requesting window NOW so events always go back to the
// renderer that initiated the download, even if focus changes later.
const senderWindow = getSenderWindow();
// Wire progress events before starting the download.
const progressHandler = (info) => {
if (senderWindow && !senderWindow.isDestroyed()) {
senderWindow.webContents.send("netcatty:update:download-progress", {
percent: info.percent ?? 0,
bytesPerSecond: info.bytesPerSecond ?? 0,
transferred: info.transferred ?? 0,
total: info.total ?? 0,
});
}
};
const downloadedHandler = () => {
if (senderWindow && !senderWindow.isDestroyed()) {
senderWindow.webContents.send("netcatty:update:downloaded");
}
// Cleanup one-shot listeners.
updater.removeListener("download-progress", progressHandler);
updater.removeListener("update-downloaded", downloadedHandler);
updater.removeListener("error", errorHandler);
};
const errorHandler = (err) => {
if (senderWindow && !senderWindow.isDestroyed()) {
senderWindow.webContents.send("netcatty:update:error", {
error: err?.message || "Download failed",
});
}
updater.removeListener("download-progress", progressHandler);
updater.removeListener("update-downloaded", downloadedHandler);
updater.removeListener("error", errorHandler);
};
updater.on("download-progress", progressHandler);
updater.on("update-downloaded", downloadedHandler);
updater.on("error", errorHandler);
await updater.downloadUpdate();
return { success: true };
} catch (err) {
// Clean up listeners to prevent leaks if downloadUpdate() rejects
// before the error event is emitted.
const updaterForCleanup = getAutoUpdater();
if (updaterForCleanup) {
updaterForCleanup.removeAllListeners("download-progress");
updaterForCleanup.removeAllListeners("update-downloaded");
updaterForCleanup.removeAllListeners("error");
}
console.error("[AutoUpdate] Download failed:", err?.message || err);
return { success: false, error: err?.message || "Download failed" };
}
});
// ---- Install (quit & install) ------------------------------------------
ipcMain.handle("netcatty:update:install", () => {
const updater = getAutoUpdater();
if (!updater) return;
updater.quitAndInstall(false, true);
});
console.log("[AutoUpdate] Handlers registered");
}
module.exports = { init, registerHandlers, isAutoUpdateSupported };

View File

@@ -55,10 +55,10 @@ async function listLocalDir(event, payload) {
const fullPath = path.join(dirPath, entry.name);
// fs.promises.stat follows symlinks, so we get the target's stats
const stat = await fs.promises.stat(fullPath);
let type;
let linkTarget = null;
if (entry.isSymbolicLink()) {
// This is a symlink - mark it as such and record the target type
type = "symlink";
@@ -69,10 +69,10 @@ async function listLocalDir(event, payload) {
} else {
type = "file";
}
// Check for Windows hidden attribute
const hidden = isWindows ? await isWindowsHiddenFile(fullPath) : false;
result[i] = {
name: entry.name,
type,
@@ -201,7 +201,7 @@ async function getSystemInfo() {
async function readKnownHosts() {
const homeDir = os.homedir();
const knownHostsPaths = [];
if (process.platform === "win32") {
knownHostsPaths.push(path.join(homeDir, ".ssh", "known_hosts"));
knownHostsPaths.push(path.join(process.env.PROGRAMDATA || "C:\\ProgramData", "ssh", "known_hosts"));
@@ -212,9 +212,9 @@ async function readKnownHosts() {
knownHostsPaths.push(path.join(homeDir, ".ssh", "known_hosts"));
knownHostsPaths.push("/etc/ssh/ssh_known_hosts");
}
let combinedContent = "";
for (const knownHostsPath of knownHostsPaths) {
try {
if (fs.existsSync(knownHostsPath)) {
@@ -227,7 +227,7 @@ async function readKnownHosts() {
console.warn(`Failed to read known_hosts from ${knownHostsPath}:`, err.message);
}
}
return combinedContent || null;
}

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

@@ -100,6 +100,9 @@ async function startPortForward(event, payload) {
}));
return new Promise((resolve, reject) => {
// Track whether the Promise has been settled so conn.on('close')
// can reject if the tunnel was killed during SSH handshake.
let settled = false;
conn.on('ready', () => {
console.log(`[PortForward] SSH connection ready for tunnel ${tunnelId}`);
@@ -131,6 +134,7 @@ async function startPortForward(event, payload) {
sendStatus('error', err.message);
conn.end();
portForwardingTunnels.delete(tunnelId);
settled = true;
reject(err);
});
@@ -140,9 +144,11 @@ async function startPortForward(event, payload) {
type: 'local',
conn,
server,
status: 'active',
webContentsId: sender.id
});
sendStatus('active');
settled = true;
resolve({ tunnelId, success: true });
});
@@ -153,6 +159,7 @@ async function startPortForward(event, payload) {
console.error(`[PortForward] Remote forward error:`, err.message);
sendStatus('error', err.message);
conn.end();
settled = true;
reject(err);
return;
}
@@ -161,9 +168,11 @@ async function startPortForward(event, payload) {
portForwardingTunnels.set(tunnelId, {
type: 'remote',
conn,
status: 'active',
webContentsId: sender.id
});
sendStatus('active');
settled = true;
resolve({ tunnelId, success: true });
});
@@ -265,6 +274,7 @@ async function startPortForward(event, payload) {
sendStatus('error', err.message);
conn.end();
portForwardingTunnels.delete(tunnelId);
settled = true;
reject(err);
});
@@ -274,12 +284,15 @@ async function startPortForward(event, payload) {
type: 'dynamic',
conn,
server,
status: 'active',
webContentsId: sender.id
});
sendStatus('active');
settled = true;
resolve({ tunnelId, success: true });
});
} else {
settled = true;
reject(new Error(`Unknown forwarding type: ${type}`));
}
});
@@ -288,12 +301,15 @@ async function startPortForward(event, payload) {
console.error(`[PortForward] SSH error:`, err.message);
sendStatus('error', err.message);
portForwardingTunnels.delete(tunnelId);
settled = true;
reject(err);
});
conn.on('close', () => {
console.log(`[PortForward] SSH connection closed for tunnel ${tunnelId}`);
const tunnel = portForwardingTunnels.get(tunnelId);
// Capture the cancelled flag BEFORE cleanup deletes the entry.
const wasCancelled = !!tunnel?.cancelled;
if (tunnel) {
if (tunnel.server) {
try { tunnel.server.close(); } catch { }
@@ -301,9 +317,30 @@ async function startPortForward(event, payload) {
sendStatus('inactive');
portForwardingTunnels.delete(tunnelId);
}
// If the Promise was never settled (tunnel killed during
// handshake by stopPortForwardByRuleId), settle it.
if (!settled) {
settled = true;
if (wasCancelled) {
resolve({ tunnelId, success: false, cancelled: true });
} else {
reject(new Error(`Tunnel ${tunnelId} closed before connection established`));
}
}
});
sendStatus('connecting');
// Register the connection BEFORE the handshake starts so that
// stopPortForwardByRuleId can find and kill it at any point,
// including during the SSH handshake window. The conn.on('ready')
// handler updates the entry to include the server object later.
portForwardingTunnels.set(tunnelId, {
type,
conn,
server: null,
status: 'connecting',
webContentsId: sender.id,
});
conn.connect(connectOpts);
});
}
@@ -320,13 +357,17 @@ async function stopPortForward(event, payload) {
}
try {
// Mark as cancelled so conn.on('close') resolves gracefully
// instead of rejecting for in-flight handshakes.
tunnel.cancelled = true;
if (tunnel.server) {
tunnel.server.close();
}
if (tunnel.conn) {
tunnel.conn.end();
}
portForwardingTunnels.delete(tunnelId);
// Don't delete here — let conn.on('close') handle cleanup
// so it can read the cancelled flag.
return { tunnelId, success: true };
} catch (err) {
@@ -345,7 +386,7 @@ async function getPortForwardStatus(event, payload) {
return { tunnelId, status: 'inactive' };
}
return { tunnelId, status: 'active', type: tunnel.type };
return { tunnelId, status: tunnel.status || 'active', type: tunnel.type };
}
/**
@@ -357,7 +398,7 @@ async function listPortForwards() {
list.push({
tunnelId,
type: tunnel.type,
status: 'active',
status: tunnel.status || 'active',
});
}
return list;
@@ -370,21 +411,54 @@ function stopAllPortForwards() {
console.log(`[PortForward] Stopping all ${portForwardingTunnels.size} active tunnels...`);
for (const [tunnelId, tunnel] of portForwardingTunnels) {
try {
// Mark as cancelled so conn.on('close') resolves gracefully
// instead of rejecting with an error for in-flight handshakes.
tunnel.cancelled = true;
if (tunnel.server) {
tunnel.server.close();
}
if (tunnel.conn) {
tunnel.conn.end();
}
// Don't delete here — let conn.on('close') handle cleanup
// so it can read the cancelled flag.
console.log(`[PortForward] Stopped tunnel ${tunnelId}`);
} catch (err) {
console.warn(`[PortForward] Failed to stop tunnel ${tunnelId}:`, err.message);
}
}
portForwardingTunnels.clear();
console.log('[PortForward] All tunnels stopped');
}
/**
* Stop all active port forwards for a given rule ID.
* Tunnel IDs follow the format `pf-{ruleId}-{timestamp}`, so we match
* by checking if the tunnelId contains the ruleId.
* This catches tunnels in ANY state (connecting, active) because it
* operates on the main-process portForwardingTunnels map directly.
*/
function stopPortForwardByRuleId(_event, { ruleId }) {
let stopped = 0;
for (const [tunnelId, tunnel] of portForwardingTunnels) {
if (tunnelId.includes(ruleId)) {
try {
// Mark as intentionally cancelled BEFORE conn.end() so the
// close handler resolves gracefully instead of rejecting.
tunnel.cancelled = true;
if (tunnel.server) tunnel.server.close();
if (tunnel.conn) tunnel.conn.end();
// Don't delete here — let the conn.on('close') handler delete
// the entry so it can read tunnel.cancelled first.
console.log(`[PortForward] Stopped tunnel ${tunnelId} for rule ${ruleId}`);
stopped++;
} catch (err) {
console.warn(`[PortForward] Failed to stop tunnel ${tunnelId}:`, err.message);
}
}
}
return { stopped };
}
/**
* Register IPC handlers for port forwarding operations
*/
@@ -393,6 +467,8 @@ function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:portforward:stop", stopPortForward);
ipcMain.handle("netcatty:portforward:status", getPortForwardStatus);
ipcMain.handle("netcatty:portforward:list", listPortForwards);
ipcMain.handle("netcatty:portforward:stopAll", () => stopAllPortForwards());
ipcMain.handle("netcatty:portforward:stopByRuleId", stopPortForwardByRuleId);
}
module.exports = {
@@ -402,4 +478,5 @@ module.exports = {
getPortForwardStatus,
listPortForwards,
stopAllPortForwards,
stopPortForwardByRuleId,
};

View File

@@ -23,12 +23,13 @@ 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,
getAvailableAgentSocket,
} = require("./sshAuthHelper.cjs");
// SFTP clients storage - shared reference passed from main
@@ -127,7 +128,102 @@ const encodePathForSession = (sftpId, inputPath, requestedEncoding) => {
return encodePath(inputPath, encoding);
};
const getSftpChannel = (client) => client?.sftp || client?.client?.sftp;
const hasSftpChannelApi = (value) =>
!!value &&
typeof value.readdir === "function" &&
typeof value.stat === "function" &&
typeof value.mkdir === "function" &&
typeof value.unlink === "function";
const SFTP_CHANNEL_OPEN_TIMEOUT_MS = 10_000;
const tryOpenSftpChannel = (client) =>
new Promise((resolve, reject) => {
const sshClient = client?.client;
if (!sshClient || typeof sshClient.sftp !== "function") {
resolve(null);
return;
}
let settled = false;
const timer = setTimeout(() => {
settled = true;
reject(new Error("SFTP channel open timed out"));
}, SFTP_CHANNEL_OPEN_TIMEOUT_MS);
try {
sshClient.sftp((err, sftp) => {
clearTimeout(timer);
if (settled) {
// Timeout already fired — close the orphaned channel to prevent leaks
try { sftp?.end?.(); } catch { }
return;
}
if (err) return reject(err);
resolve(sftp || null);
});
} catch (err) {
clearTimeout(timer);
if (settled) return;
settled = true;
reject(err);
}
});
const getSftpChannel = async (client) => {
if (!client) return null;
if (hasSftpChannelApi(client.sftp)) {
return client.sftp;
}
// sudo sessions must keep using the sudo-bootstrapped SFTP wrapper.
// Reopening with sshClient.sftp() would silently downgrade permissions.
if (client.__netcattySudoMode) {
console.warn("[SFTP] Sudo SFTP channel is unavailable; automatic recovery is disabled for sudo sessions. Please reconnect.");
return null;
}
// Do not treat ssh2's "client.sftp" method as a channel object.
// Re-open a fresh channel when the cached channel is stale.
if (!client.client || typeof client.client.sftp !== "function") {
return null;
}
// Deduplicate per-client: avoid concurrent channel re-open attempts
if (client._reopeningPromise) {
try {
return await client._reopeningPromise;
} catch {
return null;
}
}
client._reopeningPromise = (async () => {
try {
const reopened = await tryOpenSftpChannel(client);
if (hasSftpChannelApi(reopened)) {
client.sftp = reopened;
return reopened;
}
} catch (err) {
console.warn("[SFTP] Failed to recover SFTP channel", err?.message || String(err));
}
return null;
})();
try {
return await client._reopeningPromise;
} finally {
client._reopeningPromise = null;
}
};
const requireSftpChannel = async (client) => {
const sftp = await getSftpChannel(client);
if (!sftp) {
throw new Error("SFTP session lost. Please reconnect.");
}
return sftp;
};
const statAsync = (sftp, targetPath) =>
new Promise((resolve, reject) => {
@@ -167,9 +263,20 @@ const normalizeRemotePathString = async (client, inputPath) => {
return inputPath;
};
const isWindowsRemotePath = (dirPath) => /^[A-Za-z]:[\\/]/.test(dirPath) || /^[A-Za-z]:$/.test(dirPath);
const normalizeRemoteDirPath = (dirPath) => {
if (isWindowsRemotePath(dirPath)) {
const normalized = dirPath.replace(/\//g, "\\").replace(/\\+/g, "\\");
if (/^[A-Za-z]:$/.test(normalized)) return `${normalized}\\`;
return normalized;
}
return path.posix.normalize(dirPath);
};
const ensureRemoteDirInternal = async (sftp, dirPath, encoding) => {
if (!dirPath || dirPath === ".") return;
const normalized = path.posix.normalize(dirPath);
const normalized = normalizeRemoteDirPath(dirPath);
if (!normalized || normalized === ".") return;
// Optimization: Check if the full path already exists to avoid O(N) round trips
@@ -184,12 +291,22 @@ const ensureRemoteDirInternal = async (sftp, dirPath, encoding) => {
// If path doesn't exist or other error, proceed to recursive check
}
const isWindowsPath = isWindowsRemotePath(normalized);
const isAbsolute = normalized.startsWith("/");
const parts = normalized.split("/").filter(Boolean);
let current = isAbsolute ? "/" : "";
const parts = isWindowsPath
? normalized.slice(2).replace(/^[\\]+/, "").split(/[\\]+/).filter(Boolean)
: normalized.split("/").filter(Boolean);
let current = isWindowsPath
? `${normalized.slice(0, 2)}\\`
: (isAbsolute ? "/" : "");
for (const part of parts) {
current = current === "/" ? `/${part}` : (current ? `${current}/${part}` : part);
if (isWindowsPath) {
const base = current.replace(/[\\]+$/, "");
current = `${base}\\${part}`;
} else {
current = current === "/" ? `/${part}` : (current ? `${current}/${part}` : part);
}
const encodedCurrent = encodePath(current, encoding);
try {
const stats = await statAsync(sftp, encodedCurrent);
@@ -240,20 +357,54 @@ const ensureRemoteDirForSession = async (sftpId, dirPath, requestedEncoding) =>
if (!dirPath || dirPath === ".") return true;
const encoding = resolveEncodingForRequest(sftpId, requestedEncoding);
if (encoding === "utf-8") {
const encodedPath = encodePath(dirPath, encoding);
await client.mkdir(encodedPath, true);
return true;
}
const sftp = getSftpChannel(client);
if (!sftp) throw new Error("SFTP channel not ready");
const sftp = await requireSftpChannel(client);
// Always walk the path segment-by-segment. This lets sftp.stat() follow
// symlinked directory segments before deciding whether the next mkdir is
// valid, which avoids recursive mkdir failures on paths like /link/subdir.
const normalizedPath = await normalizeRemotePathString(client, dirPath);
await ensureRemoteDirInternal(sftp, normalizedPath, encoding);
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
*/
@@ -277,7 +428,7 @@ function init(deps) {
/**
* Connect through a chain of jump hosts for SFTP
*/
async function connectThroughChainForSftp(event, options, jumpHosts, targetHost, targetPort, connId) {
async function connectThroughChainForSftp(event, options, jumpHosts, targetHost, targetPort, connId, agentSocket) {
const sender = event.sender;
const connections = [];
let currentSocket = null;
@@ -307,22 +458,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
@@ -363,6 +499,7 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
logPrefix: `[SFTP Chain] Hop ${i + 1}`,
unlockedEncryptedKeys: options._unlockedEncryptedKeys || [],
defaultKeys,
sshAgentSocketOverride: agentSocket,
});
applyAuthToConnOpts(connOpts, authConfig);
@@ -386,6 +523,11 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
resolve();
});
conn.on('error', (err) => {
// Filter out non-fatal agent auth errors (same as in openSftp)
if (err.level === 'agent') {
console.log(`[SFTP Chain] Hop ${i + 1} non-fatal agent auth error (will try next method):`, err.message);
return;
}
console.error(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: ${hopLabel} error:`, err.message);
reject(err);
});
@@ -693,6 +835,10 @@ async function openSftp(event, options) {
let chainConnections = [];
let connectionSocket = null;
// Pre-fetch agent socket (async check for Windows SSH Agent service)
// This is used by both jump host chain auth and final host auth
const agentSocket = await getAvailableAgentSocket();
// Handle chain/proxy connections
if (hasJumpHosts) {
console.log(`[SFTP] Opening connection through ${jumpHosts.length} jump host(s) to ${options.hostname}:${options.port || 22}`);
@@ -706,7 +852,8 @@ async function openSftp(event, options) {
jumpHosts,
options.hostname,
options.port || 22,
connId
connId,
agentSocket
);
connectionSocket = chainResult.socket;
chainConnections = chainResult.connections;
@@ -726,6 +873,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
@@ -759,6 +907,7 @@ async function openSftp(event, options) {
if (options.password) connectOpts.password = options.password;
// Build auth handler using shared helper
// Use pre-fetched agentSocket (validated async, including Windows service check)
const authConfig = buildAuthHandler({
privateKey: connectOpts.privateKey,
password: connectOpts.password,
@@ -767,6 +916,7 @@ async function openSftp(event, options) {
username: connectOpts.username,
logPrefix: "[SFTP]",
defaultKeys,
sshAgentSocketOverride: agentSocket,
});
applyAuthToConnOpts(connectOpts, authConfig);
@@ -786,44 +936,104 @@ async function openSftp(event, options) {
connectOpts.readyTimeout = 120000; // 2 minutes for 2FA input
try {
if (options.sudo) {
console.log(`[SFTP] Using sudo mode for connection: ${connId}`);
const sshClient = client.client;
// IMPORTANT: We bypass ssh2-sftp-client's connect() method and use the
// underlying ssh2 Client directly. This is because ssh2-sftp-client adds
// temporary error listeners that reject the entire connect promise on ANY
// error, including non-fatal auth errors (e.g. 'Failed to connect to agent'
// when ssh2 tries agent auth and falls through to the next method).
// By connecting directly, we can filter these non-fatal errors and allow
// the auth flow to continue to keyboard-interactive/password/etc.
const sshClient = client.client;
await new Promise((resolve, reject) => {
// Set up error handler for initial connection
const onConnectError = (err) => reject(err);
sshClient.once('error', onConnectError);
await new Promise((resolve, reject) => {
let settled = false;
const settle = (fn, val) => {
if (settled) return;
settled = true;
cleanup();
fn(val);
};
sshClient.once('ready', async () => {
sshClient.removeListener('error', onConnectError);
try {
// Use provided password or try empty if using key auth (and hope for nopasswd sudo)
const sudoPass = options.password || "";
const sftpWrapper = await connectSudoSftp(sshClient, sudoPass);
const onError = (err) => {
// Filter out non-fatal authentication errors.
// ssh2 sets err.level = 'agent' when agent auth fails — it then
// internally calls tryNextAuth() to proceed with the next method.
// We must NOT reject here, or the fallback won't execute.
if (err.level === 'agent') {
console.log('[SFTP] Non-fatal agent auth error (will try next method):', err.message);
return;
}
settle(reject, err);
};
// Inject into sftp-client
client.sftp = sftpWrapper;
const onEnd = () => {
settle(reject, new Error('Connection closed before SFTP session was ready'));
};
// Important: attach cleanup listener expected by sftp-client
client.sftp.on('close', () => client.end());
const onClose = () => {
settle(reject, new Error('Connection closed before SFTP session was ready'));
};
const cleanup = () => {
sshClient.removeListener('error', onError);
sshClient.removeListener('end', onEnd);
sshClient.removeListener('close', onClose);
};
sshClient.on('error', onError);
sshClient.on('end', onEnd);
sshClient.on('close', onClose);
sshClient.once('ready', () => {
cleanup();
if (options.sudo) {
console.log(`[SFTP] Using sudo mode for connection: ${connId}`);
(async () => {
try {
const sudoPass = options.password || "";
const sftpWrapper = await connectSudoSftp(sshClient, sudoPass);
client.sftp = sftpWrapper;
client.sftp.on('close', () => client.end());
resolve();
} catch (e) {
// Fallback: if sftp-server binary is missing (exit code 127),
// try standard SFTP subsystem instead of failing completely.
// This handles systems like ESXi that don't have sftp-server
// but support the SFTP subsystem natively.
if (e.message && e.message.includes('exit code 127')) {
console.warn('[SFTP] sftp-server not found, falling back to standard SFTP subsystem');
options.sudo = false; // Mark as non-sudo for downstream logic
sshClient.sftp((sftpErr, sftp) => {
if (sftpErr) {
sshClient.end();
return reject(sftpErr);
}
client.sftp = sftp;
resolve();
});
} else {
sshClient.end();
reject(e);
}
}
})();
} else {
// Open standard SFTP subsystem channel
sshClient.sftp((err, sftp) => {
if (err) return reject(err);
client.sftp = sftp;
resolve();
} catch (e) {
sshClient.end();
reject(e);
}
});
try {
sshClient.connect(connectOpts);
} catch (e) {
reject(e);
});
}
});
} else {
await client.connect(connectOpts);
}
try {
sshClient.connect(connectOpts);
} catch (e) {
settle(reject, e);
}
});
// Increase max listeners AFTER connect, when the internal ssh2 Client exists
// This prevents Node.js MaxListenersExceededWarning when performing many operations
// ssh2-sftp-client adds temporary listeners for each operation, so we need a high limit
@@ -831,6 +1041,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
@@ -865,10 +1077,7 @@ async function listSftp(event, payload) {
const pathEncoding = resolveEncodingForRequest(payload.sftpId, requestedEncoding);
const encodedPath = encodePath(basePath, pathEncoding);
const sftp = getSftpChannel(client);
if (!sftp) {
throw new Error("SFTP channel not ready");
}
const sftp = await requireSftpChannel(client);
let list;
try {
@@ -989,6 +1198,7 @@ async function readSftp(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
await requireSftpChannel(client);
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
const encodedPath = encodePath(payload.path, encoding);
const buffer = await client.get(encodedPath);
@@ -1002,6 +1212,7 @@ async function readSftpBinary(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
await requireSftpChannel(client);
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
const encodedPath = encodePath(payload.path, encoding);
const buffer = await client.get(encodedPath);
@@ -1016,6 +1227,7 @@ async function writeSftp(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
await requireSftpChannel(client);
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
const encodedPath = encodePath(payload.path, encoding);
await client.put(Buffer.from(payload.content, "utf-8"), encodedPath);
@@ -1029,6 +1241,7 @@ async function writeSftpBinary(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
await requireSftpChannel(client);
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
const encodedPath = encodePath(payload.path, encoding);
await client.put(Buffer.from(payload.content), encodedPath);
@@ -1045,6 +1258,7 @@ async function writeSftpBinaryWithProgress(event, payload) {
if (!client) throw new Error("SFTP session not found");
const { sftpId, path: remotePath, content, transferId } = payload;
await requireSftpChannel(client);
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
const encodedPath = encodePath(remotePath, encoding);
@@ -1279,6 +1493,7 @@ async function deleteSftp(event, payload) {
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
if (encoding === "utf-8") {
await requireSftpChannel(client);
const encodedPath = encodePath(payload.path, encoding);
const stat = await client.stat(encodedPath);
if (stat.isDirectory) {
@@ -1316,8 +1531,7 @@ async function deleteSftp(event, payload) {
return true;
}
const sftp = getSftpChannel(client);
if (!sftp) throw new Error("SFTP channel not ready");
const sftp = await requireSftpChannel(client);
const normalizedPath = await normalizeRemotePathString(client, payload.path);
await removeRemotePathInternal(sftp, normalizedPath, encoding);
return true;
@@ -1330,6 +1544,7 @@ async function renameSftp(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
await requireSftpChannel(client);
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
const encodedOldPath = encodePath(payload.oldPath, encoding);
const encodedNewPath = encodePath(payload.newPath, encoding);
@@ -1344,6 +1559,7 @@ async function statSftp(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
await requireSftpChannel(client);
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
const encodedPath = encodePath(payload.path, encoding);
const stat = await client.stat(encodedPath);
@@ -1363,6 +1579,7 @@ async function chmodSftp(event, payload) {
const client = sftpClients.get(payload.sftpId);
if (!client) throw new Error("SFTP session not found");
await requireSftpChannel(client);
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
const encodedPath = encodePath(payload.path, encoding);
await client.chmod(encodedPath, parseInt(payload.mode, 8));
@@ -1400,6 +1617,7 @@ module.exports = {
init,
registerHandlers,
getSftpClients,
requireSftpChannel,
encodePathForSession,
ensureRemoteDirForSession,
openSftp,

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