Compare commits

...

123 Commits

Author SHA1 Message Date
陈大猫
7355e29b89 Merge pull request #137 from Nightsuki/fix/ssh-jump-host-default-key-auth
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
fix: add default SSH key fallback for jump host connections
2026-01-27 17:07:50 +08:00
bincxz
64686cc237 fix: pass unlocked encrypted keys to jump host auth handler
When auth failure triggers the passphrase flow and user unlocks
encrypted default keys, the retry connection now correctly passes
these unlocked keys to connectThroughChain/connectThroughChainForSftp.

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

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

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

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

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

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

Fixes #134

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Now cancelling a folder transfer actually stops the operation.

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

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

Now we wrap the function to properly adapt the interfaces.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes #80

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

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

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

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

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

Closes #108

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

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

Addresses code review feedback on PR #109.

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

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

Closes #104

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -3,7 +3,17 @@
"allow": [
"Bash(npx tsc:*)",
"Bash(npm run lint:*)",
"Bash(npm run build:*)"
"Bash(npm run build:*)",
"Bash(gh pr view:*)",
"Bash(gh pr list:*)",
"Bash(gh api:*)",
"Bash(ls:*)",
"Bash(gh issue view:*)",
"Bash(npm run dev:*)",
"Bash(git checkout:*)",
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(sftp\\): bundle folder uploads and improve cancel/delete operations\n\n- Bundle folder uploads as single tasks showing aggregate progress\n- Add unique file transfer IDs for proper cancellation tracking\n- Fix cancel button to call cancelExternalUpload for external uploads\n- Improve backend cancel detection using cancelled flag instead of error message\n- Use SSH exec with rm -rf for fast folder deletion on remote servers\n- Add FolderUp icon for folder upload tasks in transfer queue\n- Add i18n key for upload cancelled message\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF\n\\)\")",
"Bash(git push:*)",
"Bash(gh pr create --title \"feat\\(sftp\\): bundle folder uploads and improve cancel/delete operations\" --body \"$\\(cat <<''EOF''\n## Summary\n\n- **Bundle folder uploads as single tasks** - When uploading a folder from computer, show it as one aggregated task with total progress instead of individual files\n- **Fix cancel upload** - Properly cancel external uploads by calling the correct cancel function and using unique file transfer IDs for backend tracking\n- **Fast folder deletion** - Use SSH exec with `rm -rf` command for remote folder deletion instead of slow recursive SFTP rmdir\n- **UI improvements** - Add FolderUp icon for folder upload tasks, add cancelled status toast message\n\n## Changes\n\n### Bundle folder uploads\n- Added `detectRootFolders` helper to group entries by root folder\n- Create single bundled task per folder with aggregate byte count\n- Track progress across all files in the bundle\n\n### Fix cancel upload\n- Each file now uses unique `fileTransferId` for backend cancellation tracking\n- Added `activeFileTransferIdsRef` to track all active uploads\n- Modified `cancelExternalUpload` to cancel all active file uploads\n- Backend now checks `uploadState.cancelled` flag instead of just error message\n- Frontend catch block checks `cancelUploadRef.current` to break out of loop\n\n### Fast folder deletion\n- Added `execSshCommand` helper function in sftpBridge.cjs\n- Uses `client.client` \\(underlying ssh2 Client\\) to execute `rm -rf` command\n- Falls back to SFTP rmdir if SSH exec fails\n\n## Test plan\n- [ ] Drag a folder from computer to SFTP pane - should show as single task with aggregate progress\n- [ ] Click cancel button during folder upload - should stop immediately without errors\n- [ ] Delete a large folder on remote server - should complete quickly using rm -rf\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\nEOF\n\\)\")"
]
}
}

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

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

View File

@@ -37,11 +37,16 @@ jobs:
- name: Install deps
run: npm ci
- name: Set version from tag
if: startsWith(github.ref, 'refs/tags/v')
- name: Set version
shell: bash
run: |
VERSION="${GITHUB_REF_NAME#v}"
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
# Tag release: use version from tag
VERSION="${GITHUB_REF_NAME#v}"
else
# workflow_dispatch: use short commit ID
VERSION="${GITHUB_SHA:0:7}"
fi
echo "Setting version to ${VERSION}"
npm pkg set version="${VERSION}"
@@ -70,15 +75,12 @@ jobs:
name: netcatty-${{ matrix.os }}
path: |
release/*.dmg
release/*.zip
release/*.exe
release/*.msi
release/*.AppImage
release/*.deb
release/*.rpm
release/*.tar.gz
release/*.blockmap
release/latest*.yml
if-no-files-found: ignore
release:
@@ -101,20 +103,23 @@ jobs:
- name: List artifacts
run: ls -la artifacts/
- name: Generate Release Body
run: node .github/scripts/generate-release-note.js
env:
GITHUB_REF_NAME: ${{ github.ref_name }}
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_SHA: ${{ github.sha }}
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
body_path: release_notes.md
files: |
artifacts/*.dmg
artifacts/*.zip
artifacts/*.exe
artifacts/*.msi
artifacts/*.AppImage
artifacts/*.deb
artifacts/*.rpm
artifacts/*.tar.gz
artifacts/*.blockmap
artifacts/latest*.yml
generate_release_notes: true
fail_on_unmatched_files: false
token: ${{ secrets.RELEASE_TOKEN }}

42
.github/workflows/sync.yml vendored Normal file
View File

@@ -0,0 +1,42 @@
name: Sync Upstream
env:
UPSTREAM_URL: "https://github.com/binaricat/Netcatty.git"
UPSTREAM_BRANCH: "main"
TARGET_BRANCH: "main"
on:
schedule:
- cron: '0 0 * * *' # Run daily at midnight
workflow_dispatch: # Allow manual trigger
jobs:
sync:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ env.TARGET_BRANCH }}
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Configure Git
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
- name: Merge Upstream
run: |
echo "Adding upstream remote..."
git remote add upstream ${{ env.UPSTREAM_URL }}
git fetch upstream ${{ env.UPSTREAM_BRANCH }}
echo "Merging upstream/${{ env.UPSTREAM_BRANCH }} into ${{ env.TARGET_BRANCH }}..."
# This will fail if there are conflicts, which is the desired behavior (notify user via failure)
git merge upstream/${{ env.UPSTREAM_BRANCH }} --no-edit
echo "Pushing changes..."
git push origin ${{ env.TARGET_BRANCH }}

122
App.tsx
View File

@@ -8,6 +8,7 @@ import { useUpdateCheck } from './application/state/useUpdateCheck';
import { useVaultState } from './application/state/useVaultState';
import { useWindowControls } from './application/state/useWindowControls';
import { initializeFonts } from './application/state/fontStore';
import { initializeUIFonts } from './application/state/uiFontStore';
import { I18nProvider, useI18n } from './application/i18n/I18nProvider';
import { matchesKeyBinding } from './domain/models';
import { resolveHostAuth } from './domain/sshAuth';
@@ -20,6 +21,7 @@ import { Label } from './components/ui/label';
import { ToastProvider, toast } from './components/ui/toast';
import { VaultView, VaultSection } from './components/VaultView';
import { KeyboardInteractiveModal, KeyboardInteractiveRequest } from './components/KeyboardInteractiveModal';
import { PassphraseModal, PassphraseRequest } from './components/PassphraseModal';
import { cn } from './lib/utils';
import { ConnectionLog, Host, HostProtocol, SerialConfig, TerminalTheme } from './types';
import { LogView as LogViewType } from './application/state/useSessionState';
@@ -28,6 +30,7 @@ import type { TerminalLayer as TerminalLayerComponent } from './components/Termi
// Initialize fonts eagerly at app startup
initializeFonts();
initializeUIFonts();
// Visibility container for VaultView - isolates isActive subscription
const VaultViewContainer: React.FC<{ children: React.ReactNode }> = ({ children }) => {
@@ -153,6 +156,8 @@ function App({ settings }: { settings: SettingsState }) {
const [navigateToSection, setNavigateToSection] = useState<VaultSection | null>(null);
// Keyboard-interactive authentication queue (2FA/MFA) - queue-based to handle multiple concurrent sessions
const [keyboardInteractiveQueue, setKeyboardInteractiveQueue] = useState<KeyboardInteractiveRequest[]>([]);
// Passphrase request queue for encrypted SSH keys
const [passphraseQueue, setPassphraseQueue] = useState<PassphraseRequest[]>([]);
const {
theme,
@@ -167,6 +172,9 @@ function App({ settings }: { settings: SettingsState }) {
hotkeyScheme,
keyBindings,
isHotkeyRecording,
sessionLogsEnabled,
sessionLogsDir,
sessionLogsFormat,
} = settings;
const {
@@ -237,6 +245,7 @@ function App({ settings }: { settings: SettingsState }) {
logViews,
openLogView,
closeLogView,
copySession,
} = useSessionState();
// isMacClient is used for window controls styling
@@ -288,10 +297,16 @@ function App({ settings }: { settings: SettingsState }) {
}
}, [updateState.hasUpdate, updateState.latestRelease, t, openReleasePage, dismissUpdate]);
// Memoize keys for port forwarding to prevent unnecessary re-renders
const portForwardingKeys = useMemo(
() => keys.map((k) => ({ id: k.id, privateKey: k.privateKey })),
[keys]
);
// Auto-start port forwarding rules on app launch
usePortForwardingAutoStart({
hosts,
keys: keys.map((k) => ({ id: k.id, privateKey: k.privateKey })),
keys: portForwardingKeys,
});
// Keyboard-interactive authentication (2FA/MFA) event listener
@@ -337,6 +352,76 @@ function App({ settings }: { settings: SettingsState }) {
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
}, []);
// Passphrase request event listener for encrypted SSH keys
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onPassphraseRequest) return;
const unsubscribe = bridge.onPassphraseRequest((request) => {
console.log('[App] Passphrase request received:', request);
setPassphraseQueue(prev => [...prev, {
requestId: request.requestId,
keyPath: request.keyPath,
keyName: request.keyName,
hostname: request.hostname,
}]);
});
return () => {
unsubscribe?.();
};
}, []);
// Handle passphrase submit
const handlePassphraseSubmit = useCallback((requestId: string, passphrase: string) => {
const bridge = netcattyBridge.get();
if (bridge?.respondPassphrase) {
void bridge.respondPassphrase(requestId, passphrase, false);
}
setPassphraseQueue(prev => prev.filter(r => r.requestId !== requestId));
}, []);
// Handle passphrase cancel
const handlePassphraseCancel = useCallback((requestId: string) => {
const bridge = netcattyBridge.get();
if (bridge?.respondPassphrase) {
// Cancel = stop the entire passphrase flow
void bridge.respondPassphrase(requestId, '', true);
}
setPassphraseQueue(prev => prev.filter(r => r.requestId !== requestId));
}, []);
// Handle passphrase skip (skip this key, continue with others)
const handlePassphraseSkip = useCallback((requestId: string) => {
const bridge = netcattyBridge.get();
if (bridge?.respondPassphraseSkip) {
// Skip = skip this key but continue asking for others
void bridge.respondPassphraseSkip(requestId);
} else if (bridge?.respondPassphrase) {
// Fallback for older API
void bridge.respondPassphrase(requestId, '', false);
}
setPassphraseQueue(prev => prev.filter(r => r.requestId !== requestId));
}, []);
// Handle passphrase timeout (request expired on backend)
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onPassphraseTimeout) return;
const unsubscribe = bridge.onPassphraseTimeout((event) => {
console.log('[App] Passphrase request timed out:', event.requestId);
// Remove from queue - the modal will close automatically
setPassphraseQueue(prev => prev.filter(r => r.requestId !== event.requestId));
// Show a toast notification to inform user
toast.error('Passphrase request timed out. Please try connecting again.');
});
return () => {
unsubscribe?.();
};
}, []);
// Debounce ref for moveFocus to prevent double-triggering when focus switches
const lastMoveFocusTimeRef = useRef<number>(0);
const MOVE_FOCUS_DEBOUNCE_MS = 200;
@@ -614,7 +699,7 @@ function App({ settings }: { settings: SettingsState }) {
(h.group || '').toLowerCase().includes(term)
)
: hosts;
return filtered.slice(0, 8);
return filtered;
}, [hosts, quickSearch, isQuickSwitcherOpen]);
const handleDeleteHost = useCallback((hostId: string) => {
@@ -751,10 +836,32 @@ function App({ settings }: { settings: SettingsState }) {
terminalData: data,
});
if (IS_DEV) console.log('[handleTerminalDataCapture] Updated log with terminalData');
// Auto-save session log if enabled
if (sessionLogsEnabled && sessionLogsDir && data) {
import('./infrastructure/services/netcattyBridge').then(({ netcattyBridge }) => {
const bridge = netcattyBridge.get();
if (bridge?.autoSaveSessionLog) {
bridge.autoSaveSessionLog({
terminalData: data,
hostLabel: matchingLog.hostLabel,
hostname: matchingLog.hostname,
hostId: matchingLog.hostId,
startTime: matchingLog.startTime,
format: sessionLogsFormat,
directory: sessionLogsDir,
}).then(result => {
if (IS_DEV) console.log('[handleTerminalDataCapture] Auto-save result:', result);
}).catch(err => {
console.error('[handleTerminalDataCapture] Auto-save failed:', err);
});
}
});
}
} else {
if (IS_DEV) console.log('[handleTerminalDataCapture] No matching log found!');
}
}, [sessions, connectionLogs, updateConnectionLog]);
}, [sessions, connectionLogs, updateConnectionLog, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat]);
// Check if host has multiple protocols enabled
const hasMultipleProtocols = useCallback((host: Host) => {
@@ -831,6 +938,7 @@ function App({ settings }: { settings: SettingsState }) {
isMacClient={isMacClient}
onCloseSession={closeSession}
onRenameSession={startSessionRename}
onCopySession={copySession}
onRenameWorkspace={startWorkspaceRename}
onCloseWorkspace={closeWorkspace}
onCloseLogView={closeLogView}
@@ -1048,6 +1156,14 @@ function App({ settings }: { settings: SettingsState }) {
{keyboardInteractiveQueue.length - 1} more pending
</div>
)}
{/* Global Passphrase Modal for encrypted SSH keys */}
<PassphraseModal
request={passphraseQueue[0] || null}
onSubmit={handlePassphraseSubmit}
onCancel={handlePassphraseCancel}
onSkip={handlePassphraseSkip}
/>
</div>
);
}

View File

@@ -98,6 +98,8 @@
### 📁 SFTP
- **デュアルペインファイルブラウザ** — ローカル ↔ リモート または リモート ↔ リモート
- **Sudo 特権昇格** — sudo を使用して root 権限のファイルを閲覧および編集
- **ドラッグ&ドロップ** アップロードおよびダウンロード
- **ドラッグ&ドロップ**ファイル転送
- **キュー管理**でバッチ転送
- **進捗追跡**、転送速度表示
@@ -278,11 +280,11 @@ Netcatty は接続したホストの OS アイコンを自動的に検出・表
[GitHub Releases](https://github.com/binaricat/Netcatty/releases/latest) からお使いのプラットフォームに対応した最新版をダウンロードしてください。
| プラットフォーム | アーキテクチャ | ステータス |
|------------------|----------------|------------|
| **macOS** | Apple Silicon (M1/M2/M3) | ✅ サポート |
| **macOS** | Intel | ✅ サポート |
| **Windows** | x64 | ✅ サポート |
| OS | サポート状況 |
| :--- | :--- |
| **macOS** | Universal (x64 / arm64) |
| **Windows** | x64 / arm64 |
| **Linux** | x64 / arm64 |
または [GitHub Releases](https://github.com/binaricat/Netcatty/releases) ですべてのリリースを参照してください。

View File

@@ -98,7 +98,8 @@
### 📁 SFTP
- **Dual-pane file browser** — local ↔ remote or remote ↔ remote
- **Drag & drop** file transfers
- **Sudo Privilege Escalation** — Browse and edit root-owned files with sudo
- **Drag & Drop** uploads and downloads
- **Queue management** for batch transfers
- **Progress tracking** with transfer speed
@@ -278,11 +279,11 @@ Netcatty automatically detects and displays OS icons for connected hosts:
Download the latest release for your platform from [GitHub Releases](https://github.com/binaricat/Netcatty/releases/latest).
| Platform | Architecture | Status |
|----------|--------------|--------|
| **macOS** | Apple Silicon (M1/M2/M3) | ✅ Supported |
| **macOS** | Intel | ✅ Supported |
| **Windows** | x64 | ✅ Supported |
| OS | Support |
| :--- | :--- |
| **macOS** | Universal (x64 / arm64) |
| **Windows** | x64 / arm64 |
| **Linux** | x64 / arm64 |
Or browse all releases at [GitHub Releases](https://github.com/binaricat/Netcatty/releases).

View File

@@ -98,6 +98,8 @@
### 📁 SFTP
- **双窗格文件浏览器** —— 本地 ↔ 远程 或 远程 ↔ 远程
- **Sudo 提权支持** —— 使用 sudo 浏览和编辑 root 权限文件
- **拖放操作** —— 支持上传和下载
- **拖放传输** 文件
- **队列管理** 批量传输
- **进度跟踪** 显示传输速度
@@ -278,11 +280,11 @@ Netcatty 自动检测并显示已连接主机的操作系统图标:
从 [GitHub Releases](https://github.com/binaricat/Netcatty/releases/latest) 下载适合您平台的最新版本。
| 平台 | 架构 | 状态 |
|------|------|------|
| **macOS** | Apple Silicon (M1/M2/M3) | ✅ 支持 |
| **macOS** | Intel | ✅ 支持 |
| **Windows** | x64 | ✅ 支持 |
| 操作系统 | 支持情况 |
| :--- | :--- |
| **macOS** | Universal (x64 / arm64) |
| **Windows** | x64 / arm64 |
| **Linux** | x64 / arm64 |
或在 [GitHub Releases](https://github.com/binaricat/Netcatty/releases) 浏览所有版本。

View File

@@ -77,6 +77,24 @@ const en: Messages = {
'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 > Session Logs
'settings.sessionLogs.title': 'Session Logs',
'settings.sessionLogs.description': 'Configure session log export and auto-save settings.',
'settings.sessionLogs.autoSave': 'Auto-Save',
'settings.sessionLogs.enableAutoSave': 'Enable auto-save',
'settings.sessionLogs.enableAutoSaveDesc': 'Automatically save session logs when terminal sessions end.',
'settings.sessionLogs.directory': 'Save Directory',
'settings.sessionLogs.noDirectory': 'No directory selected',
'settings.sessionLogs.browse': 'Browse',
'settings.sessionLogs.openFolder': 'Open folder',
'settings.sessionLogs.directoryHint': 'Logs will be organized by host in subdirectories.',
'settings.sessionLogs.format': 'Log Format',
'settings.sessionLogs.formatDesc': 'Choose the format for saved log files.',
'settings.sessionLogs.formatTxt': 'Plain Text (.txt)',
'settings.sessionLogs.formatRaw': 'Raw with ANSI (.log)',
'settings.sessionLogs.formatHtml': 'HTML (.html)',
'settings.sessionLogs.hint': 'Session logs capture all terminal output for troubleshooting and auditing purposes.',
// Settings > Application
'settings.application.checkUpdates': 'Check for updates',
'settings.application.reportProblem': 'Report a problem',
@@ -119,6 +137,8 @@ const en: Messages = {
'/* Example: */\n.terminal { background: #1a1a2e !important; }\n:root { --radius: 0.25rem; }',
'settings.appearance.language': 'Language',
'settings.appearance.language.desc': 'Choose the UI language',
'settings.appearance.uiFont': 'Interface Font',
'settings.appearance.uiFont.desc': 'Choose the font for the application interface',
// Settings > Terminal
'settings.terminal.section.theme': 'Terminal Theme',
@@ -201,6 +221,18 @@ const en: Messages = {
'settings.terminal.section.connection': 'Connection',
'settings.terminal.connection.keepaliveInterval': 'Keepalive Interval',
'settings.terminal.connection.keepaliveInterval.desc': 'How often (in seconds) to send SSH-level keepalive packets to server. Set to 0 to disable.',
'settings.terminal.section.serverStats': 'Server Stats (Linux)',
'settings.terminal.serverStats.show': 'Show Server Stats',
'settings.terminal.serverStats.show.desc': 'Display CPU, memory, and disk usage in the terminal statusbar (Linux servers only).',
'settings.terminal.serverStats.refreshInterval': 'Refresh Interval',
'settings.terminal.serverStats.refreshInterval.desc': 'How often to refresh server stats.',
'settings.terminal.serverStats.seconds': 'seconds',
// Settings > Terminal > Rendering
'settings.terminal.section.rendering': 'Rendering',
'settings.terminal.rendering.renderer': 'Renderer',
'settings.terminal.rendering.renderer.desc': 'Choose the terminal rendering technology. Auto will use Canvas on low-memory devices. Changes take effect on new terminal sessions.',
'settings.terminal.rendering.auto': 'Auto',
// Settings > Shortcuts
'settings.shortcuts.section.scheme': 'Hotkey Scheme',
@@ -299,7 +331,14 @@ const en: Messages = {
'vault.hosts.newHost': 'New Host',
'vault.hosts.newGroup': 'New Group',
'vault.hosts.import': 'Import',
'vault.hosts.export': 'Export',
'vault.hosts.export.toast.success': 'Exported {count} hosts to CSV',
'vault.hosts.export.toast.successWithSkipped': 'Exported {count} hosts to CSV ({skipped} unsupported hosts skipped)',
'vault.hosts.export.toast.noHosts': 'No hosts to export',
'vault.hosts.allHosts': 'All hosts',
'vault.hosts.copyCredentials': 'Copy Credentials',
'vault.hosts.copyCredentials.toast.success': 'Credentials copied to clipboard',
'vault.hosts.copyCredentials.toast.noPassword': 'No password saved for this host',
// Vault import
'vault.import.title': 'Add data to your vault',
@@ -431,6 +470,10 @@ const en: Messages = {
'sftp.status.uploading': 'Uploading...',
'sftp.status.ready': 'Ready',
'sftp.goUp': 'Go up',
'sftp.encoding.label': 'Filename Encoding',
'sftp.encoding.auto': 'Auto',
'sftp.encoding.utf8': 'UTF-8',
'sftp.encoding.gb18030': 'GB18030',
'sftp.goHome': 'Go to home',
'sftp.folderName': 'Folder name',
'sftp.folderName.placeholder': 'Enter folder name',
@@ -441,6 +484,7 @@ const en: Messages = {
'sftp.rename.newName': 'New name',
'sftp.rename.placeholder': 'Enter new name',
'sftp.confirm.deleteOne': 'Delete "{name}"?',
'sftp.deleteConfirm.single': 'Delete "{name}"?',
'sftp.deleteConfirm.title': 'Delete {count} item(s)?',
'sftp.deleteConfirm.desc': 'This action cannot be undone. The following will be deleted:',
'sftp.error.loadFailed': 'Failed to load directory',
@@ -553,6 +597,7 @@ const en: Messages = {
// SFTP Folder Upload Progress
'sftp.upload.progress': 'Uploading {current} of {total} files...',
'sftp.upload.uploading': 'Uploading...',
'sftp.upload.currentFile': 'Current: {fileName}',
'sftp.upload.cancelled': 'Upload cancelled',
'sftp.upload.cancel': 'Cancel',
@@ -614,6 +659,8 @@ const en: Messages = {
'hostDetails.sftp.sudo': 'Sudo Mode',
'hostDetails.sftp.sudo.desc': 'Automatically acquire Root privileges using stored password',
'hostDetails.sftp.sudo.passwordWarning': 'Sudo mode requires a password. Configure one above, or ensure the server allows passwordless sudo.',
'hostDetails.sftp.encoding': 'Filename Encoding',
'hostDetails.sftp.encoding.desc': 'Select the encoding used to decode and send SFTP filenames.',
'hostDetails.label.placeholder': 'Label (e.g., Production Server)',
'hostDetails.group.placeholder': 'Parent Group',
'hostDetails.section.credentials': 'Credentials',
@@ -622,6 +669,9 @@ const en: Messages = {
'hostDetails.section.mosh': 'Mosh',
'hostDetails.username.placeholder': 'Username',
'hostDetails.password.placeholder': 'Password',
'hostDetails.password.show': 'Show password',
'hostDetails.password.hide': 'Hide password',
'hostDetails.password.save': 'Save password',
'hostDetails.identity.suggestions': 'Identities',
'hostDetails.identity.missing': 'Identity not found',
'hostDetails.credential.keyCertificate': 'Key, Certificate',
@@ -726,7 +776,7 @@ const en: Messages = {
'logs.empty.title': 'No Connection Logs',
'logs.empty.desc':
'Your connection history will appear here when you connect to hosts or open local terminals.',
'logs.showing': 'Showing {limit} of {total} logs.',
'logs.loadMore': 'Load {count} more logs',
'logs.ongoing': 'ongoing',
'logs.localTerminal': 'Local Terminal',
'logs.action.save': 'Save',
@@ -737,6 +787,7 @@ const en: Messages = {
'logView.customizeAppearance': 'Customize appearance',
'logView.appearance': 'Appearance',
'logView.readOnly': 'Read-only',
'logView.export': 'Export',
// Terminal toolbar / search / context menu / auth
'terminal.toolbar.openSftp': 'Open SFTP',
@@ -754,6 +805,20 @@ const en: Messages = {
'terminal.toolbar.focus': 'Focus',
'terminal.toolbar.focusMode': 'Focus Mode',
'terminal.toolbar.closeSession': 'Close session',
'terminal.serverStats.cpu': 'CPU Usage',
'terminal.serverStats.cpuCores': 'CPU Core Usage',
'terminal.serverStats.memory': 'Memory Usage',
'terminal.serverStats.memoryDetails': 'Memory Details',
'terminal.serverStats.memUsed': 'Used',
'terminal.serverStats.memBuffers': 'Buffers',
'terminal.serverStats.memCached': 'Cache',
'terminal.serverStats.memFree': 'Free',
'terminal.serverStats.topProcesses': 'Top Processes by Memory',
'terminal.serverStats.disk': 'Disk Usage (Root)',
'terminal.serverStats.diskDetails': 'Mounted Disks',
'terminal.serverStats.network': 'Network Speed',
'terminal.serverStats.networkDetails': 'Network Interfaces',
'terminal.serverStats.noData': 'No data available',
'terminal.search.placeholder': 'Search...',
'terminal.search.noResults': 'No results',
'terminal.search.prevMatch': 'Previous match (Shift+Enter)',
@@ -1045,6 +1110,7 @@ const en: Messages = {
'tabs.closeLogViewAria': 'Close log view',
'tabs.logPrefix': 'Log:',
'tabs.logLocal': 'Local',
'tabs.copyTab': 'Copy Tab',
'keychain.edit.labelRequired': 'Label *',
'keychain.edit.keyLabelPlaceholder': 'Key label',
'keychain.edit.privateKeyRequired': 'Private key *',
@@ -1150,6 +1216,16 @@ const en: Messages = {
'keyboard.interactive.fillSaved': 'Fill with saved password',
'keyboard.interactive.useSaved': 'Use saved',
'keyboard.interactive.useSavedPassword': 'Use saved password',
// Passphrase Modal for encrypted SSH keys
'passphrase.title': 'SSH Key Passphrase',
'passphrase.desc': 'Enter the passphrase for {keyName}',
'passphrase.descWithHost': 'Enter the passphrase for {keyName} to connect to {hostname}',
'passphrase.label': 'Passphrase',
'passphrase.keyPath': 'Key',
'passphrase.unlock': 'Unlock',
'passphrase.unlocking': 'Unlocking...',
'passphrase.skip': 'Skip',
};
export default en;

View File

@@ -65,6 +65,24 @@ const zhCN: Messages = {
'settings.system.clearResult': '已删除 {deleted} 个文件,{failed} 个失败。',
'settings.system.tempDirectoryHint': '临时文件在使用外部应用打开远程文件时创建。SFTP 会话关闭时会自动清理。',
// Settings > Session Logs
'settings.sessionLogs.title': '会话日志',
'settings.sessionLogs.description': '配置会话日志导出和自动保存设置。',
'settings.sessionLogs.autoSave': '自动保存',
'settings.sessionLogs.enableAutoSave': '启用自动保存',
'settings.sessionLogs.enableAutoSaveDesc': '在终端会话结束时自动保存会话日志。',
'settings.sessionLogs.directory': '保存目录',
'settings.sessionLogs.noDirectory': '未选择目录',
'settings.sessionLogs.browse': '浏览',
'settings.sessionLogs.openFolder': '打开文件夹',
'settings.sessionLogs.directoryHint': '日志将按主机名组织在子目录中。',
'settings.sessionLogs.format': '日志格式',
'settings.sessionLogs.formatDesc': '选择保存日志文件的格式。',
'settings.sessionLogs.formatTxt': '纯文本 (.txt)',
'settings.sessionLogs.formatRaw': '原始格式 (.log)',
'settings.sessionLogs.formatHtml': 'HTML (.html)',
'settings.sessionLogs.hint': '会话日志用于记录终端输出,便于故障排查和审计。',
// Settings > Application
'settings.application.checkUpdates': '检查更新',
'settings.application.reportProblem': '反馈问题',
@@ -106,6 +124,8 @@ const zhCN: Messages = {
'/* 示例:*/\n.terminal { background: #1a1a2e !important; }\n:root { --radius: 0.25rem; }',
'settings.appearance.language': '语言',
'settings.appearance.language.desc': '选择界面语言',
'settings.appearance.uiFont': '界面字体',
'settings.appearance.uiFont.desc': '选择软件界面使用的字体',
// Context menus / common actions
'action.newHost': '新建主机',
@@ -182,7 +202,14 @@ const zhCN: Messages = {
'vault.hosts.newHost': '新建主机',
'vault.hosts.newGroup': '新建分组',
'vault.hosts.import': '导入',
'vault.hosts.export': '导出',
'vault.hosts.export.toast.success': '已导出 {count} 个主机到 CSV',
'vault.hosts.export.toast.successWithSkipped': '已导出 {count} 个主机到 CSV跳过 {skipped} 个不支持的主机)',
'vault.hosts.export.toast.noHosts': '没有主机可导出',
'vault.hosts.allHosts': '全部主机',
'vault.hosts.copyCredentials': '复制账密信息',
'vault.hosts.copyCredentials.toast.success': '账密信息已复制到剪贴板',
'vault.hosts.copyCredentials.toast.noPassword': '该主机未保存密码',
// Vault import
'vault.import.title': '添加数据到你的 Vault',
@@ -296,6 +323,10 @@ const zhCN: Messages = {
'sftp.status.uploading': '上传中...',
'sftp.status.ready': '就绪',
'sftp.goUp': '上一级',
'sftp.encoding.label': '文件名编码',
'sftp.encoding.auto': '自动',
'sftp.encoding.utf8': 'UTF-8',
'sftp.encoding.gb18030': 'GB18030',
'sftp.goHome': '返回主目录',
'sftp.folderName': '文件夹名称',
'sftp.folderName.placeholder': '输入文件夹名称',
@@ -306,6 +337,7 @@ const zhCN: Messages = {
'sftp.rename.newName': '新名称',
'sftp.rename.placeholder': '输入新名称',
'sftp.confirm.deleteOne': '删除 "{name}"',
'sftp.deleteConfirm.single': '删除 "{name}"',
'sftp.deleteConfirm.title': '删除 {count} 个项目?',
'sftp.deleteConfirm.desc': '此操作不可撤销,将删除以下内容:',
'sftp.error.loadFailed': '加载目录失败',
@@ -374,6 +406,8 @@ const zhCN: Messages = {
'hostDetails.sftp.sudo': 'Sudo 提权模式',
'hostDetails.sftp.sudo.desc': '使用保存的密码自动获取 Root 权限',
'hostDetails.sftp.sudo.passwordWarning': 'Sudo 模式需要密码。请在上方配置密码,或确保服务器允许免密 sudo。',
'hostDetails.sftp.encoding': '文件名编码',
'hostDetails.sftp.encoding.desc': '选择用于解码和发送 SFTP 文件名的编码。',
'hostDetails.label.placeholder': '名称例如Production Server',
'hostDetails.group.placeholder': '父级 Group',
'hostDetails.section.credentials': '凭据',
@@ -382,6 +416,9 @@ const zhCN: Messages = {
'hostDetails.section.mosh': 'Mosh',
'hostDetails.username.placeholder': '用户名',
'hostDetails.password.placeholder': '密码',
'hostDetails.password.show': '显示密码',
'hostDetails.password.hide': '隐藏密码',
'hostDetails.password.save': '保存密码',
'hostDetails.identity.suggestions': '身份',
'hostDetails.identity.missing': '身份不存在',
'hostDetails.credential.keyCertificate': '密钥 / 证书',
@@ -457,7 +494,7 @@ const zhCN: Messages = {
'logs.table.saved': '收藏',
'logs.empty.title': '暂无连接日志',
'logs.empty.desc': '当你连接主机或打开本地终端后,这里会显示连接历史。',
'logs.showing': '显示 {limit}/{total} 条日志。',
'logs.loadMore': '加载更多 ({count} 条)',
'logs.ongoing': '进行中',
'logs.localTerminal': '本地终端',
'logs.action.save': '收藏',
@@ -468,6 +505,7 @@ const zhCN: Messages = {
'logView.customizeAppearance': '自定义外观',
'logView.appearance': '外观',
'logView.readOnly': '只读',
'logView.export': '导出',
// Terminal toolbar / search / context menu / auth
'terminal.toolbar.openSftp': '打开 SFTP',
@@ -485,6 +523,20 @@ const zhCN: Messages = {
'terminal.toolbar.focus': '聚焦',
'terminal.toolbar.focusMode': '聚焦模式',
'terminal.toolbar.closeSession': '关闭会话',
'terminal.serverStats.cpu': 'CPU 使用率',
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
'terminal.serverStats.memory': '内存使用',
'terminal.serverStats.memoryDetails': '内存详情',
'terminal.serverStats.memUsed': '已用',
'terminal.serverStats.memBuffers': '缓冲区',
'terminal.serverStats.memCached': '缓存',
'terminal.serverStats.memFree': '空闲',
'terminal.serverStats.topProcesses': '内存占用前十进程',
'terminal.serverStats.disk': '磁盘使用(根分区)',
'terminal.serverStats.diskDetails': '已挂载磁盘',
'terminal.serverStats.network': '网络速度',
'terminal.serverStats.networkDetails': '网络接口',
'terminal.serverStats.noData': '暂无数据',
'terminal.search.placeholder': '搜索…',
'terminal.search.noResults': '无结果',
'terminal.search.prevMatch': '上一个匹配 (Shift+Enter)',
@@ -799,6 +851,7 @@ const zhCN: Messages = {
// SFTP Folder Upload Progress
'sftp.upload.progress': '正在上传 {current}/{total} 个文件...',
'sftp.upload.uploading': '正在上传...',
'sftp.upload.currentFile': '当前: {fileName}',
'sftp.upload.cancelled': '上传已取消',
'sftp.upload.cancel': '取消',
@@ -893,6 +946,18 @@ const zhCN: Messages = {
'settings.terminal.section.connection': '连接',
'settings.terminal.connection.keepaliveInterval': '会话保持间隔',
'settings.terminal.connection.keepaliveInterval.desc': '向服务器发送 SSH 级别保活数据包的频率(秒)。设为 0 表示禁用。',
'settings.terminal.section.serverStats': '服务器状态Linux',
'settings.terminal.serverStats.show': '显示服务器状态',
'settings.terminal.serverStats.show.desc': '在终端状态栏显示 CPU、内存和磁盘使用情况仅限 Linux 服务器)。',
'settings.terminal.serverStats.refreshInterval': '刷新间隔',
'settings.terminal.serverStats.refreshInterval.desc': '服务器状态刷新的频率。',
'settings.terminal.serverStats.seconds': '秒',
// Settings > Terminal > Rendering
'settings.terminal.section.rendering': '渲染',
'settings.terminal.rendering.renderer': '渲染器',
'settings.terminal.rendering.renderer.desc': '选择终端渲染技术。自动模式会在低内存设备上使用 Canvas。更改将在新终端会话中生效。',
'settings.terminal.rendering.auto': '自动',
// Settings > Shortcuts
'settings.shortcuts.section.scheme': '快捷键方案',
@@ -1034,6 +1099,7 @@ const zhCN: Messages = {
'tabs.closeLogViewAria': '关闭日志视图',
'tabs.logPrefix': '日志:',
'tabs.logLocal': '本地',
'tabs.copyTab': '复制标签页',
'keychain.edit.labelRequired': 'Label *',
'keychain.edit.keyLabelPlaceholder': '密钥 Label',
'keychain.edit.privateKeyRequired': '私钥 *',
@@ -1139,6 +1205,16 @@ const zhCN: Messages = {
'keyboard.interactive.fillSaved': '填入已保存的密码',
'keyboard.interactive.useSaved': '使用已保存',
'keyboard.interactive.useSavedPassword': '使用已保存的密码',
// Passphrase Modal for encrypted SSH keys
'passphrase.title': 'SSH 密钥密码',
'passphrase.desc': '请输入 {keyName} 的密码',
'passphrase.descWithHost': '请输入 {keyName} 的密码以连接到 {hostname}',
'passphrase.label': '密码',
'passphrase.keyPath': '密钥',
'passphrase.unlock': '解锁',
'passphrase.unlocking': '解锁中...',
'passphrase.skip': '跳过',
};
export default zhCN;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Host, PortForwardingRule } from "../../domain/models";
import {
STORAGE_KEY_PF_PREFER_FORM_MODE,
@@ -76,6 +76,9 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
return localStorageAdapter.readBoolean(STORAGE_KEY_PF_PREFER_FORM_MODE) ?? false;
});
// Track if sync has been executed for this component instance
const syncExecutedRef = useRef(false);
const setPreferFormMode = useCallback((prefer: boolean) => {
setPreferFormModeState(prefer);
localStorageAdapter.writeBoolean(STORAGE_KEY_PF_PREFER_FORM_MODE, prefer);
@@ -84,9 +87,12 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
// Load rules from storage on mount and sync with backend
useEffect(() => {
const loadAndSync = async () => {
// First, sync with backend to get any active tunnels
await syncWithBackend();
// Only sync once per component instance (prevents duplicate calls from React StrictMode)
if (!syncExecutedRef.current) {
syncExecutedRef.current = true;
await syncWithBackend();
}
const saved = localStorageAdapter.read<PortForwardingRule[]>(
STORAGE_KEY_PORT_FORWARDING,
);

View File

@@ -547,6 +547,31 @@ export const useSessionState = () => {
});
}, [setActiveTabId]);
// Copy a session - creates a new session with the same host connection
const copySession = useCallback((sessionId: string) => {
setSessions(prevSessions => {
const session = prevSessions.find(s => s.id === sessionId);
if (!session) return prevSessions;
// Create a new session with the same connection info
const newSession: TerminalSession = {
id: crypto.randomUUID(),
hostId: session.hostId,
hostLabel: session.hostLabel,
hostname: session.hostname,
username: session.username,
status: 'connecting',
protocol: session.protocol,
port: session.port,
moshEnabled: session.moshEnabled,
serialConfig: session.serialConfig,
};
setActiveTabId(newSession.id);
return [...prevSessions, newSession];
});
}, [setActiveTabId]);
// Toggle broadcast mode for a workspace
const toggleBroadcast = useCallback((workspaceId: string) => {
setBroadcastWorkspaceIds(prev => {
@@ -662,5 +687,7 @@ export const useSessionState = () => {
logViews,
openLogView,
closeLogView,
// Copy session
copySession,
};
};

View File

@@ -1,5 +1,5 @@
import { useCallback,useEffect,useLayoutEffect,useMemo,useState } from 'react';
import { SyncConfig, TerminalSettings, DEFAULT_TERMINAL_SETTINGS, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage } from '../../domain/models';
import { SyncConfig, TerminalSettings, DEFAULT_TERMINAL_SETTINGS, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat } from '../../domain/models';
import {
STORAGE_KEY_COLOR,
STORAGE_KEY_SYNC,
@@ -16,14 +16,20 @@ STORAGE_KEY_UI_LANGUAGE,
STORAGE_KEY_ACCENT_MODE,
STORAGE_KEY_UI_THEME_LIGHT,
STORAGE_KEY_UI_THEME_DARK,
STORAGE_KEY_UI_FONT_FAMILY,
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
STORAGE_KEY_SFTP_AUTO_SYNC,
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
STORAGE_KEY_SESSION_LOGS_ENABLED,
STORAGE_KEY_SESSION_LOGS_DIR,
STORAGE_KEY_SESSION_LOGS_FORMAT,
} from '../../infrastructure/config/storageKeys';
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
import { DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
import { DARK_UI_THEMES, LIGHT_UI_THEMES, UiThemeTokens, getUiThemeById } from '../../infrastructure/config/uiThemes';
import { UI_FONTS, DEFAULT_UI_FONT_ID } from '../../infrastructure/config/uiFonts';
import { uiFontStore, useUIFontsLoaded } from './uiFontStore';
import { useAvailableFonts } from './fontStore';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
@@ -44,6 +50,10 @@ const DEFAULT_SFTP_DOUBLE_CLICK_BEHAVIOR: 'open' | 'transfer' = 'open';
const DEFAULT_SFTP_AUTO_SYNC = false;
const DEFAULT_SFTP_SHOW_HIDDEN_FILES = false;
// Session Logs defaults
const DEFAULT_SESSION_LOGS_ENABLED = false;
const DEFAULT_SESSION_LOGS_FORMAT: SessionLogFormat = 'txt';
const readStoredString = (key: string): string | null => {
const raw = localStorageAdapter.readString(key);
if (!raw) return null;
@@ -70,6 +80,14 @@ const isValidUiThemeId = (theme: 'light' | 'dark', value: string): boolean => {
return list.some((preset) => preset.id === value);
};
const isValidUiFontId = (value: string): boolean => {
// Local fonts are always considered valid
if (value.startsWith('local-')) return true;
// Check bundled fonts first, then check dynamically loaded fonts
return UI_FONTS.some((font) => font.id === value) ||
uiFontStore.getAvailableFonts().some((font) => font.id === value);
};
const applyThemeTokens = (
theme: 'light' | 'dark',
tokens: UiThemeTokens,
@@ -112,6 +130,7 @@ const applyThemeTokens = (
export const useSettingsState = () => {
const availableFonts = useAvailableFonts();
const uiFontsLoaded = useUIFontsLoaded();
const [theme, setTheme] = useState<'dark' | 'light'>(() => {
const stored = readStoredString(STORAGE_KEY_THEME);
return stored && isValidTheme(stored) ? stored : DEFAULT_THEME;
@@ -134,6 +153,10 @@ export const useSettingsState = () => {
const legacyColor = readStoredString(STORAGE_KEY_COLOR);
return legacyColor && isValidHslToken(legacyColor) ? 'custom' : DEFAULT_ACCENT_MODE;
});
const [uiFontFamilyId, setUiFontFamilyId] = useState<string>(() => {
const stored = readStoredString(STORAGE_KEY_UI_FONT_FAMILY);
return stored && isValidUiFontId(stored) ? stored : DEFAULT_UI_FONT_ID;
});
const [syncConfig, setSyncConfig] = useState<SyncConfig | null>(() => localStorageAdapter.read<SyncConfig>(STORAGE_KEY_SYNC));
const [terminalThemeId, setTerminalThemeId] = useState<string>(() => localStorageAdapter.readString(STORAGE_KEY_TERM_THEME) || DEFAULT_TERMINAL_THEME);
const [terminalFontFamilyId, setTerminalFontFamilyId] = useState<string>(() => localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_FAMILY) || DEFAULT_FONT_FAMILY);
@@ -174,6 +197,20 @@ export const useSettingsState = () => {
return stored === 'true' ? true : DEFAULT_SFTP_SHOW_HIDDEN_FILES;
});
// Session Logs Settings
const [sessionLogsEnabled, setSessionLogsEnabled] = useState<boolean>(() => {
const stored = readStoredString(STORAGE_KEY_SESSION_LOGS_ENABLED);
return stored === 'true' ? true : DEFAULT_SESSION_LOGS_ENABLED;
});
const [sessionLogsDir, setSessionLogsDir] = useState<string>(() => {
return readStoredString(STORAGE_KEY_SESSION_LOGS_DIR) || '';
});
const [sessionLogsFormat, setSessionLogsFormat] = useState<SessionLogFormat>(() => {
const stored = readStoredString(STORAGE_KEY_SESSION_LOGS_FORMAT);
if (stored === 'txt' || stored === 'raw' || stored === 'html') return stored;
return DEFAULT_SESSION_LOGS_FORMAT;
});
// Helper to notify other windows about settings changes via IPC
const notifySettingsChanged = useCallback((key: string, value: unknown) => {
try {
@@ -233,6 +270,15 @@ export const useSettingsState = () => {
notifySettingsChanged(STORAGE_KEY_UI_LANGUAGE, uiLanguage);
}, [uiLanguage, notifySettingsChanged]);
// Apply and persist UI font family
// Re-run when fonts finish loading to get correct family for local fonts
useLayoutEffect(() => {
const font = uiFontStore.getFontById(uiFontFamilyId);
document.documentElement.style.setProperty('--font-sans', font.family);
localStorageAdapter.writeString(STORAGE_KEY_UI_FONT_FAMILY, uiFontFamilyId);
notifySettingsChanged(STORAGE_KEY_UI_FONT_FAMILY, uiFontFamilyId);
}, [uiFontFamilyId, uiFontsLoaded, notifySettingsChanged]);
// Listen for settings changes from other windows via IPC
useEffect(() => {
const bridge = netcattyBridge.get();
@@ -257,6 +303,11 @@ export const useSettingsState = () => {
if (key === STORAGE_KEY_CUSTOM_CSS && typeof value === 'string') {
syncCustomCssFromStorage();
}
if (key === STORAGE_KEY_UI_FONT_FAMILY && typeof value === 'string') {
if (isValidUiFontId(value)) {
setUiFontFamilyId(value);
}
}
if (key === STORAGE_KEY_TERM_THEME && typeof value === 'string') {
setTerminalThemeId(value);
}
@@ -343,6 +394,11 @@ export const useSettingsState = () => {
setCustomCSS(e.newValue);
}
}
if (e.key === STORAGE_KEY_UI_FONT_FAMILY && e.newValue) {
if (isValidUiFontId(e.newValue) && e.newValue !== uiFontFamilyId) {
setUiFontFamilyId(e.newValue);
}
}
if (e.key === STORAGE_KEY_HOTKEY_SCHEME && e.newValue) {
const newScheme = e.newValue as HotkeyScheme;
if (newScheme !== hotkeyScheme) {
@@ -415,7 +471,7 @@ export const useSettingsState = () => {
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles]);
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
@@ -484,6 +540,22 @@ export const useSettingsState = () => {
notifySettingsChanged(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, sftpShowHiddenFiles);
}, [sftpShowHiddenFiles, notifySettingsChanged]);
// Persist Session Logs settings
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_ENABLED, sessionLogsEnabled ? 'true' : 'false');
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_ENABLED, sessionLogsEnabled);
}, [sessionLogsEnabled, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_DIR, sessionLogsDir);
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_DIR, sessionLogsDir);
}, [sessionLogsDir, notifySettingsChanged]);
useEffect(() => {
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_FORMAT, sessionLogsFormat);
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_FORMAT, sessionLogsFormat);
}, [sessionLogsFormat, notifySettingsChanged]);
// Get merged key bindings (defaults + custom overrides)
const keyBindings = useMemo((): KeyBinding[] => {
return DEFAULT_KEY_BINDINGS.map(binding => {
@@ -564,6 +636,8 @@ export const useSettingsState = () => {
setAccentMode,
customAccent,
setCustomAccent,
uiFontFamilyId,
setUiFontFamilyId,
syncConfig,
updateSyncConfig,
uiLanguage,
@@ -597,5 +671,12 @@ export const useSettingsState = () => {
sftpShowHiddenFiles,
setSftpShowHiddenFiles,
availableFonts,
// Session Logs
sessionLogsEnabled,
setSessionLogsEnabled,
sessionLogsDir,
setSessionLogsDir,
sessionLogsFormat,
setSessionLogsFormat,
};
};

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -2,6 +2,8 @@ import {
AlertTriangle,
Check,
ChevronDown,
Eye,
EyeOff,
FolderLock,
FolderPlus,
Forward,
@@ -43,6 +45,7 @@ import { Combobox, ComboboxOption, MultiCombobox } from "./ui/combobox";
import { Input } from "./ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { ScrollArea } from "./ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
// Import host-details sub-panels
import {
@@ -122,6 +125,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
// Identity suggestion dropdown state (popover anchored to username input)
const [identitySuggestionsOpen, setIdentitySuggestionsOpen] = useState(false);
// Password visibility state
const [showPassword, setShowPassword] = useState(false);
// New group creation state
const [newGroupName, setNewGroupName] = useState("");
const [newGroupParent, setNewGroupParent] = useState("");
@@ -163,6 +169,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
}
setForm(updatedData);
setGroupInputValue(initialData.group || "");
// Reset password visibility when host changes for privacy
setShowPassword(false);
}
}, [initialData]);
@@ -243,12 +251,17 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
};
const handleSubmit = () => {
if (!form.hostname || !form.label) return;
if (!form.hostname) return;
// If label is empty, use hostname as label
const finalLabel = form.label?.trim() || form.hostname;
const cleaned: Host = {
...form,
label: finalLabel,
group: groupInputValue.trim() || form.group,
tags: form.tags || [],
port: form.port || 22,
// Clear password if savePassword is explicitly set to false
password: form.savePassword === false ? undefined : form.password,
};
onSave(cleaned);
};
@@ -498,7 +511,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
size="icon"
className="h-8 w-8"
onClick={handleSubmit}
disabled={!form.hostname || !form.label}
disabled={!form.hostname}
aria-label={t("hostDetails.saveAria")}
>
<Check size={16} />
@@ -797,13 +810,36 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
)}
{!selectedIdentity && !form.identityId && (
<Input
placeholder={t("hostDetails.password.placeholder")}
type="password"
value={form.password || ""}
onChange={(e) => update("password", e.target.value)}
className="h-10"
/>
<div className="relative">
<Input
placeholder={t("hostDetails.password.placeholder")}
type={showPassword ? "text" : "password"}
value={form.password || ""}
onChange={(e) => update("password", e.target.value)}
className="h-10 pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-muted-foreground hover:text-foreground transition-colors"
title={showPassword ? t("hostDetails.password.hide") : t("hostDetails.password.show")}
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
)}
{/* Save Password toggle - shown when password is entered */}
{!selectedIdentity && !form.identityId && form.password && (
<div className="flex items-center justify-between py-1">
<span className="text-xs text-muted-foreground">
{t("hostDetails.password.save")}
</span>
<Switch
checked={form.savePassword ?? true}
onCheckedChange={(val) => update("savePassword" as keyof Host, val)}
/>
</div>
)}
{/* Selected credential display */}
@@ -987,6 +1023,27 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
{t("hostDetails.sftp.sudo.passwordWarning")}
</p>
)}
<div className="space-y-1">
<div className="text-sm font-medium">
{t("hostDetails.sftp.encoding")}
</div>
<div className="text-xs text-muted-foreground">
{t("hostDetails.sftp.encoding.desc")}
</div>
<Select
value={form.sftpEncoding || "auto"}
onValueChange={(val) => update("sftpEncoding", val as Host["sftpEncoding"])}
>
<SelectTrigger className="h-8">
<SelectValue placeholder={t("sftp.encoding.label")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{t("sftp.encoding.auto")}</SelectItem>
<SelectItem value="utf-8">{t("sftp.encoding.utf8")}</SelectItem>
<SelectItem value="gb18030">{t("sftp.encoding.gb18030")}</SelectItem>
</SelectContent>
</Select>
</div>
</Card>
<Card className="p-3 space-y-3 bg-card border-border/80">
@@ -1423,7 +1480,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
<Button
className="w-full h-10"
onClick={handleSubmit}
disabled={!form.hostname || !form.label}
disabled={!form.hostname}
>
{t("common.save")}
</Button>

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ import {
Terminal,
TerminalSquare,
} from "lucide-react";
import React, { memo, useEffect, useRef, useState } from "react";
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { Host, TerminalSession, Workspace } from "../types";
import { KeyBinding } from "../domain/models";
@@ -21,6 +21,42 @@ import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { ScrollArea } from "./ui/scroll-area";
// Compute once at module level
const IS_MAC = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform);
// Memoized host item component to prevent unnecessary re-renders
const HostItem = memo(({
host,
isSelected,
onSelect,
onMouseEnter,
}: {
host: Host;
isSelected: boolean;
onSelect: (host: Host) => void;
onMouseEnter: () => void;
}) => (
<div
className={`flex items-center justify-between px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
}`}
onClick={() => onSelect(host)}
onMouseEnter={onMouseEnter}
>
<div className="flex items-center gap-3 min-w-0">
<DistroAvatar
host={host}
fallback={host.label.slice(0, 2).toUpperCase()}
size="sm"
/>
<span className="text-sm font-medium truncate">{host.label}</span>
</div>
<div className="text-[11px] text-muted-foreground">
{host.group ? `Personal / ${host.group}` : "Personal"}
</div>
</div>
));
HostItem.displayName = "HostItem";
interface QuickSwitcherProps {
isOpen: boolean;
query: string;
@@ -52,12 +88,11 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
}) => {
const { t } = useI18n();
// Get hotkey display strings
const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform);
const getHotkeyLabel = (actionId: string) => {
const getHotkeyLabel = useCallback((actionId: string) => {
const binding = keyBindings?.find(k => k.id === actionId);
if (!binding) return '';
return isMac ? binding.mac : binding.pc;
};
return IS_MAC ? binding.mac : binding.pc;
}, [keyBindings]);
const quickSwitchKey = getHotkeyLabel('quick-switch');
const [isFocused, setIsFocused] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
@@ -93,15 +128,16 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isOpen, onClose]);
if (!isOpen) return null;
// Memoize orphan sessions
const orphanSessions = useMemo(
() => sessions.filter((s) => !s.workspaceId),
[sessions]
);
const showCategorized = isFocused || query.trim().length > 0;
// Get orphan sessions (sessions without workspace)
const orphanSessions = sessions.filter((s) => !s.workspaceId);
// Build categorized items for navigation
const buildFlatItems = () => {
// Memoize flat items list and index map
const { flatItems, itemIndexMap } = useMemo(() => {
const items: QuickSwitcherItem[] = [];
if (showCategorized) {
@@ -127,10 +163,21 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
);
}
return items;
};
// Build index map for O(1) lookup
const indexMap = new Map<string, number>();
items.forEach((item, idx) => {
indexMap.set(`${item.type}:${item.id}`, idx);
});
const flatItems = buildFlatItems();
return { flatItems: items, itemIndexMap: indexMap };
}, [showCategorized, results, orphanSessions, workspaces]);
// O(1) index lookup
const getItemIndex = useCallback((type: string, id: string) => {
return itemIndexMap.get(`${type}:${id}`) ?? -1;
}, [itemIndexMap]);
if (!isOpen) return null;
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") {
@@ -165,40 +212,6 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
}
};
// Helper to get item index in flat list
const getItemIndex = (type: string, id: string) => {
return flatItems.findIndex((item) => item.type === type && item.id === id);
};
const renderHostItem = (host: Host) => {
const idx = getItemIndex("host", host.id);
const isSelected = idx === selectedIndex;
return (
<div
key={host.id}
className={`flex items-center justify-between px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
}`}
onClick={() => {
onSelect(host);
}}
onMouseEnter={() => setSelectedIndex(idx)}
>
<div className="flex items-center gap-3 min-w-0">
<DistroAvatar
host={host}
fallback={host.label.slice(0, 2).toUpperCase()}
size="sm"
/>
<span className="text-sm font-medium truncate">{host.label}</span>
</div>
<div className="text-[11px] text-muted-foreground">
{host.group ? `Personal / ${host.group}` : "Personal"}
</div>
</div>
);
};
return (
<div
className="fixed inset-x-0 top-12 z-50 flex justify-center pt-2"
@@ -260,7 +273,15 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
</div>
<div>
{results.length > 0 ? (
results.map(renderHostItem)
results.map((host) => (
<HostItem
key={host.id}
host={host}
isSelected={getItemIndex("host", host.id) === selectedIndex}
onSelect={onSelect}
onMouseEnter={() => setSelectedIndex(getItemIndex("host", host.id))}
/>
))
) : (
<div className="px-4 py-6 text-sm text-muted-foreground text-center">
No recent connections
@@ -289,7 +310,15 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
Hosts
</span>
</div>
{results.map(renderHostItem)}
{results.map((host) => (
<HostItem
key={host.id}
host={host}
isSelected={getItemIndex("host", host.id) === selectedIndex}
onSelect={onSelect}
onMouseEnter={() => setSelectedIndex(getItemIndex("host", host.id))}
/>
))}
</div>
)}

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -565,12 +565,12 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
{!snippets.length && displayedPackages.length === 0 && (
<div className="flex-1 flex items-center justify-center px-4">
<div className="max-w-md w-full text-center space-y-3 py-12 rounded-2xl bg-secondary/60 border border-border/60 shadow-lg">
<div className="mx-auto h-12 w-12 rounded-xl bg-muted text-muted-foreground flex items-center justify-center">
<FileCode size={22} />
<div className="flex flex-col items-center justify-center text-muted-foreground">
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
<FileCode size={32} className="opacity-60" />
</div>
<div className="text-sm font-semibold text-foreground">{t('snippets.empty.title')}</div>
<div className="text-xs text-muted-foreground px-8">{t('snippets.empty.desc')}</div>
<h3 className="text-lg font-semibold text-foreground mb-2">{t('snippets.empty.title')}</h3>
<p className="text-sm text-center max-w-sm">{t('snippets.empty.desc')}</p>
</div>
</div>
)}

View File

@@ -3,7 +3,7 @@ import { FitAddon } from "@xterm/addon-fit";
import { SerializeAddon } from "@xterm/addon-serialize";
import { SearchAddon } from "@xterm/addon-search";
import "@xterm/xterm/css/xterm.css";
import { Maximize2, Radio } from "lucide-react";
import { Cpu, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine } from "lucide-react";
import React, { memo, useEffect, useMemo, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { useI18n } from "../application/i18n/I18nProvider";
@@ -26,6 +26,7 @@ import { useTerminalBackend } from "../application/state/useTerminalBackend";
import KnownHostConfirmDialog, { HostKeyInfo } from "./KnownHostConfirmDialog";
import SFTPModal from "./SFTPModal";
import { Button } from "./ui/button";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "./ui/hover-card";
import { toast } from "./ui/toast";
import { useAvailableFonts } from "../application/state/fontStore";
import { TERMINAL_THEMES } from "../infrastructure/config/terminalThemes";
@@ -34,13 +35,13 @@ import { TerminalConnectionDialog } from "./terminal/TerminalConnectionDialog";
import { TerminalToolbar } from "./terminal/TerminalToolbar";
import { TerminalContextMenu } from "./terminal/TerminalContextMenu";
import { TerminalSearchBar } from "./terminal/TerminalSearchBar";
import { createHighlightProcessor } from "./terminal/keywordHighlight";
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
import { createXTermRuntime, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
import { XTERM_PERFORMANCE_CONFIG } from "../infrastructure/config/xtermPerformance";
import { useTerminalSearch } from "./terminal/hooks/useTerminalSearch";
import { useTerminalContextActions } from "./terminal/hooks/useTerminalContextActions";
import { useTerminalAuthState } from "./terminal/hooks/useTerminalAuthState";
import { useServerStats } from "./terminal/hooks/useServerStats";
interface TerminalProps {
host: Host;
@@ -88,6 +89,19 @@ interface TerminalProps {
onBroadcastInput?: (data: string, sourceSessionId: string) => void;
}
// Helper function to format network speed (bytes/sec) to human-readable format
function formatNetSpeed(bytesPerSec: number): string {
if (bytesPerSec < 1024) {
return `${bytesPerSec}B/s`;
} else if (bytesPerSec < 1024 * 1024) {
return `${(bytesPerSec / 1024).toFixed(1)}K/s`;
} else if (bytesPerSec < 1024 * 1024 * 1024) {
return `${(bytesPerSec / (1024 * 1024)).toFixed(1)}M/s`;
} else {
return `${(bytesPerSec / (1024 * 1024 * 1024)).toFixed(1)}G/s`;
}
}
const TerminalComponent: React.FC<TerminalProps> = ({
host,
keys,
@@ -149,12 +163,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const terminalSettingsRef = useRef(terminalSettings);
terminalSettingsRef.current = terminalSettings;
const highlightProcessorRef = useRef<(text: string) => string>((t) => t);
useEffect(() => {
highlightProcessorRef.current = createHighlightProcessor(
terminalSettings?.keywordHighlightRules ?? [],
terminalSettings?.keywordHighlightEnabled ?? false,
);
if (xtermRuntimeRef.current) {
xtermRuntimeRef.current.keywordHighlighter.setRules(
terminalSettings?.keywordHighlightRules ?? [],
terminalSettings?.keywordHighlightEnabled ?? false
);
}
}, [terminalSettings?.keywordHighlightEnabled, terminalSettings?.keywordHighlightRules]);
const hotkeySchemeRef = useRef(hotkeyScheme);
@@ -208,6 +223,15 @@ const TerminalComponent: React.FC<TerminalProps> = ({
handleCloseSearch,
} = terminalSearch;
// Server stats (CPU, Memory, Disk) for Linux servers
const { stats: serverStats } = useServerStats({
sessionId,
enabled: terminalSettings?.showServerStats ?? true,
refreshInterval: terminalSettings?.serverStatsRefreshInterval ?? 5,
isLinux: host.os === 'linux',
isConnected: status === 'connected',
});
useEffect(() => {
if (!error) {
lastToastedErrorRef.current = null;
@@ -298,7 +322,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
disposeExitRef,
fitAddonRef,
serializeAddonRef,
highlightProcessorRef,
pendingAuthRef,
updateStatus,
setStatus,
@@ -525,7 +548,18 @@ const TerminalComponent: React.FC<TerminalProps> = ({
| 700
| 800
| 900;
termRef.current.options.fontWeightBold = terminalSettings.fontWeightBold as
const resolvedFontWeightBold = (() => {
const fontFamily = termRef.current?.options.fontFamily || "";
if (typeof document === "undefined" || !document.fonts?.check) {
return terminalSettings.fontWeightBold;
}
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${fontFamily}`;
return document.fonts.check(weightSpec)
? terminalSettings.fontWeightBold
: terminalSettings.fontWeight;
})();
termRef.current.options.fontWeightBold = resolvedFontWeightBold as
| 100
| 200
| 300
@@ -601,6 +635,27 @@ const TerminalComponent: React.FC<TerminalProps> = ({
logger.warn("Fit after fonts ready failed", err);
}
if (terminalSettings && termRef.current) {
const fontFamily = termRef.current.options?.fontFamily || "";
const effectiveFontSize = host.fontSize || fontSize;
if (typeof document !== "undefined" && document.fonts?.check) {
const weightSpec = `${terminalSettings.fontWeightBold} ${effectiveFontSize}px ${fontFamily}`;
const resolvedBold = document.fonts.check(weightSpec)
? terminalSettings.fontWeightBold
: terminalSettings.fontWeight;
termRef.current.options.fontWeightBold = resolvedBold as
| 100
| 200
| 300
| 400
| 500
| 600
| 700
| 800
| 900;
}
}
const id = sessionRef.current;
if (id && term) {
try {
@@ -618,7 +673,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return () => {
cancelled = true;
};
}, [host.id, sessionId, resizeSession]);
}, [host.id, host.fontFamily, host.fontSize, fontFamilyId, fontSize, resizeSession, sessionId, terminalSettings]);
useEffect(() => {
if (!containerRef.current || !fitAddonRef.current) return;
@@ -864,6 +919,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
<TerminalContextMenu
hasSelection={hasSelection}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
rightClickBehavior={terminalSettings?.rightClickBehavior}
onCopy={terminalContextActions.onCopy}
onPaste={terminalContextActions.onPaste}
@@ -898,6 +954,266 @@ const TerminalComponent: React.FC<TerminalProps> = ({
)}
/>
</div>
{/* Server Stats Display - Linux only */}
{host.os === 'linux' && terminalSettings?.showServerStats && status === 'connected' && serverStats.lastUpdated && (
<div className="flex items-center gap-2.5 ml-2 text-[10px] opacity-80 flex-nowrap overflow-hidden min-w-0">
{/* CPU with HoverCard for per-core details */}
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild>
<button
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
title={t("terminal.serverStats.cpu")}
>
<Cpu size={10} className="flex-shrink-0" />
<span>
{serverStats.cpu !== null ? `${serverStats.cpu}%` : '--'}
{serverStats.cpuCores !== null && ` (${serverStats.cpuCores}C)`}
</span>
</button>
</HoverCardTrigger>
<HoverCardContent
className="w-auto p-3"
side="bottom"
align="start"
sideOffset={8}
>
<div className="text-xs space-y-2">
<div className="font-medium text-sm mb-2">{t("terminal.serverStats.cpuCores")}</div>
{serverStats.cpuPerCore.length > 0 ? (
<div className="grid gap-1.5" style={{ gridTemplateColumns: `repeat(${Math.min(4, serverStats.cpuPerCore.length)}, 1fr)` }}>
{serverStats.cpuPerCore.map((usage, index) => (
<div key={index} className="flex flex-col items-center gap-1 min-w-[48px]">
<div className="text-[10px] text-muted-foreground">Core {index}</div>
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all",
usage >= 90 ? "bg-red-500" : usage >= 70 ? "bg-amber-500" : "bg-emerald-500"
)}
style={{ width: `${usage}%` }}
/>
</div>
<div className={cn(
"text-[11px] font-medium",
usage >= 90 ? "text-red-400" : usage >= 70 ? "text-amber-400" : "text-emerald-400"
)}>
{usage}%
</div>
</div>
))}
</div>
) : (
<div className="text-muted-foreground">{t("terminal.serverStats.noData")}</div>
)}
</div>
</HoverCardContent>
</HoverCard>
{/* Memory with HoverCard for htop-style bar and top processes */}
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild>
<button
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
title={t("terminal.serverStats.memory")}
>
<MemoryStick size={10} className="flex-shrink-0" />
<span>
{serverStats.memUsed !== null && serverStats.memTotal !== null
? `${(serverStats.memUsed / 1024).toFixed(1)}/${(serverStats.memTotal / 1024).toFixed(1)}G`
: '--'}
</span>
</button>
</HoverCardTrigger>
<HoverCardContent
className="w-auto p-3"
side="bottom"
align="start"
sideOffset={8}
>
<div className="text-xs space-y-3 min-w-[280px]">
<div className="font-medium text-sm">{t("terminal.serverStats.memoryDetails")}</div>
{/* htop-style memory bar */}
{serverStats.memTotal !== null && (
<div className="space-y-1.5">
<div className="w-full h-3 bg-muted rounded overflow-hidden flex">
{/* Used (green) */}
{serverStats.memUsed !== null && serverStats.memUsed > 0 && (
<div
className="h-full bg-emerald-500"
style={{ width: `${(serverStats.memUsed / serverStats.memTotal) * 100}%` }}
title={`${t("terminal.serverStats.memUsed")}: ${(serverStats.memUsed / 1024).toFixed(1)}G`}
/>
)}
{/* Buffers (blue) */}
{serverStats.memBuffers !== null && serverStats.memBuffers > 0 && (
<div
className="h-full bg-blue-500"
style={{ width: `${(serverStats.memBuffers / serverStats.memTotal) * 100}%` }}
title={`${t("terminal.serverStats.memBuffers")}: ${(serverStats.memBuffers / 1024).toFixed(1)}G`}
/>
)}
{/* Cached (amber/orange) */}
{serverStats.memCached !== null && serverStats.memCached > 0 && (
<div
className="h-full bg-amber-500"
style={{ width: `${(serverStats.memCached / serverStats.memTotal) * 100}%` }}
title={`${t("terminal.serverStats.memCached")}: ${(serverStats.memCached / 1024).toFixed(1)}G`}
/>
)}
</div>
{/* Legend */}
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[10px]">
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm bg-emerald-500" />
<span>{t("terminal.serverStats.memUsed")}: {serverStats.memUsed !== null ? `${(serverStats.memUsed / 1024).toFixed(1)}G` : '--'}</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm bg-blue-500" />
<span>{t("terminal.serverStats.memBuffers")}: {serverStats.memBuffers !== null ? `${(serverStats.memBuffers / 1024).toFixed(1)}G` : '--'}</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm bg-amber-500" />
<span>{t("terminal.serverStats.memCached")}: {serverStats.memCached !== null ? `${(serverStats.memCached / 1024).toFixed(1)}G` : '--'}</span>
</div>
<div className="flex items-center gap-1">
<div className="w-2 h-2 rounded-sm bg-muted border border-border" />
<span>{t("terminal.serverStats.memFree")}: {serverStats.memFree !== null ? `${(serverStats.memFree / 1024).toFixed(1)}G` : '--'}</span>
</div>
</div>
</div>
)}
{/* Top 10 processes */}
{serverStats.topProcesses.length > 0 && (
<div className="space-y-1.5">
<div className="font-medium text-[11px] text-muted-foreground">{t("terminal.serverStats.topProcesses")}</div>
<div className="space-y-0.5 max-h-[150px] overflow-y-auto">
{serverStats.topProcesses.map((proc, index) => (
<div key={index} className="flex items-center gap-2 text-[10px]">
<span className="w-[32px] text-right text-muted-foreground">{proc.memPercent.toFixed(1)}%</span>
<div className="flex-1 h-1 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-emerald-500 rounded-full"
style={{ width: `${Math.min(100, proc.memPercent * 2)}%` }}
/>
</div>
<span className="flex-shrink-0 font-mono truncate max-w-[140px]" title={proc.command}>
{proc.command.split('/').pop()?.split(' ')[0] || proc.command}
</span>
</div>
))}
</div>
</div>
)}
</div>
</HoverCardContent>
</HoverCard>
{/* Disk - with HoverCard for disk details */}
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild>
<button
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
title={t("terminal.serverStats.disk")}
>
<HardDrive size={10} className="flex-shrink-0" />
<span className={cn(
serverStats.diskPercent !== null && serverStats.diskPercent >= 90 && "text-red-400",
serverStats.diskPercent !== null && serverStats.diskPercent >= 80 && serverStats.diskPercent < 90 && "text-amber-400"
)}>
{serverStats.diskUsed !== null && serverStats.diskTotal !== null && serverStats.diskPercent !== null
? `${serverStats.diskUsed}/${serverStats.diskTotal}G (${serverStats.diskPercent}%)`
: serverStats.diskPercent !== null
? `${serverStats.diskPercent}%`
: '--'}
</span>
</button>
</HoverCardTrigger>
<HoverCardContent
className="w-auto p-3"
side="bottom"
align="start"
sideOffset={8}
>
<div className="text-xs space-y-2">
<div className="font-medium text-sm mb-2">{t("terminal.serverStats.diskDetails")}</div>
{serverStats.disks.length > 0 ? (
<div className="space-y-2 max-h-[200px] overflow-y-auto">
{serverStats.disks.map((disk, index) => (
<div key={index} className="flex flex-col gap-1 min-w-[180px]">
<div className="flex items-center justify-between gap-4">
<span className="text-[10px] text-muted-foreground font-mono truncate max-w-[120px]" title={disk.mountPoint}>
{disk.mountPoint}
</span>
<span className={cn(
"text-[11px] font-medium whitespace-nowrap",
disk.percent >= 90 ? "text-red-400" : disk.percent >= 80 ? "text-amber-400" : "text-emerald-400"
)}>
{disk.used}/{disk.total}G ({disk.percent}%)
</span>
</div>
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all",
disk.percent >= 90 ? "bg-red-500" : disk.percent >= 80 ? "bg-amber-500" : "bg-emerald-500"
)}
style={{ width: `${disk.percent}%` }}
/>
</div>
</div>
))}
</div>
) : (
<div className="text-muted-foreground">{t("terminal.serverStats.noData")}</div>
)}
</div>
</HoverCardContent>
</HoverCard>
{/* Network - with HoverCard for per-interface details */}
{serverStats.netInterfaces.length > 0 && (
<HoverCard openDelay={200} closeDelay={100}>
<HoverCardTrigger asChild>
<button
className="flex items-center gap-1 hover:opacity-100 opacity-80 transition-opacity cursor-pointer flex-shrink-0"
title={t("terminal.serverStats.network")}
>
<ArrowDownToLine size={9} className="flex-shrink-0 text-emerald-400" />
<span>{formatNetSpeed(serverStats.netRxSpeed)}</span>
<ArrowUpFromLine size={9} className="flex-shrink-0 text-sky-400" />
<span>{formatNetSpeed(serverStats.netTxSpeed)}</span>
</button>
</HoverCardTrigger>
<HoverCardContent
className="w-auto p-3"
side="bottom"
align="start"
sideOffset={8}
>
<div className="text-xs space-y-2">
<div className="font-medium text-sm mb-2">{t("terminal.serverStats.networkDetails")}</div>
<div className="space-y-2 max-h-[200px] overflow-y-auto">
{serverStats.netInterfaces.map((iface, index) => (
<div key={index} className="flex items-center justify-between gap-4 min-w-[200px]">
<span className="text-[10px] text-muted-foreground font-mono">
{iface.name}
</span>
<div className="flex items-center gap-2">
<span className="flex items-center gap-0.5 text-emerald-400">
<ArrowDownToLine size={9} />
{formatNetSpeed(iface.rxSpeed)}
</span>
<span className="flex items-center gap-0.5 text-sky-400">
<ArrowUpFromLine size={9} />
{formatNetSpeed(iface.txSpeed)}
</span>
</div>
</div>
))}
</div>
</div>
</HoverCardContent>
</HoverCard>
)}
</div>
)}
<div className="flex-1" />
<div className="flex items-center gap-0.5 flex-shrink-0">
{inWorkspace && onToggleBroadcast && (

View File

@@ -7,7 +7,7 @@ import {
Search,
X,
} from 'lucide-react';
import Editor, { type OnMount, loader } from '@monaco-editor/react';
import Editor, { type OnMount, loader, useMonaco } from '@monaco-editor/react';
import type * as Monaco from 'monaco-editor';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
@@ -82,6 +82,50 @@ const languageIdToMonaco = (langId: string): string => {
return mapping[langId] || 'plaintext';
};
// Convert HSL string "h s% l%" to hex color
const hslToHex = (hslString: string): string => {
const parts = hslString.trim().split(/\s+/);
if (parts.length < 3) return '#1e1e1e';
const h = parseFloat(parts[0]) / 360;
const s = parseFloat(parts[1].replace('%', '')) / 100;
const l = parseFloat(parts[2].replace('%', '')) / 100;
const hue2rgb = (p: number, q: number, t: number) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
let r: number, g: number, b: number;
if (s === 0) {
r = g = b = l;
} else {
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1 / 3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1 / 3);
}
const toHex = (x: number) => {
const hex = Math.round(x * 255).toString(16);
return hex.length === 1 ? '0' + hex : hex;
};
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
};
// Get background color from CSS variable
const getBackgroundColor = (): string => {
const bgValue = getComputedStyle(document.documentElement)
.getPropertyValue('--background')
.trim();
return bgValue ? hslToHex(bgValue) : '#1e1e1e';
};
export const TextEditorModal: React.FC<TextEditorModalProps> = ({
open,
onClose,
@@ -90,12 +134,13 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
onSave,
}) => {
const { t } = useI18n();
const monaco = useMonaco();
const [content, setContent] = useState(initialContent);
const [saving, setSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
const [languageId, setLanguageId] = useState(() => getLanguageId(fileName));
const editorRef = useRef<Monaco.editor.IStandaloneCodeEditor | null>(null);
// Ref to store the latest save function to avoid stale closure in keyboard shortcut
const handleSaveRef = useRef<() => Promise<void>>(() => Promise.resolve());
@@ -104,13 +149,49 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
document.documentElement.classList.contains('dark')
);
// Listen for theme changes via MutationObserver on <html> class
// Track background color for custom theme
const [bgColor, setBgColor] = useState(() => getBackgroundColor());
// Custom theme name
const customThemeName = isDarkTheme ? 'netcatty-dark' : 'netcatty-light';
// Define and update custom Monaco themes based on UI background color
useEffect(() => {
if (!monaco) return;
// Define dark theme with custom background
monaco.editor.defineTheme('netcatty-dark', {
base: 'vs-dark',
inherit: true,
rules: [],
colors: {
'editor.background': bgColor,
},
});
// Define light theme with custom background
monaco.editor.defineTheme('netcatty-light', {
base: 'vs',
inherit: true,
rules: [],
colors: {
'editor.background': bgColor,
},
});
// Apply the current theme
monaco.editor.setTheme(customThemeName);
}, [monaco, isDarkTheme, bgColor, customThemeName]);
// Listen for theme changes via MutationObserver on <html> class and style
useEffect(() => {
const root = document.documentElement;
const observer = new MutationObserver(() => {
const updateTheme = () => {
setIsDarkTheme(root.classList.contains('dark'));
});
observer.observe(root, { attributes: true, attributeFilter: ['class'] });
setBgColor(getBackgroundColor());
};
const observer = new MutationObserver(updateTheme);
observer.observe(root, { attributes: true, attributeFilter: ['class', 'style'] });
return () => observer.disconnect();
}, []);
@@ -185,7 +266,6 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
const supportedLanguages = useMemo(() => getSupportedLanguages(), []);
const monacoLanguage = useMemo(() => languageIdToMonaco(languageId), [languageId]);
const monacoTheme = isDarkTheme ? 'vs-dark' : 'light';
const languageOptions = useMemo(
() => supportedLanguages.map((lang) => ({ value: lang.id, label: lang.name })),
[supportedLanguages],
@@ -265,7 +345,7 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
value={content}
onChange={handleEditorChange}
onMount={handleEditorMount}
theme={monacoTheme}
theme={customThemeName}
loading={
<div className="absolute inset-0 flex items-center justify-center bg-background">
<Loader2 size={32} className="animate-spin text-muted-foreground" />

View File

@@ -25,6 +25,7 @@ interface TopTabsProps {
isMacClient: boolean;
onCloseSession: (sessionId: string, e?: React.MouseEvent) => void;
onRenameSession: (sessionId: string) => void;
onCopySession: (sessionId: string) => void;
onRenameWorkspace: (workspaceId: string) => void;
onCloseWorkspace: (workspaceId: string) => void;
onCloseLogView: (logViewId: string) => void;
@@ -121,6 +122,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
isMacClient,
onCloseSession,
onRenameSession,
onCopySession,
onRenameWorkspace,
onCloseWorkspace,
onCloseLogView,
@@ -410,6 +412,9 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
<ContextMenuItem onClick={() => onRenameSession(session.id)}>
{t('common.rename')}
</ContextMenuItem>
<ContextMenuItem onClick={() => onCopySession(session.id)}>
{t('tabs.copyTab')}
</ContextMenuItem>
<ContextMenuItem className="text-destructive" onClick={() => onCloseSession(session.id)}>
{t('common.close')}
</ContextMenuItem>

View File

@@ -2,6 +2,9 @@ import {
Activity,
BookMarked,
ChevronDown,
ClipboardCopy,
Copy,
Download,
Edit2,
FileCode,
FolderPlus,
@@ -23,7 +26,7 @@ import React, { Suspense, lazy, memo, useCallback, useEffect, useMemo, useState
import { useI18n } from "../application/i18n/I18nProvider";
import { useStoredViewMode } from "../application/state/useStoredViewMode";
import { sanitizeHost } from "../domain/host";
import { importVaultHostsFromText } from "../domain/vaultImport";
import { importVaultHostsFromText, exportHostsToCsvWithStats } from "../domain/vaultImport";
import type { VaultImportFormat } from "../domain/vaultImport";
import { STORAGE_KEY_VAULT_HOSTS_VIEW_MODE } from "../infrastructure/config/storageKeys";
import { cn } from "../lib/utils";
@@ -41,7 +44,6 @@ import {
TerminalSession,
} from "../types";
import { AppLogo } from "./AppLogo";
import ConnectionLogsManager from "./ConnectionLogsManager";
import { DistroAvatar } from "./DistroAvatar";
import HostDetailsPanel from "./HostDetailsPanel";
import KeychainManager from "./KeychainManager";
@@ -76,6 +78,7 @@ import { TagFilterDropdown } from "./ui/tag-filter-dropdown";
import { toast } from "./ui/toast";
const LazyProtocolSelectDialog = lazy(() => import("./ProtocolSelectDialog"));
const LazyConnectionLogsManager = lazy(() => import("./ConnectionLogsManager"));
export type VaultSection = "hosts" | "keys" | "snippets" | "port" | "knownhosts" | "logs";
@@ -301,6 +304,96 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
setIsHostPanelOpen(true);
}, []);
const handleDuplicateHost = useCallback((host: Host) => {
// Create a copy of the host with a new ID and modified label
const duplicatedHost: Host = {
...host,
id: crypto.randomUUID(),
label: `${host.label} (${t('action.copy')})`,
createdAt: Date.now(),
};
// Open the edit panel with the duplicated host for modification
setEditingHost(duplicatedHost);
setIsHostPanelOpen(true);
}, [t]);
// Export hosts to CSV
const handleExportHosts = useCallback(() => {
if (hosts.length === 0) {
toast.warning(t('vault.hosts.export.toast.noHosts'));
return;
}
const { csv, exportedCount, skippedCount } = exportHostsToCsvWithStats(hosts);
if (exportedCount === 0) {
toast.warning(t('vault.hosts.export.toast.noHosts'));
return;
}
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `hosts_export_${new Date().toISOString().slice(0, 10)}.csv`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
if (skippedCount > 0) {
toast.warning(t('vault.hosts.export.toast.successWithSkipped', { count: exportedCount, skipped: skippedCount }));
} else {
toast.success(t('vault.hosts.export.toast.success', { count: exportedCount }));
}
}, [hosts, t]);
// Copy host credentials to clipboard
const handleCopyCredentials = useCallback((host: Host) => {
// Only use telnet-specific port and credentials when protocol is explicitly telnet
// Don't treat telnetEnabled as primary - that's just an optional protocol
const isTelnet = host.protocol === "telnet";
const defaultPort = isTelnet ? 23 : 22;
const effectivePort = isTelnet
? (host.telnetPort ?? host.port ?? 23)
: (host.port ?? 22);
// Bracket IPv6 addresses when appending non-default port
let address: string;
if (effectivePort !== defaultPort) {
const isIPv6 = host.hostname.includes(":") && !host.hostname.startsWith("[");
const hostname = isIPv6 ? `[${host.hostname}]` : host.hostname;
address = `${hostname}:${effectivePort}`;
} else {
address = host.hostname;
}
// Resolve credentials from identity if configured, otherwise use host credentials
// For telnet hosts, use telnet-specific credentials
const identity = host.identityId
? identities.find((i) => i.id === host.identityId)
: undefined;
const username = isTelnet
? (host.telnetUsername?.trim() || host.username?.trim())
: (identity?.username?.trim() || host.username?.trim());
const password = isTelnet
? (host.telnetPassword || host.password)
: (identity?.password || host.password);
if (!password) {
toast.warning(t('vault.hosts.copyCredentials.toast.noPassword'));
return;
}
const text = `host: ${address}\nusername: ${username ?? ''}\npassword: ${password}`;
navigator.clipboard.writeText(text).then(() => {
toast.success(t('vault.hosts.copyCredentials.toast.success'));
});
}, [identities, t]);
const readTextFile = useCallback(async (file: File): Promise<string> => {
const buf = await file.arrayBuffer();
const bytes = new Uint8Array(buf);
@@ -470,7 +563,15 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
const displayedHosts = useMemo(() => {
let filtered = hosts;
if (selectedGroupPath) {
filtered = filtered.filter((h) => (h.group || "") === selectedGroupPath);
// Match hosts whose group equals the selected path
// For "General" group, also match hosts with empty/undefined group
filtered = filtered.filter((h) => {
const hostGroup = h.group || "";
if (selectedGroupPath === "General") {
return hostGroup === "" || hostGroup === "General";
}
return hostGroup === selectedGroupPath;
});
}
if (search.trim()) {
const s = search.toLowerCase();
@@ -545,9 +646,20 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
const displayedGroups = useMemo(() => {
if (!selectedGroupPath) {
return (Object.values(buildGroupTree) as GroupNode[]).sort((a, b) =>
a.name.localeCompare(b.name),
// Hide "General" group at root level only if it's auto-generated
// (not user-created and has no subgroups)
const isGeneralUserCreated = customGroups.some(
(g) => g === "General" || g.startsWith("General/")
);
return (Object.values(buildGroupTree) as GroupNode[])
.filter((node) => {
if (node.name !== "General") return true;
// Keep General if user explicitly created it or it has subgroups
if (isGeneralUserCreated) return true;
if (Object.keys(node.children).length > 0) return true;
return false;
})
.sort((a, b) => a.name.localeCompare(b.name));
}
const node = findGroupNode(selectedGroupPath);
if (!node || !node.children) return [];
@@ -555,7 +667,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
a.name.localeCompare(b.name),
);
// eslint-disable-next-line react-hooks/exhaustive-deps -- findGroupNode is derived from buildGroupTree
}, [buildGroupTree, selectedGroupPath]);
}, [buildGroupTree, selectedGroupPath, customGroups]);
// Known Hosts callbacks - use refs to keep stable references
// Store latest values in refs so callbacks don't need to depend on them
@@ -747,7 +859,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
className={cn(
"w-full justify-start gap-3 h-10",
currentSection === "hosts" &&
"bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40",
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => {
setCurrentSection("hosts");
@@ -761,7 +873,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
className={cn(
"w-full justify-start gap-3 h-10",
currentSection === "keys" &&
"bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40",
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => {
setCurrentSection("keys");
@@ -774,7 +886,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
className={cn(
"w-full justify-start gap-3 h-10",
currentSection === "port" &&
"bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40",
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => setCurrentSection("port")}
>
@@ -785,7 +897,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
className={cn(
"w-full justify-start gap-3 h-10",
currentSection === "snippets" &&
"bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40",
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => {
setCurrentSection("snippets");
@@ -798,7 +910,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
className={cn(
"w-full justify-start gap-3 h-10",
currentSection === "knownhosts" &&
"bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40",
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => setCurrentSection("knownhosts")}
>
@@ -809,7 +921,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
className={cn(
"w-full justify-start gap-3 h-10",
currentSection === "logs" &&
"bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40",
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
)}
onClick={() => setCurrentSection("logs")}
>
@@ -952,6 +1064,13 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
>
<Upload size={14} /> {t("vault.hosts.import")}
</Button>
<Button
variant="ghost"
className="w-full justify-start gap-2"
onClick={handleExportHosts}
>
<Download size={14} /> {t("vault.hosts.export")}
</Button>
</DropdownContent>
</Dropdown>
</div>
@@ -1219,18 +1338,28 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
<ContextMenuItem
onClick={() => handleHostConnect(host)}
>
<Plug className="mr-2 h-4 w-4" /> Connect
<Plug className="mr-2 h-4 w-4" /> {t('vault.hosts.connect')}
</ContextMenuItem>
<ContextMenuItem
onClick={() => handleEditHost(host)}
>
<Edit2 className="mr-2 h-4 w-4" /> Edit
<Edit2 className="mr-2 h-4 w-4" /> {t('action.edit')}
</ContextMenuItem>
<ContextMenuItem
onClick={() => handleDuplicateHost(host)}
>
<Copy className="mr-2 h-4 w-4" /> {t('action.duplicate')}
</ContextMenuItem>
<ContextMenuItem
onClick={() => handleCopyCredentials(host)}
>
<ClipboardCopy className="mr-2 h-4 w-4" /> {t('vault.hosts.copyCredentials')}
</ContextMenuItem>
<ContextMenuItem
className="text-destructive"
onClick={() => onDeleteHost(host.id)}
>
<Trash2 className="mr-2 h-4 w-4" /> Delete
<Trash2 className="mr-2 h-4 w-4" /> {t('action.delete')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
@@ -1350,14 +1479,16 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
</div>
{/* Connection Logs */}
{currentSection === "logs" && (
<ConnectionLogsManager
logs={connectionLogs}
hosts={hosts}
onToggleSaved={onToggleConnectionLogSaved}
onDelete={onDeleteConnectionLog}
onClearUnsaved={onClearUnsavedConnectionLogs}
onOpenLogView={onOpenLogView}
/>
<Suspense fallback={<div className="flex-1 flex items-center justify-center text-muted-foreground">Loading...</div>}>
<LazyConnectionLogsManager
logs={connectionLogs}
hosts={hosts}
onToggleSaved={onToggleConnectionLogSaved}
onDelete={onDeleteConnectionLog}
onClearUnsaved={onClearUnsavedConnectionLogs}
onOpenLogView={onOpenLogView}
/>
</Suspense>
)}
</div>
@@ -1377,8 +1508,10 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
allHosts={hosts}
defaultGroup={editingHost ? undefined : selectedGroupPath}
onSave={(host) => {
// Check if host already exists in the list (for updates vs. new/duplicate)
const hostExists = hosts.some((h) => h.id === host.id);
onUpdateHosts(
editingHost
hostExists
? hosts.map((h) => (h.id === host.id ? host : h))
: [...hosts, host],
);

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,14 @@
/**
* Settings System Tab - System information and temp file management
* Settings System Tab - System information, temp file management, and session logs
*/
import { FolderOpen, HardDrive, RefreshCw, Trash2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { FileText, FolderOpen, HardDrive, RefreshCw, Trash2 } from "lucide-react";
import React, { useCallback, useEffect, useState } from "react";
import { useI18n } from "../../../application/i18n/I18nProvider";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { SessionLogFormat } from "../../../domain/models";
import { TabsContent } from "../../ui/tabs";
import { Button } from "../../ui/button";
import { Toggle, Select, SettingRow } from "../settings-ui";
interface TempDirInfo {
path: string;
@@ -22,9 +24,25 @@ function formatBytes(bytes: number): string {
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}
const SettingsSystemTab: React.FC = () => {
interface SettingsSystemTabProps {
sessionLogsEnabled: boolean;
setSessionLogsEnabled: (enabled: boolean) => void;
sessionLogsDir: string;
setSessionLogsDir: (dir: string) => void;
sessionLogsFormat: SessionLogFormat;
setSessionLogsFormat: (format: SessionLogFormat) => void;
}
const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
sessionLogsEnabled,
setSessionLogsEnabled,
sessionLogsDir,
setSessionLogsDir,
sessionLogsFormat,
setSessionLogsFormat,
}) => {
const { t } = useI18n();
const [tempDirInfo, setTempDirInfo] = useState<TempDirInfo | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isClearing, setIsClearing] = useState(false);
@@ -33,7 +51,7 @@ const SettingsSystemTab: React.FC = () => {
const loadTempDirInfo = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.getTempDirInfo) return;
setIsLoading(true);
try {
const info = await bridge.getTempDirInfo();
@@ -52,7 +70,7 @@ const SettingsSystemTab: React.FC = () => {
const handleClearTempFiles = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.clearTempDir) return;
setIsClearing(true);
setClearResult(null);
try {
@@ -73,6 +91,37 @@ const SettingsSystemTab: React.FC = () => {
await bridge.openTempDir();
}, [tempDirInfo]);
const handleSelectSessionLogsDir = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.selectSessionLogsDir) return;
try {
const result = await bridge.selectSessionLogsDir();
if (result.success && result.directory) {
setSessionLogsDir(result.directory);
}
} catch (err) {
console.error("[SettingsSystemTab] Failed to select directory:", err);
}
}, [setSessionLogsDir]);
const handleOpenSessionLogsDir = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!sessionLogsDir || !bridge?.openSessionLogsDir) return;
try {
await bridge.openSessionLogsDir(sessionLogsDir);
} catch (err) {
console.error("[SettingsSystemTab] Failed to open directory:", err);
}
}, [sessionLogsDir]);
const formatOptions = [
{ value: "txt", label: t("settings.sessionLogs.formatTxt") },
{ value: "raw", label: t("settings.sessionLogs.formatRaw") },
{ value: "html", label: t("settings.sessionLogs.formatHtml") },
];
return (
<TabsContent
value="system"
@@ -171,6 +220,81 @@ const SettingsSystemTab: React.FC = () => {
{t("settings.system.tempDirectoryHint")}
</p>
</div>
{/* Session Logs Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<FileText size={18} className="text-muted-foreground" />
<h3 className="text-base font-medium">{t("settings.sessionLogs.title")}</h3>
</div>
<div className="bg-muted/30 rounded-lg p-4 space-y-4">
{/* Enable Toggle */}
<SettingRow
label={t("settings.sessionLogs.enableAutoSave")}
description={t("settings.sessionLogs.enableAutoSaveDesc")}
>
<Toggle
checked={sessionLogsEnabled}
onChange={setSessionLogsEnabled}
/>
</SettingRow>
{/* Directory Selection */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{t("settings.sessionLogs.directory")}</span>
</div>
<div className="flex items-center gap-2">
<div className="flex-1 min-w-0">
<div className="bg-background border border-input rounded-md px-3 py-2 text-sm font-mono truncate">
{sessionLogsDir || t("settings.sessionLogs.noDirectory")}
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={handleSelectSessionLogsDir}
className="shrink-0"
>
{t("settings.sessionLogs.browse")}
</Button>
{sessionLogsDir && (
<Button
variant="ghost"
size="icon"
onClick={handleOpenSessionLogsDir}
className="shrink-0"
title={t("settings.sessionLogs.openFolder")}
>
<FolderOpen size={16} />
</Button>
)}
</div>
<p className="text-xs text-muted-foreground">
{t("settings.sessionLogs.directoryHint")}
</p>
</div>
{/* Format Selection */}
<SettingRow
label={t("settings.sessionLogs.format")}
description={t("settings.sessionLogs.formatDesc")}
>
<Select
value={sessionLogsFormat}
options={formatOptions}
onChange={(val) => setSessionLogsFormat(val as SessionLogFormat)}
className="w-32"
disabled={!sessionLogsEnabled}
/>
</SettingRow>
</div>
<p className="text-xs text-muted-foreground">
{t("settings.sessionLogs.hint")}
</p>
</div>
</div>
</div>
</TabsContent>

View File

@@ -17,6 +17,7 @@ import { Input } from "../../ui/input";
import { Label } from "../../ui/label";
import { SectionHeader, Select, SettingsTabContent, SettingRow, Toggle } from "../settings-ui";
import { ThemeSelectModal } from "../ThemeSelectModal";
import { TerminalFontSelect } from "../TerminalFontSelect";
// Theme preview button component
const ThemePreviewButton: React.FC<{
@@ -207,11 +208,11 @@ export default function SettingsTerminalTab(props: {
label={t("settings.terminal.font.family")}
description={t("settings.terminal.font.family.desc")}
>
<Select
<TerminalFontSelect
value={terminalFontFamilyId}
options={availableFonts.map((f) => ({ value: f.id, label: f.name }))}
fonts={availableFonts}
onChange={(id) => setTerminalFontFamilyId(id)}
className="w-40"
className="w-48"
/>
</SettingRow>
@@ -608,6 +609,62 @@ export default function SettingsTerminalTab(props: {
/>
</SettingRow>
</div>
<SectionHeader title={t("settings.terminal.section.serverStats")} />
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
<SettingRow
label={t("settings.terminal.serverStats.show")}
description={t("settings.terminal.serverStats.show.desc")}
>
<Toggle
checked={terminalSettings.showServerStats}
onChange={(v) => updateTerminalSetting("showServerStats", v)}
/>
</SettingRow>
{terminalSettings.showServerStats && (
<SettingRow
label={t("settings.terminal.serverStats.refreshInterval")}
description={t("settings.terminal.serverStats.refreshInterval.desc")}
>
<div className="flex items-center gap-2">
<Input
type="number"
min={5}
max={300}
value={terminalSettings.serverStatsRefreshInterval}
onChange={(e) => {
const val = parseInt(e.target.value) || 5;
if (val >= 5 && val <= 300) {
updateTerminalSetting("serverStatsRefreshInterval", val);
}
}}
className="w-20"
/>
<span className="text-sm text-muted-foreground">{t("settings.terminal.serverStats.seconds")}</span>
</div>
</SettingRow>
)}
</div>
<SectionHeader title={t("settings.terminal.section.rendering")} />
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
<SettingRow
label={t("settings.terminal.rendering.renderer")}
description={t("settings.terminal.rendering.renderer.desc")}
>
<Select
value={terminalSettings.rendererType}
options={[
{ value: "auto", label: t("settings.terminal.rendering.auto") },
{ value: "webgl", label: "WebGL" },
{ value: "canvas", label: "Canvas" },
]}
onChange={(v) => updateTerminalSetting("rendererType", v as "auto" | "webgl" | "canvas")}
className="w-32"
/>
</SettingRow>
</div>
</SettingsTabContent>
);
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,253 @@
import React from "react";
import { ArrowUp, ChevronRight, Home, MoreHorizontal, Plus, RefreshCw, Upload } from "lucide-react";
import { cn } from "../../lib/utils";
import type { Host, SftpFilenameEncoding } from "../../types";
import { DistroAvatar } from "../DistroAvatar";
import { Button } from "../ui/button";
import { DialogHeader, DialogTitle } from "../ui/dialog";
import { Input } from "../ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select";
interface BreadcrumbPart {
part: string;
originalIndex: number;
}
interface SftpModalHeaderProps {
t: (key: string, params?: Record<string, unknown>) => string;
host: Host;
credentials: { username?: string; hostname: string; port?: number };
showEncoding: boolean;
filenameEncoding: SftpFilenameEncoding;
onFilenameEncodingChange: (encoding: SftpFilenameEncoding) => void;
currentPath: string;
isEditingPath: boolean;
editingPathValue: string;
setEditingPathValue: (value: string) => void;
handlePathSubmit: () => void;
handlePathKeyDown: (e: React.KeyboardEvent) => void;
handlePathDoubleClick: () => void;
isAtRoot: boolean;
rootLabel: string;
isRefreshing: boolean;
onUp: () => void;
onHome: () => void;
onRefresh: () => void;
visibleBreadcrumbs: BreadcrumbPart[];
hiddenBreadcrumbs: BreadcrumbPart[];
needsBreadcrumbTruncation: boolean;
breadcrumbs: string[];
onBreadcrumbSelect: (index: number) => void;
onRootSelect: () => void;
inputRef: React.RefObject<HTMLInputElement>;
pathInputRef: React.RefObject<HTMLInputElement>;
uploading: boolean;
onTriggerUpload: () => void;
onCreateFolder: () => void;
onCreateFile: () => void;
onFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
}
export const SftpModalHeader: React.FC<SftpModalHeaderProps> = ({
t,
host,
credentials,
showEncoding,
filenameEncoding,
onFilenameEncodingChange,
currentPath,
isEditingPath,
editingPathValue,
setEditingPathValue,
handlePathSubmit,
handlePathKeyDown,
handlePathDoubleClick,
isAtRoot,
rootLabel,
isRefreshing,
onUp,
onHome,
onRefresh,
visibleBreadcrumbs,
hiddenBreadcrumbs,
needsBreadcrumbTruncation,
breadcrumbs,
onBreadcrumbSelect,
onRootSelect,
inputRef,
pathInputRef,
uploading,
onTriggerUpload,
onCreateFolder,
onCreateFile,
onFileSelect,
}) => (
<>
<DialogHeader className="px-4 py-3 border-b border-border/60 flex-shrink-0">
<div className="flex items-center gap-3 pr-8">
<DistroAvatar
host={host}
fallback={host.label.slice(0, 2).toUpperCase()}
className="h-8 w-8"
/>
<div className="flex-1 min-w-0">
<DialogTitle className="text-sm font-semibold">
{host.label}
</DialogTitle>
<div className="text-xs text-muted-foreground font-mono">
{credentials.username || "root"}@{credentials.hostname}:
{credentials.port || 22}
</div>
</div>
</div>
</DialogHeader>
<div className="px-4 py-2 border-b border-border/60 flex items-center gap-2 flex-shrink-0 bg-muted/30">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onUp}
disabled={isAtRoot}
>
<ArrowUp size={14} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onHome}
>
<Home size={14} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onRefresh}
>
<RefreshCw
size={14}
className={cn(isRefreshing && "animate-spin")}
/>
</Button>
{showEncoding && (
<Select
value={filenameEncoding}
onValueChange={(value) => onFilenameEncodingChange(value as SftpFilenameEncoding)}
>
<SelectTrigger className="h-7 w-[130px] text-xs" 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>
)}
<div className="flex items-center gap-1 text-sm flex-1 min-w-0 overflow-hidden">
{isEditingPath ? (
<Input
ref={pathInputRef}
value={editingPathValue}
onChange={(e) => setEditingPathValue(e.target.value)}
onBlur={handlePathSubmit}
onKeyDown={handlePathKeyDown}
className="h-7 text-sm bg-background"
autoFocus
/>
) : (
<div
className="flex items-center gap-1 flex-1 min-w-0 cursor-text hover:bg-secondary/50 rounded px-1 py-0.5 transition-colors"
onDoubleClick={handlePathDoubleClick}
title={currentPath}
>
<button
className="text-muted-foreground hover:text-foreground px-1 shrink-0"
onClick={onRootSelect}
>
{rootLabel}
</button>
{visibleBreadcrumbs.map(({ part, originalIndex }, displayIdx) => {
const isLast = originalIndex === breadcrumbs.length - 1;
const showEllipsisBefore =
needsBreadcrumbTruncation && displayIdx === 1;
return (
<React.Fragment key={originalIndex}>
{showEllipsisBefore && (
<>
<ChevronRight
size={12}
className="text-muted-foreground flex-shrink-0"
/>
<span
className="text-muted-foreground px-1 shrink-0 flex items-center cursor-default"
title={`${t("sftp.showHiddenPaths")}: ${hiddenBreadcrumbs
.map((h) => h.part)
.join(" > ")}`}
>
<MoreHorizontal size={14} />
</span>
</>
)}
<ChevronRight
size={12}
className="text-muted-foreground flex-shrink-0"
/>
<button
className={cn(
"text-muted-foreground hover:text-foreground truncate px-1 max-w-[100px]",
isLast && "text-foreground font-medium",
)}
onClick={() => onBreadcrumbSelect(originalIndex)}
title={part}
>
{part}
</button>
</React.Fragment>
);
})}
</div>
)}
</div>
<div className="flex items-center gap-2 ml-auto">
<Button
variant="outline"
size="sm"
className="h-7"
onClick={onTriggerUpload}
disabled={uploading}
>
<Upload size={14} className="mr-1.5" /> {t("sftp.upload")}
</Button>
<Button
variant="outline"
size="sm"
className="h-7"
onClick={onCreateFolder}
>
<Plus size={14} className="mr-1.5" /> {t("sftp.newFolder")}
</Button>
<Button
variant="outline"
size="sm"
className="h-7"
onClick={onCreateFile}
>
<Plus size={14} className="mr-1.5" /> {t("sftp.newFile")}
</Button>
<input
type="file"
className="hidden"
ref={inputRef}
onChange={onFileSelect}
multiple
/>
</div>
</div>
</>
);

View File

@@ -0,0 +1,175 @@
import React from "react";
import { Loader2, Upload, X, XCircle } from "lucide-react";
import { cn } from "../../lib/utils";
import { Button } from "../ui/button";
interface UploadTask {
id: string;
fileName: string;
totalBytes: number;
transferredBytes: number;
progress: number;
speed: number;
status: "pending" | "uploading" | "completed" | "failed" | "cancelled";
error?: string;
}
interface SftpModalUploadTasksProps {
tasks: UploadTask[];
t: (key: string, params?: Record<string, unknown>) => string;
onCancel?: () => void;
onDismiss?: (taskId: string) => void;
}
export const SftpModalUploadTasks: React.FC<SftpModalUploadTasksProps> = ({ tasks, t, onCancel, onDismiss }) => {
if (tasks.length === 0) return null;
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) => {
const formatSpeed = (bytesPerSec: number) => {
if (bytesPerSec <= 0) return "";
if (bytesPerSec >= 1024 * 1024)
return `${(bytesPerSec / (1024 * 1024)).toFixed(1)} MB/s`;
if (bytesPerSec >= 1024)
return `${(bytesPerSec / 1024).toFixed(1)} KB/s`;
return `${Math.round(bytesPerSec)} B/s`;
};
const formatBytes = (bytes: number) => {
if (bytes === 0) return "0 B";
if (bytes >= 1024 * 1024)
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${bytes} B`;
};
const remainingBytes = task.totalBytes - task.transferredBytes;
const remainingTime =
task.speed > 0 ? Math.ceil(remainingBytes / task.speed) : 0;
const remainingStr =
remainingTime > 60
? `~${Math.ceil(remainingTime / 60)}m left`
: remainingTime > 0
? `~${remainingTime}s left`
: "";
return (
<div
key={task.id}
className="px-4 py-2.5 flex items-center gap-3 border-b border-border/30 last:border-b-0"
>
<div className="shrink-0">
{task.status === "uploading" && (
<Loader2 size={14} className="animate-spin text-primary" />
)}
{task.status === "pending" && (
<Upload size={14} className="text-muted-foreground animate-pulse" />
)}
{task.status === "completed" && (
<Upload size={14} className="text-green-500" />
)}
{task.status === "failed" && (
<XCircle size={14} className="text-destructive" />
)}
{task.status === "cancelled" && (
<XCircle size={14} className="text-muted-foreground" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-medium truncate">
{task.fileName}
</span>
{task.status === "uploading" && task.speed > 0 && (
<span className="text-[10px] text-primary font-mono shrink-0">
{formatSpeed(task.speed)}
</span>
)}
{task.status === "uploading" && remainingStr && (
<span className="text-[10px] text-muted-foreground shrink-0">
{remainingStr}
</span>
)}
</div>
{(task.status === "uploading" || task.status === "pending") && (
<div className="mt-1.5 flex items-center gap-2">
<div className="flex-1 h-1.5 bg-secondary rounded-full overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all duration-150",
task.status === "pending"
? "bg-muted-foreground/50 animate-pulse w-full"
: "bg-primary",
)}
style={{
width:
task.status === "uploading"
? `${task.progress}%`
: undefined,
}}
/>
</div>
<span className="text-[10px] text-muted-foreground font-mono shrink-0 w-8 text-right">
{task.status === "uploading" ? `${Math.round(task.progress)}%` : "..."}
</span>
</div>
)}
{task.status === "uploading" && task.totalBytes > 0 && (
<div className="text-[10px] text-muted-foreground mt-0.5 font-mono">
{formatBytes(task.transferredBytes)} / {formatBytes(task.totalBytes)}
</div>
)}
{task.status === "completed" && (
<div className="text-[10px] text-green-600 mt-0.5">
Completed - {formatBytes(task.totalBytes)}
</div>
)}
{task.status === "cancelled" && (
<div className="text-[10px] text-muted-foreground mt-0.5">
Cancelled
</div>
)}
{task.status === "failed" && task.error && (
<div className="text-[10px] text-destructive truncate mt-0.5">
{task.error}
</div>
)}
</div>
<div className="shrink-0 flex items-center gap-1">
{task.status === "pending" && (
<span className="text-[10px] text-muted-foreground">
{t("sftp.task.waiting")}
</span>
)}
{(task.status === "uploading" || task.status === "pending") && onCancel && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive hover:text-destructive"
onClick={onCancel}
title={t("sftp.action.cancel")}
>
<X size={12} />
</Button>
)}
{(task.status === "completed" || task.status === "failed" || task.status === "cancelled") && onDismiss && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-foreground"
onClick={() => onDismiss(task.id)}
title={t("sftp.action.dismiss")}
>
<X size={12} />
</Button>
)}
</div>
</div>
);
})}
</div>
</div>
);
};

View File

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

View File

@@ -0,0 +1,108 @@
import { useCallback } from "react";
import type { RemoteFile } from "../../../types";
import { toast } from "../../ui/toast";
interface UseSftpModalCreateDeleteParams {
currentPath: string;
isLocalSession: boolean;
joinPath: (base: string, name: string) => string;
ensureSftp: () => Promise<string>;
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
deleteLocalFile: (path: string) => Promise<void>;
deleteSftp: (sftpId: string, path: string) => Promise<void>;
mkdirLocal: (path: string) => Promise<void>;
mkdirSftp: (sftpId: string, path: string) => Promise<void>;
writeLocalFile: (path: string, data: ArrayBuffer) => Promise<void>;
writeSftpBinary: (sftpId: string, path: string, data: ArrayBuffer) => Promise<void>;
writeSftp: (sftpId: string, path: string, data: string) => Promise<void>;
t: (key: string, params?: Record<string, unknown>) => string;
}
interface UseSftpModalCreateDeleteResult {
handleDelete: (file: RemoteFile) => Promise<void>;
handleCreateFolder: () => Promise<void>;
handleCreateFile: () => Promise<void>;
}
export const useSftpModalCreateDelete = ({
currentPath,
isLocalSession,
joinPath,
ensureSftp,
loadFiles,
deleteLocalFile,
deleteSftp,
mkdirLocal,
mkdirSftp,
writeLocalFile,
writeSftpBinary,
writeSftp,
t,
}: UseSftpModalCreateDeleteParams): UseSftpModalCreateDeleteResult => {
const handleDelete = useCallback(
async (file: RemoteFile) => {
if (file.name === "..") return;
if (!confirm(t("sftp.deleteConfirm.single", { name: file.name }))) return;
try {
const fullPath = joinPath(currentPath, file.name);
if (isLocalSession) {
await deleteLocalFile(fullPath);
} else {
await deleteSftp(await ensureSftp(), fullPath);
}
await loadFiles(currentPath, { force: true });
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.deleteFailed"),
"SFTP",
);
}
},
[currentPath, deleteLocalFile, deleteSftp, ensureSftp, isLocalSession, joinPath, loadFiles, t],
);
const handleCreateFolder = useCallback(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 handleCreateFile = useCallback(async () => {
const fileName = prompt(t("sftp.fileName.placeholder"));
if (!fileName) return;
try {
const fullPath = joinPath(currentPath, fileName);
if (isLocalSession) {
await writeLocalFile(fullPath, new ArrayBuffer(0));
} else {
try {
await writeSftpBinary(await ensureSftp(), fullPath, new ArrayBuffer(0));
} catch {
await writeSftp(await ensureSftp(), fullPath, "");
}
}
await loadFiles(currentPath, { force: true });
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.createFileFailed"),
"SFTP",
);
}
}, [currentPath, ensureSftp, isLocalSession, joinPath, loadFiles, t, writeLocalFile, writeSftp, writeSftpBinary]);
return { handleDelete, handleCreateFolder, handleCreateFile };
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,602 @@
import React, { useCallback, useState, useRef, useMemo } from "react";
import type { RemoteFile } from "../../../types";
import { toast } from "../../ui/toast";
import {
UploadController,
uploadFromDataTransfer,
uploadFromFileList,
UploadBridge,
UploadCallbacks,
UploadTaskInfo,
UploadProgress,
} from "../../../lib/uploadService";
interface UploadTask {
id: string;
fileName: string;
status: "pending" | "uploading" | "completed" | "failed" | "cancelled";
progress: number;
totalBytes: number;
transferredBytes: number;
speed: number;
startTime: number;
error?: string;
isDirectory?: boolean;
fileCount?: number;
completedCount?: number;
}
interface UseSftpModalTransfersParams {
currentPath: string;
isLocalSession: boolean;
joinPath: (base: string, name: string) => string;
ensureSftp: () => Promise<string>;
loadFiles: (path: string, options?: { force?: boolean }) => Promise<void>;
readLocalFile: (path: string) => Promise<ArrayBuffer>;
readSftp: (sftpId: string, path: string) => Promise<string>;
writeLocalFile: (path: string, data: ArrayBuffer) => Promise<void>;
writeSftpBinaryWithProgress: (
sftpId: string,
path: string,
data: ArrayBuffer,
taskId: string,
onProgress: (transferred: number, total: number, speed: number) => void,
onComplete: () => void,
onError: (error: string) => void,
) => Promise<boolean>;
writeSftpBinary: (sftpId: string, path: string, data: ArrayBuffer) => Promise<void>;
writeSftp: (sftpId: string, path: string, data: string) => Promise<void>;
mkdirLocal: (path: string) => Promise<void>;
mkdirSftp: (sftpId: string, path: string) => Promise<void>;
cancelSftpUpload?: (taskId: string) => Promise<unknown>;
startStreamTransfer?: (
options: {
transferId: string;
sourcePath: string;
targetPath: string;
sourceType: 'local' | 'sftp';
targetType: 'local' | 'sftp';
sourceSftpId?: string;
targetSftpId?: string;
totalBytes?: number;
},
onProgress?: (transferred: number, total: number, speed: number) => void,
onComplete?: () => void,
onError?: (error: string) => void
) => Promise<{ transferId: string; totalBytes?: number; error?: string }>;
cancelTransfer?: (transferId: string) => Promise<void>;
setLoading: (loading: boolean) => void;
t: (key: string, params?: Record<string, unknown>) => string;
}
interface UseSftpModalTransfersResult {
uploading: boolean;
uploadTasks: UploadTask[];
dragActive: boolean;
handleDownload: (file: RemoteFile) => Promise<void>;
handleUploadMultiple: (fileList: FileList) => Promise<void>;
handleUploadFromDrop: (dataTransfer: DataTransfer) => Promise<void>;
handleFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
handleDrag: (e: React.DragEvent) => void;
handleDrop: (e: React.DragEvent) => void;
cancelUpload: () => Promise<void>;
dismissTask: (taskId: string) => void;
}
export const useSftpModalTransfers = ({
currentPath,
isLocalSession,
joinPath,
ensureSftp,
loadFiles,
readLocalFile,
readSftp,
writeLocalFile,
writeSftpBinaryWithProgress,
writeSftpBinary,
mkdirLocal,
mkdirSftp,
cancelSftpUpload,
startStreamTransfer,
cancelTransfer,
setLoading,
t,
}: UseSftpModalTransfersParams): UseSftpModalTransfersResult => {
const [uploading, setUploading] = useState(false);
const [uploadTasks, setUploadTasks] = useState<UploadTask[]>([]);
const [dragActive, setDragActive] = useState(false);
// Upload controller for cancellation support
const uploadControllerRef = useRef<UploadController | null>(null);
// Cached SFTP ID to avoid multiple calls to ensureSftp
const cachedSftpIdRef = useRef<string | null>(null);
// Track cancelled transfer IDs to detect cancellation in bridge wrapper
const cancelledTransferIdsRef = useRef<Set<string>>(new Set());
const handleDownload = useCallback(
async (file: RemoteFile) => {
try {
const fullPath = joinPath(currentPath, file.name);
setLoading(true);
const content = isLocalSession
? await readLocalFile(fullPath)
: await readSftp(await ensureSftp(), fullPath);
const blob = new Blob([content], { type: "application/octet-stream" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = file.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (e) {
toast.error(
e instanceof Error ? e.message : t("sftp.error.downloadFailed"),
"SFTP",
);
} finally {
setLoading(false);
}
},
[currentPath, ensureSftp, isLocalSession, joinPath, readLocalFile, readSftp, setLoading, t],
);
// Create upload bridge that adapts the modal's functions to the service interface
const createUploadBridge = useMemo((): UploadBridge => {
return {
writeLocalFile,
mkdirLocal,
mkdirSftp: async (sftpId: string, path: string) => {
await mkdirSftp(sftpId, path);
},
writeSftpBinary: async (sftpId: string, path: string, data: ArrayBuffer) => {
await writeSftpBinary(sftpId, path, data);
},
writeSftpBinaryWithProgress: async (
sftpId: string,
path: string,
data: ArrayBuffer,
taskId: string,
onProgress: (transferred: number, total: number, speed: number) => void,
_onComplete?: () => void,
_onError?: (error: string) => void
) => {
try {
const result = await writeSftpBinaryWithProgress(
sftpId,
path,
data,
taskId,
onProgress,
() => { },
() => { }
);
// Check if this transfer was cancelled
const wasCancelled = cancelledTransferIdsRef.current.has(taskId);
if (wasCancelled) {
cancelledTransferIdsRef.current.delete(taskId);
}
return { success: result, cancelled: wasCancelled };
} catch (error) {
// Check if this was a user-initiated cancellation
const wasCancelled = cancelledTransferIdsRef.current.has(taskId);
if (wasCancelled) {
cancelledTransferIdsRef.current.delete(taskId);
return { success: false, cancelled: true };
}
// Real error - propagate it by re-throwing
throw error;
}
},
cancelSftpUpload,
startStreamTransfer: startStreamTransfer ? async (
options,
onProgress,
onComplete,
onError
) => {
try {
const result = await startStreamTransfer(options, onProgress, onComplete, onError);
const wasCancelled = cancelledTransferIdsRef.current.has(options.transferId);
if (wasCancelled) {
cancelledTransferIdsRef.current.delete(options.transferId);
}
// Handle case where result might be undefined (bridge not available)
if (!result) {
return { transferId: options.transferId, error: 'Stream transfer not available' };
}
return { ...result, cancelled: wasCancelled };
} catch (error) {
const wasCancelled = cancelledTransferIdsRef.current.has(options.transferId);
if (wasCancelled) {
cancelledTransferIdsRef.current.delete(options.transferId);
return { transferId: options.transferId, cancelled: true };
}
return { transferId: options.transferId, error: error instanceof Error ? error.message : String(error) };
}
} : undefined,
cancelTransfer,
};
}, [writeLocalFile, mkdirLocal, mkdirSftp, writeSftpBinary, writeSftpBinaryWithProgress, cancelSftpUpload, startStreamTransfer, cancelTransfer]);
// Create upload callbacks
const createUploadCallbacks = useCallback((): UploadCallbacks => {
return {
onScanningStart: (taskId: string) => {
const scanningTask: UploadTask = {
id: taskId,
fileName: "Scanning files...",
status: "pending",
progress: 0,
totalBytes: 0,
transferredBytes: 0,
speed: 0,
startTime: Date.now(),
isDirectory: true,
};
setUploadTasks(prev => [...prev, scanningTask]);
},
onScanningEnd: (taskId: string) => {
setUploadTasks(prev => prev.filter(t => t.id !== taskId));
},
onTaskCreated: (task: UploadTaskInfo) => {
const uploadTask: UploadTask = {
id: task.id,
fileName: task.displayName,
status: "uploading",
progress: 0,
totalBytes: task.totalBytes,
transferredBytes: 0,
speed: 0,
startTime: Date.now(),
isDirectory: task.isDirectory,
fileCount: task.fileCount,
completedCount: 0,
};
// Filter out any pending scanning tasks before adding the real task.
// This ensures that even if onScanningEnd's state update hasn't been applied yet
// (due to React state batching), the scanning placeholder will still be removed.
setUploadTasks(prev => [
...prev.filter(t => !(t.status === "pending" && t.fileName === "Scanning files...")),
uploadTask
]);
},
onTaskProgress: (taskId: string, progress: UploadProgress) => {
setUploadTasks(prev =>
prev.map(task =>
task.id === taskId && task.status === "uploading"
? {
...task,
transferredBytes: progress.transferred,
progress: progress.percent,
speed: progress.speed,
}
: task
)
);
},
onTaskCompleted: (taskId: string, totalBytes: number) => {
setUploadTasks(prev =>
prev.map(task =>
task.id === taskId
? {
...task,
status: "completed" as const,
progress: 100,
transferredBytes: totalBytes,
speed: 0,
}
: task
)
);
},
onTaskFailed: (taskId: string, error: string) => {
// Any error marks the task as failed
setUploadTasks(prev =>
prev.map(task =>
task.id === taskId
? {
...task,
status: "failed" as const,
error,
speed: 0,
}
: task
)
);
// Auto-clear failed tasks after 3 seconds
setTimeout(() => {
setUploadTasks(prev => prev.filter(t => t.id !== taskId));
}, 3000);
},
onTaskCancelled: (taskId: string) => {
setUploadTasks(prev =>
prev.map(task =>
task.id === taskId
? {
...task,
status: "cancelled" as const,
speed: 0,
}
: task
)
);
// Auto-clear cancelled tasks after 2 seconds
setTimeout(() => {
setUploadTasks(prev => prev.filter(t => t.id !== taskId));
}, 2000);
},
};
}, []);
const handleUploadMultiple = useCallback(
async (fileList: FileList) => {
console.log('[useSftpModalTransfers] handleUploadMultiple called', {
length: fileList.length,
currentPath,
isLocalSession
});
if (fileList.length === 0) return;
setUploading(true);
// Get SFTP ID for remote sessions
let sftpId: string | null = null;
if (!isLocalSession) {
sftpId = await ensureSftp();
cachedSftpIdRef.current = sftpId;
}
// Create controller for cancellation
const controller = new UploadController();
uploadControllerRef.current = controller;
const callbacks = createUploadCallbacks();
try {
await uploadFromFileList(
fileList,
{
targetPath: currentPath,
sftpId,
isLocal: isLocalSession,
bridge: createUploadBridge,
joinPath,
callbacks,
},
controller
);
await loadFiles(currentPath, { force: true });
// Auto-clear completed tasks after 3 seconds
setTimeout(() => {
setUploadTasks(prev => prev.filter(t => t.status !== "completed"));
}, 3000);
} catch (error) {
toast.error(
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
"SFTP"
);
} finally {
setUploading(false);
uploadControllerRef.current = null;
cachedSftpIdRef.current = null;
}
},
[currentPath, createUploadBridge, createUploadCallbacks, ensureSftp, isLocalSession, joinPath, loadFiles, t],
);
const handleUploadFromDrop = useCallback(
async (dataTransfer: DataTransfer) => {
setUploading(true);
// Get SFTP ID for remote sessions
let sftpId: string | null = null;
if (!isLocalSession) {
sftpId = await ensureSftp();
cachedSftpIdRef.current = sftpId;
}
// Create controller for cancellation
const controller = new UploadController();
uploadControllerRef.current = controller;
const callbacks = createUploadCallbacks();
try {
await uploadFromDataTransfer(
dataTransfer,
{
targetPath: currentPath,
sftpId,
isLocal: isLocalSession,
bridge: createUploadBridge,
joinPath,
callbacks,
},
controller
);
await loadFiles(currentPath, { force: true });
// Auto-clear completed tasks after 3 seconds
setTimeout(() => {
setUploadTasks(prev => prev.filter(t => t.status !== "completed"));
}, 3000);
} catch (error) {
toast.error(
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
"SFTP"
);
} finally {
setUploading(false);
uploadControllerRef.current = null;
cachedSftpIdRef.current = null;
}
},
[currentPath, createUploadBridge, createUploadCallbacks, ensureSftp, isLocalSession, joinPath, loadFiles, t],
);
// Handle upload from File array (used by file input after copying files)
const handleUploadFromFiles = useCallback(
async (files: File[]) => {
console.log('[useSftpModalTransfers] handleUploadFromFiles called', {
length: files.length,
currentPath,
isLocalSession
});
if (files.length === 0) return;
setUploading(true);
// Get SFTP ID for remote sessions
let sftpId: string | null = null;
if (!isLocalSession) {
sftpId = await ensureSftp();
cachedSftpIdRef.current = sftpId;
}
// Create controller for cancellation
const controller = new UploadController();
uploadControllerRef.current = controller;
const callbacks = createUploadCallbacks();
try {
await uploadFromFileList(
files,
{
targetPath: currentPath,
sftpId,
isLocal: isLocalSession,
bridge: createUploadBridge,
joinPath,
callbacks,
},
controller
);
await loadFiles(currentPath, { force: true });
// Auto-clear completed tasks after 3 seconds
setTimeout(() => {
setUploadTasks(prev => prev.filter(t => t.status !== "completed"));
}, 3000);
} catch (error) {
toast.error(
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
"SFTP"
);
} finally {
setUploading(false);
uploadControllerRef.current = null;
cachedSftpIdRef.current = null;
}
},
[currentPath, createUploadBridge, createUploadCallbacks, ensureSftp, isLocalSession, joinPath, loadFiles, t],
);
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
console.log('[useSftpModalTransfers] handleFileSelect called', {
files: e.target.files,
length: e.target.files?.length
});
if (e.target.files && e.target.files.length > 0) {
console.log('[useSftpModalTransfers] Starting upload for', e.target.files.length, 'files');
// Copy the files before clearing the input, because clearing the input
// will also clear the FileList reference
const files = Array.from(e.target.files);
// Clear input first to allow selecting the same file again
e.target.value = "";
// Now start the upload with the copied files
void handleUploadFromFiles(files);
} else {
e.target.value = "";
}
},
[handleUploadFromFiles],
);
const handleDrag = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === "dragenter" || e.type === "dragover") {
setDragActive(true);
} else if (e.type === "dragleave") {
setDragActive(false);
}
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
void handleUploadFromDrop(e.dataTransfer);
}
},
[handleUploadFromDrop],
);
const cancelUpload = useCallback(async () => {
console.log('[useSftpModalTransfers] cancelUpload called');
const controller = uploadControllerRef.current;
if (controller) {
// Mark all active transfer IDs as cancelled before calling cancel
const activeIds = controller.getActiveTransferIds();
console.log('[useSftpModalTransfers] Active transfer IDs:', activeIds);
for (const id of activeIds) {
cancelledTransferIdsRef.current.add(id);
}
await controller.cancel();
console.log('[useSftpModalTransfers] controller.cancel() completed');
} else {
console.log('[useSftpModalTransfers] No controller found');
}
// Always clear all uploading/pending tasks immediately, even without controller
setUploadTasks(prev => {
const hasActiveTasks = prev.some(t => t.status === "uploading" || t.status === "pending");
if (!hasActiveTasks) return prev;
return prev.map(task =>
task.status === "uploading" || task.status === "pending"
? { ...task, status: "cancelled" as const, speed: 0 }
: task
);
});
// Auto-clear cancelled tasks after 2 seconds
setTimeout(() => {
setUploadTasks(prev => prev.filter(t => t.status !== "cancelled"));
}, 2000);
// Also reset uploading state
setUploading(false);
}, []);
const dismissTask = useCallback((taskId: string) => {
setUploadTasks(prev => prev.filter(t => t.id !== taskId));
}, []);
return {
uploading,
uploadTasks,
dragActive,
handleDownload,
handleUploadMultiple,
handleUploadFromDrop,
handleFileSelect,
handleDrag,
handleDrop,
cancelUpload,
dismissTask,
};
};

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,7 @@
*/
import React, { createContext, useContext, useMemo, useSyncExternalStore } from "react";
import { Host, SftpFileEntry } from "../../types";
import { Host, SftpFileEntry, SftpFilenameEncoding } from "../../types";
// Types for the context
export interface SftpPaneCallbacks {
@@ -16,6 +16,7 @@ export interface SftpPaneCallbacks {
onNavigateTo: (path: string) => void;
onNavigateUp: () => void;
onRefresh: () => void;
onSetFilenameEncoding: (encoding: SftpFilenameEncoding) => void;
onOpenEntry: (entry: SftpFileEntry) => void;
onToggleSelection: (fileName: string, multiSelect: boolean) => void;
onRangeSelect: (fileNames: string[]) => void;

View File

@@ -38,9 +38,11 @@ const SftpHostPickerInner: React.FC<SftpHostPickerProps> = ({
const filteredHosts = useMemo(() => {
const term = hostSearch.trim().toLowerCase();
return hosts.filter(h =>
!term ||
// Filter out serial hosts - SFTP is not supported for serial connections
h.protocol !== "serial" &&
(!term ||
h.label.toLowerCase().includes(term) ||
h.hostname.toLowerCase().includes(term)
h.hostname.toLowerCase().includes(term))
).sort((a, b) => a.label.localeCompare(b.label));
}, [hosts, hostSearch]);
const sideLabel = side === 'left' ? t('common.left') : t('common.right');

View File

@@ -0,0 +1,196 @@
import React from "react";
import type { Host, SftpFileEntry } from "../../types";
import type { FileOpenerType, SystemAppInfo } from "../../lib/sftpFileUtils";
import type { useSftpState } from "../../application/state/useSftpState";
import { Button } from "../ui/button";
import FileOpenerDialog from "../FileOpenerDialog";
import TextEditorModal from "../TextEditorModal";
import { SftpConflictDialog, SftpHostPicker, SftpPermissionsDialog, SftpTransferItem } from "./index";
type SftpState = ReturnType<typeof useSftpState>;
interface SftpOverlaysProps {
hosts: Host[];
sftp: SftpState;
visibleTransfers: SftpState["transfers"];
showHostPickerLeft: boolean;
showHostPickerRight: boolean;
hostSearchLeft: string;
hostSearchRight: string;
setShowHostPickerLeft: (open: boolean) => void;
setShowHostPickerRight: (open: boolean) => void;
setHostSearchLeft: (value: string) => void;
setHostSearchRight: (value: string) => void;
handleHostSelectLeft: (host: Host | "local") => void;
handleHostSelectRight: (host: Host | "local") => void;
permissionsState: { file: SftpFileEntry; side: "left" | "right" } | null;
setPermissionsState: (state: { file: SftpFileEntry; side: "left" | "right" } | null) => void;
showTextEditor: boolean;
setShowTextEditor: (open: boolean) => void;
textEditorTarget: { file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null;
setTextEditorTarget: (target: { file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null) => void;
textEditorContent: string;
setTextEditorContent: (content: string) => void;
handleSaveTextFile: (content: string) => Promise<void>;
showFileOpenerDialog: boolean;
setShowFileOpenerDialog: (open: boolean) => void;
fileOpenerTarget: { file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null;
setFileOpenerTarget: (target: { file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null) => void;
handleFileOpenerSelect: (openerType: FileOpenerType, setAsDefault: boolean, systemApp?: SystemAppInfo) => void;
handleSelectSystemApp: (systemApp: { path: string; name: string }) => void;
}
export const SftpOverlays: React.FC<SftpOverlaysProps> = ({
hosts,
sftp,
visibleTransfers,
showHostPickerLeft,
showHostPickerRight,
hostSearchLeft,
hostSearchRight,
setShowHostPickerLeft,
setShowHostPickerRight,
setHostSearchLeft,
setHostSearchRight,
handleHostSelectLeft,
handleHostSelectRight,
permissionsState,
setPermissionsState,
showTextEditor,
setShowTextEditor,
textEditorTarget,
setTextEditorTarget,
textEditorContent,
setTextEditorContent,
handleSaveTextFile,
showFileOpenerDialog,
setShowFileOpenerDialog,
fileOpenerTarget,
setFileOpenerTarget,
handleFileOpenerSelect,
handleSelectSystemApp,
}) => {
return (
<>
{/* Host pickers for adding new tabs */}
<SftpHostPicker
open={showHostPickerLeft}
onOpenChange={setShowHostPickerLeft}
hosts={hosts}
side="left"
hostSearch={hostSearchLeft}
onHostSearchChange={setHostSearchLeft}
onSelectLocal={() => handleHostSelectLeft("local")}
onSelectHost={handleHostSelectLeft}
/>
<SftpHostPicker
open={showHostPickerRight}
onOpenChange={setShowHostPickerRight}
hosts={hosts}
side="right"
hostSearch={hostSearchRight}
onHostSearchChange={setHostSearchRight}
onSelectLocal={() => handleHostSelectRight("local")}
onSelectHost={handleHostSelectRight}
/>
{/* Transfer status area - shows folder uploads and file transfers */}
{sftp.transfers.length > 0 && (
<div className="border-t border-border/70 bg-secondary/80 backdrop-blur-sm shrink-0">
<div className="flex items-center justify-between px-4 py-2 text-xs text-muted-foreground border-b border-border/40">
<span className="font-medium">
Transfers
{sftp.activeTransfersCount > 0 && (
<span className="ml-2 text-primary">
({sftp.activeTransfersCount} active)
</span>
)}
</span>
{sftp.transfers.some(
(t) => t.status === "completed" || t.status === "cancelled",
) && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={sftp.clearCompletedTransfers}
>
Clear completed
</Button>
)}
</div>
<div className="max-h-40 overflow-auto">
{visibleTransfers.map((task) => (
<SftpTransferItem
key={task.id}
task={task}
onCancel={() => {
// External uploads use a different cancel mechanism
if (task.sourceConnectionId === "external") {
sftp.cancelExternalUpload();
}
sftp.cancelTransfer(task.id);
}}
onRetry={() => sftp.retryTransfer(task.id)}
onDismiss={() => sftp.dismissTransfer(task.id)}
/>
))}
</div>
</div>
)}
<SftpConflictDialog
conflicts={sftp.conflicts}
onResolve={sftp.resolveConflict}
formatFileSize={sftp.formatFileSize}
/>
<SftpPermissionsDialog
open={!!permissionsState}
onOpenChange={(open) => !open && setPermissionsState(null)}
file={permissionsState?.file ?? null}
onSave={(file, permissions) => {
if (permissionsState) {
const fullPath = sftp.joinPath(
permissionsState.side === "left"
? sftp.leftPane.connection?.currentPath || ""
: sftp.rightPane.connection?.currentPath || "",
file.name,
);
sftp.changePermissions(
permissionsState.side,
fullPath,
permissions,
);
}
setPermissionsState(null);
}}
/>
{/* Text Editor Modal */}
<TextEditorModal
open={showTextEditor}
onClose={() => {
setShowTextEditor(false);
setTextEditorTarget(null);
setTextEditorContent("");
}}
fileName={textEditorTarget?.file.name || ""}
initialContent={textEditorContent}
onSave={handleSaveTextFile}
/>
{/* File Opener Dialog */}
<FileOpenerDialog
open={showFileOpenerDialog}
onClose={() => {
setShowFileOpenerDialog(false);
setFileOpenerTarget(null);
}}
fileName={fileOpenerTarget?.file.name || ""}
onSelect={handleFileOpenerSelect}
onSelectSystemApp={handleSelectSystemApp}
/>
</>
);
};

View File

@@ -0,0 +1,313 @@
import React from "react";
import { Loader2, Trash2 } from "lucide-react";
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 { SftpHostPicker } from "./index";
import type { Host } from "../../types";
interface SftpPaneDialogsProps {
t: (key: string, params?: Record<string, unknown>) => string;
// New folder
showNewFolderDialog: boolean;
setShowNewFolderDialog: (open: boolean) => void;
newFolderName: string;
setNewFolderName: (value: string) => void;
handleCreateFolder: () => void;
isCreating: boolean;
// New file
showNewFileDialog: boolean;
setShowNewFileDialog: (open: boolean) => void;
newFileName: string;
setNewFileName: (value: string) => void;
fileNameError: string | null;
setFileNameError: (value: string | null) => void;
handleCreateFile: () => void;
isCreatingFile: boolean;
// Overwrite confirm
showOverwriteConfirm: boolean;
setShowOverwriteConfirm: (open: boolean) => void;
overwriteTarget: string | null;
handleOverwriteConfirm: () => void;
// Rename
showRenameDialog: boolean;
setShowRenameDialog: (open: boolean) => void;
renameName: string;
setRenameName: (value: string) => void;
handleRename: () => void;
isRenaming: boolean;
// Delete
showDeleteConfirm: boolean;
setShowDeleteConfirm: (open: boolean) => void;
deleteTargets: string[];
handleDelete: () => void;
isDeleting: boolean;
// Host picker (connected view)
showHostPicker: boolean;
setShowHostPicker: (open: boolean) => void;
hosts: Host[];
side: "left" | "right";
hostSearch: string;
setHostSearch: (value: string) => void;
onConnect: (host: Host | "local") => void;
onDisconnect: () => void;
}
export const SftpPaneDialogs: React.FC<SftpPaneDialogsProps> = ({
t,
showNewFolderDialog,
setShowNewFolderDialog,
newFolderName,
setNewFolderName,
handleCreateFolder,
isCreating,
showNewFileDialog,
setShowNewFileDialog,
newFileName,
setNewFileName,
fileNameError,
setFileNameError,
handleCreateFile,
isCreatingFile,
showOverwriteConfirm,
setShowOverwriteConfirm,
overwriteTarget,
handleOverwriteConfirm,
showRenameDialog,
setShowRenameDialog,
renameName,
setRenameName,
handleRename,
isRenaming,
showDeleteConfirm,
setShowDeleteConfirm,
deleteTargets,
handleDelete,
isDeleting,
showHostPicker,
setShowHostPicker,
hosts,
side,
hostSearch,
setHostSearch,
onConnect,
onDisconnect,
}) => (
<>
{/* Dialogs */}
<Dialog open={showNewFolderDialog} onOpenChange={setShowNewFolderDialog}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{t("sftp.newFolder")}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>{t("sftp.folderName")}</Label>
<Input
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
placeholder={t("sftp.folderName.placeholder")}
onKeyDown={(e) => e.key === "Enter" && handleCreateFolder()}
autoFocus
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowNewFolderDialog(false)}
>
{t("common.cancel")}
</Button>
<Button
onClick={handleCreateFolder}
disabled={!newFolderName.trim() || isCreating}
>
{isCreating && (
<Loader2 size={14} className="mr-2 animate-spin" />
)}
{t("common.create")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showNewFileDialog} onOpenChange={(open) => {
setShowNewFileDialog(open);
if (!open) {
setFileNameError(null);
}
}}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{t("sftp.newFile")}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>{t("sftp.fileName")}</Label>
<Input
value={newFileName}
onChange={(e) => {
setNewFileName(e.target.value);
setFileNameError(null);
}}
placeholder={t("sftp.fileName.placeholder")}
onKeyDown={(e) => e.key === "Enter" && handleCreateFile()}
autoFocus
/>
{fileNameError && (
<div className="text-xs text-destructive">{fileNameError}</div>
)}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowNewFileDialog(false)}
>
{t("common.cancel")}
</Button>
<Button
onClick={handleCreateFile}
disabled={!newFileName.trim() || isCreatingFile}
>
{isCreatingFile && (
<Loader2 size={14} className="mr-2 animate-spin" />
)}
{t("common.create")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Overwrite Confirmation Dialog */}
<Dialog open={showOverwriteConfirm} onOpenChange={setShowOverwriteConfirm}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{t("sftp.overwrite.title")}</DialogTitle>
<DialogDescription>
{t("sftp.overwrite.desc", { name: overwriteTarget || "" })}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowOverwriteConfirm(false)}
>
{t("common.cancel")}
</Button>
<Button
variant="destructive"
onClick={handleOverwriteConfirm}
>
{t("sftp.overwrite.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showRenameDialog} onOpenChange={setShowRenameDialog}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{t("sftp.rename.title")}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>{t("sftp.rename.newName")}</Label>
<Input
value={renameName}
onChange={(e) => setRenameName(e.target.value)}
placeholder={t("sftp.rename.placeholder")}
onKeyDown={(e) => e.key === "Enter" && handleRename()}
autoFocus
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowRenameDialog(false)}
>
{t("common.cancel")}
</Button>
<Button
onClick={handleRename}
disabled={!renameName.trim() || isRenaming}
>
{isRenaming && (
<Loader2 size={14} className="mr-2 animate-spin" />
)}
{t("common.rename")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>
{t("sftp.deleteConfirm.title", { count: deleteTargets.length })}
</DialogTitle>
<DialogDescription>
{t("sftp.deleteConfirm.desc")}
</DialogDescription>
</DialogHeader>
<div className="max-h-32 overflow-auto text-sm space-y-1">
{deleteTargets.map((name) => (
<div
key={name}
className="flex items-center gap-2 text-muted-foreground"
>
<Trash2 size={12} />
<span className="truncate">{name}</span>
</div>
))}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowDeleteConfirm(false)}
>
{t("common.cancel")}
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={isDeleting}
>
{isDeleting && (
<Loader2 size={14} className="mr-2 animate-spin" />
)}
{t("action.delete")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<SftpHostPicker
open={showHostPicker}
onOpenChange={setShowHostPicker}
hosts={hosts}
side={side}
hostSearch={hostSearch}
onHostSearchChange={setHostSearch}
onSelectLocal={() => {
onDisconnect();
onConnect("local");
}}
onSelectHost={(host) => {
onDisconnect();
onConnect(host);
}}
/>
</>
);

View File

@@ -0,0 +1,80 @@
import React from "react";
import { HardDrive, Monitor, Plus } from "lucide-react";
import { Button } from "../ui/button";
import { SftpHostPicker } from "./SftpHostPicker";
import type { Host } from "../../domain/models";
interface SftpPaneEmptyStateProps {
side: "left" | "right";
showEmptyHeader: boolean;
t: (key: string, params?: Record<string, unknown>) => string;
showHostPicker: boolean;
setShowHostPicker: (open: boolean) => void;
hostSearch: string;
setHostSearch: (value: string) => void;
hosts: Host[];
onConnect: (hostId: string) => void;
}
export const SftpPaneEmptyState: React.FC<SftpPaneEmptyStateProps> = ({
side,
showEmptyHeader,
t,
showHostPicker,
setShowHostPicker,
hostSearch,
setHostSearch,
hosts,
onConnect,
}) => {
return (
<div className="absolute inset-0 flex flex-col">
{showEmptyHeader && (
<div className="h-12 px-4 border-b border-border/60 flex items-center gap-3 shrink-0">
<div className="flex items-center gap-2 text-sm font-semibold text-muted-foreground">
{side === "left" ? <Monitor size={14} /> : <HardDrive size={14} />}
<span>
{side === "left" ? t("sftp.pane.local") : t("sftp.pane.remote")}
</span>
</div>
<Button
variant="outline"
size="sm"
className="h-8 px-3"
onClick={() => setShowHostPicker(true)}
>
<Plus size={14} className="mr-2" /> {t("sftp.pane.selectHost")}
</Button>
</div>
)}
<div className="flex-1 flex flex-col items-center justify-center text-center gap-4 p-6">
<div className="h-14 w-14 rounded-xl bg-secondary/60 text-primary flex items-center justify-center">
{side === "left" ? <Monitor size={24} /> : <HardDrive size={24} />}
</div>
<div>
<div className="text-sm font-semibold mb-1">
{t("sftp.pane.selectHostToStart")}
</div>
<div className="text-xs text-muted-foreground">
{t("sftp.pane.chooseFilesystem")}
</div>
</div>
<Button onClick={() => setShowHostPicker(true)}>
<Plus size={14} className="mr-2" /> {t("sftp.pane.selectHost")}
</Button>
</div>
<SftpHostPicker
open={showHostPicker}
onOpenChange={setShowHostPicker}
hosts={hosts}
side={side}
hostSearch={hostSearch}
onHostSearchChange={setHostSearch}
onSelectLocal={() => onConnect("local")}
onSelectHost={onConnect}
/>
</div>
);
};

View File

@@ -0,0 +1,433 @@
import React, { useCallback, useMemo } from "react";
import { AlertCircle, ArrowDown, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderPlus, Loader2, Pencil, RefreshCw, Shield, Trash2 } from "lucide-react";
import { Button } from "../ui/button";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "../ui/context-menu";
import { cn } from "../../lib/utils";
import type { SftpFileEntry } from "../../types";
import type { SftpPane } from "../../application/state/sftp/types";
import type { ColumnWidths, SortField, SortOrder } from "./utils";
import { isNavigableDirectory } from "./index";
import { isKnownBinaryFile } from "../../lib/sftpFileUtils";
import { SftpFileRow } from "./index";
interface SftpPaneFileListProps {
t: (key: string, params?: Record<string, unknown>) => string;
pane: SftpPane;
side: "left" | "right";
columnWidths: ColumnWidths;
sortField: SortField;
sortOrder: SortOrder;
handleSort: (field: SortField) => void;
handleResizeStart: (field: keyof ColumnWidths, e: React.MouseEvent) => void;
fileListRef: React.RefObject<HTMLDivElement>;
handleFileListScroll: (e: React.UIEvent<HTMLDivElement>) => void;
shouldVirtualize: boolean;
totalHeight: number;
sortedDisplayFiles: SftpFileEntry[];
isDragOverPane: boolean;
draggedFiles: { name: string; isDirectory: boolean; side: "left" | "right" }[] | null;
onRefresh: () => void;
setShowNewFolderDialog: (open: boolean) => void;
setShowNewFileDialog: (open: boolean) => void;
getNextUntitledName: (existingNames: string[]) => string;
setNewFileName: (value: string) => void;
setFileNameError: (value: string | null) => void;
// Row rendering
dragOverEntry: string | null;
handleRowSelect: (entry: SftpFileEntry, index: number, e: React.MouseEvent) => void;
handleRowOpen: (entry: SftpFileEntry) => void;
handleFileDragStart: (entry: SftpFileEntry, e: React.DragEvent) => void;
onDragEnd: () => void;
handleEntryDragOver: (entry: SftpFileEntry, e: React.DragEvent) => void;
handleRowDragLeave: () => void;
handleEntryDrop: (entry: SftpFileEntry, e: React.DragEvent) => void;
onCopyToOtherPane: (files: { name: string; isDirectory: boolean }[]) => void;
onOpenFileWith?: (entry: SftpFileEntry) => void;
onEditFile?: (entry: SftpFileEntry) => void;
onDownloadFile?: (entry: SftpFileEntry) => void;
onEditPermissions?: (entry: SftpFileEntry) => void;
openRenameDialog: (name: string) => void;
openDeleteConfirm: (targets: string[]) => void;
rowHeight: number;
visibleRows: { entry: SftpFileEntry; index: number; top: number }[];
}
export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
t,
pane,
side,
columnWidths,
sortField,
sortOrder,
handleSort,
handleResizeStart,
fileListRef,
handleFileListScroll,
shouldVirtualize,
totalHeight,
sortedDisplayFiles,
isDragOverPane,
draggedFiles,
onRefresh,
setShowNewFolderDialog,
setShowNewFileDialog,
getNextUntitledName,
setNewFileName,
setFileNameError,
dragOverEntry,
handleRowSelect,
handleRowOpen,
handleFileDragStart,
onDragEnd,
handleEntryDragOver,
handleRowDragLeave,
handleEntryDrop,
onCopyToOtherPane,
onOpenFileWith,
onEditFile,
onDownloadFile,
onEditPermissions,
openRenameDialog,
openDeleteConfirm,
rowHeight,
visibleRows,
}) => {
const filesByName = useMemo(() => {
const map = new Map<string, SftpFileEntry>();
sortedDisplayFiles.forEach((entry) => {
map.set(entry.name, entry);
});
return map;
}, [sortedDisplayFiles]);
const renderRow = useCallback(
(entry: SftpFileEntry, index: number) => (
<ContextMenu>
<ContextMenuTrigger>
<SftpFileRow
entry={entry}
index={index}
isSelected={pane.selectedFiles.has(entry.name)}
isDragOver={dragOverEntry === entry.name}
columnWidths={columnWidths}
onSelect={handleRowSelect}
onOpen={handleRowOpen}
onDragStart={handleFileDragStart}
onDragEnd={onDragEnd}
onDragOver={handleEntryDragOver}
onDragLeave={handleRowDragLeave}
onDrop={handleEntryDrop}
/>
</ContextMenuTrigger>
{entry.name !== ".." && (
<ContextMenuContent>
<ContextMenuItem onClick={() => handleRowOpen(entry)}>
{isNavigableDirectory(entry) ? (
<>
<Folder size={14} className="mr-2" /> {t("sftp.context.open")}
</>
) : (
<>
<ExternalLink size={14} className="mr-2" />{" "}
{t("sftp.context.open")}
</>
)}
</ContextMenuItem>
{!isNavigableDirectory(entry) && onOpenFileWith && (
<ContextMenuItem onClick={() => onOpenFileWith(entry)}>
<ExternalLink size={14} className="mr-2" />{" "}
{t("sftp.context.openWith")}
</ContextMenuItem>
)}
{!isNavigableDirectory(entry) && !isKnownBinaryFile(entry.name) && onEditFile && (
<ContextMenuItem onClick={() => onEditFile(entry)}>
<Edit2 size={14} className="mr-2" />{" "}
{t("sftp.context.edit")}
</ContextMenuItem>
)}
{!isNavigableDirectory(entry) && onDownloadFile && (
<ContextMenuItem onClick={() => onDownloadFile(entry)}>
<Download size={14} className="mr-2" />{" "}
{t("sftp.context.download")}
</ContextMenuItem>
)}
<ContextMenuSeparator />
<ContextMenuItem
onClick={() => {
const files = pane.selectedFiles.has(entry.name)
? Array.from(pane.selectedFiles)
: [entry.name];
const fileData = files.map((name) => {
const fileName = String(name);
const file = filesByName.get(fileName);
return {
name: fileName,
isDirectory: file ? isNavigableDirectory(file) : false,
};
});
onCopyToOtherPane(fileData);
}}
>
<Copy size={14} className="mr-2" />{" "}
{t("sftp.context.copyToOtherPane")}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => openRenameDialog(entry.name)}>
<Pencil size={14} className="mr-2" /> {t("common.rename")}
</ContextMenuItem>
{onEditPermissions && pane.connection && !pane.connection.isLocal && (
<ContextMenuItem onClick={() => onEditPermissions(entry)}>
<Shield size={14} className="mr-2" />{" "}
{t("sftp.context.permissions")}
</ContextMenuItem>
)}
<ContextMenuItem
className="text-destructive"
onClick={() => {
const files = pane.selectedFiles.has(entry.name)
? Array.from(pane.selectedFiles)
: [entry.name];
openDeleteConfirm(files);
}}
>
<Trash2 size={14} className="mr-2" /> {t("action.delete")}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem onClick={onRefresh}>
<RefreshCw size={14} className="mr-2" /> {t("common.refresh")}
</ContextMenuItem>
<ContextMenuItem onClick={() => setShowNewFolderDialog(true)}>
<FolderPlus size={14} className="mr-2" /> {t("sftp.newFolder")}
</ContextMenuItem>
<ContextMenuItem onClick={() => setShowNewFileDialog(true)}>
<FilePlus size={14} className="mr-2" /> {t("sftp.newFile")}
</ContextMenuItem>
</ContextMenuContent>
)}
</ContextMenu>
),
[
columnWidths,
dragOverEntry,
filesByName,
handleEntryDragOver,
handleEntryDrop,
handleFileDragStart,
handleRowDragLeave,
handleRowOpen,
handleRowSelect,
onCopyToOtherPane,
onDownloadFile,
onDragEnd,
onEditFile,
onEditPermissions,
onOpenFileWith,
onRefresh,
openDeleteConfirm,
openRenameDialog,
pane.connection,
pane.selectedFiles,
setShowNewFolderDialog,
setShowNewFileDialog,
t,
],
);
const fileRows = useMemo(
() =>
shouldVirtualize
? visibleRows.map(({ entry, index, top }) => (
<div
key={entry.name}
className="absolute left-0 right-0 border-b border-border/30"
style={{ top, height: rowHeight }}
>
{renderRow(entry, index)}
</div>
))
: sortedDisplayFiles.map((entry, index) => (
<React.Fragment key={entry.name}>
{renderRow(entry, index)}
</React.Fragment>
)),
[renderRow, rowHeight, shouldVirtualize, sortedDisplayFiles, visibleRows],
);
return (
<>
{/* File list header */}
<div
className="text-[11px] uppercase tracking-wide text-muted-foreground px-4 py-2 border-b border-border/40 bg-secondary/10 select-none"
style={{
display: "grid",
gridTemplateColumns: `${columnWidths.name}% ${columnWidths.modified}% ${columnWidths.size}% ${columnWidths.type}%`,
}}
>
<div
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2"
onClick={() => handleSort("name")}
>
<span>{t("sftp.columns.name")}</span>
{sortField === "name" && (
<span className="text-primary">
{sortOrder === "asc" ? "↑" : "↓"}
</span>
)}
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
onMouseDown={(e) => handleResizeStart("name", e)}
/>
</div>
<div
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2"
onClick={() => handleSort("modified")}
>
<span>{t("sftp.columns.modified")}</span>
{sortField === "modified" && (
<span className="text-primary">
{sortOrder === "asc" ? "↑" : "↓"}
</span>
)}
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
onMouseDown={(e) => handleResizeStart("modified", e)}
/>
</div>
<div
className="flex items-center gap-1 cursor-pointer hover:text-foreground relative pr-2 justify-end"
onClick={() => handleSort("size")}
>
{sortField === "size" && (
<span className="text-primary">
{sortOrder === "asc" ? "↑" : "↓"}
</span>
)}
<span>{t("sftp.columns.size")}</span>
<div
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-primary/50 transition-colors"
onMouseDown={(e) => handleResizeStart("size", e)}
/>
</div>
<div
className="flex items-center gap-1 cursor-pointer hover:text-foreground justify-end"
onClick={() => handleSort("type")}
>
{sortField === "type" && (
<span className="text-primary">
{sortOrder === "asc" ? "↑" : "↓"}
</span>
)}
<span>{t("sftp.columns.kind")}</span>
</div>
</div>
{/* File list with empty area context menu */}
<ContextMenu>
<ContextMenuTrigger asChild>
<div
ref={fileListRef}
className={cn(
"flex-1 min-h-0 overflow-y-auto relative",
isDragOverPane && "ring-2 ring-primary/30 ring-inset",
)}
onScroll={handleFileListScroll}
>
{pane.loading && sortedDisplayFiles.length === 0 ? (
<div className="flex items-center justify-center h-full">
<Loader2 size={24} className="animate-spin text-muted-foreground" />
</div>
) : pane.error && !pane.reconnecting ? (
<div className="flex flex-col items-center justify-center h-full gap-2 text-destructive">
<AlertCircle size={24} />
<span className="text-sm">{t(pane.error)}</span>
<Button variant="outline" size="sm" onClick={onRefresh}>
{t("sftp.retry")}
</Button>
</div>
) : sortedDisplayFiles.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<Folder size={32} className="mb-2 opacity-50" />
<span className="text-sm">{t("sftp.emptyDirectory")}</span>
</div>
) : (
<div
className={cn(
shouldVirtualize ? "relative" : "divide-y divide-border/30",
)}
style={shouldVirtualize ? { height: totalHeight } : undefined}
>
{fileRows}
</div>
)}
{/* Drop overlay */}
{isDragOverPane && draggedFiles && draggedFiles[0]?.side !== side && (
<div className="absolute inset-0 flex items-center justify-center bg-primary/5 pointer-events-none">
<div className="flex flex-col items-center gap-2 text-primary">
<ArrowDown size={32} />
<span className="text-sm font-medium">{t("sftp.dropFilesHere")}</span>
</div>
</div>
)}
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={onRefresh}>
<RefreshCw size={14} className="mr-2" />{t("sftp.context.refresh")}
</ContextMenuItem>
<ContextMenuItem onClick={() => setShowNewFolderDialog(true)}>
<FolderPlus size={14} className="mr-2" />{t("sftp.newFolder")}
</ContextMenuItem>
<ContextMenuItem onClick={() => {
const defaultName = getNextUntitledName(pane.files.map(f => f.name));
setNewFileName(defaultName);
setFileNameError(null);
setShowNewFileDialog(true);
}}>
<FilePlus size={14} className="mr-2" />{t("sftp.newFile")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
{/* Footer */}
<div className="h-9 shrink-0 px-4 flex items-center justify-between text-[11px] text-muted-foreground border-t border-border/40 bg-secondary/30">
<span>
{t("sftp.itemsCount", {
count: sortedDisplayFiles.filter((f) => f.name !== "..").length,
})}
{pane.selectedFiles.size > 0 &&
` - ${t("sftp.selectedCount", { count: pane.selectedFiles.size })}`}
</span>
<span className="truncate max-w-[200px]">
{pane.connection.currentPath}
</span>
</div>
{/* Loading overlay - covers entire pane when navigating directories */}
{pane.loading && sortedDisplayFiles.length > 0 && !pane.reconnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-background/40 backdrop-blur-[1px] pointer-events-none z-10">
<Loader2 size={24} className="animate-spin text-muted-foreground" />
</div>
)}
{/* Reconnecting overlay - shows when SFTP connection is lost and reconnecting */}
{pane.reconnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-20">
<div className="flex flex-col items-center gap-3 p-6 rounded-xl bg-secondary/90 border border-border/60 shadow-lg">
<Loader2 size={32} className="animate-spin text-primary" />
<div className="text-center">
<div className="text-sm font-medium">{t("sftp.reconnecting.title")}</div>
<div className="text-xs text-muted-foreground mt-1">{t("sftp.reconnecting.desc")}</div>
</div>
</div>
</div>
)}
</>
);
};

View File

@@ -0,0 +1,277 @@
import React from "react";
import { ChevronLeft, FilePlus, Folder, FolderPlus, Home, RefreshCw, Search, X } from "lucide-react";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select";
import { cn } from "../../lib/utils";
import { SftpBreadcrumb } from "./index";
import type { SftpFilenameEncoding } from "../../types";
import type { SftpPane } from "../../application/state/sftp/types";
interface SftpPaneToolbarProps {
t: (key: string, params?: Record<string, unknown>) => string;
pane: SftpPane;
onNavigateUp: () => void;
onNavigateTo: (path: string) => void;
onSetFilter: (value: string) => void;
onSetFilenameEncoding: (encoding: SftpFilenameEncoding) => void;
onRefresh: () => void;
showFilterBar: boolean;
setShowFilterBar: (open: boolean) => void;
filterInputRef: React.RefObject<HTMLInputElement>;
isEditingPath: boolean;
editingPathValue: string;
setEditingPathValue: (value: string) => void;
setShowPathSuggestions: (open: boolean) => void;
showPathSuggestions: boolean;
setPathSuggestionIndex: (value: number) => void;
pathSuggestions: { path: string; type: "folder" | "history" }[];
pathSuggestionIndex: number;
pathInputRef: React.RefObject<HTMLInputElement>;
pathDropdownRef: React.RefObject<HTMLDivElement>;
handlePathBlur: () => void;
handlePathKeyDown: (e: React.KeyboardEvent) => void;
handlePathDoubleClick: () => void;
handlePathSubmit: (pathOverride?: string) => void;
startTransition: React.TransitionStartFunction;
getNextUntitledName: (existingNames: string[]) => string;
setNewFileName: (value: string) => void;
setFileNameError: (value: string | null) => void;
setShowNewFileDialog: (open: boolean) => void;
setShowNewFolderDialog: (open: boolean) => void;
}
export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
t,
pane,
onNavigateUp,
onNavigateTo,
onSetFilter,
onSetFilenameEncoding,
onRefresh,
showFilterBar,
setShowFilterBar,
filterInputRef,
isEditingPath,
editingPathValue,
setEditingPathValue,
setShowPathSuggestions,
setPathSuggestionIndex,
showPathSuggestions,
pathSuggestions,
pathSuggestionIndex,
pathInputRef,
pathDropdownRef,
handlePathBlur,
handlePathKeyDown,
handlePathDoubleClick,
handlePathSubmit,
startTransition,
getNextUntitledName,
setNewFileName,
setFileNameError,
setShowNewFileDialog,
setShowNewFolderDialog,
}) => (
<>
{/* Toolbar - always visible when connected */}
<div className="h-7 px-2 flex items-center gap-1 border-b border-border/40 bg-secondary/20">
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={onNavigateUp}
title={t("sftp.goUp")}
>
<ChevronLeft size={12} />
</Button>
{/* Editable Breadcrumb with autocomplete */}
{isEditingPath ? (
<div className="relative flex-1">
<Input
ref={pathInputRef}
value={editingPathValue}
onChange={(e) => {
setEditingPathValue(e.target.value);
setShowPathSuggestions(true);
setPathSuggestionIndex(-1);
}}
onBlur={handlePathBlur}
onKeyDown={handlePathKeyDown}
onFocus={() => setShowPathSuggestions(true)}
className="h-5 w-full text-[10px] bg-background"
autoFocus
/>
{showPathSuggestions && pathSuggestions.length > 0 && (
<div
ref={pathDropdownRef}
className="absolute top-full left-0 right-0 mt-1 bg-popover border border-border rounded-md shadow-lg z-50 max-h-48 overflow-auto"
>
{pathSuggestions.map((suggestion, idx) => (
<button
key={suggestion.path}
type="button"
className={cn(
"w-full px-3 py-2 text-left text-xs flex items-center gap-2 hover:bg-secondary/60 transition-colors",
idx === pathSuggestionIndex && "bg-secondary/80",
)}
onMouseDown={(e) => {
e.preventDefault();
handlePathSubmit(suggestion.path);
}}
>
{suggestion.type === "folder" ? (
<Folder size={12} className="text-primary shrink-0" />
) : (
<Home
size={12}
className="text-muted-foreground shrink-0"
/>
)}
<span className="truncate font-mono">
{suggestion.path}
</span>
</button>
))}
</div>
)}
</div>
) : (
<div
className="flex-1 cursor-text hover:bg-secondary/50 rounded px-1 transition-colors"
onDoubleClick={handlePathDoubleClick}
title={t("sftp.path.doubleClickToEdit")}
>
<SftpBreadcrumb
path={pane.connection.currentPath}
onNavigate={onNavigateTo}
onHome={() =>
pane.connection?.homeDir &&
onNavigateTo(pane.connection.homeDir)
}
/>
</div>
)}
<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>
)}
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setShowNewFolderDialog(true)}
title={t("sftp.newFolder")}
>
<FolderPlus size={14} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => {
const defaultName = getNextUntitledName(pane.files.map(f => f.name));
setNewFileName(defaultName);
setFileNameError(null);
setShowNewFileDialog(true);
}}
title={t("sftp.newFile")}
>
<FilePlus size={14} />
</Button>
<Button
variant={showFilterBar || pane.filter ? "secondary" : "ghost"}
size="icon"
className={cn("h-6 w-6", pane.filter && "text-primary")}
onClick={() => {
setShowFilterBar(!showFilterBar);
if (!showFilterBar) {
setTimeout(() => filterInputRef.current?.focus(), 0);
}
}}
title={t("sftp.filter")}
>
<Search size={14} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={onRefresh}
title={t("common.refresh")}
>
<RefreshCw
size={14}
className={
pane.loading || pane.reconnecting ? "animate-spin" : ""
}
/>
</Button>
</div>
</div>
{/* Inline filter bar - appears below toolbar when search is active */}
{showFilterBar && (
<div className="h-8 px-3 flex items-center gap-2 border-b border-border/40 bg-secondary/10">
<div className="relative flex-1">
<Search
size={12}
className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
ref={filterInputRef}
value={pane.filter}
onChange={(e) =>
startTransition(() => onSetFilter(e.target.value))
}
placeholder={t("sftp.filter.placeholder")}
className="h-6 w-full pl-7 pr-7 text-xs bg-background"
onKeyDown={(e) => {
if (e.key === "Escape") {
if (pane.filter) {
startTransition(() => onSetFilter(""));
} else {
setShowFilterBar(false);
}
}
}}
/>
{pane.filter && (
<button
onClick={() => startTransition(() => onSetFilter(""))}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X size={12} />
</button>
)}
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0"
onClick={() => {
startTransition(() => onSetFilter(""));
setShowFilterBar(false);
}}
title={t("common.close")}
>
<X size={14} />
</Button>
</div>
)}
</>
);

View File

@@ -0,0 +1,370 @@
import React, { memo, useEffect, useRef, useState, useTransition } from "react";
import { useI18n } from "../../application/i18n/I18nProvider";
import { logger } from "../../lib/logger";
import { useRenderTracker } from "../../lib/useRenderTracker";
import { cn } from "../../lib/utils";
import { SftpPaneDialogs } from "./SftpPaneDialogs";
import { SftpPaneEmptyState } from "./SftpPaneEmptyState";
import { SftpPaneFileList } from "./SftpPaneFileList";
import { SftpPaneToolbar } from "./SftpPaneToolbar";
import {
useActiveTabId,
useSftpDrag,
useSftpHosts,
useSftpPaneCallbacks,
useSftpShowHiddenFiles,
} from "./index";
import type { SftpPane } from "../../application/state/sftp/types";
import { useSftpPaneDialogs } from "./hooks/useSftpPaneDialogs";
import { useSftpPaneDragAndSelect } from "./hooks/useSftpPaneDragAndSelect";
import { useSftpPaneFiles } from "./hooks/useSftpPaneFiles";
import { useSftpPanePath } from "./hooks/useSftpPanePath";
import { useSftpPaneSorting } from "./hooks/useSftpPaneSorting";
import { useSftpPaneVirtualList } from "./hooks/useSftpPaneVirtualList";
interface SftpPaneWrapperProps {
side: "left" | "right";
paneId: string;
isFirstPane: boolean;
children: React.ReactNode;
}
const SftpPaneWrapper = memo<SftpPaneWrapperProps>(({ side, paneId, isFirstPane, children }) => {
const activeTabId = useActiveTabId(side);
const isActive = activeTabId ? paneId === activeTabId : isFirstPane;
const containerStyle: React.CSSProperties = isActive
? {}
: { visibility: "hidden", pointerEvents: "none" };
return (
<div
className={cn("absolute inset-0", isActive ? "z-10" : "z-0")}
style={containerStyle}
>
{children}
</div>
);
});
SftpPaneWrapper.displayName = "SftpPaneWrapper";
interface SftpPaneViewProps {
side: "left" | "right";
pane: SftpPane;
showHeader?: boolean;
showEmptyHeader?: boolean;
}
const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
side,
pane,
showHeader = true,
showEmptyHeader = true,
}) => {
const isActive = true;
const callbacks = useSftpPaneCallbacks(side);
const { draggedFiles, onDragStart, onDragEnd } = useSftpDrag();
const hosts = useSftpHosts();
const showHiddenFiles = useSftpShowHiddenFiles();
const { t } = useI18n();
const [, startTransition] = useTransition();
const [showFilterBar, setShowFilterBar] = useState(false);
const filterInputRef = useRef<HTMLInputElement>(null);
useRenderTracker(`SftpPaneView[${side}]`, {
side,
paneId: pane.id,
paneConnected: pane.connected,
panePath: pane.currentPath,
showHeader,
draggedFilesCount: draggedFiles?.length ?? 0,
});
const { sortField, sortOrder, columnWidths, handleSort, handleResizeStart } = useSftpPaneSorting();
const { filteredFiles, sortedDisplayFiles } = useSftpPaneFiles({
files: pane.files,
filter: pane.filter,
connection: pane.connection,
showHiddenFiles,
sortField,
sortOrder,
});
const {
isEditingPath,
editingPathValue,
showPathSuggestions,
pathSuggestionIndex,
pathInputRef,
pathDropdownRef,
pathSuggestions,
setEditingPathValue,
setShowPathSuggestions,
setPathSuggestionIndex,
handlePathBlur,
handlePathKeyDown,
handlePathDoubleClick,
handlePathSubmit,
} = useSftpPanePath({
connection: pane.connection,
filteredFiles,
onNavigateTo: callbacks.onNavigateTo,
});
const {
showHostPicker,
hostSearch,
showNewFolderDialog,
newFolderName,
showNewFileDialog,
newFileName,
fileNameError,
showOverwriteConfirm,
overwriteTarget,
showRenameDialog,
renameTarget: _renameTarget,
renameName,
showDeleteConfirm,
deleteTargets,
isCreating,
isCreatingFile,
isRenaming,
isDeleting,
setShowHostPicker,
setHostSearch,
setShowNewFolderDialog,
setNewFolderName,
setShowNewFileDialog,
setNewFileName,
setFileNameError,
setShowOverwriteConfirm,
setShowRenameDialog,
setRenameName,
setShowDeleteConfirm,
handleCreateFolder,
handleCreateFile,
handleConfirmOverwrite,
handleRename,
handleDelete,
openRenameDialog,
openDeleteConfirm,
getNextUntitledName,
} = useSftpPaneDialogs({
t,
pane,
onCreateDirectory: callbacks.onCreateDirectory,
onCreateFile: callbacks.onCreateFile,
onRenameFile: callbacks.onRenameFile,
onDeleteFiles: callbacks.onDeleteFiles,
onClearSelection: callbacks.onClearSelection,
});
const {
dragOverEntry,
isDragOverPane,
paneContainerRef,
handlePaneDragOver,
handlePaneDragLeave,
handlePaneDrop,
handleFileDragStart,
handleEntryDragOver,
handleEntryDrop,
handleRowDragLeave,
handleRowSelect,
handleRowOpen,
} = useSftpPaneDragAndSelect({
side,
pane,
sortedDisplayFiles,
draggedFiles,
onDragStart,
onReceiveFromOtherPane: callbacks.onReceiveFromOtherPane,
onUploadExternalFiles: callbacks.onUploadExternalFiles,
onOpenEntry: callbacks.onOpenEntry,
onRangeSelect: callbacks.onRangeSelect,
onToggleSelection: callbacks.onToggleSelection,
});
const {
fileListRef,
rowHeight,
handleFileListScroll,
shouldVirtualize,
totalHeight,
visibleRows,
} = useSftpPaneVirtualList({
isActive,
sortedDisplayFiles,
});
const handleSortWithTransition = (field: typeof sortField) => {
startTransition(() => handleSort(field));
};
useEffect(() => {
logger.debug("SftpPaneView active state", {
side,
paneId: pane.id,
isActive,
});
}, [isActive, pane.id, side]);
if (!pane.connection) {
return (
<SftpPaneEmptyState
side={side}
showEmptyHeader={showEmptyHeader}
t={t}
showHostPicker={showHostPicker}
setShowHostPicker={setShowHostPicker}
hostSearch={hostSearch}
setHostSearch={setHostSearch}
hosts={hosts}
onConnect={callbacks.onConnect}
/>
);
}
return (
<div
ref={paneContainerRef}
className={cn(
"absolute inset-0 flex flex-col transition-colors",
isDragOverPane && "bg-primary/5",
)}
onDragOver={handlePaneDragOver}
onDragLeave={handlePaneDragLeave}
onDrop={handlePaneDrop}
>
<SftpPaneToolbar
t={t}
pane={pane}
onNavigateUp={callbacks.onNavigateUp}
onNavigateTo={callbacks.onNavigateTo}
onSetFilter={callbacks.onSetFilter}
onSetFilenameEncoding={callbacks.onSetFilenameEncoding}
onRefresh={callbacks.onRefresh}
showFilterBar={showFilterBar}
setShowFilterBar={setShowFilterBar}
filterInputRef={filterInputRef}
isEditingPath={isEditingPath}
editingPathValue={editingPathValue}
setEditingPathValue={setEditingPathValue}
setShowPathSuggestions={setShowPathSuggestions}
showPathSuggestions={showPathSuggestions}
setPathSuggestionIndex={setPathSuggestionIndex}
pathSuggestions={pathSuggestions}
pathSuggestionIndex={pathSuggestionIndex}
pathInputRef={pathInputRef}
pathDropdownRef={pathDropdownRef}
handlePathBlur={handlePathBlur}
handlePathKeyDown={handlePathKeyDown}
handlePathDoubleClick={handlePathDoubleClick}
handlePathSubmit={handlePathSubmit}
startTransition={startTransition}
getNextUntitledName={getNextUntitledName}
setNewFileName={setNewFileName}
setFileNameError={setFileNameError}
setShowNewFileDialog={setShowNewFileDialog}
setShowNewFolderDialog={setShowNewFolderDialog}
/>
<SftpPaneFileList
t={t}
pane={pane}
side={side}
columnWidths={columnWidths}
sortField={sortField}
sortOrder={sortOrder}
handleSort={handleSortWithTransition}
handleResizeStart={handleResizeStart}
fileListRef={fileListRef}
handleFileListScroll={handleFileListScroll}
shouldVirtualize={shouldVirtualize}
totalHeight={totalHeight}
sortedDisplayFiles={sortedDisplayFiles}
isDragOverPane={isDragOverPane}
draggedFiles={draggedFiles}
onRefresh={callbacks.onRefresh}
setShowNewFolderDialog={setShowNewFolderDialog}
setShowNewFileDialog={setShowNewFileDialog}
getNextUntitledName={getNextUntitledName}
setNewFileName={setNewFileName}
setFileNameError={setFileNameError}
dragOverEntry={dragOverEntry}
handleRowSelect={handleRowSelect}
handleRowOpen={handleRowOpen}
handleFileDragStart={handleFileDragStart}
onDragEnd={onDragEnd}
handleEntryDragOver={handleEntryDragOver}
handleRowDragLeave={handleRowDragLeave}
handleEntryDrop={handleEntryDrop}
onCopyToOtherPane={callbacks.onCopyToOtherPane}
onOpenFileWith={callbacks.onOpenFileWith}
onEditFile={callbacks.onEditFile}
onDownloadFile={callbacks.onDownloadFile}
onEditPermissions={callbacks.onEditPermissions}
openRenameDialog={openRenameDialog}
openDeleteConfirm={openDeleteConfirm}
rowHeight={rowHeight}
visibleRows={visibleRows}
/>
<SftpPaneDialogs
t={t}
showNewFolderDialog={showNewFolderDialog}
setShowNewFolderDialog={setShowNewFolderDialog}
newFolderName={newFolderName}
setNewFolderName={setNewFolderName}
handleCreateFolder={handleCreateFolder}
isCreating={isCreating}
showNewFileDialog={showNewFileDialog}
setShowNewFileDialog={setShowNewFileDialog}
newFileName={newFileName}
setNewFileName={setNewFileName}
fileNameError={fileNameError}
setFileNameError={setFileNameError}
handleCreateFile={handleCreateFile}
isCreatingFile={isCreatingFile}
showOverwriteConfirm={showOverwriteConfirm}
setShowOverwriteConfirm={setShowOverwriteConfirm}
overwriteTarget={overwriteTarget}
handleOverwriteConfirm={handleConfirmOverwrite}
showRenameDialog={showRenameDialog}
setShowRenameDialog={setShowRenameDialog}
renameName={renameName}
setRenameName={setRenameName}
handleRename={handleRename}
isRenaming={isRenaming}
showDeleteConfirm={showDeleteConfirm}
setShowDeleteConfirm={setShowDeleteConfirm}
deleteTargets={deleteTargets}
handleDelete={handleDelete}
isDeleting={isDeleting}
showHostPicker={showHostPicker}
setShowHostPicker={setShowHostPicker}
hosts={hosts}
side={side}
hostSearch={hostSearch}
setHostSearch={setHostSearch}
onConnect={callbacks.onConnect}
onDisconnect={callbacks.onDisconnect}
/>
</div>
);
};
const sftpPaneViewAreEqual = (
prev: SftpPaneViewProps,
next: SftpPaneViewProps,
): boolean => {
if (prev.pane !== next.pane) return false;
if (prev.side !== next.side) return false;
if (prev.showHeader !== next.showHeader) return false;
if (prev.showEmptyHeader !== next.showEmptyHeader) return false;
return true;
};
const SftpPaneView = memo(SftpPaneViewInner, sftpPaneViewAreEqual);
SftpPaneView.displayName = "SftpPaneView";
export { SftpPaneView, SftpPaneWrapper };

View File

@@ -5,12 +5,13 @@
import {
ArrowDown,
CheckCircle2,
FolderUp,
Loader2,
RefreshCw,
X,
XCircle,
} from 'lucide-react';
import React,{ memo } from 'react';
import React,{ memo, useRef, useEffect } from 'react';
import { cn } from '../../lib/utils';
import { TransferTask } from '../../types';
import { Button } from '../ui/button';
@@ -26,11 +27,49 @@ 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;
const speedFormatted = formatSpeed(task.speed);
// 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
const remainingBytes = task.totalBytes - task.transferredBytes;
const remainingTime = task.speed > 0
? Math.ceil(remainingBytes / task.speed)
const stableSpeed = lastSpeedRef.current > 0 ? lastSpeedRef.current : task.speed;
const remainingTime = stableSpeed > 0
? Math.ceil(remainingBytes / stableSpeed)
: 0;
const remainingFormatted = remainingTime > 60
? `~${Math.ceil(remainingTime / 60)}m left`
@@ -45,11 +84,17 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({ task, onCancel
? formatTransferBytes(task.totalBytes)
: '';
// Use the stable display speed
const speedFormatted = displaySpeedRef.current;
return (
<div className="flex items-center gap-3 px-4 py-2.5 bg-background/60 border-t border-border/40 backdrop-blur-sm">
<div className="h-6 w-6 rounded flex items-center justify-center shrink-0">
{task.status === 'transferring' && <Loader2 size={14} className="animate-spin text-primary" />}
{task.status === 'pending' && <ArrowDown size={14} className="text-muted-foreground animate-bounce" />}
{task.status === 'pending' && (task.isDirectory
? <FolderUp size={14} className="text-muted-foreground animate-pulse" />
: <ArrowDown size={14} className="text-muted-foreground animate-bounce" />
)}
{task.status === 'completed' && <CheckCircle2 size={14} className="text-green-500" />}
{task.status === 'failed' && <XCircle size={14} className="text-destructive" />}
{task.status === 'cancelled' && <XCircle size={14} className="text-muted-foreground" />}
@@ -59,10 +104,10 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({ task, onCancel
<div className="flex items-center gap-2">
<span className="text-sm truncate font-medium">{task.fileName}</span>
{task.status === 'transferring' && speedFormatted && (
<span className="text-xs text-primary/80 font-mono">{speedFormatted}</span>
<span className="text-xs text-primary/80 font-mono transition-opacity duration-300">{speedFormatted}</span>
)}
{task.status === 'transferring' && remainingFormatted && (
<span className="text-xs text-muted-foreground">{remainingFormatted}</span>
<span className="text-xs text-muted-foreground transition-opacity duration-300">{remainingFormatted}</span>
)}
</div>
{(task.status === 'transferring' || task.status === 'pending') && (
@@ -133,5 +178,44 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({ task, onCancel
);
};
export const SftpTransferItem = memo(SftpTransferItemInner);
// Custom comparison function to reduce unnecessary re-renders
// Only re-render if meaningful values change
const arePropsEqual = (
prevProps: SftpTransferItemProps,
nextProps: SftpTransferItemProps
): boolean => {
const prev = prevProps.task;
const next = nextProps.task;
// Always re-render on status change
if (prev.status !== next.status) return false;
// Always re-render on error change
if (prev.error !== next.error) return false;
// Always re-render on fileName change
if (prev.fileName !== next.fileName) return false;
// For transferring status, throttle updates based on progress
if (next.status === 'transferring') {
// Re-render if progress changed by more than 0.5%
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;
// 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
}
// For pending status, don't re-render unless status changes
if (next.status === 'pending') {
return true;
}
return true;
};
export const SftpTransferItem = memo(SftpTransferItemInner, arePropsEqual);
SftpTransferItem.displayName = 'SftpTransferItem';

View File

@@ -0,0 +1,283 @@
import { useCallback, useState } from "react";
import type { SftpPaneCallbacks } from "../SftpContext";
import type { SftpPane } from "../../../application/state/sftp/types";
interface UseSftpPaneDialogsParams {
t: (key: string, params?: Record<string, unknown>) => string;
pane: SftpPane;
onCreateDirectory: SftpPaneCallbacks["onCreateDirectory"];
onCreateFile: SftpPaneCallbacks["onCreateFile"];
onRenameFile: SftpPaneCallbacks["onRenameFile"];
onDeleteFiles: SftpPaneCallbacks["onDeleteFiles"];
onClearSelection: SftpPaneCallbacks["onClearSelection"];
}
interface UseSftpPaneDialogsResult {
showHostPicker: boolean;
hostSearch: string;
showNewFolderDialog: boolean;
newFolderName: string;
showNewFileDialog: boolean;
newFileName: string;
fileNameError: string | null;
showOverwriteConfirm: boolean;
overwriteTarget: string | null;
showRenameDialog: boolean;
renameTarget: string | null;
renameName: string;
showDeleteConfirm: boolean;
deleteTargets: string[];
isCreating: boolean;
isCreatingFile: boolean;
isRenaming: boolean;
isDeleting: boolean;
setShowHostPicker: (open: boolean) => void;
setHostSearch: (value: string) => void;
setShowNewFolderDialog: (open: boolean) => void;
setNewFolderName: (value: string) => void;
setShowNewFileDialog: (open: boolean) => void;
setNewFileName: (value: string) => void;
setFileNameError: (value: string | null) => void;
setShowOverwriteConfirm: (open: boolean) => void;
setShowRenameDialog: (open: boolean) => void;
setRenameName: (value: string) => void;
setShowDeleteConfirm: (open: boolean) => void;
handleCreateFolder: () => Promise<void>;
handleCreateFile: (forceOverwrite?: boolean) => Promise<void>;
handleConfirmOverwrite: () => Promise<void>;
handleRename: () => Promise<void>;
handleDelete: () => Promise<void>;
openRenameDialog: (name: string) => void;
openDeleteConfirm: (names: string[]) => void;
getNextUntitledName: (existingFiles: string[]) => string;
}
export const useSftpPaneDialogs = ({
t,
pane,
onCreateDirectory,
onCreateFile,
onRenameFile,
onDeleteFiles,
onClearSelection,
}: UseSftpPaneDialogsParams): UseSftpPaneDialogsResult => {
const [showHostPicker, setShowHostPicker] = useState(false);
const [hostSearch, setHostSearch] = useState("");
const [showNewFolderDialog, setShowNewFolderDialog] = useState(false);
const [newFolderName, setNewFolderName] = useState("");
const [showNewFileDialog, setShowNewFileDialog] = useState(false);
const [newFileName, setNewFileName] = useState("");
const [fileNameError, setFileNameError] = useState<string | null>(null);
const [showOverwriteConfirm, setShowOverwriteConfirm] = useState(false);
const [overwriteTarget, setOverwriteTarget] = useState<string | null>(null);
const [showRenameDialog, setShowRenameDialog] = useState(false);
const [renameTarget, setRenameTarget] = useState<string | null>(null);
const [renameName, setRenameName] = useState("");
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleteTargets, setDeleteTargets] = useState<string[]>([]);
const [isCreating, setIsCreating] = useState(false);
const [isCreatingFile, setIsCreatingFile] = useState(false);
const [isRenaming, setIsRenaming] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const validateFileName = useCallback(
(name: string): string | null => {
const INVALID_FILENAME_CHARS = /[/\\:*?"<>|]/;
const RESERVED_NAMES = new Set([
"CON",
"PRN",
"AUX",
"NUL",
"COM1",
"COM2",
"COM3",
"COM4",
"COM5",
"COM6",
"COM7",
"COM8",
"COM9",
"LPT1",
"LPT2",
"LPT3",
"LPT4",
"LPT5",
"LPT6",
"LPT7",
"LPT8",
"LPT9",
]);
const trimmed = name.trim();
if (!trimmed) return null;
const invalidMatch = trimmed.match(INVALID_FILENAME_CHARS);
if (invalidMatch) {
return t("sftp.error.invalidFileName", { chars: invalidMatch[0] });
}
const baseName = trimmed.split(".")[0].toUpperCase();
if (RESERVED_NAMES.has(baseName)) {
return t("sftp.error.reservedName");
}
return null;
},
[t],
);
const getNextUntitledName = useCallback((existingFiles: string[]): string => {
const existingSet = new Set(existingFiles.map((f) => f.toLowerCase()));
if (!existingSet.has("untitled.txt")) {
return "untitled.txt";
}
let counter = 1;
while (counter < 1000) {
const name = `untitled (${counter}).txt`;
if (!existingSet.has(name.toLowerCase())) {
return name;
}
counter++;
}
return `untitled_${Date.now()}.txt`;
}, []);
const handleCreateFolder = async () => {
if (!newFolderName.trim() || isCreating) return;
setIsCreating(true);
try {
await onCreateDirectory(newFolderName.trim());
setShowNewFolderDialog(false);
setNewFolderName("");
} catch {
/* Error handling */
} finally {
setIsCreating(false);
}
};
const handleCreateFile = async (forceOverwrite = false) => {
const trimmedName = newFileName.trim();
if (!trimmedName || isCreatingFile) return;
const error = validateFileName(trimmedName);
if (error) {
setFileNameError(error);
return;
}
if (!forceOverwrite) {
const existingFile = pane.files.find(
(f) =>
f.name.toLowerCase() === trimmedName.toLowerCase() && f.type === "file",
);
if (existingFile) {
setOverwriteTarget(trimmedName);
setShowOverwriteConfirm(true);
return;
}
}
setIsCreatingFile(true);
try {
await onCreateFile(trimmedName);
setShowNewFileDialog(false);
setShowOverwriteConfirm(false);
setOverwriteTarget(null);
setNewFileName("");
setFileNameError(null);
} catch {
/* Error handling */
} finally {
setIsCreatingFile(false);
}
};
const handleConfirmOverwrite = async () => {
await handleCreateFile(true);
};
const handleRename = async () => {
if (!renameTarget || !renameName.trim() || isRenaming) return;
setIsRenaming(true);
try {
await onRenameFile(renameTarget, renameName.trim());
setShowRenameDialog(false);
setRenameTarget(null);
setRenameName("");
} catch {
/* Error handling */
} finally {
setIsRenaming(false);
}
};
const handleDelete = async () => {
if (deleteTargets.length === 0 || isDeleting) return;
setIsDeleting(true);
try {
await onDeleteFiles(deleteTargets);
setShowDeleteConfirm(false);
setDeleteTargets([]);
onClearSelection();
} catch {
/* Error handling */
} finally {
setIsDeleting(false);
}
};
const openRenameDialog = useCallback((name: string) => {
setRenameTarget(name);
setRenameName(name);
setShowRenameDialog(true);
}, []);
const openDeleteConfirm = useCallback((names: string[]) => {
setDeleteTargets(names);
setShowDeleteConfirm(true);
}, []);
return {
showHostPicker,
hostSearch,
showNewFolderDialog,
newFolderName,
showNewFileDialog,
newFileName,
fileNameError,
showOverwriteConfirm,
overwriteTarget,
showRenameDialog,
renameTarget,
renameName,
showDeleteConfirm,
deleteTargets,
isCreating,
isCreatingFile,
isRenaming,
isDeleting,
setShowHostPicker,
setHostSearch,
setShowNewFolderDialog,
setNewFolderName,
setShowNewFileDialog,
setNewFileName,
setFileNameError,
setShowOverwriteConfirm,
setShowRenameDialog,
setRenameName,
setShowDeleteConfirm,
handleCreateFolder,
handleCreateFile,
handleConfirmOverwrite,
handleRename,
handleDelete,
openRenameDialog,
openDeleteConfirm,
getNextUntitledName,
};
};

View File

@@ -0,0 +1,206 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import type { SftpFileEntry } from "../../../types";
import type { SftpPaneCallbacks, SftpDragCallbacks } from "../SftpContext";
import { isNavigableDirectory } from "../index";
interface UseSftpPaneDragAndSelectParams {
side: "left" | "right";
pane: { selectedFiles: Set<string> };
sortedDisplayFiles: SftpFileEntry[];
draggedFiles: { name: string; isDirectory: boolean; side: "left" | "right" }[] | null;
onDragStart: SftpDragCallbacks["onDragStart"];
onReceiveFromOtherPane: SftpPaneCallbacks["onReceiveFromOtherPane"];
onUploadExternalFiles?: SftpPaneCallbacks["onUploadExternalFiles"];
onOpenEntry: SftpPaneCallbacks["onOpenEntry"];
onRangeSelect: SftpPaneCallbacks["onRangeSelect"];
onToggleSelection: SftpPaneCallbacks["onToggleSelection"];
}
interface UseSftpPaneDragAndSelectResult {
dragOverEntry: string | null;
isDragOverPane: boolean;
paneContainerRef: React.RefObject<HTMLDivElement>;
handlePaneDragOver: (e: React.DragEvent) => void;
handlePaneDragLeave: (e: React.DragEvent) => void;
handlePaneDrop: (e: React.DragEvent) => Promise<void>;
handleFileDragStart: (entry: SftpFileEntry, e: React.DragEvent) => void;
handleEntryDragOver: (entry: SftpFileEntry, e: React.DragEvent) => void;
handleEntryDrop: (entry: SftpFileEntry, e: React.DragEvent) => void;
handleRowDragLeave: () => void;
handleRowSelect: (entry: SftpFileEntry, index: number, e: React.MouseEvent) => void;
handleRowOpen: (entry: SftpFileEntry) => void;
}
export const useSftpPaneDragAndSelect = ({
side,
pane,
sortedDisplayFiles,
draggedFiles,
onDragStart,
onReceiveFromOtherPane,
onUploadExternalFiles,
onOpenEntry,
onRangeSelect,
onToggleSelection,
}: UseSftpPaneDragAndSelectParams): UseSftpPaneDragAndSelectResult => {
const [dragOverEntry, setDragOverEntry] = useState<string | null>(null);
const [isDragOverPane, setIsDragOverPane] = useState(false);
const paneContainerRef = useRef<HTMLDivElement>(null);
const lastSelectedIndexRef = useRef<number | null>(null);
const selectedFilesRef = useRef(pane.selectedFiles);
const sortedFilesRef = useRef(sortedDisplayFiles);
useEffect(() => {
selectedFilesRef.current = pane.selectedFiles;
}, [pane.selectedFiles]);
useEffect(() => {
sortedFilesRef.current = sortedDisplayFiles;
}, [sortedDisplayFiles]);
const handlePaneDragOver = (e: React.DragEvent) => {
const hasFiles = e.dataTransfer.types.includes("Files");
if (hasFiles) {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
setIsDragOverPane(true);
return;
}
if (!draggedFiles || draggedFiles[0]?.side === side) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
setIsDragOverPane(true);
};
const handlePaneDragLeave = (e: React.DragEvent) => {
const relatedTarget = e.relatedTarget as Node | null;
if (relatedTarget && paneContainerRef.current?.contains(relatedTarget)) return;
setIsDragOverPane(false);
setDragOverEntry(null);
};
const handlePaneDrop = async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOverPane(false);
setDragOverEntry(null);
if (draggedFiles && draggedFiles.length > 0) {
if (draggedFiles[0]?.side !== side) {
onReceiveFromOtherPane(
draggedFiles.map((f) => ({ name: f.name, isDirectory: f.isDirectory })),
);
}
return;
}
if (e.dataTransfer.items.length > 0 && onUploadExternalFiles) {
await onUploadExternalFiles(e.dataTransfer);
}
};
const handleFileDragStart = useCallback(
(entry: SftpFileEntry, e: React.DragEvent) => {
if (entry.name === "..") {
e.preventDefault();
return;
}
const selectedNames = Array.from(selectedFilesRef.current);
const files = selectedNames.includes(entry.name)
? sortedFilesRef.current
.filter((f) => selectedNames.includes(f.name))
.map((f) => ({
name: f.name,
isDirectory: isNavigableDirectory(f),
side,
}))
: [
{
name: entry.name,
isDirectory: isNavigableDirectory(entry),
side,
},
];
e.dataTransfer.effectAllowed = "copy";
e.dataTransfer.setData("text/plain", files.map((f) => f.name).join("\n"));
onDragStart(files, side);
},
[onDragStart, side],
);
const handleEntryDragOver = useCallback(
(entry: SftpFileEntry, e: React.DragEvent) => {
if (!draggedFiles || draggedFiles[0]?.side === side) return;
if (isNavigableDirectory(entry) && entry.name !== "..") {
e.preventDefault();
e.stopPropagation();
setDragOverEntry(entry.name);
}
},
[draggedFiles, side],
);
const handleEntryDrop = useCallback(
(entry: SftpFileEntry, e: React.DragEvent) => {
if (!draggedFiles || draggedFiles[0]?.side === side) return;
if (isNavigableDirectory(entry) && entry.name !== "..") {
e.preventDefault();
e.stopPropagation();
setDragOverEntry(null);
setIsDragOverPane(false);
onReceiveFromOtherPane(
draggedFiles.map((f) => ({ name: f.name, isDirectory: f.isDirectory })),
);
}
},
[draggedFiles, onReceiveFromOtherPane, side],
);
const handleRowSelect = useCallback(
(entry: SftpFileEntry, index: number, e: React.MouseEvent) => {
if (entry.name === "..") return;
if (e.shiftKey && lastSelectedIndexRef.current !== null) {
const start = Math.min(lastSelectedIndexRef.current, index);
const end = Math.max(lastSelectedIndexRef.current, index);
const selectedFileNames = sortedDisplayFiles
.slice(start, end + 1)
.filter((f) => f.name !== "..")
.map((f) => f.name);
onRangeSelect(selectedFileNames);
} else {
onToggleSelection(entry.name, e.ctrlKey || e.metaKey);
lastSelectedIndexRef.current = index;
}
},
[onRangeSelect, onToggleSelection, sortedDisplayFiles],
);
const handleRowOpen = useCallback(
(entry: SftpFileEntry) => {
onOpenEntry(entry);
},
[onOpenEntry],
);
const handleRowDragLeave = useCallback(() => {
setDragOverEntry(null);
}, []);
return {
dragOverEntry,
isDragOverPane,
paneContainerRef,
handlePaneDragOver,
handlePaneDragLeave,
handlePaneDrop,
handleFileDragStart,
handleEntryDragOver,
handleEntryDrop,
handleRowDragLeave,
handleRowSelect,
handleRowOpen,
};
};

View File

@@ -0,0 +1,99 @@
import { useMemo } from "react";
import type { SftpFileEntry } from "../../../types";
import type { SftpPane } from "../../../application/state/sftp/types";
import type { SortField, SortOrder } from "../utils";
import { filterHiddenFiles } from "../index";
interface UseSftpPaneFilesParams {
files: SftpFileEntry[];
filter: string;
connection: SftpPane["connection"] | null;
showHiddenFiles: boolean;
sortField: SortField;
sortOrder: SortOrder;
}
interface UseSftpPaneFilesResult {
filteredFiles: SftpFileEntry[];
displayFiles: SftpFileEntry[];
sortedDisplayFiles: SftpFileEntry[];
}
export const useSftpPaneFiles = ({
files,
filter,
connection,
showHiddenFiles,
sortField,
sortOrder,
}: UseSftpPaneFilesParams): UseSftpPaneFilesResult => {
const filteredFiles = useMemo(() => {
const term = filter.trim().toLowerCase();
let nextFiles = filterHiddenFiles(files, showHiddenFiles);
if (!term) return nextFiles;
return nextFiles.filter(
(f) => f.name === ".." || f.name.toLowerCase().includes(term),
);
}, [files, filter, showHiddenFiles]);
const displayFiles = useMemo(() => {
if (!connection) return [];
const isRootPath =
connection.currentPath === "/" ||
/^[A-Za-z]:[\\/]?$/.test(connection.currentPath);
if (isRootPath) return filteredFiles;
const parentEntry: SftpFileEntry = {
name: "..",
type: "directory",
size: 0,
sizeFormatted: "--",
lastModified: 0,
lastModifiedFormatted: "--",
};
return [parentEntry, ...filteredFiles.filter((f) => f.name !== "..")] ;
}, [connection, filteredFiles]);
const sortedDisplayFiles = useMemo(() => {
if (!displayFiles.length) return displayFiles;
const parentEntry = displayFiles.find((f) => f.name === "..");
const otherFiles = displayFiles.filter((f) => f.name !== "..");
const sorted = [...otherFiles].sort((a, b) => {
if (sortField !== "type") {
if (a.type === "directory" && b.type !== "directory") return -1;
if (a.type !== "directory" && b.type === "directory") return 1;
}
let cmp = 0;
switch (sortField) {
case "name":
cmp = a.name.localeCompare(b.name);
break;
case "size":
cmp = (a.size || 0) - (b.size || 0);
break;
case "modified":
cmp = (a.lastModified || 0) - (b.lastModified || 0);
break;
case "type": {
const extA =
a.type === "directory"
? "folder"
: a.name.split(".").pop()?.toLowerCase() || "";
const extB =
b.type === "directory"
? "folder"
: b.name.split(".").pop()?.toLowerCase() || "";
cmp = extA.localeCompare(extB);
break;
}
}
return sortOrder === "asc" ? cmp : -cmp;
});
return parentEntry ? [parentEntry, ...sorted] : sorted;
}, [displayFiles, sortField, sortOrder]);
return { filteredFiles, displayFiles, sortedDisplayFiles };
};

View File

@@ -0,0 +1,160 @@
import React, { useCallback, useMemo, useRef, useState } from "react";
import type { SftpFileEntry } from "../../../types";
import type { SftpPane } from "../../../application/state/sftp/types";
import { isNavigableDirectory } from "../index";
interface UseSftpPanePathParams {
connection: SftpPane["connection"] | null;
filteredFiles: SftpFileEntry[];
onNavigateTo: (path: string) => void;
}
interface UseSftpPanePathResult {
isEditingPath: boolean;
editingPathValue: string;
showPathSuggestions: boolean;
pathSuggestionIndex: number;
pathInputRef: React.RefObject<HTMLInputElement>;
pathDropdownRef: React.RefObject<HTMLDivElement>;
pathSuggestions: { path: string; type: "folder" | "history" }[];
setEditingPathValue: (value: string) => void;
setShowPathSuggestions: (value: boolean) => void;
setPathSuggestionIndex: (value: number) => void;
handlePathBlur: () => void;
handlePathKeyDown: (e: React.KeyboardEvent) => void;
handlePathDoubleClick: () => void;
handlePathSubmit: (pathOverride?: string) => void;
}
export const useSftpPanePath = ({
connection,
filteredFiles,
onNavigateTo,
}: UseSftpPanePathParams): UseSftpPanePathResult => {
const [isEditingPath, setIsEditingPath] = useState(false);
const [editingPathValue, setEditingPathValue] = useState("");
const [showPathSuggestions, setShowPathSuggestions] = useState(false);
const [pathSuggestionIndex, setPathSuggestionIndex] = useState(-1);
const pathInputRef = useRef<HTMLInputElement>(null);
const pathDropdownRef = useRef<HTMLDivElement>(null);
const pathSuggestions = useMemo(() => {
if (!isEditingPath || !connection) return [];
const currentValue = editingPathValue.trim().toLowerCase();
const suggestions: { path: string; type: "folder" | "history" }[] = [];
const folders = filteredFiles.filter(
(f) => isNavigableDirectory(f) && f.name !== "..",
);
folders.forEach((f) => {
const fullPath =
connection.currentPath === "/"
? `/${f.name}`
: `${connection.currentPath}/${f.name}`;
if (
!currentValue ||
fullPath.toLowerCase().includes(currentValue) ||
f.name.toLowerCase().includes(currentValue)
) {
suggestions.push({ path: fullPath, type: "folder" });
}
});
const quickPaths = ["/home", "/var", "/etc", "/tmp", "/usr", "/opt", "/root"];
quickPaths.forEach((qp) => {
if (!currentValue || qp.toLowerCase().includes(currentValue)) {
if (!suggestions.some((s) => s.path === qp)) {
suggestions.push({ path: qp, type: "history" });
}
}
});
return suggestions.slice(0, 8);
}, [connection, editingPathValue, filteredFiles, isEditingPath]);
const handlePathDoubleClick = () => {
if (!connection) return;
setEditingPathValue(connection.currentPath);
setIsEditingPath(true);
setShowPathSuggestions(true);
setPathSuggestionIndex(-1);
setTimeout(() => pathInputRef.current?.select(), 0);
};
const handlePathSubmit = useCallback((pathOverride?: string) => {
const newPath = (pathOverride ?? editingPathValue).trim() || "/";
setIsEditingPath(false);
setShowPathSuggestions(false);
setPathSuggestionIndex(-1);
if (connection && newPath !== connection.currentPath) {
const isWindowsPath = /^[A-Za-z]:/.test(newPath);
if (isWindowsPath) {
let normalizedPath = newPath;
if (/^[A-Za-z]:[\\/]?$/.test(newPath)) {
normalizedPath = newPath.charAt(0).toUpperCase() + ":\\";
}
onNavigateTo(normalizedPath);
} else {
onNavigateTo(newPath.startsWith("/") ? newPath : `/${newPath}`);
}
}
}, [connection, editingPathValue, onNavigateTo]);
const handlePathKeyDown = (e: React.KeyboardEvent) => {
if (showPathSuggestions && pathSuggestions.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
setPathSuggestionIndex((prev) =>
prev < pathSuggestions.length - 1 ? prev + 1 : 0,
);
return;
} else if (e.key === "ArrowUp") {
e.preventDefault();
setPathSuggestionIndex((prev) =>
prev > 0 ? prev - 1 : pathSuggestions.length - 1,
);
return;
} else if (e.key === "Tab" && pathSuggestionIndex >= 0) {
e.preventDefault();
setEditingPathValue(pathSuggestions[pathSuggestionIndex].path);
return;
}
}
if (e.key === "Enter") {
if (pathSuggestionIndex >= 0 && pathSuggestions[pathSuggestionIndex]) {
handlePathSubmit(pathSuggestions[pathSuggestionIndex].path);
} else {
handlePathSubmit();
}
} else if (e.key === "Escape") {
setIsEditingPath(false);
setShowPathSuggestions(false);
setPathSuggestionIndex(-1);
}
};
const handlePathBlur = useCallback(() => {
setTimeout(() => {
if (!pathDropdownRef.current?.contains(document.activeElement)) {
handlePathSubmit();
}
}, 150);
}, [handlePathSubmit]);
return {
isEditingPath,
editingPathValue,
showPathSuggestions,
pathSuggestionIndex,
pathInputRef,
pathDropdownRef,
pathSuggestions,
setEditingPathValue,
setShowPathSuggestions,
setPathSuggestionIndex,
handlePathBlur,
handlePathKeyDown,
handlePathDoubleClick,
handlePathSubmit,
};
};

View File

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

View File

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

View File

@@ -0,0 +1,441 @@
import React, { useCallback, useState } from "react";
import type { MutableRefObject } from "react";
import type { SftpFileEntry } from "../../../types";
import type { SftpStateApi } from "../../../application/state/useSftpState";
import { logger } from "../../../lib/logger";
import { toast } from "../../ui/toast";
import { getFileExtension, FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
import { isNavigableDirectory } from "../index";
interface UseSftpViewFileOpsParams {
sftpRef: MutableRefObject<SftpStateApi>;
behaviorRef: MutableRefObject<string>;
autoSyncRef: MutableRefObject<boolean>;
getOpenerForFileRef: MutableRefObject<
(fileName: string) => { openerType?: FileOpenerType; systemApp?: SystemAppInfo } | null
>;
setOpenerForExtension: (
extension: string,
openerType: FileOpenerType,
systemApp?: SystemAppInfo,
) => void;
t: (key: string, vars?: Record<string, string | number>) => string;
}
interface UseSftpViewFileOpsResult {
permissionsState: { file: SftpFileEntry; side: "left" | "right" } | null;
setPermissionsState: React.Dispatch<
React.SetStateAction<{ file: SftpFileEntry; side: "left" | "right" } | null>
>;
showTextEditor: boolean;
setShowTextEditor: React.Dispatch<React.SetStateAction<boolean>>;
textEditorTarget: {
file: SftpFileEntry;
side: "left" | "right";
fullPath: string;
} | null;
setTextEditorTarget: React.Dispatch<
React.SetStateAction<{
file: SftpFileEntry;
side: "left" | "right";
fullPath: string;
} | null>
>;
textEditorContent: string;
setTextEditorContent: React.Dispatch<React.SetStateAction<string>>;
loadingTextContent: boolean;
showFileOpenerDialog: boolean;
setShowFileOpenerDialog: React.Dispatch<React.SetStateAction<boolean>>;
fileOpenerTarget: {
file: SftpFileEntry;
side: "left" | "right";
fullPath: string;
} | null;
setFileOpenerTarget: React.Dispatch<
React.SetStateAction<{
file: SftpFileEntry;
side: "left" | "right";
fullPath: string;
} | null>
>;
handleSaveTextFile: (content: string) => Promise<void>;
handleFileOpenerSelect: (
openerType: FileOpenerType,
setAsDefault: boolean,
systemApp?: SystemAppInfo,
) => Promise<void>;
handleSelectSystemApp: () => Promise<SystemAppInfo | null>;
onEditPermissionsLeft: (file: SftpFileEntry) => void;
onEditPermissionsRight: (file: SftpFileEntry) => void;
onOpenEntryLeft: (entry: SftpFileEntry) => void;
onOpenEntryRight: (entry: SftpFileEntry) => void;
onEditFileLeft: (file: SftpFileEntry) => void;
onEditFileRight: (file: SftpFileEntry) => void;
onOpenFileLeft: (file: SftpFileEntry) => void;
onOpenFileRight: (file: SftpFileEntry) => void;
onOpenFileWithLeft: (file: SftpFileEntry) => void;
onOpenFileWithRight: (file: SftpFileEntry) => void;
onDownloadFileLeft: (file: SftpFileEntry) => void;
onDownloadFileRight: (file: SftpFileEntry) => void;
onUploadExternalFilesLeft: (dataTransfer: DataTransfer) => void;
onUploadExternalFilesRight: (dataTransfer: DataTransfer) => void;
}
export const useSftpViewFileOps = ({
sftpRef,
behaviorRef,
autoSyncRef,
getOpenerForFileRef,
setOpenerForExtension,
t,
}: UseSftpViewFileOpsParams): UseSftpViewFileOpsResult => {
const [permissionsState, setPermissionsState] = useState<{
file: SftpFileEntry;
side: "left" | "right";
} | null>(null);
const [showTextEditor, setShowTextEditor] = useState(false);
const [textEditorTarget, setTextEditorTarget] = useState<{
file: SftpFileEntry;
side: "left" | "right";
fullPath: string;
} | null>(null);
const [textEditorContent, setTextEditorContent] = useState("");
const [loadingTextContent, setLoadingTextContent] = useState(false);
const [showFileOpenerDialog, setShowFileOpenerDialog] = useState(false);
const [fileOpenerTarget, setFileOpenerTarget] = useState<{
file: SftpFileEntry;
side: "left" | "right";
fullPath: string;
} | null>(null);
const onEditPermissionsLeft = useCallback(
(file: SftpFileEntry) => setPermissionsState({ file, side: "left" }),
[],
);
const onEditPermissionsRight = useCallback(
(file: SftpFileEntry) => setPermissionsState({ file, side: "right" }),
[],
);
const handleEditFileForSide = useCallback(
async (side: "left" | "right", file: SftpFileEntry) => {
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
if (!pane.connection) return;
const fullPath = sftpRef.current.joinPath(pane.connection.currentPath, file.name);
try {
setLoadingTextContent(true);
setTextEditorTarget({ file, side, fullPath });
const content = await sftpRef.current.readTextFile(side, fullPath);
setTextEditorContent(content);
setShowTextEditor(true);
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to load file", "SFTP");
setTextEditorTarget(null);
} finally {
setLoadingTextContent(false);
}
},
[sftpRef],
);
const handleOpenFileForSide = useCallback(
async (side: "left" | "right", file: SftpFileEntry) => {
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
if (!pane.connection) return;
const fullPath = sftpRef.current.joinPath(pane.connection.currentPath, file.name);
const savedOpener = getOpenerForFileRef.current(file.name);
if (savedOpener && savedOpener.openerType) {
if (savedOpener.openerType === "builtin-editor") {
handleEditFileForSide(side, file);
return;
} else if (savedOpener.openerType === "system-app" && savedOpener.systemApp) {
try {
await sftpRef.current.downloadToTempAndOpen(
side,
fullPath,
file.name,
savedOpener.systemApp.path,
{ enableWatch: autoSyncRef.current },
);
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to open file", "SFTP");
}
return;
}
}
setFileOpenerTarget({ file, side, fullPath });
setShowFileOpenerDialog(true);
},
[sftpRef, handleEditFileForSide, getOpenerForFileRef, autoSyncRef],
);
const handleFileOpenerSelect = useCallback(
async (openerType: FileOpenerType, setAsDefault: boolean, systemApp?: SystemAppInfo) => {
if (!fileOpenerTarget) return;
if (setAsDefault) {
const ext = getFileExtension(fileOpenerTarget.file.name);
setOpenerForExtension(ext, openerType, systemApp);
}
setShowFileOpenerDialog(false);
if (openerType === "builtin-editor") {
handleEditFileForSide(fileOpenerTarget.side, fileOpenerTarget.file);
} else if (openerType === "system-app" && systemApp) {
try {
await sftpRef.current.downloadToTempAndOpen(
fileOpenerTarget.side,
fileOpenerTarget.fullPath,
fileOpenerTarget.file.name,
systemApp.path,
{ enableWatch: autoSyncRef.current },
);
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to open file", "SFTP");
}
}
setFileOpenerTarget(null);
},
[fileOpenerTarget, setOpenerForExtension, handleEditFileForSide, autoSyncRef, sftpRef],
);
const handleSelectSystemApp = useCallback(async (): Promise<SystemAppInfo | null> => {
const result = await sftpRef.current.selectApplication();
if (result) {
return { path: result.path, name: result.name };
}
return null;
}, [sftpRef]);
const handleSaveTextFile = useCallback(
async (content: string) => {
if (!textEditorTarget) return;
await sftpRef.current.writeTextFile(
textEditorTarget.side,
textEditorTarget.fullPath,
content,
);
},
[textEditorTarget, sftpRef],
);
const onEditFileLeft = useCallback(
(file: SftpFileEntry) => handleEditFileForSide("left", file),
[handleEditFileForSide],
);
const onEditFileRight = useCallback(
(file: SftpFileEntry) => handleEditFileForSide("right", file),
[handleEditFileForSide],
);
const onOpenFileLeft = useCallback(
(file: SftpFileEntry) => handleOpenFileForSide("left", file),
[handleOpenFileForSide],
);
const onOpenFileRight = useCallback(
(file: SftpFileEntry) => handleOpenFileForSide("right", file),
[handleOpenFileForSide],
);
const handleOpenFileWithForSide = useCallback(
(side: "left" | "right", file: SftpFileEntry) => {
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
if (!pane.connection) return;
const fullPath = sftpRef.current.joinPath(pane.connection.currentPath, file.name);
setFileOpenerTarget({ file, side, fullPath });
setShowFileOpenerDialog(true);
},
[sftpRef],
);
const onOpenFileWithLeft = useCallback(
(file: SftpFileEntry) => handleOpenFileWithForSide("left", file),
[handleOpenFileWithForSide],
);
const onOpenFileWithRight = useCallback(
(file: SftpFileEntry) => handleOpenFileWithForSide("right", file),
[handleOpenFileWithForSide],
);
const handleUploadExternalFilesForSide = useCallback(
async (side: "left" | "right", dataTransfer: DataTransfer) => {
try {
const results = await sftpRef.current.uploadExternalFiles(side, dataTransfer);
// Check if upload was cancelled
if (results.some((r) => r.cancelled)) {
toast.info(t("sftp.upload.cancelled"), "SFTP");
return;
}
const failCount = results.filter((r) => !r.success && !r.cancelled).length;
const successCount = results.filter((r) => r.success).length;
if (failCount === 0) {
const message =
successCount === 1
? `${t("sftp.upload")}: ${results[0].fileName}`
: `${t("sftp.uploadFiles")}: ${successCount}`;
toast.success(message, "SFTP");
} else {
const failedFiles = results.filter((r) => !r.success && !r.cancelled);
failedFiles.forEach((failed) => {
const errorMsg = failed.error ? ` - ${failed.error}` : "";
toast.error(
`${t("sftp.error.uploadFailed")}: ${failed.fileName}${errorMsg}`,
"SFTP",
);
});
}
} catch (error) {
logger.error("[SftpView] Failed to upload external files:", error);
toast.error(
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
"SFTP",
);
}
},
[sftpRef, t],
);
const onUploadExternalFilesLeft = useCallback(
(dataTransfer: DataTransfer) => handleUploadExternalFilesForSide("left", dataTransfer),
[handleUploadExternalFilesForSide],
);
const onUploadExternalFilesRight = useCallback(
(dataTransfer: DataTransfer) => handleUploadExternalFilesForSide("right", dataTransfer),
[handleUploadExternalFilesForSide],
);
const handleDownloadFileForSide = useCallback(
async (side: "left" | "right", file: SftpFileEntry) => {
const pane = side === "left" ? sftpRef.current.leftPane : sftpRef.current.rightPane;
if (!pane.connection) return;
const fullPath = sftpRef.current.joinPath(pane.connection.currentPath, file.name);
try {
const content = await sftpRef.current.readBinaryFile(side, fullPath);
const blob = new Blob([content], { type: "application/octet-stream" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = file.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
} catch (e) {
logger.error("[SftpView] Failed to download file:", e);
toast.error(
e instanceof Error ? e.message : t("sftp.error.downloadFailed"),
"SFTP",
);
}
},
[sftpRef, t],
);
const onDownloadFileLeft = useCallback(
(file: SftpFileEntry) => handleDownloadFileForSide("left", file),
[handleDownloadFileForSide],
);
const onDownloadFileRight = useCallback(
(file: SftpFileEntry) => handleDownloadFileForSide("right", file),
[handleDownloadFileForSide],
);
const onOpenEntryLeft = useCallback(
(entry: SftpFileEntry) => {
const isDir = isNavigableDirectory(entry);
if (entry.name === ".." || isDir) {
sftpRef.current.openEntry("left", entry);
return;
}
if (behaviorRef.current === "transfer") {
const fileData = [{
name: entry.name,
isDirectory: isDir,
}];
sftpRef.current.startTransfer(fileData, "left", "right");
} else {
onOpenFileLeft(entry);
}
},
[sftpRef, onOpenFileLeft, behaviorRef],
);
const onOpenEntryRight = useCallback(
(entry: SftpFileEntry) => {
const isDir = isNavigableDirectory(entry);
if (entry.name === ".." || isDir) {
sftpRef.current.openEntry("right", entry);
return;
}
if (behaviorRef.current === "transfer") {
const fileData = [{
name: entry.name,
isDirectory: isDir,
}];
sftpRef.current.startTransfer(fileData, "right", "left");
} else {
onOpenFileRight(entry);
}
},
[sftpRef, onOpenFileRight, behaviorRef],
);
return {
permissionsState,
setPermissionsState,
showTextEditor,
setShowTextEditor,
textEditorTarget,
setTextEditorTarget,
textEditorContent,
setTextEditorContent,
loadingTextContent,
showFileOpenerDialog,
setShowFileOpenerDialog,
fileOpenerTarget,
setFileOpenerTarget,
handleSaveTextFile,
handleFileOpenerSelect,
handleSelectSystemApp,
onEditPermissionsLeft,
onEditPermissionsRight,
onOpenEntryLeft,
onOpenEntryRight,
onEditFileLeft,
onEditFileRight,
onOpenFileLeft,
onOpenFileRight,
onOpenFileWithLeft,
onOpenFileWithRight,
onDownloadFileLeft,
onDownloadFileRight,
onUploadExternalFilesLeft,
onUploadExternalFilesRight,
};
};

View File

@@ -0,0 +1,224 @@
import { useCallback, useMemo, useState } from "react";
import type { MutableRefObject } from "react";
import type { SftpStateApi } from "../../../application/state/useSftpState";
import type { SftpDragCallbacks } from "../SftpContext";
interface UseSftpViewPaneActionsParams {
sftpRef: MutableRefObject<SftpStateApi>;
}
interface UseSftpViewPaneActionsResult {
dragCallbacks: SftpDragCallbacks;
draggedFiles: { name: string; isDirectory: boolean; side: "left" | "right" }[] | null;
onConnectLeft: (host: Parameters<SftpStateApi["connect"]>[1]) => void;
onConnectRight: (host: Parameters<SftpStateApi["connect"]>[1]) => void;
onDisconnectLeft: () => void;
onDisconnectRight: () => void;
onNavigateToLeft: (path: string) => void;
onNavigateToRight: (path: string) => void;
onNavigateUpLeft: () => void;
onNavigateUpRight: () => void;
onRefreshLeft: () => void;
onRefreshRight: () => void;
onSetFilenameEncodingLeft: (encoding: Parameters<SftpStateApi["setFilenameEncoding"]>[1]) => void;
onSetFilenameEncodingRight: (encoding: Parameters<SftpStateApi["setFilenameEncoding"]>[1]) => void;
onToggleSelectionLeft: (name: string, multi: boolean) => void;
onToggleSelectionRight: (name: string, multi: boolean) => void;
onRangeSelectLeft: (fileNames: string[]) => void;
onRangeSelectRight: (fileNames: string[]) => void;
onClearSelectionLeft: () => void;
onClearSelectionRight: () => void;
onSetFilterLeft: (filter: string) => void;
onSetFilterRight: (filter: string) => void;
onCreateDirectoryLeft: (name: string) => void;
onCreateDirectoryRight: (name: string) => void;
onCreateFileLeft: (name: string) => void;
onCreateFileRight: (name: string) => void;
onDeleteFilesLeft: (names: string[]) => void;
onDeleteFilesRight: (names: string[]) => void;
onRenameFileLeft: (old: string, newName: string) => void;
onRenameFileRight: (old: string, newName: string) => void;
onCopyToOtherPaneLeft: (files: { name: string; isDirectory: boolean }[]) => void;
onCopyToOtherPaneRight: (files: { name: string; isDirectory: boolean }[]) => void;
onReceiveFromOtherPaneLeft: (files: { name: string; isDirectory: boolean }[]) => void;
onReceiveFromOtherPaneRight: (files: { name: string; isDirectory: boolean }[]) => void;
}
export const useSftpViewPaneActions = ({
sftpRef,
}: UseSftpViewPaneActionsParams): UseSftpViewPaneActionsResult => {
const [draggedFiles, setDraggedFiles] = useState<
{ name: string; isDirectory: boolean; side: "left" | "right" }[] | null
>(null);
const handleDragStart = useCallback(
(
files: { name: string; isDirectory: boolean }[],
side: "left" | "right",
) => {
setDraggedFiles(files.map((f) => ({ ...f, side })));
},
[],
);
const handleDragEnd = useCallback(() => {
setDraggedFiles(null);
}, []);
const onCopyToOtherPaneLeft = useCallback(
(files: { name: string; isDirectory: boolean }[]) =>
sftpRef.current.startTransfer(files, "left", "right"),
[sftpRef],
);
const onCopyToOtherPaneRight = useCallback(
(files: { name: string; isDirectory: boolean }[]) =>
sftpRef.current.startTransfer(files, "right", "left"),
[sftpRef],
);
const onReceiveFromOtherPaneLeft = useCallback(
(files: { name: string; isDirectory: boolean }[]) =>
sftpRef.current.startTransfer(files, "right", "left"),
[sftpRef],
);
const onReceiveFromOtherPaneRight = useCallback(
(files: { name: string; isDirectory: boolean }[]) =>
sftpRef.current.startTransfer(files, "left", "right"),
[sftpRef],
);
const onConnectLeft = useCallback(
(host: Parameters<SftpStateApi["connect"]>[1]) => sftpRef.current.connect("left", host),
[sftpRef],
);
const onConnectRight = useCallback(
(host: Parameters<SftpStateApi["connect"]>[1]) => sftpRef.current.connect("right", host),
[sftpRef],
);
const onDisconnectLeft = useCallback(() => sftpRef.current.disconnect("left"), [sftpRef]);
const onDisconnectRight = useCallback(() => sftpRef.current.disconnect("right"), [sftpRef]);
const onNavigateToLeft = useCallback(
(path: string) => sftpRef.current.navigateTo("left", path),
[sftpRef],
);
const onNavigateToRight = useCallback(
(path: string) => sftpRef.current.navigateTo("right", path),
[sftpRef],
);
const onNavigateUpLeft = useCallback(() => sftpRef.current.navigateUp("left"), [sftpRef]);
const onNavigateUpRight = useCallback(() => sftpRef.current.navigateUp("right"), [sftpRef]);
const onRefreshLeft = useCallback(() => sftpRef.current.refresh("left"), [sftpRef]);
const onRefreshRight = useCallback(() => sftpRef.current.refresh("right"), [sftpRef]);
const onSetFilenameEncodingLeft = useCallback(
(encoding: Parameters<SftpStateApi["setFilenameEncoding"]>[1]) =>
sftpRef.current.setFilenameEncoding("left", encoding),
[sftpRef],
);
const onSetFilenameEncodingRight = useCallback(
(encoding: Parameters<SftpStateApi["setFilenameEncoding"]>[1]) =>
sftpRef.current.setFilenameEncoding("right", encoding),
[sftpRef],
);
const onToggleSelectionLeft = useCallback(
(name: string, multi: boolean) => sftpRef.current.toggleSelection("left", name, multi),
[sftpRef],
);
const onToggleSelectionRight = useCallback(
(name: string, multi: boolean) => sftpRef.current.toggleSelection("right", name, multi),
[sftpRef],
);
const onRangeSelectLeft = useCallback(
(fileNames: string[]) => sftpRef.current.rangeSelect("left", fileNames),
[sftpRef],
);
const onRangeSelectRight = useCallback(
(fileNames: string[]) => sftpRef.current.rangeSelect("right", fileNames),
[sftpRef],
);
const onClearSelectionLeft = useCallback(() => sftpRef.current.clearSelection("left"), [sftpRef]);
const onClearSelectionRight = useCallback(() => sftpRef.current.clearSelection("right"), [sftpRef]);
const onSetFilterLeft = useCallback(
(filter: string) => sftpRef.current.setFilter("left", filter),
[sftpRef],
);
const onSetFilterRight = useCallback(
(filter: string) => sftpRef.current.setFilter("right", filter),
[sftpRef],
);
const onCreateDirectoryLeft = useCallback(
(name: string) => sftpRef.current.createDirectory("left", name),
[sftpRef],
);
const onCreateDirectoryRight = useCallback(
(name: string) => sftpRef.current.createDirectory("right", name),
[sftpRef],
);
const onCreateFileLeft = useCallback(
(name: string) => sftpRef.current.createFile("left", name),
[sftpRef],
);
const onCreateFileRight = useCallback(
(name: string) => sftpRef.current.createFile("right", name),
[sftpRef],
);
const onDeleteFilesLeft = useCallback(
(names: string[]) => sftpRef.current.deleteFiles("left", names),
[sftpRef],
);
const onDeleteFilesRight = useCallback(
(names: string[]) => sftpRef.current.deleteFiles("right", names),
[sftpRef],
);
const onRenameFileLeft = useCallback(
(old: string, newName: string) => sftpRef.current.renameFile("left", old, newName),
[sftpRef],
);
const onRenameFileRight = useCallback(
(old: string, newName: string) => sftpRef.current.renameFile("right", old, newName),
[sftpRef],
);
const dragCallbacks = useMemo<SftpDragCallbacks>(
() => ({
onDragStart: handleDragStart,
onDragEnd: handleDragEnd,
}),
[handleDragStart, handleDragEnd],
);
return {
dragCallbacks,
draggedFiles,
onConnectLeft,
onConnectRight,
onDisconnectLeft,
onDisconnectRight,
onNavigateToLeft,
onNavigateToRight,
onNavigateUpLeft,
onNavigateUpRight,
onRefreshLeft,
onRefreshRight,
onSetFilenameEncodingLeft,
onSetFilenameEncodingRight,
onToggleSelectionLeft,
onToggleSelectionRight,
onRangeSelectLeft,
onRangeSelectRight,
onClearSelectionLeft,
onClearSelectionRight,
onSetFilterLeft,
onSetFilterRight,
onCreateDirectoryLeft,
onCreateDirectoryRight,
onCreateFileLeft,
onCreateFileRight,
onDeleteFilesLeft,
onDeleteFilesRight,
onRenameFileLeft,
onRenameFileRight,
onCopyToOtherPaneLeft,
onCopyToOtherPaneRight,
onReceiveFromOtherPaneLeft,
onReceiveFromOtherPaneRight,
};
};

View File

@@ -0,0 +1,124 @@
import { useMemo } from "react";
import type { MutableRefObject } from "react";
import type { SftpStateApi } from "../../../application/state/useSftpState";
import type { SftpPaneCallbacks } from "../SftpContext";
import { useSftpViewPaneActions } from "./useSftpViewPaneActions";
import { useSftpViewFileOps } from "./useSftpViewFileOps";
import type { FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
interface UseSftpViewPaneCallbacksParams {
sftpRef: MutableRefObject<SftpStateApi>;
behaviorRef: MutableRefObject<string>;
autoSyncRef: MutableRefObject<boolean>;
getOpenerForFileRef: MutableRefObject<
(fileName: string) => { openerType?: FileOpenerType; systemApp?: SystemAppInfo } | null
>;
setOpenerForExtension: (
extension: string,
openerType: FileOpenerType,
systemApp?: SystemAppInfo,
) => void;
t: (key: string, vars?: Record<string, string | number>) => string;
}
export const useSftpViewPaneCallbacks = ({
sftpRef,
behaviorRef,
autoSyncRef,
getOpenerForFileRef,
setOpenerForExtension,
t,
}: UseSftpViewPaneCallbacksParams) => {
const paneActions = useSftpViewPaneActions({ sftpRef });
const fileOps = useSftpViewFileOps({
sftpRef,
behaviorRef,
autoSyncRef,
getOpenerForFileRef,
setOpenerForExtension,
t,
});
/* eslint-disable react-hooks/exhaustive-deps -- Handlers use refs, so they are stable */
const leftCallbacks = useMemo<SftpPaneCallbacks>(
() => ({
onConnect: paneActions.onConnectLeft,
onDisconnect: paneActions.onDisconnectLeft,
onNavigateTo: paneActions.onNavigateToLeft,
onNavigateUp: paneActions.onNavigateUpLeft,
onRefresh: paneActions.onRefreshLeft,
onSetFilenameEncoding: paneActions.onSetFilenameEncodingLeft,
onOpenEntry: fileOps.onOpenEntryLeft,
onToggleSelection: paneActions.onToggleSelectionLeft,
onRangeSelect: paneActions.onRangeSelectLeft,
onClearSelection: paneActions.onClearSelectionLeft,
onSetFilter: paneActions.onSetFilterLeft,
onCreateDirectory: paneActions.onCreateDirectoryLeft,
onCreateFile: paneActions.onCreateFileLeft,
onDeleteFiles: paneActions.onDeleteFilesLeft,
onRenameFile: paneActions.onRenameFileLeft,
onCopyToOtherPane: paneActions.onCopyToOtherPaneLeft,
onReceiveFromOtherPane: paneActions.onReceiveFromOtherPaneLeft,
onEditPermissions: fileOps.onEditPermissionsLeft,
onEditFile: fileOps.onEditFileLeft,
onOpenFile: fileOps.onOpenFileLeft,
onOpenFileWith: fileOps.onOpenFileWithLeft,
onDownloadFile: fileOps.onDownloadFileLeft,
onUploadExternalFiles: fileOps.onUploadExternalFilesLeft,
}),
[],
);
const rightCallbacks = useMemo<SftpPaneCallbacks>(
() => ({
onConnect: paneActions.onConnectRight,
onDisconnect: paneActions.onDisconnectRight,
onNavigateTo: paneActions.onNavigateToRight,
onNavigateUp: paneActions.onNavigateUpRight,
onRefresh: paneActions.onRefreshRight,
onSetFilenameEncoding: paneActions.onSetFilenameEncodingRight,
onOpenEntry: fileOps.onOpenEntryRight,
onToggleSelection: paneActions.onToggleSelectionRight,
onRangeSelect: paneActions.onRangeSelectRight,
onClearSelection: paneActions.onClearSelectionRight,
onSetFilter: paneActions.onSetFilterRight,
onCreateDirectory: paneActions.onCreateDirectoryRight,
onCreateFile: paneActions.onCreateFileRight,
onDeleteFiles: paneActions.onDeleteFilesRight,
onRenameFile: paneActions.onRenameFileRight,
onCopyToOtherPane: paneActions.onCopyToOtherPaneRight,
onReceiveFromOtherPane: paneActions.onReceiveFromOtherPaneRight,
onEditPermissions: fileOps.onEditPermissionsRight,
onEditFile: fileOps.onEditFileRight,
onOpenFile: fileOps.onOpenFileRight,
onOpenFileWith: fileOps.onOpenFileWithRight,
onDownloadFile: fileOps.onDownloadFileRight,
onUploadExternalFiles: fileOps.onUploadExternalFilesRight,
}),
[],
);
/* eslint-enable react-hooks/exhaustive-deps */
return {
leftCallbacks,
rightCallbacks,
dragCallbacks: paneActions.dragCallbacks,
draggedFiles: paneActions.draggedFiles,
permissionsState: fileOps.permissionsState,
setPermissionsState: fileOps.setPermissionsState,
showTextEditor: fileOps.showTextEditor,
setShowTextEditor: fileOps.setShowTextEditor,
textEditorTarget: fileOps.textEditorTarget,
setTextEditorTarget: fileOps.setTextEditorTarget,
textEditorContent: fileOps.textEditorContent,
setTextEditorContent: fileOps.setTextEditorContent,
loadingTextContent: fileOps.loadingTextContent,
showFileOpenerDialog: fileOps.showFileOpenerDialog,
setShowFileOpenerDialog: fileOps.setShowFileOpenerDialog,
fileOpenerTarget: fileOps.fileOpenerTarget,
setFileOpenerTarget: fileOps.setFileOpenerTarget,
handleSaveTextFile: fileOps.handleSaveTextFile,
handleFileOpenerSelect: fileOps.handleFileOpenerSelect,
handleSelectSystemApp: fileOps.handleSelectSystemApp,
};
};

View File

@@ -0,0 +1,159 @@
import React, { useCallback, useMemo, useState } from "react";
import type { MutableRefObject } from "react";
import type { Host } from "../../../types";
import type { SftpStateApi } from "../../../application/state/useSftpState";
interface UseSftpViewTabsParams {
sftp: SftpStateApi;
sftpRef: MutableRefObject<SftpStateApi>;
}
interface UseSftpViewTabsResult {
leftPanes: SftpStateApi["leftPane"][];
rightPanes: SftpStateApi["rightPane"][];
leftTabsInfo: { id: string; label: string; isLocal: boolean; hostId: string | null }[];
rightTabsInfo: { id: string; label: string; isLocal: boolean; hostId: string | null }[];
showHostPickerLeft: boolean;
showHostPickerRight: boolean;
hostSearchLeft: string;
hostSearchRight: string;
setShowHostPickerLeft: React.Dispatch<React.SetStateAction<boolean>>;
setShowHostPickerRight: React.Dispatch<React.SetStateAction<boolean>>;
setHostSearchLeft: React.Dispatch<React.SetStateAction<string>>;
setHostSearchRight: React.Dispatch<React.SetStateAction<string>>;
handleAddTabLeft: () => void;
handleAddTabRight: () => void;
handleCloseTabLeft: (tabId: string) => void;
handleCloseTabRight: (tabId: string) => void;
handleSelectTabLeft: (tabId: string) => void;
handleSelectTabRight: (tabId: string) => void;
handleReorderTabsLeft: (draggedId: string, targetId: string, position: "before" | "after") => void;
handleReorderTabsRight: (draggedId: string, targetId: string, position: "before" | "after") => void;
handleMoveTabFromLeftToRight: (tabId: string) => void;
handleMoveTabFromRightToLeft: (tabId: string) => void;
handleHostSelectLeft: (host: Host | "local") => void;
handleHostSelectRight: (host: Host | "local") => void;
}
export const useSftpViewTabs = ({ sftp, sftpRef }: UseSftpViewTabsParams): UseSftpViewTabsResult => {
const [showHostPickerLeft, setShowHostPickerLeft] = useState(false);
const [showHostPickerRight, setShowHostPickerRight] = useState(false);
const [hostSearchLeft, setHostSearchLeft] = useState("");
const [hostSearchRight, setHostSearchRight] = useState("");
const handleAddTabLeft = useCallback(() => {
sftpRef.current.addTab("left");
setShowHostPickerLeft(true);
}, [sftpRef]);
const handleAddTabRight = useCallback(() => {
sftpRef.current.addTab("right");
setShowHostPickerRight(true);
}, [sftpRef]);
const handleCloseTabLeft = useCallback((tabId: string) => {
sftpRef.current.closeTab("left", tabId);
}, [sftpRef]);
const handleCloseTabRight = useCallback((tabId: string) => {
sftpRef.current.closeTab("right", tabId);
}, [sftpRef]);
const handleSelectTabLeft = useCallback((tabId: string) => {
sftpRef.current.selectTab("left", tabId);
}, [sftpRef]);
const handleSelectTabRight = useCallback((tabId: string) => {
sftpRef.current.selectTab("right", tabId);
}, [sftpRef]);
const leftPanes = useMemo(
() => (sftp.leftTabs.tabs.length > 0 ? sftp.leftTabs.tabs : [sftp.leftPane]),
[sftp.leftTabs.tabs, sftp.leftPane],
);
const rightPanes = useMemo(
() => (sftp.rightTabs.tabs.length > 0 ? sftp.rightTabs.tabs : [sftp.rightPane]),
[sftp.rightTabs.tabs, sftp.rightPane],
);
const handleReorderTabsLeft = useCallback(
(draggedId: string, targetId: string, position: "before" | "after") => {
sftpRef.current.reorderTabs("left", draggedId, targetId, position);
},
[sftpRef],
);
const handleReorderTabsRight = useCallback(
(draggedId: string, targetId: string, position: "before" | "after") => {
sftpRef.current.reorderTabs("right", draggedId, targetId, position);
},
[sftpRef],
);
const handleMoveTabFromLeftToRight = useCallback((tabId: string) => {
sftpRef.current.moveTabToOtherSide("left", tabId);
}, [sftpRef]);
const handleMoveTabFromRightToLeft = useCallback((tabId: string) => {
sftpRef.current.moveTabToOtherSide("right", tabId);
}, [sftpRef]);
const handleHostSelectLeft = useCallback((host: Host | "local") => {
sftpRef.current.connect("left", host);
setShowHostPickerLeft(false);
}, [sftpRef]);
const handleHostSelectRight = useCallback((host: Host | "local") => {
sftpRef.current.connect("right", host);
setShowHostPickerRight(false);
}, [sftpRef]);
const leftTabsInfo = useMemo(
() =>
sftp.leftTabs.tabs.map((pane) => ({
id: pane.id,
label: pane.connection?.hostLabel || "New Tab",
isLocal: pane.connection?.isLocal || false,
hostId: pane.connection?.hostId || null,
})),
[sftp.leftTabs.tabs],
);
const rightTabsInfo = useMemo(
() =>
sftp.rightTabs.tabs.map((pane) => ({
id: pane.id,
label: pane.connection?.hostLabel || "New Tab",
isLocal: pane.connection?.isLocal || false,
hostId: pane.connection?.hostId || null,
})),
[sftp.rightTabs.tabs],
);
return {
leftPanes,
rightPanes,
leftTabsInfo,
rightTabsInfo,
showHostPickerLeft,
showHostPickerRight,
hostSearchLeft,
hostSearchRight,
setShowHostPickerLeft,
setShowHostPickerRight,
setHostSearchLeft,
setHostSearchRight,
handleAddTabLeft,
handleAddTabRight,
handleCloseTabLeft,
handleCloseTabRight,
handleSelectTabLeft,
handleSelectTabRight,
handleReorderTabsLeft,
handleReorderTabsRight,
handleMoveTabFromLeftToRight,
handleMoveTabFromRightToLeft,
handleHostSelectLeft,
handleHostSelectRight,
};
};

View File

@@ -48,14 +48,14 @@ export const formatTransferBytes = (bytes: number): string => {
};
/**
* Format date as YYYY-MM-DD HH:mm:ss in local timezone
* Format date as YYYY-MM-DD hh:mm in local timezone
*/
export const formatDate = (timestamp: number | undefined): string => {
if (!timestamp) return '--';
const date = new Date(timestamp);
if (isNaN(date.getTime())) return '--';
const pad = (n: number) => n.toString().padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
};
/**

View File

@@ -12,7 +12,7 @@ import {
} from 'lucide-react';
import React, { useCallback } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { RightClickBehavior } from '../../domain/models';
import { KeyBinding, RightClickBehavior } from '../../domain/models';
import {
ContextMenu,
ContextMenuContent,
@@ -26,6 +26,7 @@ export interface TerminalContextMenuProps {
children: React.ReactNode;
hasSelection?: boolean;
hotkeyScheme?: 'disabled' | 'mac' | 'pc';
keyBindings?: KeyBinding[];
rightClickBehavior?: RightClickBehavior;
onCopy?: () => void;
onPaste?: () => void;
@@ -41,6 +42,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
children,
hasSelection = false,
hotkeyScheme = 'mac',
keyBindings,
rightClickBehavior = 'context-menu',
onCopy,
onPaste,
@@ -54,12 +56,24 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
const { t } = useI18n();
const isMac = hotkeyScheme === 'mac';
const copyShortcut = isMac ? '⌘C' : 'Ctrl+Shift+C';
const pasteShortcut = isMac ? '⌘V' : 'Ctrl+Shift+V';
const selectAllShortcut = isMac ? '⌘A' : 'Ctrl+Shift+A';
const splitHShortcut = isMac ? '⌘D' : 'Ctrl+Shift+D';
const splitVShortcut = isMac ? '⌘E' : 'Ctrl+Shift+E';
const clearShortcut = isMac ? '⌘K' : 'Ctrl+L';
// Helper to get shortcut from keyBindings and format for display
const getShortcut = (bindingId: string): string => {
const binding = keyBindings?.find(b => b.id === bindingId);
if (!binding) return '';
const key = isMac ? binding.mac : binding.pc;
if (!key || key === 'Disabled') return '';
// Replace " + " with space for cleaner display (e.g., "⌘ + Shift + D" → "⌘ Shift D")
return key.replace(/\s*\+\s*/g, ' ').trim();
};
const copyShortcut = getShortcut('copy');
const pasteShortcut = getShortcut('paste');
const selectAllShortcut = getShortcut('select-all');
const splitHShortcut = getShortcut('split-horizontal');
const splitVShortcut = getShortcut('split-vertical');
const clearShortcut = getShortcut('clear-buffer');
const showContextMenu = rightClickBehavior === 'context-menu';
const handleRightClick = useCallback(
(e: React.MouseEvent) => {
@@ -76,71 +90,72 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
[rightClickBehavior, onPaste, onSelectWord],
);
if (rightClickBehavior !== 'context-menu') {
return (
<div onContextMenu={handleRightClick} className="contents">
{children}
</div>
);
}
// Always use ContextMenu wrapper to maintain consistent React tree structure
// This prevents terminal from unmounting when rightClickBehavior changes
return (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent className="w-56">
<ContextMenuItem onClick={onCopy} disabled={!hasSelection}>
<Copy size={14} className="mr-2" />
{t('terminal.menu.copy')}
<ContextMenuShortcut>{copyShortcut}</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem onClick={onPaste}>
<ClipboardPaste size={14} className="mr-2" />
{t('terminal.menu.paste')}
<ContextMenuShortcut>{pasteShortcut}</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem onClick={onSelectAll}>
<TerminalIcon size={14} className="mr-2" />
{t('terminal.menu.selectAll')}
<ContextMenuShortcut>{selectAllShortcut}</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuTrigger
asChild
disabled={!showContextMenu}
onContextMenu={!showContextMenu ? handleRightClick : undefined}
>
{children}
</ContextMenuTrigger>
{showContextMenu && (
<ContextMenuContent className="w-56">
<ContextMenuItem onClick={onCopy} disabled={!hasSelection}>
<Copy size={14} className="mr-2" />
{t('terminal.menu.copy')}
<ContextMenuShortcut>{copyShortcut}</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem onClick={onPaste}>
<ClipboardPaste size={14} className="mr-2" />
{t('terminal.menu.paste')}
<ContextMenuShortcut>{pasteShortcut}</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem onClick={onSelectAll}>
<TerminalIcon size={14} className="mr-2" />
{t('terminal.menu.selectAll')}
<ContextMenuShortcut>{selectAllShortcut}</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuSeparator />
<ContextMenuItem onClick={onSplitVertical}>
<SplitSquareHorizontal size={14} className="mr-2" />
{t('terminal.menu.splitHorizontal')}
<ContextMenuShortcut>{splitVShortcut}</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem onClick={onSplitHorizontal}>
<SplitSquareVertical size={14} className="mr-2" />
{t('terminal.menu.splitVertical')}
<ContextMenuShortcut>{splitHShortcut}</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem onClick={onSplitVertical}>
<SplitSquareHorizontal size={14} className="mr-2" />
{t('terminal.menu.splitHorizontal')}
<ContextMenuShortcut>{splitVShortcut}</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem onClick={onSplitHorizontal}>
<SplitSquareVertical size={14} className="mr-2" />
{t('terminal.menu.splitVertical')}
<ContextMenuShortcut>{splitHShortcut}</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuSeparator />
<ContextMenuItem onClick={onClear}>
<Trash2 size={14} className="mr-2" />
{t('terminal.menu.clearBuffer')}
<ContextMenuShortcut>{clearShortcut}</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem onClick={onClear}>
<Trash2 size={14} className="mr-2" />
{t('terminal.menu.clearBuffer')}
<ContextMenuShortcut>{clearShortcut}</ContextMenuShortcut>
</ContextMenuItem>
{onClose && (
<>
<ContextMenuSeparator />
<ContextMenuItem
onClick={onClose}
className="text-destructive focus:text-destructive"
>
<Trash2 size={14} className="mr-2" />
{t('terminal.menu.closeTerminal')}
</ContextMenuItem>
</>
)}
</ContextMenuContent>
{onClose && (
<>
<ContextMenuSeparator />
<ContextMenuItem
onClick={onClose}
className="text-destructive focus:text-destructive"
>
<Trash2 size={14} className="mr-2" />
{t('terminal.menu.closeTerminal')}
</ContextMenuItem>
</>
)}
</ContextMenuContent>
)}
</ContextMenu>
);
};
export default TerminalContextMenu;

View File

@@ -0,0 +1,201 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { netcattyBridge } from '../../../infrastructure/services/netcattyBridge';
export interface DiskInfo {
mountPoint: string;
used: number; // Used in GB
total: number; // Total in GB
percent: number; // Usage percentage
}
export interface NetInterfaceInfo {
name: string; // Interface name (e.g., eth0, ens33)
rxBytes: number; // Total received bytes
txBytes: number; // Total transmitted bytes
rxSpeed: number; // Receive speed (bytes/sec)
txSpeed: number; // Transmit speed (bytes/sec)
}
export interface ProcessInfo {
pid: string;
memPercent: number;
command: string;
}
export interface ServerStats {
cpu: number | null; // CPU usage percentage (0-100)
cpuCores: number | null; // Number of CPU cores
cpuPerCore: number[]; // Per-core CPU usage array
memTotal: number | null; // Total memory in MB
memUsed: number | null; // Used memory in MB (excluding buffers/cache)
memFree: number | null; // Free memory in MB
memBuffers: number | null; // Buffers in MB
memCached: number | null; // Cached 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
diskTotal: number | null; // Total disk in GB
disks: DiskInfo[]; // All mounted disks
netRxSpeed: number; // Total network receive speed (bytes/sec)
netTxSpeed: number; // Total network transmit speed (bytes/sec)
netInterfaces: NetInterfaceInfo[]; // Per-interface network stats
lastUpdated: number | null; // Timestamp of last successful update
}
interface UseServerStatsOptions {
sessionId: string;
enabled: boolean; // Whether stats collection is enabled (from settings)
refreshInterval: number; // Refresh interval in seconds
isLinux: boolean; // Only collect stats for Linux servers
isConnected: boolean; // Only collect when connected
}
export function useServerStats({
sessionId,
enabled,
refreshInterval,
isLinux,
isConnected,
}: UseServerStatsOptions) {
const [stats, setStats] = useState<ServerStats>({
cpu: null,
cpuCores: null,
cpuPerCore: [],
memTotal: null,
memUsed: null,
memFree: null,
memBuffers: null,
memCached: null,
topProcesses: [],
diskPercent: null,
diskUsed: null,
diskTotal: null,
disks: [],
netRxSpeed: 0,
netTxSpeed: 0,
netInterfaces: [],
lastUpdated: null,
});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const isMountedRef = useRef(true);
const fetchStats = useCallback(async () => {
if (!enabled || !isLinux || !isConnected || !sessionId) {
return;
}
const bridge = netcattyBridge.get();
if (!bridge?.getServerStats) {
return;
}
setIsLoading(true);
setError(null);
try {
const result = await bridge.getServerStats(sessionId);
if (!isMountedRef.current) return;
if (result.success && result.stats) {
setStats({
cpu: result.stats.cpu,
cpuCores: result.stats.cpuCores,
cpuPerCore: result.stats.cpuPerCore || [],
memTotal: result.stats.memTotal,
memUsed: result.stats.memUsed,
memFree: result.stats.memFree,
memBuffers: result.stats.memBuffers,
memCached: result.stats.memCached,
topProcesses: result.stats.topProcesses || [],
diskPercent: result.stats.diskPercent,
diskUsed: result.stats.diskUsed,
diskTotal: result.stats.diskTotal,
disks: result.stats.disks || [],
netRxSpeed: result.stats.netRxSpeed || 0,
netTxSpeed: result.stats.netTxSpeed || 0,
netInterfaces: result.stats.netInterfaces || [],
lastUpdated: Date.now(),
});
} else if (result.error) {
setError(result.error);
}
} catch (err) {
if (isMountedRef.current) {
setError(err instanceof Error ? err.message : 'Unknown error');
}
} finally {
if (isMountedRef.current) {
setIsLoading(false);
}
}
}, [sessionId, enabled, isLinux, isConnected]);
// Initial fetch and periodic refresh
useEffect(() => {
isMountedRef.current = true;
// Clear any existing interval
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
// Don't run if not enabled or not a Linux system
if (!enabled || !isLinux || !isConnected) {
// Reset stats when disabled or not connected
setStats({
cpu: null,
cpuCores: null,
cpuPerCore: [],
memTotal: null,
memUsed: null,
memFree: null,
memBuffers: null,
memCached: null,
topProcesses: [],
diskPercent: null,
diskUsed: null,
diskTotal: null,
disks: [],
netRxSpeed: 0,
netTxSpeed: 0,
netInterfaces: [],
lastUpdated: null,
});
return;
}
// Initial fetch with a small delay to let the connection stabilize
const initialTimer = setTimeout(() => {
fetchStats();
}, 2000);
// Set up periodic refresh
const intervalMs = Math.max(5, refreshInterval) * 1000; // Minimum 5 seconds
intervalRef.current = setInterval(fetchStats, intervalMs);
return () => {
isMountedRef.current = false;
clearTimeout(initialTimer);
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [enabled, isLinux, isConnected, refreshInterval, fetchStats]);
// Manual refresh function
const refresh = useCallback(() => {
fetchStats();
}, [fetchStats]);
return {
stats,
isLoading,
error,
refresh,
};
}

View File

@@ -21,7 +21,7 @@ export type { TerminalContextMenuProps } from './TerminalContextMenu';
export { TerminalSearchBar } from './TerminalSearchBar';
export type { TerminalSearchBarProps } from './TerminalSearchBar';
export { createHighlightProcessor, highlightKeywords, compileHighlightRules } from './keywordHighlight';
export { KeywordHighlighter } from './keywordHighlight';
export { useTerminalSearch } from './hooks/useTerminalSearch';
export { useTerminalContextActions } from './hooks/useTerminalContextActions';

View File

@@ -1,136 +1,218 @@
import { Terminal as XTerm, IDecoration, IDisposable, IMarker, IBufferLine } from "@xterm/xterm";
import { KeywordHighlightRule } from "../../types";
// ESC character as unicode escape for ESLint compatibility
const ESC = "\u001b";
/**
* Convert a hex color to ANSI 24-bit true color escape sequence
* Format: ESC[38;2;R;G;Bm for foreground color
*/
function hexToAnsi(hex: string): string {
// Remove # if present
const cleanHex = hex.replace("#", "");
const r = parseInt(cleanHex.slice(0, 2), 16);
const g = parseInt(cleanHex.slice(2, 4), 16);
const b = parseInt(cleanHex.slice(4, 6), 16);
return `${ESC}[38;2;${r};${g};${b}m`;
}
const ANSI_RESET = `${ESC}[0m`;
// Regex to match ANSI escape sequences (to skip them during highlighting)
// Using RegExp constructor to avoid ESLint control character warning
// eslint-disable-next-line no-control-regex
const ANSI_ESCAPE_REGEX = /\u001b\[[0-9;]*[a-zA-Z]/g;
import { XTERM_PERFORMANCE_CONFIG } from "../../infrastructure/config/xtermPerformance";
/** Pre-compiled rule with regex ready for matching */
interface CompiledRule {
regex: RegExp;
ansiColor: string;
color: string;
}
/**
* Pre-compile keyword highlight rules for better performance
* Manages terminal decorations for keyword highlighting.
* Uses xterm.js Decoration API to overlay styles without modifying the data stream.
* This ensures zero impact on scrolling performance ("lazy" highlighting).
*/
export function compileHighlightRules(
rules: KeywordHighlightRule[],
enabled: boolean
): CompiledRule[] {
if (!enabled) return [];
return rules
.filter((rule) => rule.enabled && rule.patterns.length > 0)
.map((rule) => {
// Combine all patterns with OR, case-insensitive
const combinedPattern = rule.patterns.join("|");
return {
regex: new RegExp(`(${combinedPattern})`, "gi"),
ansiColor: hexToAnsi(rule.color),
};
});
}
export class KeywordHighlighter implements IDisposable {
private term: XTerm;
private compiledRules: CompiledRule[] = [];
private decorations: { decoration: IDecoration; marker: IMarker }[] = [];
private debounceTimer: NodeJS.Timeout | null = null;
private enabled: boolean = false;
private disposables: IDisposable[] = [];
/**
* Apply keyword highlighting to terminal output
* This processes text and adds ANSI color codes for matched keywords
*
* Note: This is a simplified approach that works well for most cases.
* It processes the text while preserving existing ANSI escape sequences.
*/
export function highlightKeywords(
text: string,
compiledRules: CompiledRule[]
): string {
if (compiledRules.length === 0 || !text) {
return text;
constructor(term: XTerm) {
this.term = term;
// Debug logging
console.log('[KeywordHighlighter] Initialized');
// Hook into terminal events to trigger highlighting
this.disposables.push(
// When user scrolls, refresh visible area
this.term.onScroll(() => {
// console.log('[KeywordHighlighter] onScroll');
this.triggerRefresh();
}),
// When new data is written, refresh
this.term.onWriteParsed(() => {
// console.log('[KeywordHighlighter] onWriteParsed');
this.triggerRefresh();
}),
// Also refresh on resize as viewport content changes
this.term.onResize(() => this.triggerRefresh())
);
}
// Split text into segments: ANSI sequences and regular text
const segments: Array<{ isAnsi: boolean; content: string }> = [];
let lastIndex = 0;
// Find all ANSI escape sequences
let match: RegExpExecArray | null;
const ansiRegex = new RegExp(ANSI_ESCAPE_REGEX.source, "g");
while ((match = ansiRegex.exec(text)) !== null) {
// Add text before this ANSI sequence
if (match.index > lastIndex) {
segments.push({
isAnsi: false,
content: text.slice(lastIndex, match.index),
});
}
// Add the ANSI sequence itself
segments.push({
isAnsi: true,
content: match[0],
});
lastIndex = match.index + match[0].length;
}
// Add remaining text after last ANSI sequence
if (lastIndex < text.length) {
segments.push({
isAnsi: false,
content: text.slice(lastIndex),
});
}
// Process only non-ANSI segments
const processedSegments = segments.map((segment) => {
if (segment.isAnsi) {
return segment.content;
}
let processed = segment.content;
// Apply each rule
for (const rule of compiledRules) {
processed = processed.replace(rule.regex, (matched) => {
return `${rule.ansiColor}${matched}${ANSI_RESET}`;
});
}
return processed;
});
return processedSegments.join("");
}
public setRules(rules: KeywordHighlightRule[], enabled: boolean) {
this.enabled = enabled;
/**
* Create a highlight processor function with pre-compiled rules
* Use this for better performance when processing multiple chunks
*/
export function createHighlightProcessor(
rules: KeywordHighlightRule[],
enabled: boolean
): (text: string) => string {
const compiledRules = compileHighlightRules(rules, enabled);
if (compiledRules.length === 0) {
// Return identity function if no rules are enabled
return (text: string) => text;
// Pre-compile all patterns into regexes for better performance
// This avoids creating new RegExp objects on every viewport refresh
this.compiledRules = [];
for (const rule of rules) {
if (!rule.enabled || rule.patterns.length === 0) continue;
for (const pattern of rule.patterns) {
try {
this.compiledRules.push({
regex: new RegExp(pattern, "gi"),
color: rule.color,
});
} catch (err) {
console.error("Invalid regex pattern:", pattern, err);
}
}
}
// Clear existing and force an immediate refresh if enabling
this.clearDecorations();
if (this.enabled && this.compiledRules.length > 0) {
this.triggerRefresh();
}
}
public dispose() {
this.clearDecorations();
this.disposables.forEach(d => d.dispose());
this.disposables = [];
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
}
private triggerRefresh() {
if (!this.enabled || this.compiledRules.length === 0) return;
// Optimization: Disable highlighting in Alternate Buffer (e.g. Vim, Htop)
// These apps manage their own highlighting and have rapid repaints.
if (this.term.buffer.active.type === 'alternate') {
if (this.decorations.length > 0) {
this.clearDecorations();
}
return;
}
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
const delay = XTERM_PERFORMANCE_CONFIG.highlighting.debounceMs;
this.debounceTimer = setTimeout(() => this.refreshViewport(), delay);
}
private clearDecorations() {
this.decorations.forEach(({ decoration, marker }) => {
decoration.dispose();
marker.dispose();
});
this.decorations = [];
}
/**
* Build a mapping from string character index to terminal cell column.
* This handles wide characters (CJK, emoji) and combining characters correctly.
*
* For example, with "A中B":
* - String indices: 0='A', 1='中', 2='B'
* - Cell columns: 0='A', 1='中'(width 2), 3='B'
* - Result map: [0, 1, 3, 4] (includes end position)
*/
private buildStringToCellMap(line: IBufferLine): number[] {
const map: number[] = [];
let cellCol = 0;
for (let col = 0; col < line.length; col++) {
const cell = line.getCell(col);
if (!cell) break;
const chars = cell.getChars();
const width = cell.getWidth();
// Skip continuation cells (width 0) - these are the 2nd cell of wide characters
if (width === 0) continue;
// Map each character in this cell to the current cell column
for (let i = 0; i < chars.length; i++) {
map.push(cellCol);
}
cellCol += width;
}
// Add final position for calculating end column of matches
map.push(cellCol);
return map;
}
private refreshViewport() {
// Safety check just in case
if (!this.term?.buffer?.active) return;
const buffer = this.term.buffer.active;
const viewportY = buffer.viewportY;
const rows = this.term.rows;
const cursorY = buffer.cursorY;
const baseY = buffer.baseY;
const cursorAbsoluteY = baseY + cursorY;
// Clear old decorations to avoid duplicates/memory leaks
this.clearDecorations();
// Iterate only over the visible rows
for (let y = 0; y < rows; y++) {
const lineY = viewportY + y;
const line = buffer.getLine(lineY);
if (!line) continue;
const lineText = line.translateToString(true); // true = trim right whitespace
if (!lineText) continue;
// Build mapping from string index to cell column for wide char support
const cellMap = this.buildStringToCellMap(line);
// Process each pre-compiled rule
for (const { regex, color } of this.compiledRules) {
// Reset regex state for reuse (global flag maintains lastIndex)
regex.lastIndex = 0;
let match;
while ((match = regex.exec(lineText)) !== null) {
const strStart = match.index;
const strEnd = strStart + match[0].length;
// Map string indices to cell columns
const cellStartCol = cellMap[strStart] ?? strStart;
const cellEndCol = cellMap[strEnd] ?? strEnd;
const cellWidth = cellEndCol - cellStartCol;
// Skip if width is 0 or negative (shouldn't happen, but be safe)
if (cellWidth <= 0) continue;
// Calculate offset relative to the absolute cursor position
// offset = targetLineAbs - (baseY + cursorY)
const offset = lineY - cursorAbsoluteY;
const marker = this.term.registerMarker(offset);
if (marker) {
const deco = this.term.registerDecoration({
marker,
x: cellStartCol,
width: cellWidth,
foregroundColor: color,
});
if (deco) {
this.decorations.push({ decoration: deco, marker });
} else {
// If decoration failed, cleanup marker
marker.dispose();
}
}
}
}
}
}
return (text: string) => highlightKeywords(text, compiledRules);
}

View File

@@ -74,7 +74,6 @@ export type TerminalSessionStartersContext = {
disposeExitRef: RefObject<(() => void) | null>;
fitAddonRef: RefObject<FitAddon | null>;
serializeAddonRef: RefObject<SerializeAddon | null>;
highlightProcessorRef: RefObject<(text: string) => string>;
pendingAuthRef: RefObject<PendingAuth>;
updateStatus: (next: TerminalSession["status"]) => void;
@@ -133,7 +132,7 @@ const attachSessionToTerminal = (
// Replace \n that is not preceded by \r with \r\n
data = data.replace(/(?<!\r)\n/g, "\r\n");
}
term.write(ctx.highlightProcessorRef.current(data));
term.write(data);
if (!ctx.hasConnectedRef.current) {
ctx.updateStatus("connected");
opts?.onConnected?.();
@@ -556,7 +555,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
ctx.sessionRef.current = id;
ctx.disposeDataRef.current = ctx.terminalBackend.onSessionData(id, (chunk) => {
term.write(ctx.highlightProcessorRef.current(chunk));
term.write(chunk);
if (!ctx.hasConnectedRef.current) {
ctx.updateStatus("connected");
setTimeout(() => {

View File

@@ -11,6 +11,7 @@ import {
getTerminalPassthroughActions,
} from "../../../application/state/useGlobalHotkeys";
import { fontStore } from "../../../application/state/fontStore";
import { KeywordHighlighter } from "../keywordHighlight";
import {
XTERM_PERFORMANCE_CONFIG,
type XTermPlatform,
@@ -41,6 +42,7 @@ export type XTermRuntime = {
dispose: () => void;
/** Current working directory detected via OSC 7 */
currentCwd: string | undefined;
keywordHighlighter: KeywordHighlighter;
};
export type CreateXTermRuntimeContext = {
@@ -111,9 +113,13 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
? (navigator as { deviceMemory?: number }).deviceMemory
: undefined;
const settings = ctx.terminalSettingsRef.current;
const rendererType = settings?.rendererType ?? "auto";
const performanceConfig = resolveXTermPerformanceConfig({
platform,
deviceMemoryGb,
rendererType,
});
const hostFontId = ctx.host.fontFamily || ctx.fontFamilyId || "menlo";
@@ -123,11 +129,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
const effectiveFontSize = ctx.host.fontSize || ctx.fontSize;
const settings = ctx.terminalSettingsRef.current;
const cursorStyle = settings?.cursorShape ?? "block";
const cursorBlink = settings?.cursorBlink ?? true;
const scrollback = settings?.scrollback ?? 10000;
const fontLigatures = settings?.fontLigatures ?? true;
const drawBoldTextInBrightColors = settings?.drawBoldInBrightColors ?? true;
const fontWeight = settings?.fontWeight ?? 400;
const fontWeightBold = settings?.fontWeightBold ?? 700;
@@ -136,6 +140,16 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
const scrollOnUserInput = settings?.scrollOnInput ?? true;
const altIsMeta = settings?.altAsMeta ?? false;
const wordSeparator = settings?.wordSeparators ?? " ()[]{}'\"";
const keywordHighlightRules = settings?.keywordHighlightRules ?? [];
const keywordHighlightEnabled = settings?.keywordHighlightEnabled ?? false;
const resolvedFontWeightBold = (() => {
if (typeof document === "undefined" || !document.fonts?.check) {
return fontWeightBold;
}
const weightSpec = `${fontWeightBold} ${effectiveFontSize}px ${fontFamily}`;
return document.fonts.check(weightSpec) ? fontWeightBold : fontWeight;
})();
const term = new XTerm({
...performanceConfig.options,
@@ -153,7 +167,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
| 900
| "normal"
| "bold",
fontWeightBold: fontWeightBold as
fontWeightBold: resolvedFontWeightBold as
| 100
| 200
| 300
@@ -169,7 +183,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
cursorStyle,
cursorBlink,
scrollback,
allowProposedApi: fontLigatures,
// Decorations (keyword highlighting) use proposed APIs; enable globally so toggles work at runtime.
allowProposedApi: true,
drawBoldTextInBrightColors,
minimumContrastRatio,
scrollOnUserInput,
@@ -534,13 +549,18 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
}, resizeDebounceMs);
});
const keywordHighlighter = new KeywordHighlighter(term);
keywordHighlighter.setRules(keywordHighlightRules, keywordHighlightEnabled);
return {
term,
fitAddon,
serializeAddon,
searchAddon,
keywordHighlighter,
dispose: () => {
cleanupMiddleClick?.();
keywordHighlighter.dispose();
try {
term.dispose();
} catch (err) {

View File

@@ -42,6 +42,8 @@ export function Combobox({
}: ComboboxProps) {
const [open, setOpen] = React.useState(false)
const [inputValue, setInputValue] = React.useState("")
// Track if user is actively searching (typed something after opening)
const [isSearching, setIsSearching] = React.useState(false)
const inputRef = React.useRef<HTMLInputElement>(null)
// Sync input value with external value when not focused
@@ -49,11 +51,13 @@ export function Combobox({
if (!open) {
const selected = options.find((opt) => opt.value === value)
setInputValue(selected?.label || value || "")
setIsSearching(false)
}
}, [value, options, open])
// Show all options when dropdown is open but user hasn't started searching
const filteredOptions = React.useMemo(() => {
if (!inputValue.trim()) return options
if (!isSearching || !inputValue.trim()) return options
const lower = inputValue.toLowerCase()
return options.filter(
(opt) =>
@@ -61,13 +65,13 @@ export function Combobox({
opt.value.toLowerCase().includes(lower) ||
opt.sublabel?.toLowerCase().includes(lower)
)
}, [options, inputValue])
}, [options, inputValue, isSearching])
const showCreateOption = React.useMemo(() => {
if (!allowCreate || !inputValue.trim()) return false
if (!allowCreate || !inputValue.trim() || !isSearching) return false
const lower = inputValue.toLowerCase().trim()
return !options.some((opt) => opt.value.toLowerCase() === lower || opt.label.toLowerCase() === lower)
}, [allowCreate, inputValue, options])
}, [allowCreate, inputValue, options, isSearching])
const handleSelect = (optValue: string) => {
onValueChange?.(optValue)
@@ -87,6 +91,7 @@ export function Combobox({
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value)
setIsSearching(true)
if (!open) setOpen(true)
}

View File

@@ -20,6 +20,42 @@ const getContextMenuPortalEl = () => {
zIndex: "2147483647", // max safe z-index to avoid being covered
pointerEvents: "none",
});
// Intercept aria-hidden attribute to prevent it from being set when menu is open
// This avoids "Blocked aria-hidden on an element because its descendant retained focus" warnings
let ariaHiddenValue: string | null = null;
Object.defineProperty(portal, "ariaHidden", {
get() {
return ariaHiddenValue;
},
set(value: string | null) {
// Block aria-hidden="true" when there are children (menu is open)
if (value === "true" && portal && portal.children.length > 0) {
return;
}
ariaHiddenValue = value;
},
configurable: true,
});
// Also override setAttribute for aria-hidden
const originalSetAttribute = portal.setAttribute.bind(portal);
portal.setAttribute = function (name: string, value: string) {
if (name === "aria-hidden" && value === "true" && portal && portal.children.length > 0) {
return;
}
originalSetAttribute(name, value);
};
// Override removeAttribute to sync our internal state
const originalRemoveAttribute = portal.removeAttribute.bind(portal);
portal.removeAttribute = function (name: string) {
if (name === "aria-hidden") {
ariaHiddenValue = null;
}
originalRemoveAttribute(name);
};
document.body.appendChild(portal);
}
return portal;

View File

@@ -44,6 +44,7 @@ const DialogContent = React.forwardRef<
className
)}
style={{ boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25), 0 12px 24px -8px rgba(0, 0, 0, 0.15)' }}
aria-describedby={undefined}
{...props}
>
{children}

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