Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a66fcdba02 | ||
|
|
73c95fa08e | ||
|
|
3337cd620e | ||
|
|
97bd105564 | ||
|
|
554c43dfa8 | ||
|
|
c678f36504 | ||
|
|
f40a3f075b | ||
|
|
bb40ab464e | ||
|
|
4977add389 | ||
|
|
2d14655af4 | ||
|
|
025df8788b | ||
|
|
9e6d110766 | ||
|
|
347d0a445b | ||
|
|
e8be0d72de | ||
|
|
ce34f1bba8 | ||
|
|
9f4272f83c | ||
|
|
c158d52dd5 | ||
|
|
ec8dba360c | ||
|
|
8b5cc5c302 | ||
|
|
bae0c078f5 | ||
|
|
e0cda4dc5a | ||
|
|
4c0fc897a0 | ||
|
|
9ba150de82 | ||
|
|
a78647a2e8 | ||
|
|
4b7812d27f | ||
|
|
62b3cf658e | ||
|
|
74401a2084 | ||
|
|
44d25c10e1 | ||
|
|
d67c458730 | ||
|
|
44e8167300 | ||
|
|
02b16dee9b | ||
|
|
adaa8ee524 | ||
|
|
f429eb8f28 | ||
|
|
eaae884cd7 | ||
|
|
363f0ea87f | ||
|
|
b5533a73b6 | ||
|
|
6353f2c58a | ||
|
|
7e14f73769 | ||
|
|
60c2687144 | ||
|
|
7a1597bdc1 | ||
|
|
d5ae6e5cba | ||
|
|
a46080a378 | ||
|
|
f0972cc6c1 | ||
|
|
1442b42d66 | ||
|
|
24f1dc3f36 | ||
|
|
b0251e1eaf | ||
|
|
f55a1a4c15 | ||
|
|
d4b64d564b | ||
|
|
64d3b1f26a | ||
|
|
f6148d3578 | ||
|
|
c4d6d999c1 | ||
|
|
2ca5c730b8 | ||
|
|
b3a2063ca4 | ||
|
|
e6f2da48a7 | ||
|
|
a9fad5295c | ||
|
|
41822838f1 | ||
|
|
f98c578761 | ||
|
|
449d63ca3e | ||
|
|
f6f0d0ead1 | ||
|
|
dbfd50a8e0 | ||
|
|
17ffe5d1ee | ||
|
|
394cd539b3 | ||
|
|
1289223523 | ||
|
|
1d29167b97 | ||
|
|
143f6d993e |
104
.github/scripts/generate-release-note.js
vendored
Normal file
104
.github/scripts/generate-release-note.js
vendored
Normal 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: `[](${baseUrl}/${files.win.x64})`,
|
||||
setup_arm64: `[](${baseUrl}/${files.win.arm64})`
|
||||
},
|
||||
mac: {
|
||||
apple_silicon: `[](${baseUrl}/${files.mac.arm64})`,
|
||||
intel: `[](${baseUrl}/${files.mac.x64})`
|
||||
},
|
||||
linux: {
|
||||
appimage_x64: `[](${baseUrl}/${files.linux.appimage.x64})`,
|
||||
appimage_arm64: `[](${baseUrl}/${files.linux.appimage.arm64})`,
|
||||
deb_x64: `[](${baseUrl}/${files.linux.deb.x64})`,
|
||||
deb_arm64: `[](${baseUrl}/${files.linux.deb.arm64})`,
|
||||
rpm_x64: `[](${baseUrl}/${files.linux.rpm.x64})`,
|
||||
rpm_arm64: `[](${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');
|
||||
27
.github/workflows/build.yml
vendored
27
.github/workflows/build.yml
vendored
@@ -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
42
.github/workflows/sync.yml
vendored
Normal 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 }}
|
||||
2
App.tsx
2
App.tsx
@@ -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}
|
||||
|
||||
@@ -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) ですべてのリリースを参照してください。
|
||||
|
||||
|
||||
13
README.md
13
README.md
@@ -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).
|
||||
|
||||
|
||||
@@ -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) 浏览所有版本。
|
||||
|
||||
|
||||
@@ -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 *',
|
||||
|
||||
@@ -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': '私钥 *',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())}`;
|
||||
};
|
||||
|
||||
@@ -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())}`;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -73,11 +73,7 @@ module.exports = {
|
||||
target: [
|
||||
{
|
||||
target: 'nsis',
|
||||
arch: ['x64']
|
||||
},
|
||||
{
|
||||
target: 'dir',
|
||||
arch: ['x64']
|
||||
arch: ['x64', 'arm64']
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
3
global.d.ts
vendored
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user