Compare commits

...

65 Commits

Author SHA1 Message Date
陈大猫
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
35 changed files with 1746 additions and 355 deletions

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

View File

@@ -242,6 +242,7 @@ function App({ settings }: { settings: SettingsState }) {
logViews,
openLogView,
closeLogView,
copySession,
} = useSessionState();
// isMacClient is used for window controls styling
@@ -864,6 +865,7 @@ function App({ settings }: { settings: SettingsState }) {
isMacClient={isMacClient}
onCloseSession={closeSession}
onRenameSession={startSessionRename}
onCopySession={copySession}
onRenameWorkspace={startWorkspaceRename}
onCloseWorkspace={closeWorkspace}
onCloseLogView={closeLogView}

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

@@ -331,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',
@@ -662,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',
@@ -1100,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 *',

View File

@@ -202,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',
@@ -409,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': '密钥 / 证书',
@@ -1089,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': '私钥 *',

View File

@@ -2,7 +2,7 @@ import { useCallback } from "react";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import type { SftpFileEntry, SftpFilenameEncoding } from "../../../domain/models";
import { buildMockLocalFiles } from "./mockLocalFiles";
import { formatFileSize } from "./utils";
import { formatFileSize, formatDate } from "./utils";
export const useSftpDirectoryListing = () => {
const getMockLocalFiles = useCallback((path: string): SftpFileEntry[] => {
@@ -18,13 +18,14 @@ export const useSftpDirectoryListing = () => {
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: new Date(f.lastModified).getTime(),
lastModifiedFormatted: f.lastModified,
lastModified,
lastModifiedFormatted: formatDate(lastModified),
linkTarget: f.linkTarget as "file" | "directory" | null | undefined,
hidden: f.hidden,
};
@@ -40,13 +41,14 @@ export const useSftpDirectoryListing = () => {
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: new Date(f.lastModified).getTime(),
lastModifiedFormatted: f.lastModified,
lastModified,
lastModifiedFormatted: formatDate(lastModified),
linkTarget: f.linkTarget as "file" | "directory" | null | undefined,
};
});

View File

@@ -344,6 +344,25 @@ export const useSftpExternalOperations = (
}
: 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,
};
}, []);

View File

@@ -11,13 +11,9 @@ export const formatFileSize = (bytes: number): string => {
export const formatDate = (timestamp: number): string => {
if (!timestamp) return "--";
const date = new Date(timestamp);
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
});
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 => {

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

@@ -2,6 +2,8 @@ import {
AlertTriangle,
Check,
ChevronDown,
Eye,
EyeOff,
FolderLock,
FolderPlus,
Forward,
@@ -123,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("");
@@ -164,6 +169,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
}
setForm(updatedData);
setGroupInputValue(initialData.group || "");
// Reset password visibility when host changes for privacy
setShowPassword(false);
}
}, [initialData]);
@@ -244,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);
};
@@ -499,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} />
@@ -798,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 */}
@@ -1445,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

@@ -76,8 +76,10 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
selectApplication,
downloadSftpToTempAndOpen,
cancelSftpUpload,
startStreamTransfer,
cancelTransfer,
} = useSftpBackend();
const { t, resolvedLocale } = useI18n();
const { t } = useI18n();
const { sftpAutoSync, sftpShowHiddenFiles } = useSettingsState();
const isLocalSession = host.protocol === "local";
const [filenameEncoding, setFilenameEncoding] = useState<SftpFilenameEncoding>(
@@ -365,6 +367,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
mkdirLocal,
mkdirSftp: mkdirSftpWithEncoding,
cancelSftpUpload,
startStreamTransfer,
cancelTransfer,
setLoading,
t,
});
@@ -540,7 +544,6 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
loading={loading}
loadingTextContent={loadingTextContent}
reconnecting={reconnecting}
resolvedLocale={resolvedLocale}
columnWidths={columnWidths}
sortField={sortField}
sortOrder={sortOrder}

View File

@@ -919,6 +919,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
<TerminalContextMenu
hasSelection={hasSelection}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
rightClickBehavior={terminalSettings?.rightClickBehavior}
onCopy={terminalContextActions.onCopy}
onPaste={terminalContextActions.onPaste}

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";
@@ -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>
@@ -1379,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

@@ -23,7 +23,6 @@ interface SftpModalFileListProps {
loading: boolean;
loadingTextContent: boolean;
reconnecting: boolean;
resolvedLocale: string | undefined;
columnWidths: { name: number; size: number; modified: number; actions: number };
sortField: "name" | "size" | "modified";
sortOrder: "asc" | "desc";
@@ -53,7 +52,7 @@ interface SftpModalFileListProps {
handleDeleteSelected: () => void;
loadFiles: (path: string, options?: { force?: boolean }) => void;
formatBytes: (bytes: number | string) => string;
formatDate: (dateStr: string | number | undefined, locale?: string) => string;
formatDate: (dateStr: string | number | undefined) => string;
}
export const SftpModalFileList: React.FC<SftpModalFileListProps> = ({
@@ -66,7 +65,6 @@ export const SftpModalFileList: React.FC<SftpModalFileListProps> = ({
loading,
loadingTextContent,
reconnecting,
resolvedLocale,
columnWidths,
sortField,
sortOrder,
@@ -279,7 +277,7 @@ export const SftpModalFileList: React.FC<SftpModalFileListProps> = ({
{isNavigableDirectory ? "--" : formatBytes(file.size)}
</div>
<div className="text-xs text-muted-foreground truncate">
{formatDate(file.lastModified, resolvedLocale)}
{formatDate(file.lastModified)}
</div>
<div className="flex items-center justify-end gap-1">
{isDownloadableFile && (

View File

@@ -49,6 +49,22 @@ interface UseSftpModalTransfersParams {
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;
}
@@ -81,6 +97,8 @@ export const useSftpModalTransfers = ({
mkdirLocal,
mkdirSftp,
cancelSftpUpload,
startStreamTransfer,
cancelTransfer,
setLoading,
t,
}: UseSftpModalTransfersParams): UseSftpModalTransfersResult => {
@@ -174,8 +192,35 @@ export const useSftpModalTransfers = ({
}
},
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]);
}, [writeLocalFile, mkdirLocal, mkdirSftp, writeSftpBinary, writeSftpBinaryWithProgress, cancelSftpUpload, startStreamTransfer, cancelTransfer]);
// Create upload callbacks
const createUploadCallbacks = useCallback((): UploadCallbacks => {
@@ -290,6 +335,11 @@ export const useSftpModalTransfers = ({
const handleUploadMultiple = useCallback(
async (fileList: FileList) => {
console.log('[useSftpModalTransfers] handleUploadMultiple called', {
length: fileList.length,
currentPath,
isLocalSession
});
if (fileList.length === 0) return;
setUploading(true);
@@ -392,14 +442,85 @@ export const useSftpModalTransfers = ({
[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) {
void handleUploadMultiple(e.target.files);
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 = "";
}
e.target.value = "";
},
[handleUploadMultiple],
[handleUploadFromFiles],
);
const handleDrag = useCallback((e: React.DragEvent) => {
@@ -425,14 +546,19 @@ export const useSftpModalTransfers = ({
);
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

View File

@@ -7,9 +7,10 @@ export const formatBytes = (bytes: number | string): string => {
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
};
export const formatDate = (dateStr: string | number | undefined, locale?: string): string => {
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);
return date.toLocaleString(locale || undefined);
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

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

@@ -65,6 +65,7 @@ export interface Host {
identityFileId?: string; // Reference to SSHKey
protocol?: 'ssh' | 'telnet' | 'local' | 'serial'; // Default/primary protocol
password?: string;
savePassword?: boolean; // Whether to save the password (default: true)
authMethod?: 'password' | 'key' | 'certificate';
agentForwarding?: boolean;
createdAt?: number; // Timestamp when host was created
@@ -308,7 +309,7 @@ export const DEFAULT_KEY_BINDINGS: KeyBinding[] = [
{ id: 'copy', action: 'copy', label: 'Copy from Terminal', mac: '⌘ + C', pc: 'Ctrl + Shift + C', category: 'terminal' },
{ id: 'paste', action: 'paste', label: 'Paste to Terminal', mac: '⌘ + V', pc: 'Ctrl + Shift + V', category: 'terminal' },
{ id: 'select-all', action: 'selectAll', label: 'Select All in Terminal', mac: '⌘ + A', pc: 'Ctrl + Shift + A', category: 'terminal' },
{ id: 'clear-buffer', action: 'clearBuffer', label: 'Clear Terminal Buffer', mac: '⌘ + K', pc: 'Ctrl + L', category: 'terminal' },
{ id: 'clear-buffer', action: 'clearBuffer', label: 'Clear Terminal Buffer', mac: '⌘ + ⌃ + K', pc: 'Ctrl + Shift + K', category: 'terminal' },
{ id: 'search-terminal', action: 'searchTerminal', label: 'Open Terminal Search', mac: '⌘ + F', pc: 'Ctrl + Shift + F', category: 'terminal' },
// Navigation / Split View
@@ -319,11 +320,11 @@ export const DEFAULT_KEY_BINDINGS: KeyBinding[] = [
// App Features
{ id: 'open-hosts', action: 'openHosts', label: 'Open Hosts Page', mac: 'Disabled', pc: 'Disabled', category: 'app' },
{ id: 'open-local', action: 'openLocal', label: 'Open Local Terminal', mac: '⌘ + L', pc: 'Ctrl + L', category: 'app' },
{ id: 'open-sftp', action: 'openSftp', label: 'Open SFTP', mac: '⌘ + Shift + S', pc: 'Ctrl + Shift + S', category: 'app' },
{ id: 'open-sftp', action: 'openSftp', label: 'Open SFTP', mac: '⌘ + Shift + O', pc: 'Ctrl + Shift + O', category: 'app' },
{ id: 'port-forwarding', action: 'portForwarding', label: 'Open Port Forwarding', mac: '⌘ + P', pc: 'Ctrl + P', category: 'app' },
{ id: 'command-palette', action: 'commandPalette', label: 'Open Command Palette', mac: '⌘ + K', pc: 'Ctrl + K', category: 'app' },
{ id: 'quick-switch', action: 'quickSwitch', label: 'Quick Switch', mac: '⌘ + J', pc: 'Ctrl + J', category: 'app' },
{ id: 'snippets', action: 'snippets', label: 'Open Snippets', mac: '⌘ + Shift + S', pc: 'Ctrl + Alt + S', category: 'app' },
{ id: 'snippets', action: 'snippets', label: 'Open Snippets', mac: '⌘ + Shift + S', pc: 'Ctrl + Shift + S', category: 'app' },
{ id: 'broadcast', action: 'broadcast', label: 'Switch the Broadcast Mode', mac: '⌘ + B', pc: 'Ctrl + B', category: 'app' },
];

View File

@@ -998,3 +998,77 @@ export const getVaultCsvTemplate = (
return rows.map((r) => r.map((c) => escapeCsv(c)).join(",")).join("\r\n") + "\r\n";
};
export const exportHostsToCsv = (hosts: Host[]): string => {
const header = ["Groups", "Label", "Tags", "Hostname/IP", "Protocol", "Port", "Username"];
const rows: string[][] = [header];
const escapeCsv = (value: string) => {
// Prevent CSV formula injection by prefixing dangerous characters with a single quote
// These characters can be interpreted as formulas by spreadsheet applications
if (/^[=+\-@\t\r]/.test(value)) {
value = "'" + value;
}
if (value.includes('"')) value = value.replace(/"/g, '""');
if (/[",\r\n]/.test(value)) return `"${value}"`;
return value;
};
// Filter out serial hosts - CSV format doesn't support serial port configuration
// Note: mosh-enabled hosts are exported as SSH (losing mosh flag) rather than being skipped,
// since exporting partial data is better than losing the entire host entry
const isUnsupported = (h: Host) => h.protocol === "serial";
const exportableHosts = hosts.filter((h) => !isUnsupported(h));
// Helper to bracket IPv6 addresses for CSV export
// IPv6 addresses contain colons which would be misinterpreted as port separators on import
const formatHostname = (hostname: string): string => {
// Check if it looks like an IPv6 address (contains colons but not already bracketed)
if (hostname.includes(":") && !hostname.startsWith("[")) {
return `[${hostname}]`;
}
return hostname;
};
for (const host of exportableHosts) {
// For telnet hosts, use telnet-specific port and username
const isTelnet = host.protocol === "telnet";
const effectivePort = isTelnet
? (host.telnetPort ?? host.port ?? 23)
: (host.port ?? 22);
const effectiveUsername = isTelnet
? (host.telnetUsername ?? host.username ?? "")
: (host.username ?? "");
rows.push([
host.group ?? "",
host.label ?? "",
(host.tags ?? []).join(","),
formatHostname(host.hostname),
host.protocol ?? "ssh",
String(effectivePort),
effectiveUsername,
]);
}
return rows.map((r) => r.map((c) => escapeCsv(c)).join(",")).join("\r\n") + "\r\n";
};
export interface ExportHostsResult {
csv: string;
exportedCount: number;
skippedCount: number;
}
export const exportHostsToCsvWithStats = (hosts: Host[]): ExportHostsResult => {
// Only serial hosts are truly unsupported - mosh hosts are exported as SSH
const isUnsupported = (h: Host) => h.protocol === "serial";
const skippedHosts = hosts.filter((h) => isUnsupported(h));
const exportableHosts = hosts.filter((h) => !isUnsupported(h));
return {
csv: exportHostsToCsv(hosts),
exportedCount: exportableHosts.length,
skippedCount: skippedHosts.length,
};
};

View File

@@ -73,11 +73,7 @@ module.exports = {
target: [
{
target: 'nsis',
arch: ['x64']
},
{
target: 'dir',
arch: ['x64']
arch: ['x64', 'arm64']
}
]
},

View File

@@ -71,14 +71,18 @@ function isKeyEncrypted(keyContent) {
*/
function findDefaultPrivateKey() {
const sshDir = path.join(os.homedir(), ".ssh");
log("Searching for default SSH keys", { sshDir, keyNames: DEFAULT_KEY_NAMES });
for (const name of DEFAULT_KEY_NAMES) {
const keyPath = path.join(sshDir, name);
log("Checking key file", { keyPath, exists: fs.existsSync(keyPath) });
if (fs.existsSync(keyPath)) {
try {
const privateKey = fs.readFileSync(keyPath, "utf8");
// Skip encrypted keys - they require a passphrase and would abort
// authentication before password/keyboard-interactive can be tried
if (isKeyEncrypted(privateKey)) {
const encrypted = isKeyEncrypted(privateKey);
log("Key file read", { keyPath, keyName: name, encrypted, keyLength: privateKey.length });
if (encrypted) {
log("Skipping encrypted default key", { keyPath, keyName: name });
continue;
}
@@ -90,6 +94,7 @@ function findDefaultPrivateKey() {
}
}
}
log("No suitable default SSH key found");
return null;
}
@@ -124,13 +129,45 @@ const logFile = path.join(require("os").tmpdir(), "netcatty-ssh.log");
const log = (msg, data) => {
const line = `[${new Date().toISOString()}] ${msg} ${data ? JSON.stringify(data) : ""}\n`;
try { fs.appendFileSync(logFile, line); } catch { }
console.log("[SSH]", msg, data || "");
console.log("[SSH]", msg, data ? JSON.stringify(data, null, 2) : "");
};
// Session storage - shared reference passed from main
let sessions = null;
let electronModule = null;
// Authentication method cache - remembers successful auth methods per host
// Key format: "username@hostname:port"
// Value: { method: "password" | "publickey" | "publickey-default" }
// Cache persists until auth failure, then cleared to retry all methods
const authMethodCache = new Map();
function getAuthCacheKey(username, hostname, port) {
return `${username}@${hostname}:${port || 22}`;
}
function getCachedAuthMethod(username, hostname, port) {
const key = getAuthCacheKey(username, hostname, port);
const cached = authMethodCache.get(key);
if (cached) {
log("Using cached auth method", { key, method: cached.method });
return cached.method;
}
return null;
}
function setCachedAuthMethod(username, hostname, port, method) {
const key = getAuthCacheKey(username, hostname, port);
log("Caching successful auth method", { key, method });
authMethodCache.set(key, { method });
}
function clearCachedAuthMethod(username, hostname, port) {
const key = getAuthCacheKey(username, hostname, port);
log("Clearing cached auth method", { key });
authMethodCache.delete(key);
}
// Normalize charset inputs (often provided as bare encodings like "UTF-8")
// into a usable LANG locale for remote shells.
function resolveLangFromCharset(charset) {
@@ -408,21 +445,38 @@ async function startSSHSession(event, options) {
}
}
if (options.password) {
if (options.password && typeof options.password === "string" && options.password.trim().length > 0) {
connectOpts.password = options.password;
}
// Fallback to default SSH key if no authentication method is configured
let usedDefaultKey = null;
// Always try to find default SSH key for fallback authentication
// This allows fallback even when password auth fails
let defaultKeyInfo = null;
let usedDefaultKeyAsPrimary = false;
const defaultKey = findDefaultPrivateKey();
if (defaultKey) {
defaultKeyInfo = defaultKey;
log("Found default SSH key for fallback", { keyPath: defaultKey.keyPath, keyName: defaultKey.keyName });
}
// If no primary auth method configured, use default key as primary
if (!connectOpts.privateKey && !connectOpts.password && !connectOpts.agent) {
const defaultKey = findDefaultPrivateKey();
if (defaultKey) {
log("Using default SSH key as fallback", { keyPath: defaultKey.keyPath });
connectOpts.privateKey = defaultKey.privateKey;
usedDefaultKey = defaultKey;
log("No auth method configured, using default SSH key as primary auth");
if (defaultKeyInfo) {
connectOpts.privateKey = defaultKeyInfo.privateKey;
usedDefaultKeyAsPrimary = true; // Track that we promoted default key to primary
} else {
log("No default SSH key found in ~/.ssh directory");
}
}
log("Final auth configuration", {
hasPrivateKey: !!connectOpts.privateKey,
hasPassword: !!connectOpts.password,
hasAgent: !!connectOpts.agent,
hasDefaultKeyFallback: !!defaultKeyInfo,
});
// Agent forwarding
if (options.agentForwarding) {
connectOpts.agentForward = true;
@@ -435,12 +489,228 @@ async function startSSHSession(event, options) {
}
}
// Prefer agent-based auth when we created an in-process agent (cert)
// Build authentication handler with fallback support
// ssh2 authHandler can be a function that returns the next auth method to try
// Check if we have a cached successful auth method for this host
const cachedMethod = getCachedAuthMethod(connectOpts.username, options.hostname, options.port);
// Track which method succeeded for caching
let lastTriedMethod = null;
if (authAgent) {
const order = ["agent"];
// Allow password fallback if provided
if (connectOpts.password) order.push("password");
// Add default key fallback if available and no user key configured
// Must also set connectOpts.privateKey for ssh2 to actually try publickey auth
if (defaultKeyInfo && !options.privateKey) {
connectOpts.privateKey = defaultKeyInfo.privateKey;
order.push("publickey");
}
order.push("keyboard-interactive");
connectOpts.authHandler = order;
log("Auth order (agent mode)", { order });
} else {
// Build dynamic auth handler for fallback support
const authMethods = [];
// First try user-configured key if available (explicit user choice)
if (connectOpts.privateKey) {
authMethods.push({ type: "publickey", key: connectOpts.privateKey, passphrase: connectOpts.passphrase, id: "publickey-user" });
}
// Then try password if available (explicit user choice)
// Password before agent because agent may be auto-set via SSH_AUTH_SOCK
// and on servers with low MaxAuthTries, agent attempt could exhaust tries
if (connectOpts.password) {
authMethods.push({ type: "password", id: "password" });
}
// Then try agent if configured (agentForwarding or SSH_AUTH_SOCK)
// Agent after password since it may be auto-configured rather than explicit
if (connectOpts.agent) {
authMethods.push({ type: "agent", id: "agent" });
}
// Then try default SSH key as fallback (if not already used as primary)
if (defaultKeyInfo && !options.privateKey && !usedDefaultKeyAsPrimary) {
authMethods.push({ type: "publickey", key: defaultKeyInfo.privateKey, isDefault: true, id: "publickey-default" });
}
// Finally try keyboard-interactive
authMethods.push({ type: "keyboard-interactive", id: "keyboard-interactive" });
log("Auth methods configured", {
methods: authMethods.map(m => ({ type: m.type, id: m.id, isDefault: m.isDefault || false })),
cachedMethod
});
// Reorder methods based on cached successful method
if (cachedMethod) {
const cachedIndex = authMethods.findIndex(m => m.id === cachedMethod);
if (cachedIndex > 0) {
const [cachedAuthMethod] = authMethods.splice(cachedIndex, 1);
authMethods.unshift(cachedAuthMethod);
log("Reordered auth methods based on cache", {
methods: authMethods.map(m => m.id)
});
}
}
// Use dynamic authHandler if we have multiple auth options
if (authMethods.length > 1) {
let authIndex = 0;
// Track methods that have been attempted (to avoid re-trying on failure)
// This prevents reusing the same key when server requires multiple publickey auth steps
// and also prevents re-attempting failed methods
const attemptedMethodIds = new Set();
// Track the first successful method for caching (not the last one in multi-step flows)
let firstSuccessfulMethod = null;
// Track if we've gone through a partialSuccess flow (multi-step auth)
let hadPartialSuccess = false;
connectOpts.authHandler = (methodsLeft, partialSuccess, callback) => {
log("authHandler called", { methodsLeft, partialSuccess, authIndex, attemptedMethodIds: Array.from(attemptedMethodIds) });
// methodsLeft can be null on first call (before server responds with available methods)
// Include "agent" for SSH agent-based auth (used with agentForwarding)
const availableMethods = methodsLeft || ["publickey", "password", "keyboard-interactive", "agent"];
// Handle partialSuccess case (e.g., password succeeded but server requires additional auth like MFA)
// When partialSuccess is true, we should try the remaining methods the server is asking for
if (partialSuccess && methodsLeft && methodsLeft.length > 0) {
hadPartialSuccess = true;
// Record the first successful method (the one that triggered partialSuccess)
if (lastTriedMethod && !firstSuccessfulMethod) {
firstSuccessfulMethod = lastTriedMethod;
log("Recorded first successful method for caching", { method: firstSuccessfulMethod });
}
// Mark the last tried method as attempted (it succeeded, so we shouldn't retry it)
if (lastTriedMethod) {
attemptedMethodIds.add(lastTriedMethod);
log("Marked method as attempted (partial success)", { method: lastTriedMethod });
}
log("Partial success - server requires additional auth", { methodsLeft, attemptedMethodIds: Array.from(attemptedMethodIds) });
// Find a method from our list that matches what the server wants
// Skip methods that have already been attempted
for (const serverMethod of methodsLeft) {
// Map server method names to our method types
const matchingMethod = authMethods.find(m => {
// Skip already attempted methods
if (attemptedMethodIds.has(m.id)) return false;
if (serverMethod === "keyboard-interactive" && m.type === "keyboard-interactive") return true;
if (serverMethod === "password" && m.type === "password") return true;
if (serverMethod === "publickey" && (m.type === "publickey" || m.type === "agent")) return true;
return false;
});
if (matchingMethod) {
log("Found matching method for partial success", { serverMethod, matchingMethod: matchingMethod.id });
// Mark as attempted BEFORE returning to prevent re-use on failure
attemptedMethodIds.add(matchingMethod.id);
lastTriedMethod = matchingMethod.id;
if (matchingMethod.type === "keyboard-interactive") {
log("Trying keyboard-interactive auth (partial success)", { id: matchingMethod.id });
return callback("keyboard-interactive");
} else if (matchingMethod.type === "password") {
log("Trying password auth (partial success)", { id: matchingMethod.id });
return callback({
type: "password",
username: connectOpts.username,
password: connectOpts.password,
});
} else if (matchingMethod.type === "agent") {
const agentType = typeof connectOpts.agent === "string" ? "path" : "NetcattyAgent";
log("Trying agent auth (partial success)", { id: matchingMethod.id, agentType });
return callback("agent");
} else if (matchingMethod.type === "publickey") {
log("Trying publickey auth (partial success)", { id: matchingMethod.id });
return callback({
type: "publickey",
username: connectOpts.username,
key: matchingMethod.key,
passphrase: matchingMethod.passphrase,
});
}
}
}
// No matching method found for partial success
log("No matching method found for partial success requirements", { methodsLeft });
return callback(false);
}
while (authIndex < authMethods.length) {
const method = authMethods[authIndex];
authIndex++;
// Skip methods that have already been attempted (e.g., during partial success handling)
if (attemptedMethodIds.has(method.id)) {
log("Skipping already attempted method", { method: method.id });
continue;
}
// Check if this method is still available on server
// Note: "agent" uses "publickey" as the underlying method type
const methodName = method.type === "password" ? "password" :
method.type === "publickey" ? "publickey" :
method.type === "agent" ? "publickey" : "keyboard-interactive";
if (!availableMethods.includes(methodName) && !availableMethods.includes(method.type)) {
log("Auth method not available on server, skipping", { method: method.id });
continue;
}
// Mark as attempted BEFORE returning
attemptedMethodIds.add(method.id);
lastTriedMethod = method.id;
if (method.type === "agent") {
// Only log safe identifier, not the full agent object which may contain private keys
const agentType = typeof connectOpts.agent === "string" ? "path" : "NetcattyAgent";
log("Trying agent auth", { id: method.id, agentType });
// Return "agent" string to use SSH agent for authentication
return callback("agent");
} else if (method.type === "publickey") {
log("Trying publickey auth", { id: method.id, isDefault: method.isDefault || false });
return callback({
type: "publickey",
username: connectOpts.username,
key: method.key,
passphrase: method.passphrase,
});
} else if (method.type === "password") {
log("Trying password auth", { id: method.id });
return callback({
type: "password",
username: connectOpts.username,
password: connectOpts.password,
});
} else if (method.type === "keyboard-interactive") {
log("Trying keyboard-interactive auth", { id: method.id });
// Return string instead of object - ssh2 requires a prompt function
// for keyboard-interactive objects. Returning the string lets ssh2
// use its default handling and trigger the keyboard-interactive event.
return callback("keyboard-interactive");
}
}
log("All auth methods exhausted");
return callback(false);
};
// Store method reference for success callback
// For multi-step auth (partialSuccess), cache the first successful method, not the last
// This ensures next connection starts with the correct first factor
connectOpts._lastTriedMethodRef = () => {
if (hadPartialSuccess && firstSuccessfulMethod) {
log("Using first successful method for cache (multi-step auth)", { firstSuccessfulMethod });
return firstSuccessfulMethod;
}
return lastTriedMethod;
};
}
}
// Handle chain/proxy connections
@@ -476,6 +746,15 @@ async function startSSHSession(event, options) {
const logPrefix = hasJumpHosts ? '[Chain]' : '[SSH]';
conn.on("ready", () => {
console.log(`${logPrefix} ${options.hostname} ready`);
// Cache the successful auth method
if (connectOpts._lastTriedMethodRef) {
const successMethod = connectOpts._lastTriedMethodRef();
if (successMethod) {
setCachedAuthMethod(connectOpts.username, options.hostname, options.port, successMethod);
}
}
if (hasJumpHosts || hasProxy) {
sendProgress(totalHops, totalHops, options.hostname, 'connected');
}
@@ -584,8 +863,9 @@ async function startSSHSession(event, options) {
err.message?.toLowerCase().includes('password') ||
err.level === 'client-authentication';
// Use log instead of error for auth failures (normal fallback scenario)
// Clear cached auth method on auth failure so next attempt tries all methods
if (isAuthError) {
clearCachedAuthMethod(connectOpts.username, options.hostname, options.port);
console.log(`${logPrefix} ${options.hostname} auth failed:`, err.message);
safeSend(contents, "netcatty:auth:failed", {
sessionId,
@@ -670,12 +950,14 @@ async function startSSHSession(event, options) {
// Enable keyboard-interactive authentication in authHandler
if (connectOpts.authHandler) {
// Note: If authHandler is a function (for fallback support), keyboard-interactive
// is already included in the auth methods list
if (Array.isArray(connectOpts.authHandler)) {
// Add keyboard-interactive after the existing methods
if (!connectOpts.authHandler.includes("keyboard-interactive")) {
connectOpts.authHandler.push("keyboard-interactive");
}
} else {
} else if (typeof connectOpts.authHandler !== "function") {
// Create authHandler with keyboard-interactive support
const authMethods = [];
if (connectOpts.privateKey) authMethods.push("publickey");
@@ -683,6 +965,7 @@ async function startSSHSession(event, options) {
authMethods.push("keyboard-interactive");
connectOpts.authHandler = authMethods;
}
// If authHandler is a function, it already handles keyboard-interactive
// Increase timeout to allow for keyboard-interactive auth
connectOpts.readyTimeout = 120000; // 2 minutes for 2FA input

View File

@@ -23,6 +23,138 @@ function init(deps) {
electronModule = deps.electronModule;
}
/**
* Upload a local file to SFTP using streams (supports cancellation)
*/
async function uploadWithStreams(localPath, remotePath, client, fileSize, transfer, sendProgress) {
return new Promise((resolve, reject) => {
const readStream = fs.createReadStream(localPath);
// Get the underlying sftp object from ssh2-sftp-client
const sftp = client.sftp;
if (!sftp) {
reject(new Error("SFTP client not ready"));
return;
}
const writeStream = sftp.createWriteStream(remotePath);
let transferred = 0;
let finished = false;
// Store streams for cancellation
transfer.readStream = readStream;
transfer.writeStream = writeStream;
const cleanup = (err) => {
if (finished) return;
finished = true;
// Remove listeners to prevent memory leaks
readStream.removeAllListeners();
writeStream.removeAllListeners();
if (err) {
// Destroy streams on error
try { readStream.destroy(); } catch {}
try { writeStream.destroy(); } catch {}
reject(err);
} else {
resolve();
}
};
readStream.on('data', (chunk) => {
if (transfer.cancelled) {
cleanup(new Error('Transfer cancelled'));
return;
}
transferred += chunk.length;
sendProgress(transferred, fileSize);
});
readStream.on('error', (err) => cleanup(err));
writeStream.on('error', (err) => cleanup(err));
writeStream.on('close', () => {
if (transfer.cancelled) {
cleanup(new Error('Transfer cancelled'));
} else {
cleanup(null);
}
});
readStream.pipe(writeStream);
});
}
/**
* Download from SFTP to local file using streams (supports cancellation)
*/
async function downloadWithStreams(remotePath, localPath, client, fileSize, transfer, sendProgress) {
return new Promise((resolve, reject) => {
// Get the underlying sftp object from ssh2-sftp-client
const sftp = client.sftp;
if (!sftp) {
reject(new Error("SFTP client not ready"));
return;
}
const readStream = sftp.createReadStream(remotePath);
const writeStream = fs.createWriteStream(localPath);
let transferred = 0;
let finished = false;
// Store streams for cancellation
transfer.readStream = readStream;
transfer.writeStream = writeStream;
const cleanup = (err) => {
if (finished) return;
finished = true;
// Remove listeners to prevent memory leaks
readStream.removeAllListeners();
writeStream.removeAllListeners();
if (err) {
// Destroy streams on error
try { readStream.destroy(); } catch {}
try { writeStream.destroy(); } catch {}
reject(err);
} else {
resolve();
}
};
readStream.on('data', (chunk) => {
if (transfer.cancelled) {
cleanup(new Error('Transfer cancelled'));
return;
}
transferred += chunk.length;
sendProgress(transferred, fileSize);
});
readStream.on('error', (err) => cleanup(err));
writeStream.on('error', (err) => cleanup(err));
// Handle normal completion
writeStream.on('finish', () => {
if (transfer.cancelled) {
cleanup(new Error('Transfer cancelled'));
} else {
cleanup(null);
}
});
// Handle stream destruction (destroy() emits 'close' but not 'finish')
writeStream.on('close', () => {
if (transfer.cancelled) {
cleanup(new Error('Transfer cancelled'));
}
});
readStream.pipe(writeStream);
});
}
/**
* Start a file transfer
*/
@@ -40,17 +172,18 @@ async function startTransfer(event, payload) {
targetEncoding,
} = payload;
const sender = event.sender;
// Register transfer for cancellation
activeTransfers.set(transferId, { cancelled: false });
const transfer = { cancelled: false, readStream: null, writeStream: null };
activeTransfers.set(transferId, transfer);
let lastTime = Date.now();
let lastTransferred = 0;
let speed = 0;
const sendProgress = (transferred, total) => {
if (activeTransfers.get(transferId)?.cancelled) return;
if (transfer.cancelled) return;
const now = Date.now();
const elapsed = now - lastTime;
if (elapsed >= 100) {
@@ -58,25 +191,23 @@ async function startTransfer(event, payload) {
lastTime = now;
lastTransferred = transferred;
}
sender.send("netcatty:transfer:progress", { transferId, transferred, speed, totalBytes: total });
};
const sendComplete = () => {
activeTransfers.delete(transferId);
sender.send("netcatty:transfer:complete", { transferId });
};
const sendError = (error) => {
activeTransfers.delete(transferId);
sender.send("netcatty:transfer:error", { transferId, error: error.message || String(error) });
};
const isCancelled = () => activeTransfers.get(transferId)?.cancelled;
try {
let fileSize = totalBytes || 0;
// Get file size if not provided
if (!fileSize) {
if (sourceType === 'local') {
@@ -90,123 +221,124 @@ async function startTransfer(event, payload) {
fileSize = stat.size;
}
}
// Send initial progress
sendProgress(0, fileSize);
// Handle different transfer scenarios
if (sourceType === 'local' && targetType === 'sftp') {
// Upload: Local -> SFTP
// Upload: Local -> SFTP using streams (supports cancellation)
const client = sftpClients.get(targetSftpId);
if (!client) throw new Error("Target SFTP session not found");
const dir = path.dirname(targetPath).replace(/\\/g, '/');
try { await ensureRemoteDirForSession(targetSftpId, dir, targetEncoding); } catch {}
const encodedTargetPath = encodePathForSession(targetSftpId, targetPath, targetEncoding);
await client.fastPut(sourcePath, encodedTargetPath, {
step: (totalTransferred, chunk, total) => {
if (isCancelled()) {
throw new Error('Transfer cancelled');
}
sendProgress(totalTransferred, total);
}
});
await uploadWithStreams(sourcePath, encodedTargetPath, client, fileSize, transfer, sendProgress);
} else if (sourceType === 'sftp' && targetType === 'local') {
// Download: SFTP -> Local
// Download: SFTP -> Local using streams (supports cancellation)
const client = sftpClients.get(sourceSftpId);
if (!client) throw new Error("Source SFTP session not found");
const dir = path.dirname(targetPath);
await fs.promises.mkdir(dir, { recursive: true });
const encodedSourcePath = encodePathForSession(sourceSftpId, sourcePath, sourceEncoding);
await client.fastGet(encodedSourcePath, targetPath, {
step: (totalTransferred, chunk, total) => {
if (isCancelled()) {
throw new Error('Transfer cancelled');
}
sendProgress(totalTransferred, total);
}
});
await downloadWithStreams(encodedSourcePath, targetPath, client, fileSize, transfer, sendProgress);
} else if (sourceType === 'local' && targetType === 'local') {
// Local copy: use streams
const dir = path.dirname(targetPath);
await fs.promises.mkdir(dir, { recursive: true });
await new Promise((resolve, reject) => {
const readStream = fs.createReadStream(sourcePath);
const writeStream = fs.createWriteStream(targetPath);
let transferred = 0;
const transfer = activeTransfers.get(transferId);
if (transfer) {
transfer.readStream = readStream;
transfer.writeStream = writeStream;
}
let finished = false;
transfer.readStream = readStream;
transfer.writeStream = writeStream;
const cleanup = (err) => {
if (finished) return;
finished = true;
readStream.removeAllListeners();
writeStream.removeAllListeners();
if (err) {
try { readStream.destroy(); } catch {}
try { writeStream.destroy(); } catch {}
reject(err);
} else {
resolve();
}
};
readStream.on('data', (chunk) => {
if (isCancelled()) {
readStream.destroy();
writeStream.destroy();
reject(new Error('Transfer cancelled'));
if (transfer.cancelled) {
cleanup(new Error('Transfer cancelled'));
return;
}
transferred += chunk.length;
sendProgress(transferred, fileSize);
});
readStream.on('error', reject);
writeStream.on('error', reject);
writeStream.on('finish', resolve);
readStream.on('error', cleanup);
writeStream.on('error', cleanup);
// Handle normal completion
writeStream.on('finish', () => {
if (transfer.cancelled) {
cleanup(new Error('Transfer cancelled'));
} else {
cleanup(null);
}
});
// Handle stream destruction (destroy() emits 'close' but not 'finish')
writeStream.on('close', () => {
if (transfer.cancelled) {
cleanup(new Error('Transfer cancelled'));
}
});
readStream.pipe(writeStream);
});
} else if (sourceType === 'sftp' && targetType === 'sftp') {
// SFTP to SFTP: download to temp then upload
// SFTP to SFTP: download to temp then upload using streams
const tempPath = path.join(os.tmpdir(), `netcatty-transfer-${transferId}`);
const sourceClient = sftpClients.get(sourceSftpId);
const targetClient = sftpClients.get(targetSftpId);
if (!sourceClient) throw new Error("Source SFTP session not found");
if (!targetClient) throw new Error("Target SFTP session not found");
// Download phase (0-50%)
// Download phase (0-50%) - wrap progress to show 0-50%
const encodedSourcePath = encodePathForSession(sourceSftpId, sourcePath, sourceEncoding);
await sourceClient.fastGet(encodedSourcePath, tempPath, {
step: (totalTransferred, chunk, total) => {
if (isCancelled()) {
throw new Error('Transfer cancelled');
}
sendProgress(Math.floor(totalTransferred / 2), fileSize);
}
});
if (isCancelled()) {
const downloadProgress = (transferred, total) => {
sendProgress(Math.floor(transferred / 2), fileSize);
};
await downloadWithStreams(encodedSourcePath, tempPath, sourceClient, fileSize, transfer, downloadProgress);
if (transfer.cancelled) {
try { await fs.promises.unlink(tempPath); } catch {}
throw new Error('Transfer cancelled');
}
// Upload phase (50-100%)
// Upload phase (50-100%) - wrap progress to show 50-100%
const dir = path.dirname(targetPath).replace(/\\/g, '/');
try { await ensureRemoteDirForSession(targetSftpId, dir, targetEncoding); } catch {}
const encodedTargetPath = encodePathForSession(targetSftpId, targetPath, targetEncoding);
await targetClient.fastPut(tempPath, encodedTargetPath, {
step: (totalTransferred, chunk, total) => {
if (isCancelled()) {
throw new Error('Transfer cancelled');
}
sendProgress(Math.floor(fileSize / 2) + Math.floor(totalTransferred / 2), fileSize);
}
});
const uploadProgress = (transferred, total) => {
sendProgress(Math.floor(fileSize / 2) + Math.floor(transferred / 2), fileSize);
};
await uploadWithStreams(tempPath, encodedTargetPath, targetClient, fileSize, transfer, uploadProgress);
// Cleanup temp file
try { await fs.promises.unlink(tempPath); } catch {}
} else {
throw new Error("Invalid transfer configuration");
}
@@ -232,16 +364,24 @@ async function startTransfer(event, payload) {
*/
async function cancelTransfer(event, payload) {
const { transferId } = payload;
console.log('[transferBridge] cancelTransfer called for:', transferId);
const transfer = activeTransfers.get(transferId);
console.log('[transferBridge] Found transfer:', !!transfer, 'activeTransfers keys:', Array.from(activeTransfers.keys()));
if (transfer) {
transfer.cancelled = true;
console.log('[transferBridge] Set cancelled=true for transfer:', transferId);
// Destroy streams to immediately stop the transfer
if (transfer.readStream) {
try { transfer.readStream.destroy(); } catch {}
console.log('[transferBridge] Destroying read stream');
try { transfer.readStream.destroy(); } catch (e) { console.log('[transferBridge] Error destroying readStream:', e); }
}
if (transfer.writeStream) {
try { transfer.writeStream.destroy(); } catch {}
console.log('[transferBridge] Destroying write stream');
try { transfer.writeStream.destroy(); } catch (e) { console.log('[transferBridge] Error destroying writeStream:', e); }
}
activeTransfers.delete(transferId);
console.log('[transferBridge] Transfer marked for cancellation');
}
return { success: true };
}

View File

@@ -1,4 +1,4 @@
const { ipcRenderer, contextBridge } = require("electron");
const { ipcRenderer, contextBridge, webUtils } = require("electron");
const dataListeners = new Map();
const exitListeners = new Map();
@@ -626,6 +626,15 @@ const api = {
ipcRenderer.invoke("netcatty:sessionLogs:autoSave", payload),
openSessionLogsDir: (directory) =>
ipcRenderer.invoke("netcatty:sessionLogs:openDir", { directory }),
// Get file path from File object (for drag-and-drop)
getPathForFile: (file) => {
try {
return webUtils.getPathForFile(file);
} catch {
return undefined;
}
},
};
// Merge with existing netcatty (if any) to avoid stale objects on hot reload

View File

@@ -7,7 +7,7 @@ import reactHooks from "eslint-plugin-react-hooks";
export default [
js.configs.recommended,
{
ignores: ["node_modules/**", "dist/**", "electron/**", "scripts/**", "public/monaco/**"],
ignores: ["node_modules/**", "dist/**", "electron/**", "scripts/**", "public/monaco/**", ".github/**"],
},
{
files: ["**/*.{ts,tsx}"],

3
global.d.ts vendored
View File

@@ -520,6 +520,9 @@ declare global {
directory: string;
}): Promise<{ success: boolean; error?: string; filePath?: string }>;
openSessionLogsDir?(directory: string): Promise<{ success: boolean; error?: string }>;
// Get file path from File object (for drag-and-drop, uses Electron's webUtils)
getPathForFile?(file: File): string | undefined;
}
interface Window {

View File

@@ -177,9 +177,59 @@ export const LIGHT_UI_THEMES: UiThemePreset[] = [
ring: "24 80% 50%",
},
},
{
id: "lavender",
name: "Lavender",
tokens: {
background: "270 30% 97%",
foreground: "222 47% 12%",
card: "270 30% 99%",
cardForeground: "222 47% 12%",
popover: "270 30% 99%",
popoverForeground: "222 47% 12%",
primary: "270 70% 55%",
primaryForeground: "0 0% 100%",
secondary: "270 20% 92%",
secondaryForeground: "222 47% 12%",
muted: "270 20% 92%",
mutedForeground: "220 10% 45%",
accent: "270 70% 55%",
accentForeground: "222 47% 12%",
destructive: "0 70% 50%",
destructiveForeground: "0 0% 100%",
border: "270 18% 86%",
input: "270 18% 86%",
ring: "270 70% 55%",
},
},
];
export const DARK_UI_THEMES: UiThemePreset[] = [
{
id: "pure-black",
name: "Pure Black",
tokens: {
background: "0 0% 0%",
foreground: "0 0% 95%",
card: "0 0% 5%",
cardForeground: "0 0% 95%",
popover: "0 0% 5%",
popoverForeground: "0 0% 95%",
primary: "210 90% 60%",
primaryForeground: "0 0% 100%",
secondary: "0 0% 0%",
secondaryForeground: "0 0% 92%",
muted: "0 0% 15%",
mutedForeground: "0 0% 65%",
accent: "210 90% 60%",
accentForeground: "0 0% 100%",
destructive: "0 70% 50%",
destructiveForeground: "0 0% 100%",
border: "0 0% 12%",
input: "0 0% 12%",
ring: "210 90% 60%",
},
},
{
id: "midnight",
name: "Midnight",

View File

@@ -3,6 +3,8 @@
* Helper functions for file type detection and extension handling
*/
import { netcattyBridge } from "../infrastructure/services/netcattyBridge";
// Common text file extensions
const TEXT_EXTENSIONS = new Set([
// Code/Scripts
@@ -538,6 +540,22 @@ async function processEntriesIteratively(
return results;
}
/**
* Get the local file path for a File object using Electron's webUtils API
* Falls back to the legacy file.path property if webUtils is not available
*/
export function getPathForFile(file: File): string | undefined {
try {
// Try Electron's webUtils API (exposed via preload)
const path = netcattyBridge.get()?.getPathForFile?.(file);
if (path) return path;
// Fallback: try legacy file.path property
return (file as File & { path?: string }).path;
} catch {
return undefined;
}
}
/**
* Extract all files and directories from a DataTransfer object
* Supports both regular files and folders dropped from the OS
@@ -553,6 +571,20 @@ export async function extractDropEntries(
): Promise<DropEntry[]> {
const items = dataTransfer.items;
// Build a map of file/folder name to path from the original files in DataTransfer.files
const filePathMap = new Map<string, string>();
const filesWithPath = dataTransfer.files;
console.log('[extractDropEntries] DataTransfer.files count:', filesWithPath.length);
for (let i = 0; i < filesWithPath.length; i++) {
const f = filesWithPath[i];
const path = getPathForFile(f);
console.log('[extractDropEntries] File:', { name: f.name, path, size: f.size });
if (path) {
filePathMap.set(f.name, path);
}
}
console.log('[extractDropEntries] filePathMap:', Object.fromEntries(filePathMap));
// Check if webkitGetAsEntry is supported (for folder access)
if (items && items.length > 0 && typeof items[0].webkitGetAsEntry === 'function') {
// Collect all entries first (getAsEntry must be called synchronously)
@@ -568,9 +600,46 @@ export async function extractDropEntries(
}
// Process entries iteratively (non-recursive) to avoid stack overflow
return await processEntriesIteratively(entries);
const results = await processEntriesIteratively(entries);
// Restore the 'path' property for all files
// Try to get the path directly from webUtils.getPathForFile for each file
// This is more reliable than trying to reconstruct from folder paths
for (const result of results) {
if (result.file) {
// First try to get path directly from the file
const directPath = getPathForFile(result.file);
if (directPath) {
(result.file as File & { path?: string }).path = directPath;
console.log('[extractDropEntries] Direct path for:', { relativePath: result.relativePath, path: directPath });
} else {
// Fallback: try to reconstruct from root folder path
const pathParts = result.relativePath.split('/');
const rootName = pathParts[0];
const rootPath = filePathMap.get(rootName);
console.log('[extractDropEntries] Fallback matching:', { relativePath: result.relativePath, rootName, rootPath });
if (rootPath) {
if (pathParts.length === 1) {
// Root-level file: use the path directly
(result.file as File & { path?: string }).path = rootPath;
} else {
// Nested file in a folder: construct full path
// rootPath is the path to the root folder, we need to append the rest
const restOfPath = pathParts.slice(1).join('/');
const separator = rootPath.includes('\\') ? '\\' : '/';
const fullPath = rootPath + separator + restOfPath.replace(/\//g, separator);
(result.file as File & { path?: string }).path = fullPath;
}
}
}
}
}
return results;
} else {
// Fallback: use regular FileList (no folder support)
// Files from FileList in Electron already have the 'path' property
const results: DropEntry[] = [];
const files = dataTransfer.files;
for (let i = 0; i < files.length; i++) {

View File

@@ -6,7 +6,7 @@
* cancellation support, and works for both local and remote (SFTP) uploads.
*/
import { extractDropEntries, DropEntry } from "./sftpFileUtils";
import { extractDropEntries, DropEntry, getPathForFile } from "./sftpFileUtils";
// ============================================================================
// Types
@@ -72,6 +72,23 @@ export interface UploadBridge {
onError?: (error: string) => void
) => Promise<{ success: boolean; cancelled?: boolean } | undefined>;
cancelSftpUpload?: (taskId: string) => Promise<unknown>;
/** Stream transfer using local file path (avoids loading file into memory) */
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; cancelled?: boolean }>;
cancelTransfer?: (transferId: string) => Promise<void>;
}
export interface UploadConfig {
@@ -150,17 +167,24 @@ export class UploadController {
*/
async cancel(): Promise<void> {
this.cancelled = true;
if (!this.bridge?.cancelSftpUpload) {
return;
}
console.log('[UploadController] Cancelling uploads, active IDs:', Array.from(this.activeFileTransferIds));
// Cancel all active file uploads
const activeIds = Array.from(this.activeFileTransferIds);
for (const transferId of activeIds) {
try {
await this.bridge.cancelSftpUpload(transferId);
} catch {
// Try cancelTransfer first (for stream transfers)
if (this.bridge?.cancelTransfer) {
console.log('[UploadController] Calling cancelTransfer for:', transferId);
await this.bridge.cancelTransfer(transferId);
}
// Also try cancelSftpUpload (for legacy uploads)
if (this.bridge?.cancelSftpUpload) {
console.log('[UploadController] Calling cancelSftpUpload for:', transferId);
await this.bridge.cancelSftpUpload(transferId);
}
} catch (e) {
console.log('[UploadController] Cancel error:', e);
// Ignore cancel errors
}
}
@@ -168,8 +192,16 @@ export class UploadController {
// Also cancel current one if not in the set
if (this.currentTransferId && !activeIds.includes(this.currentTransferId)) {
try {
await this.bridge.cancelSftpUpload(this.currentTransferId);
} catch {
if (this.bridge?.cancelTransfer) {
console.log('[UploadController] Calling cancelTransfer for current:', this.currentTransferId);
await this.bridge.cancelTransfer(this.currentTransferId);
}
if (this.bridge?.cancelSftpUpload) {
console.log('[UploadController] Calling cancelSftpUpload for current:', this.currentTransferId);
await this.bridge.cancelSftpUpload(this.currentTransferId);
}
} catch (e) {
console.log('[UploadController] Cancel current error:', e);
// Ignore cancel errors
}
}
@@ -279,14 +311,16 @@ export async function uploadFromDataTransfer(
}
/**
* Upload a FileList with bundled folder support
* Upload a FileList or File array with bundled folder support
*/
export async function uploadFromFileList(
fileList: FileList,
fileList: FileList | File[],
config: UploadConfig,
controller?: UploadController
): Promise<UploadResult[]> {
console.log('[uploadFromFileList] Called with', fileList.length, 'files');
const { targetPath, sftpId, isLocal, bridge, joinPath, callbacks } = config;
console.log('[uploadFromFileList] Config:', { targetPath, sftpId, isLocal });
if (controller) {
controller.reset();
@@ -294,16 +328,29 @@ export async function uploadFromFileList(
}
// Convert FileList to DropEntry array (simple files, no folders)
const entries: DropEntry[] = Array.from(fileList).map(file => ({
file,
relativePath: file.name,
isDirectory: false,
}));
// Use getPathForFile to get the local file path for stream transfer
const entries: DropEntry[] = Array.from(fileList).map(file => {
const localPath = getPathForFile(file);
console.log('[uploadFromFileList] File:', { name: file.name, size: file.size, localPath });
if (localPath) {
// Set the path property on the file for stream transfer
(file as File & { path?: string }).path = localPath;
}
return {
file,
relativePath: file.name,
isDirectory: false,
};
});
console.log('[uploadFromFileList] Created', entries.length, 'entries');
if (entries.length === 0) {
console.log('[uploadFromFileList] No entries, returning empty');
return [];
}
console.log('[uploadFromFileList] Calling uploadEntries');
return uploadEntries(entries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
}
@@ -470,96 +517,196 @@ async function uploadEntries(
}
}
const arrayBuffer = await entry.file.arrayBuffer();
// Check if file has a local path (Electron provides file.path for dropped files)
const localFilePath = (entry.file as File & { path?: string }).path;
if (isLocal) {
if (!bridge.writeLocalFile) {
throw new Error("writeLocalFile not available");
}
await bridge.writeLocalFile(entryTargetPath, arrayBuffer);
} else if (sftpId) {
if (bridge.writeSftpBinaryWithProgress) {
let pendingProgressUpdate: { transferred: number; total: number; speed: number } | null = null;
let rafScheduled = false;
console.log('[UploadService] Processing file:', {
relativePath: entry.relativePath,
localFilePath,
hasStreamTransfer: !!bridge.startStreamTransfer,
sftpId,
isLocal,
fileSize: fileTotalBytes,
});
const onProgress = (transferred: number, total: number, speed: number) => {
if (controller?.isCancelled()) return;
// Use stream transfer if available and we have a local file path (avoids loading file into memory)
if (localFilePath && bridge.startStreamTransfer && sftpId && !isLocal) {
console.log('[UploadService] Using stream transfer for:', localFilePath);
let pendingProgressUpdate: { transferred: number; total: number; speed: number } | null = null;
let rafScheduled = false;
pendingProgressUpdate = { transferred, total, speed };
const onProgress = (transferred: number, total: number, speed: number) => {
if (controller?.isCancelled()) return;
if (!rafScheduled) {
rafScheduled = true;
requestAnimationFrame(() => {
rafScheduled = false;
const update = pendingProgressUpdate;
pendingProgressUpdate = null;
pendingProgressUpdate = { transferred, total, speed };
if (update && !controller?.isCancelled() && callbacks?.onTaskProgress) {
if (bundleTaskId) {
const progress = bundleProgress.get(bundleTaskId);
if (progress) {
const newTransferred = progress.completedFilesBytes + update.transferred;
progress.transferredBytes = newTransferred;
progress.currentSpeed = update.speed;
callbacks.onTaskProgress(bundleTaskId, {
transferred: newTransferred,
total: progress.totalBytes,
speed: update.speed,
percent: progress.totalBytes > 0 ? (newTransferred / progress.totalBytes) * 100 : 0,
});
}
} else if (standaloneTransferId) {
callbacks.onTaskProgress(standaloneTransferId, {
transferred: update.transferred,
total: update.total,
if (!rafScheduled) {
rafScheduled = true;
requestAnimationFrame(() => {
rafScheduled = false;
const update = pendingProgressUpdate;
pendingProgressUpdate = null;
if (update && !controller?.isCancelled() && callbacks?.onTaskProgress) {
if (bundleTaskId) {
const progress = bundleProgress.get(bundleTaskId);
if (progress) {
const newTransferred = progress.completedFilesBytes + update.transferred;
progress.transferredBytes = newTransferred;
progress.currentSpeed = update.speed;
callbacks.onTaskProgress(bundleTaskId, {
transferred: newTransferred,
total: progress.totalBytes,
speed: update.speed,
percent: update.total > 0 ? (update.transferred / update.total) * 100 : 0,
percent: progress.totalBytes > 0 ? (newTransferred / progress.totalBytes) * 100 : 0,
});
}
} else if (standaloneTransferId) {
callbacks.onTaskProgress(standaloneTransferId, {
transferred: update.transferred,
total: update.total,
speed: update.speed,
percent: update.total > 0 ? (update.transferred / update.total) * 100 : 0,
});
}
});
}
};
// Use unique file transfer ID for backend cancellation tracking
const fileTransferId = crypto.randomUUID();
controller?.addActiveTransfer(fileTransferId);
let result;
try {
result = await bridge.writeSftpBinaryWithProgress(
sftpId,
entryTargetPath,
arrayBuffer,
fileTransferId,
onProgress,
undefined,
undefined
);
} finally {
controller?.removeActiveTransfer(fileTransferId);
}
});
}
};
if (result?.cancelled) {
wasCancelled = true;
const taskId = bundleTaskId || standaloneTransferId;
if (taskId) {
callbacks?.onTaskCancelled?.(taskId);
}
break;
}
const fileTransferId = crypto.randomUUID();
controller?.addActiveTransfer(fileTransferId);
if (!result || result.success === false) {
if (bridge.writeSftpBinary) {
await bridge.writeSftpBinary(sftpId, entryTargetPath, arrayBuffer);
} else {
throw new Error("Upload failed and no fallback method available");
}
let streamResult: { transferId: string; totalBytes?: number; error?: string; cancelled?: boolean } | undefined;
try {
streamResult = await bridge.startStreamTransfer(
{
transferId: fileTransferId,
sourcePath: localFilePath,
targetPath: entryTargetPath,
sourceType: 'local',
targetType: 'sftp',
targetSftpId: sftpId,
totalBytes: fileTotalBytes,
},
onProgress,
undefined,
undefined
);
} finally {
controller?.removeActiveTransfer(fileTransferId);
}
if (streamResult?.cancelled || streamResult?.error?.includes('cancelled')) {
wasCancelled = true;
const taskId = bundleTaskId || standaloneTransferId;
if (taskId) {
callbacks?.onTaskCancelled?.(taskId);
}
break;
}
if (streamResult?.error) {
throw new Error(streamResult.error);
}
} else {
// Fallback: load file into memory (for small files or when stream transfer is not available)
console.log('[UploadService] FALLBACK: Loading file into memory:', {
relativePath: entry.relativePath,
fileSize: fileTotalBytes,
reason: !localFilePath ? 'no local path' : !bridge.startStreamTransfer ? 'no stream transfer' : 'other',
});
const arrayBuffer = await entry.file.arrayBuffer();
if (isLocal) {
if (!bridge.writeLocalFile) {
throw new Error("writeLocalFile not available");
}
await bridge.writeLocalFile(entryTargetPath, arrayBuffer);
} else if (sftpId) {
if (bridge.writeSftpBinaryWithProgress) {
let pendingProgressUpdate: { transferred: number; total: number; speed: number } | null = null;
let rafScheduled = false;
const onProgress = (transferred: number, total: number, speed: number) => {
if (controller?.isCancelled()) return;
pendingProgressUpdate = { transferred, total, speed };
if (!rafScheduled) {
rafScheduled = true;
requestAnimationFrame(() => {
rafScheduled = false;
const update = pendingProgressUpdate;
pendingProgressUpdate = null;
if (update && !controller?.isCancelled() && callbacks?.onTaskProgress) {
if (bundleTaskId) {
const progress = bundleProgress.get(bundleTaskId);
if (progress) {
const newTransferred = progress.completedFilesBytes + update.transferred;
progress.transferredBytes = newTransferred;
progress.currentSpeed = update.speed;
callbacks.onTaskProgress(bundleTaskId, {
transferred: newTransferred,
total: progress.totalBytes,
speed: update.speed,
percent: progress.totalBytes > 0 ? (newTransferred / progress.totalBytes) * 100 : 0,
});
}
} else if (standaloneTransferId) {
callbacks.onTaskProgress(standaloneTransferId, {
transferred: update.transferred,
total: update.total,
speed: update.speed,
percent: update.total > 0 ? (update.transferred / update.total) * 100 : 0,
});
}
}
});
}
};
// Use unique file transfer ID for backend cancellation tracking
const fileTransferId = crypto.randomUUID();
controller?.addActiveTransfer(fileTransferId);
let result;
try {
result = await bridge.writeSftpBinaryWithProgress(
sftpId,
entryTargetPath,
arrayBuffer,
fileTransferId,
onProgress,
undefined,
undefined
);
} finally {
controller?.removeActiveTransfer(fileTransferId);
}
if (result?.cancelled) {
wasCancelled = true;
const taskId = bundleTaskId || standaloneTransferId;
if (taskId) {
callbacks?.onTaskCancelled?.(taskId);
}
break;
}
if (!result || result.success === false) {
if (bridge.writeSftpBinary) {
await bridge.writeSftpBinary(sftpId, entryTargetPath, arrayBuffer);
} else {
throw new Error("Upload failed and no fallback method available");
}
}
} else if (bridge.writeSftpBinary) {
await bridge.writeSftpBinary(sftpId, entryTargetPath, arrayBuffer);
} else {
throw new Error("No SFTP write method available");
}
} else if (bridge.writeSftpBinary) {
await bridge.writeSftpBinary(sftpId, entryTargetPath, arrayBuffer);
} else {
throw new Error("No SFTP write method available");
}
}