Compare commits
149 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6edc4213f4 | ||
|
|
4313977bd4 | ||
|
|
dae58ef64f | ||
|
|
945a09bdef | ||
|
|
4711fea969 | ||
|
|
f59ca56e23 | ||
|
|
3d1ab2de05 | ||
|
|
adc3343d76 | ||
|
|
944d590162 | ||
|
|
15ae17f918 | ||
|
|
65c15d8931 | ||
|
|
cbd1c84cdf | ||
|
|
0839e41b07 | ||
|
|
c27788280c | ||
|
|
fd7f516b00 | ||
|
|
33780fecde | ||
|
|
89ea1c43c5 | ||
|
|
bd2936aab2 | ||
|
|
c48ac93500 | ||
|
|
34a94df831 | ||
|
|
e87ce831b4 | ||
|
|
11c0c744f5 | ||
|
|
9546f27ca1 | ||
|
|
8a465a9adf | ||
|
|
f3b28d2283 | ||
|
|
8cfa62d945 | ||
|
|
b31ea0b9ca | ||
|
|
b2f6cabd75 | ||
|
|
92af5a5675 | ||
|
|
d50e854cbe | ||
|
|
d92dbd6091 | ||
|
|
3732bce989 | ||
|
|
ec0994288f | ||
|
|
b14c5d6147 | ||
|
|
c55f5dbdb8 | ||
|
|
a7f3008904 | ||
|
|
6833000038 | ||
|
|
485f28160d | ||
|
|
9df9f9fdfb | ||
|
|
b2720d1fd5 | ||
|
|
71419b65cd | ||
|
|
ba935099c4 | ||
|
|
1a45d39c98 | ||
|
|
3f06cb638a | ||
|
|
a225f0e207 | ||
|
|
3438f4bc88 | ||
|
|
9343cfda84 | ||
|
|
89b5b2f6b1 | ||
|
|
d080c805c2 | ||
|
|
4b41b2c20f | ||
|
|
62c4aa3ea6 | ||
|
|
5d164b4150 | ||
|
|
ac62d571ef | ||
|
|
e8d060c62f | ||
|
|
653164bee8 | ||
|
|
2a67667c0b | ||
|
|
e07e5cf442 | ||
|
|
92a9eed6bf | ||
|
|
65afa21711 | ||
|
|
f413ccfba1 | ||
|
|
58b6879c71 | ||
|
|
7fe7193344 | ||
|
|
7a19b73f54 | ||
|
|
5160230426 | ||
|
|
42b1a808a1 | ||
|
|
9dd3db4c14 | ||
|
|
e74f65729c | ||
|
|
97f53ed87f | ||
|
|
ec4512eb06 | ||
|
|
93c1f1b427 | ||
|
|
58ccd4bfb9 | ||
|
|
2fb82e1cb7 | ||
|
|
159589a09f | ||
|
|
04e1ed569d | ||
|
|
38fb5e8dd4 | ||
|
|
6f2b27206a | ||
|
|
f6eb693fac | ||
|
|
32935e4e87 | ||
|
|
f55c21fc0e | ||
|
|
26d03ace3f | ||
|
|
d85709d42d | ||
|
|
5470e19ae0 | ||
|
|
cd2c18b77c | ||
|
|
7355e29b89 | ||
|
|
64686cc237 | ||
|
|
d65440ace7 | ||
|
|
2dbeddd9aa | ||
|
|
4758345448 | ||
|
|
4d3fa93083 | ||
|
|
2746aae274 | ||
|
|
a7b22b3580 | ||
|
|
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 |
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 }}
|
||||
97
App.tsx
97
App.tsx
@@ -1,6 +1,7 @@
|
||||
import React, { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { activeTabStore, useActiveTabId, useIsSftpActive, useIsTerminalLayerVisible, useIsVaultActive } from './application/state/activeTabStore';
|
||||
import { useAutoSync } from './application/state/useAutoSync';
|
||||
import { useManagedSourceSync } from './application/state/useManagedSourceSync';
|
||||
import { usePortForwardingAutoStart } from './application/state/usePortForwardingAutoStart';
|
||||
import { useSessionState } from './application/state/useSessionState';
|
||||
import { useSettingsState } from './application/state/useSettingsState';
|
||||
@@ -21,6 +22,7 @@ import { Label } from './components/ui/label';
|
||||
import { ToastProvider, toast } from './components/ui/toast';
|
||||
import { VaultView, VaultSection } from './components/VaultView';
|
||||
import { KeyboardInteractiveModal, KeyboardInteractiveRequest } from './components/KeyboardInteractiveModal';
|
||||
import { PassphraseModal, PassphraseRequest } from './components/PassphraseModal';
|
||||
import { cn } from './lib/utils';
|
||||
import { ConnectionLog, Host, HostProtocol, SerialConfig, TerminalTheme } from './types';
|
||||
import { LogView as LogViewType } from './application/state/useSessionState';
|
||||
@@ -155,6 +157,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const [navigateToSection, setNavigateToSection] = useState<VaultSection | null>(null);
|
||||
// Keyboard-interactive authentication queue (2FA/MFA) - queue-based to handle multiple concurrent sessions
|
||||
const [keyboardInteractiveQueue, setKeyboardInteractiveQueue] = useState<KeyboardInteractiveRequest[]>([]);
|
||||
// Passphrase request queue for encrypted SSH keys
|
||||
const [passphraseQueue, setPassphraseQueue] = useState<PassphraseRequest[]>([]);
|
||||
|
||||
const {
|
||||
theme,
|
||||
@@ -184,6 +188,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
knownHosts,
|
||||
shellHistory,
|
||||
connectionLogs,
|
||||
managedSources,
|
||||
updateHosts,
|
||||
updateKeys,
|
||||
updateIdentities,
|
||||
@@ -191,6 +196,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
updateSnippetPackages,
|
||||
updateCustomGroups,
|
||||
updateKnownHosts,
|
||||
updateManagedSources,
|
||||
addShellHistoryEntry,
|
||||
addConnectionLog,
|
||||
updateConnectionLog,
|
||||
@@ -242,6 +248,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
logViews,
|
||||
openLogView,
|
||||
closeLogView,
|
||||
copySession,
|
||||
} = useSessionState();
|
||||
|
||||
// isMacClient is used for window controls styling
|
||||
@@ -267,6 +274,12 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
},
|
||||
});
|
||||
|
||||
const { clearAndRemoveSource, clearAndRemoveSources, unmanageSource } = useManagedSourceSync({
|
||||
hosts,
|
||||
managedSources,
|
||||
onUpdateManagedSources: updateManagedSources,
|
||||
});
|
||||
|
||||
const handleSyncNowManual = useCallback(() => {
|
||||
return handleSyncNow({ trigger: 'manual' });
|
||||
}, [handleSyncNow]);
|
||||
@@ -348,6 +361,76 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
|
||||
}, []);
|
||||
|
||||
// Passphrase request event listener for encrypted SSH keys
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onPassphraseRequest) return;
|
||||
|
||||
const unsubscribe = bridge.onPassphraseRequest((request) => {
|
||||
console.log('[App] Passphrase request received:', request);
|
||||
setPassphraseQueue(prev => [...prev, {
|
||||
requestId: request.requestId,
|
||||
keyPath: request.keyPath,
|
||||
keyName: request.keyName,
|
||||
hostname: request.hostname,
|
||||
}]);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle passphrase submit
|
||||
const handlePassphraseSubmit = useCallback((requestId: string, passphrase: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.respondPassphrase) {
|
||||
void bridge.respondPassphrase(requestId, passphrase, false);
|
||||
}
|
||||
setPassphraseQueue(prev => prev.filter(r => r.requestId !== requestId));
|
||||
}, []);
|
||||
|
||||
// Handle passphrase cancel
|
||||
const handlePassphraseCancel = useCallback((requestId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.respondPassphrase) {
|
||||
// Cancel = stop the entire passphrase flow
|
||||
void bridge.respondPassphrase(requestId, '', true);
|
||||
}
|
||||
setPassphraseQueue(prev => prev.filter(r => r.requestId !== requestId));
|
||||
}, []);
|
||||
|
||||
// Handle passphrase skip (skip this key, continue with others)
|
||||
const handlePassphraseSkip = useCallback((requestId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.respondPassphraseSkip) {
|
||||
// Skip = skip this key but continue asking for others
|
||||
void bridge.respondPassphraseSkip(requestId);
|
||||
} else if (bridge?.respondPassphrase) {
|
||||
// Fallback for older API
|
||||
void bridge.respondPassphrase(requestId, '', false);
|
||||
}
|
||||
setPassphraseQueue(prev => prev.filter(r => r.requestId !== requestId));
|
||||
}, []);
|
||||
|
||||
// Handle passphrase timeout (request expired on backend)
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onPassphraseTimeout) return;
|
||||
|
||||
const unsubscribe = bridge.onPassphraseTimeout((event) => {
|
||||
console.log('[App] Passphrase request timed out:', event.requestId);
|
||||
// Remove from queue - the modal will close automatically
|
||||
setPassphraseQueue(prev => prev.filter(r => r.requestId !== event.requestId));
|
||||
// Show a toast notification to inform user
|
||||
toast.error('Passphrase request timed out. Please try connecting again.');
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Debounce ref for moveFocus to prevent double-triggering when focus switches
|
||||
const lastMoveFocusTimeRef = useRef<number>(0);
|
||||
const MOVE_FOCUS_DEBOUNCE_MS = 200;
|
||||
@@ -864,6 +947,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
isMacClient={isMacClient}
|
||||
onCloseSession={closeSession}
|
||||
onRenameSession={startSessionRename}
|
||||
onCopySession={copySession}
|
||||
onRenameWorkspace={startWorkspaceRename}
|
||||
onCloseWorkspace={closeWorkspace}
|
||||
onCloseLogView={closeLogView}
|
||||
@@ -888,6 +972,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
knownHosts={knownHosts}
|
||||
shellHistory={shellHistory}
|
||||
connectionLogs={connectionLogs}
|
||||
managedSources={managedSources}
|
||||
sessions={sessions}
|
||||
onOpenSettings={handleOpenSettings}
|
||||
onOpenQuickSwitcher={handleOpenQuickSwitcher}
|
||||
@@ -902,6 +987,10 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
onUpdateSnippetPackages={updateSnippetPackages}
|
||||
onUpdateCustomGroups={updateCustomGroups}
|
||||
onUpdateKnownHosts={updateKnownHosts}
|
||||
onUpdateManagedSources={updateManagedSources}
|
||||
onClearAndRemoveManagedSource={clearAndRemoveSource}
|
||||
onClearAndRemoveManagedSources={clearAndRemoveSources}
|
||||
onUnmanageSource={unmanageSource}
|
||||
onConvertKnownHost={convertKnownHostToHost}
|
||||
onToggleConnectionLogSaved={toggleConnectionLogSaved}
|
||||
onDeleteConnectionLog={deleteConnectionLog}
|
||||
@@ -1081,6 +1170,14 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
{keyboardInteractiveQueue.length - 1} more pending
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Global Passphrase Modal for encrypted SSH keys */}
|
||||
<PassphraseModal
|
||||
request={passphraseQueue[0] || null}
|
||||
onSubmit={handlePassphraseSubmit}
|
||||
onCancel={handlePassphraseCancel}
|
||||
onSkip={handlePassphraseSkip}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) 浏览所有版本。
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ const en: Messages = {
|
||||
'sort.za': 'Z-a',
|
||||
'sort.newest': 'Newest to oldest',
|
||||
'sort.oldest': 'Oldest to newest',
|
||||
'sort.group': 'By group',
|
||||
'field.label': 'Label',
|
||||
'field.type': 'Type',
|
||||
'auth.keyType': 'Type {type}',
|
||||
@@ -313,6 +314,11 @@ const en: Messages = {
|
||||
'vault.groups.createDialog.desc': 'Create a new group for organizing hosts.',
|
||||
'vault.groups.renameDialogTitle': 'Rename Group',
|
||||
'vault.groups.renameDialog.desc': 'Rename an existing group.',
|
||||
'vault.groups.deleteDialogTitle': 'Delete Group',
|
||||
'vault.groups.deleteDialog.desc': 'This will permanently delete the group and move all hosts to the root level.',
|
||||
'vault.groups.deleteDialog.managedDesc': 'This is a managed SSH config group. Deleting it will also delete all hosts and unlink from the source file.',
|
||||
'vault.groups.deleteDialog.deleteHosts': 'Also delete all hosts in this group',
|
||||
'vault.groups.ungrouped': 'Ungrouped',
|
||||
'vault.groups.field.name': 'Group Name',
|
||||
'vault.groups.placeholder.example': 'e.g. Production',
|
||||
'vault.groups.parentLabel': 'Parent',
|
||||
@@ -320,6 +326,9 @@ const en: Messages = {
|
||||
'vault.groups.errors.required': 'Group name is required.',
|
||||
'vault.groups.errors.invalidChars': "Group name cannot include '/' or '\\\\'.",
|
||||
|
||||
'vault.managedSource.unmanage': 'Unmanage',
|
||||
'vault.managedSource.unmanageSuccess': 'Successfully unmanaged group',
|
||||
|
||||
'vault.hosts.header.entries': '{count} entries',
|
||||
'vault.hosts.header.live': '{count} live',
|
||||
|
||||
@@ -328,10 +337,26 @@ const en: Messages = {
|
||||
'vault.hosts.connect': 'Connect',
|
||||
'vault.view.grid': 'Grid',
|
||||
'vault.view.list': 'List',
|
||||
'vault.view.tree': 'Tree',
|
||||
'vault.tree.expandAll': 'Expand All',
|
||||
'vault.tree.collapseAll': 'Collapse All',
|
||||
'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.hosts.multiSelect': 'Multi-select',
|
||||
'vault.hosts.selected': '{count} selected',
|
||||
'vault.hosts.selectAll': 'Select All',
|
||||
'vault.hosts.deselectAll': 'Deselect All',
|
||||
'vault.hosts.deleteSelected': 'Delete ({count})',
|
||||
'vault.hosts.deleteMultiple.success': 'Deleted {count} hosts',
|
||||
|
||||
// Vault import
|
||||
'vault.import.title': 'Add data to your vault',
|
||||
@@ -348,6 +373,18 @@ const en: Messages = {
|
||||
'vault.import.toast.summary':
|
||||
'Imported {count} hosts (skipped {skipped}, duplicates {duplicates}).',
|
||||
'vault.import.toast.firstIssue': 'First issue: {issue}',
|
||||
'vault.import.sshConfig.chooseMode': 'Choose how to import your SSH config file.',
|
||||
'vault.import.sshConfig.modeQuestion': 'How would you like to import?',
|
||||
'vault.import.sshConfig.importOnly': 'Import Only',
|
||||
'vault.import.sshConfig.importOnlyDesc': 'One-time import. Changes won\'t sync back to the file.',
|
||||
'vault.import.sshConfig.managed': 'Managed Sync',
|
||||
'vault.import.sshConfig.managedDesc': 'Keep in sync. Changes will be saved back to the file.',
|
||||
'vault.import.sshConfig.managedGroup': 'ssh config',
|
||||
'vault.import.sshConfig.managedSuccess': 'Imported {count} hosts. File is now managed.',
|
||||
'vault.import.sshConfig.alreadyManaged': 'This file is already being managed.',
|
||||
'vault.import.sshConfig.alreadyManagedDesc': 'This file is already managed under group "{group}". Remove the existing managed source first if you want to re-import.',
|
||||
'vault.import.sshConfig.noFilePath': 'Cannot manage this file.',
|
||||
'vault.import.sshConfig.noFilePathDesc': 'Unable to determine the file path. Managed sync requires access to the file system.',
|
||||
|
||||
// Known Hosts
|
||||
'knownHosts.search.placeholder': 'Search known hosts...',
|
||||
@@ -440,8 +477,12 @@ const en: Messages = {
|
||||
'sftp.columns.kind': 'Kind',
|
||||
'sftp.columns.actions': 'Actions',
|
||||
'sftp.emptyDirectory': 'Empty directory',
|
||||
'sftp.nav.up': 'Go up',
|
||||
'sftp.nav.home': 'Go to home',
|
||||
'sftp.nav.refresh': 'Refresh',
|
||||
'sftp.upload': 'Upload',
|
||||
'sftp.uploadFiles': 'Upload files',
|
||||
'sftp.uploadFolder': 'Upload folder',
|
||||
'sftp.dragDropToUpload': 'Drag and drop files here to upload',
|
||||
'sftp.retry': 'Retry',
|
||||
'sftp.context.open': 'Open',
|
||||
@@ -527,6 +568,12 @@ const en: Messages = {
|
||||
'sftp.conflict.action.keepBoth': 'Keep Both',
|
||||
'sftp.conflict.action.replace': 'Replace',
|
||||
|
||||
// SFTP Upload Phases
|
||||
'sftp.upload.phase.compressing': 'Compressing',
|
||||
'sftp.upload.phase.uploading': 'Uploading',
|
||||
'sftp.upload.phase.extracting': 'Extracting',
|
||||
'sftp.upload.phase.compressed': 'Compressed',
|
||||
|
||||
// SFTP File Opener
|
||||
'sftp.context.openWith': 'Open with...',
|
||||
'sftp.context.edit': 'Edit',
|
||||
@@ -591,10 +638,19 @@ const en: Messages = {
|
||||
// SFTP Folder Upload Progress
|
||||
'sftp.upload.progress': 'Uploading {current} of {total} files...',
|
||||
'sftp.upload.uploading': 'Uploading...',
|
||||
'sftp.upload.compressing': 'Compressing...',
|
||||
'sftp.upload.extracting': 'Extracting...',
|
||||
'sftp.upload.scanning': 'Scanning files...',
|
||||
'sftp.upload.completed': 'Completed',
|
||||
'sftp.upload.compressed': 'Compressed Transfer',
|
||||
'sftp.upload.currentFile': 'Current: {fileName}',
|
||||
'sftp.upload.cancelled': 'Upload cancelled',
|
||||
'sftp.upload.cancel': 'Cancel',
|
||||
|
||||
// SFTP Download
|
||||
'sftp.download.completed': 'Downloaded',
|
||||
'sftp.download.cancelled': 'Download cancelled',
|
||||
|
||||
// SFTP Reconnecting
|
||||
'sftp.reconnecting.title': 'Reconnecting...',
|
||||
'sftp.reconnecting.desc': 'Connection lost, attempting to reconnect',
|
||||
@@ -610,6 +666,12 @@ const en: Messages = {
|
||||
'settings.sftp.showHiddenFiles.enable': 'Show hidden files',
|
||||
'settings.sftp.showHiddenFiles.enableDesc': 'Display Windows hidden files when browsing local filesystem',
|
||||
|
||||
// Settings > SFTP Compressed Upload
|
||||
'settings.sftp.compressedUpload': 'Folder Compression Transfer',
|
||||
'settings.sftp.compressedUpload.desc': 'Compress folders before uploading to significantly reduce transfer time.',
|
||||
'settings.sftp.compressedUpload.enable': 'Enable folder compression',
|
||||
'settings.sftp.compressedUpload.enableDesc': 'Automatically compress folders using tar before transfer. Requires tar support on the server. Falls back to regular transfer if not available.',
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': 'Search hosts or tabs',
|
||||
'qs.recentConnections': 'Recent connections',
|
||||
@@ -662,6 +724,8 @@ 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',
|
||||
@@ -810,6 +874,13 @@ const en: Messages = {
|
||||
'terminal.serverStats.network': 'Network Speed',
|
||||
'terminal.serverStats.networkDetails': 'Network Interfaces',
|
||||
'terminal.serverStats.noData': 'No data available',
|
||||
'terminal.dragDrop.localTitle': 'Drop to Insert Paths',
|
||||
'terminal.dragDrop.localMessage': 'File paths will be inserted into the terminal',
|
||||
'terminal.dragDrop.remoteTitle': 'Drop to Upload Files',
|
||||
'terminal.dragDrop.remoteMessage': 'Files will be uploaded via SFTP',
|
||||
'terminal.dragDrop.notConnected': 'Cannot drop files - terminal is not connected',
|
||||
'terminal.dragDrop.errorTitle': 'Drop Error',
|
||||
'terminal.dragDrop.errorMessage': 'Failed to process dropped files',
|
||||
'terminal.search.placeholder': 'Search...',
|
||||
'terminal.search.noResults': 'No results',
|
||||
'terminal.search.prevMatch': 'Previous match (Shift+Enter)',
|
||||
@@ -840,6 +911,11 @@ const en: Messages = {
|
||||
'terminal.connection.chainOf': 'Chain {current} of {total}',
|
||||
'terminal.connection.showLogs': 'Show logs',
|
||||
'terminal.connection.hideLogs': 'Hide logs',
|
||||
'terminal.connection.protocol.ssh': 'SSH',
|
||||
'terminal.connection.protocol.telnet': 'Telnet',
|
||||
'terminal.connection.protocol.mosh': 'Mosh',
|
||||
'terminal.connection.protocol.serial': 'Serial',
|
||||
'terminal.connection.protocol.local': 'Local Shell',
|
||||
'terminal.themeModal.title': 'Terminal Appearance',
|
||||
'terminal.themeModal.tab.theme': 'Theme',
|
||||
'terminal.themeModal.tab.font': 'Font',
|
||||
@@ -1101,6 +1177,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 *',
|
||||
@@ -1152,6 +1229,14 @@ const en: Messages = {
|
||||
'snippets.packageDialog.placeholder': 'e.g. ops/maintenance',
|
||||
'snippets.packageDialog.hint': 'Use "/" to create nested packages.',
|
||||
|
||||
// Snippets Rename Dialog
|
||||
'snippets.renameDialog.title': 'Rename Package',
|
||||
'snippets.renameDialog.currentPath': 'Current path: {path}',
|
||||
'snippets.renameDialog.placeholder': 'Enter new name',
|
||||
'snippets.renameDialog.error.empty': 'Package name cannot be empty',
|
||||
'snippets.renameDialog.error.duplicate': 'A package with this name already exists',
|
||||
'snippets.renameDialog.error.invalidChars': 'Package name can only contain letters, numbers, hyphens, and underscores',
|
||||
|
||||
// Serial Port
|
||||
'serial.button': 'Serial',
|
||||
'serial.modal.title': 'Connect to Serial Port',
|
||||
@@ -1206,6 +1291,16 @@ const en: Messages = {
|
||||
'keyboard.interactive.fillSaved': 'Fill with saved password',
|
||||
'keyboard.interactive.useSaved': 'Use saved',
|
||||
'keyboard.interactive.useSavedPassword': 'Use saved password',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'SSH Key Passphrase',
|
||||
'passphrase.desc': 'Enter the passphrase for {keyName}',
|
||||
'passphrase.descWithHost': 'Enter the passphrase for {keyName} to connect to {hostname}',
|
||||
'passphrase.label': 'Passphrase',
|
||||
'passphrase.keyPath': 'Key',
|
||||
'passphrase.unlock': 'Unlock',
|
||||
'passphrase.unlocking': 'Unlocking...',
|
||||
'passphrase.skip': 'Skip',
|
||||
};
|
||||
|
||||
export default en;
|
||||
|
||||
@@ -27,6 +27,7 @@ const zhCN: Messages = {
|
||||
'sort.za': 'Z-a',
|
||||
'sort.newest': '从新到旧',
|
||||
'sort.oldest': '从旧到新',
|
||||
'sort.group': '按分组',
|
||||
'field.label': 'Label',
|
||||
'field.type': '类型',
|
||||
'auth.keyType': '类型 {type}',
|
||||
@@ -184,6 +185,11 @@ const zhCN: Messages = {
|
||||
'vault.groups.createDialog.desc': '创建新的分组用于组织主机。',
|
||||
'vault.groups.renameDialogTitle': '重命名分组',
|
||||
'vault.groups.renameDialog.desc': '重命名已有分组。',
|
||||
'vault.groups.deleteDialogTitle': '删除分组',
|
||||
'vault.groups.deleteDialog.desc': '这将永久删除该分组并将所有主机移动到根级别。',
|
||||
'vault.groups.deleteDialog.managedDesc': '这是一个托管的 SSH config 分组。删除后将同时删除所有主机并断开与源文件的连接。',
|
||||
'vault.groups.deleteDialog.deleteHosts': '同时删除该分组下的所有主机',
|
||||
'vault.groups.ungrouped': '未分组',
|
||||
'vault.groups.field.name': '分组名称',
|
||||
'vault.groups.placeholder.example': '例如:Production',
|
||||
'vault.groups.parentLabel': '父级',
|
||||
@@ -191,6 +197,9 @@ const zhCN: Messages = {
|
||||
'vault.groups.errors.required': '分组名称不能为空。',
|
||||
'vault.groups.errors.invalidChars': "分组名称不能包含 '/' 或 '\\\\'.",
|
||||
|
||||
'vault.managedSource.unmanage': '取消托管',
|
||||
'vault.managedSource.unmanageSuccess': '已取消托管分组',
|
||||
|
||||
'vault.hosts.header.entries': '{count} 条',
|
||||
'vault.hosts.header.live': '{count} 个在线',
|
||||
|
||||
@@ -199,10 +208,26 @@ const zhCN: Messages = {
|
||||
'vault.hosts.connect': '连接',
|
||||
'vault.view.grid': '网格',
|
||||
'vault.view.list': '列表',
|
||||
'vault.view.tree': '树形',
|
||||
'vault.tree.expandAll': '展开全部',
|
||||
'vault.tree.collapseAll': '折叠全部',
|
||||
'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.hosts.multiSelect': '多选',
|
||||
'vault.hosts.selected': '已选择 {count} 项',
|
||||
'vault.hosts.selectAll': '全选',
|
||||
'vault.hosts.deselectAll': '取消全选',
|
||||
'vault.hosts.deleteSelected': '删除 ({count})',
|
||||
'vault.hosts.deleteMultiple.success': '已删除 {count} 个主机',
|
||||
|
||||
// Vault import
|
||||
'vault.import.title': '添加数据到你的 Vault',
|
||||
@@ -217,6 +242,18 @@ const zhCN: Messages = {
|
||||
'vault.import.toast.noNewHosts': '从 {format} 没有导入到新的主机。',
|
||||
'vault.import.toast.summary': '已导入 {count} 个主机(跳过 {skipped},重复 {duplicates})。',
|
||||
'vault.import.toast.firstIssue': '首个问题:{issue}',
|
||||
'vault.import.sshConfig.chooseMode': '选择如何导入你的 SSH config 文件。',
|
||||
'vault.import.sshConfig.modeQuestion': '你希望如何导入?',
|
||||
'vault.import.sshConfig.importOnly': '仅导入',
|
||||
'vault.import.sshConfig.importOnlyDesc': '一次性导入,修改不会同步回文件。',
|
||||
'vault.import.sshConfig.managed': '托管同步',
|
||||
'vault.import.sshConfig.managedDesc': '保持同步,修改会自动保存回文件。',
|
||||
'vault.import.sshConfig.managedGroup': 'ssh config',
|
||||
'vault.import.sshConfig.managedSuccess': '已导入 {count} 个主机,文件已托管。',
|
||||
'vault.import.sshConfig.alreadyManaged': '该文件已被托管。',
|
||||
'vault.import.sshConfig.alreadyManagedDesc': '该文件已在分组 "{group}" 下托管。如需重新导入,请先移除现有的托管源。',
|
||||
'vault.import.sshConfig.noFilePath': '无法托管此文件。',
|
||||
'vault.import.sshConfig.noFilePathDesc': '无法确定文件路径。托管同步需要访问文件系统。',
|
||||
|
||||
// Known Hosts
|
||||
'knownHosts.search.placeholder': '搜索已知主机...',
|
||||
@@ -293,8 +330,12 @@ const zhCN: Messages = {
|
||||
'sftp.columns.kind': '类型',
|
||||
'sftp.columns.actions': '操作',
|
||||
'sftp.emptyDirectory': '空目录',
|
||||
'sftp.nav.up': '返回上层',
|
||||
'sftp.nav.home': '返回主目录',
|
||||
'sftp.nav.refresh': '刷新',
|
||||
'sftp.upload': '上传',
|
||||
'sftp.uploadFiles': '上传文件',
|
||||
'sftp.uploadFolder': '上传文件夹',
|
||||
'sftp.dragDropToUpload': '拖拽文件到这里上传',
|
||||
'sftp.retry': '重试',
|
||||
'sftp.context.open': '打开',
|
||||
@@ -409,6 +450,8 @@ 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': '身份不存在',
|
||||
@@ -528,6 +571,13 @@ const zhCN: Messages = {
|
||||
'terminal.serverStats.network': '网络速度',
|
||||
'terminal.serverStats.networkDetails': '网络接口',
|
||||
'terminal.serverStats.noData': '暂无数据',
|
||||
'terminal.dragDrop.localTitle': '拖放以插入路径',
|
||||
'terminal.dragDrop.localMessage': '文件路径将被插入到终端',
|
||||
'terminal.dragDrop.remoteTitle': '拖放以上传文件',
|
||||
'terminal.dragDrop.remoteMessage': '文件将通过 SFTP 上传',
|
||||
'terminal.dragDrop.notConnected': '无法拖放文件 - 终端未连接',
|
||||
'terminal.dragDrop.errorTitle': '拖放错误',
|
||||
'terminal.dragDrop.errorMessage': '处理拖放文件失败',
|
||||
'terminal.search.placeholder': '搜索…',
|
||||
'terminal.search.noResults': '无结果',
|
||||
'terminal.search.prevMatch': '上一个匹配 (Shift+Enter)',
|
||||
@@ -559,6 +609,11 @@ const zhCN: Messages = {
|
||||
'terminal.connection.chainOf': 'Chain {current} / {total}',
|
||||
'terminal.connection.showLogs': '显示日志',
|
||||
'terminal.connection.hideLogs': '隐藏日志',
|
||||
'terminal.connection.protocol.ssh': 'SSH',
|
||||
'terminal.connection.protocol.telnet': 'Telnet',
|
||||
'terminal.connection.protocol.mosh': 'Mosh',
|
||||
'terminal.connection.protocol.serial': '串口',
|
||||
'terminal.connection.protocol.local': '本地终端',
|
||||
'terminal.themeModal.title': 'Terminal 外观',
|
||||
'terminal.themeModal.tab.theme': '主题',
|
||||
'terminal.themeModal.tab.font': '字体',
|
||||
@@ -779,6 +834,12 @@ const zhCN: Messages = {
|
||||
'sftp.conflict.action.keepBoth': '保留两者',
|
||||
'sftp.conflict.action.replace': '替换',
|
||||
|
||||
// SFTP Upload Phases
|
||||
'sftp.upload.phase.compressing': '正在压缩',
|
||||
'sftp.upload.phase.uploading': '正在上传',
|
||||
'sftp.upload.phase.extracting': '正在解压',
|
||||
'sftp.upload.phase.compressed': '压缩传输',
|
||||
|
||||
// SFTP File Opener
|
||||
'sftp.context.openWith': '打开方式...',
|
||||
'sftp.context.edit': '编辑',
|
||||
@@ -843,10 +904,19 @@ const zhCN: Messages = {
|
||||
// SFTP Folder Upload Progress
|
||||
'sftp.upload.progress': '正在上传 {current}/{total} 个文件...',
|
||||
'sftp.upload.uploading': '正在上传...',
|
||||
'sftp.upload.compressing': '正在压缩...',
|
||||
'sftp.upload.extracting': '正在解压...',
|
||||
'sftp.upload.scanning': '正在扫描文件...',
|
||||
'sftp.upload.completed': '已完成',
|
||||
'sftp.upload.compressed': '压缩传输',
|
||||
'sftp.upload.currentFile': '当前: {fileName}',
|
||||
'sftp.upload.cancelled': '上传已取消',
|
||||
'sftp.upload.cancel': '取消',
|
||||
|
||||
// SFTP Download
|
||||
'sftp.download.completed': '已下载',
|
||||
'sftp.download.cancelled': '下载已取消',
|
||||
|
||||
// SFTP Reconnecting
|
||||
'sftp.reconnecting.title': '正在重连...',
|
||||
'sftp.reconnecting.desc': '连接已断开,正在尝试重新连接',
|
||||
@@ -862,6 +932,12 @@ const zhCN: Messages = {
|
||||
'settings.sftp.showHiddenFiles.enable': '显示隐藏文件',
|
||||
'settings.sftp.showHiddenFiles.enableDesc': '浏览本地文件系统时显示 Windows 隐藏文件',
|
||||
|
||||
// Settings > SFTP Compressed Upload
|
||||
'settings.sftp.compressedUpload': '文件夹压缩传输',
|
||||
'settings.sftp.compressedUpload.desc': '上传前压缩文件夹,可大幅减少传输时间。',
|
||||
'settings.sftp.compressedUpload.enable': '启用文件夹压缩',
|
||||
'settings.sftp.compressedUpload.enableDesc': '自动使用 tar 压缩文件夹后再传输。需要服务器支持 tar 命令,不支持时自动回退到普通传输。',
|
||||
|
||||
// Settings > Terminal
|
||||
'settings.terminal.section.theme': '终端主题',
|
||||
'settings.terminal.themeModal.title': '选择主题',
|
||||
@@ -1090,6 +1166,7 @@ const zhCN: Messages = {
|
||||
'tabs.closeLogViewAria': '关闭日志视图',
|
||||
'tabs.logPrefix': '日志:',
|
||||
'tabs.logLocal': '本地',
|
||||
'tabs.copyTab': '复制标签页',
|
||||
'keychain.edit.labelRequired': 'Label *',
|
||||
'keychain.edit.keyLabelPlaceholder': '密钥 Label',
|
||||
'keychain.edit.privateKeyRequired': '私钥 *',
|
||||
@@ -1141,6 +1218,14 @@ const zhCN: Messages = {
|
||||
'snippets.packageDialog.placeholder': '例如:ops/maintenance',
|
||||
'snippets.packageDialog.hint': '使用 "/" 创建嵌套代码包。',
|
||||
|
||||
// Snippets Rename Dialog
|
||||
'snippets.renameDialog.title': '重命名代码包',
|
||||
'snippets.renameDialog.currentPath': '当前路径:{path}',
|
||||
'snippets.renameDialog.placeholder': '输入新名称',
|
||||
'snippets.renameDialog.error.empty': '代码包名称不能为空',
|
||||
'snippets.renameDialog.error.duplicate': '已存在同名的代码包',
|
||||
'snippets.renameDialog.error.invalidChars': '代码包名称只能包含字母、数字、连字符和下划线',
|
||||
|
||||
// Serial Port
|
||||
'serial.button': '串口',
|
||||
'serial.modal.title': '连接串口',
|
||||
@@ -1195,6 +1280,16 @@ const zhCN: Messages = {
|
||||
'keyboard.interactive.fillSaved': '填入已保存的密码',
|
||||
'keyboard.interactive.useSaved': '使用已保存',
|
||||
'keyboard.interactive.useSavedPassword': '使用已保存的密码',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'SSH 密钥密码',
|
||||
'passphrase.desc': '请输入 {keyName} 的密码',
|
||||
'passphrase.descWithHost': '请输入 {keyName} 的密码以连接到 {hostname}',
|
||||
'passphrase.label': '密码',
|
||||
'passphrase.keyPath': '密钥',
|
||||
'passphrase.unlock': '解锁',
|
||||
'passphrase.unlocking': '解锁中...',
|
||||
'passphrase.skip': '跳过',
|
||||
};
|
||||
|
||||
export default zhCN;
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
358
application/state/useManagedSourceSync.ts
Normal file
358
application/state/useManagedSourceSync.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { Host, ManagedSource } from "../../domain/models";
|
||||
import {
|
||||
serializeHostsToSshConfig,
|
||||
mergeWithExistingSshConfig,
|
||||
} from "../../domain/sshConfigSerializer";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
|
||||
const MANAGED_BLOCK_BEGIN = "# BEGIN NETCATTY MANAGED - DO NOT EDIT THIS BLOCK";
|
||||
const MANAGED_BLOCK_END = "# END NETCATTY MANAGED";
|
||||
|
||||
export interface UseManagedSourceSyncOptions {
|
||||
hosts: Host[];
|
||||
managedSources: ManagedSource[];
|
||||
onUpdateManagedSources: (sources: ManagedSource[]) => void;
|
||||
}
|
||||
|
||||
export const useManagedSourceSync = ({
|
||||
hosts,
|
||||
managedSources,
|
||||
onUpdateManagedSources,
|
||||
}: UseManagedSourceSyncOptions) => {
|
||||
const previousHostsRef = useRef<Host[]>([]);
|
||||
const syncInProgressRef = useRef(false);
|
||||
// Keep a ref to the latest managedSources to avoid stale closure issues
|
||||
const managedSourcesRef = useRef(managedSources);
|
||||
managedSourcesRef.current = managedSources;
|
||||
|
||||
const getManagedHostsForSource = useCallback(
|
||||
(sourceId: string) => {
|
||||
return hosts.filter((h) => h.managedSourceId === sourceId);
|
||||
},
|
||||
[hosts],
|
||||
);
|
||||
|
||||
const readExistingFileContent = useCallback(
|
||||
async (filePath: string): Promise<string | null> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.readLocalFile) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const buffer = await bridge.readLocalFile(filePath);
|
||||
const decoder = new TextDecoder();
|
||||
return decoder.decode(buffer);
|
||||
} catch {
|
||||
// File might not exist yet
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const mergeWithExistingContent = useCallback(
|
||||
(
|
||||
existingContent: string | null,
|
||||
managedHosts: Host[],
|
||||
allHosts: Host[],
|
||||
): string => {
|
||||
// Serialize the managed hosts
|
||||
const managedContent = serializeHostsToSshConfig(managedHosts, allHosts);
|
||||
|
||||
if (!existingContent) {
|
||||
// No existing file, just wrap the managed content
|
||||
return `${MANAGED_BLOCK_BEGIN}\n${managedContent}${MANAGED_BLOCK_END}\n`;
|
||||
}
|
||||
|
||||
const beginIndex = existingContent.indexOf(MANAGED_BLOCK_BEGIN);
|
||||
const endIndex = existingContent.indexOf(MANAGED_BLOCK_END);
|
||||
|
||||
if (beginIndex === -1 || endIndex === -1 || endIndex < beginIndex) {
|
||||
// No existing managed block - need to remove duplicate Host entries
|
||||
// Build a set of hostnames/aliases that will be managed
|
||||
const managedHostnameSet = new Set<string>();
|
||||
for (const host of managedHosts) {
|
||||
if (!host.protocol || host.protocol === "ssh") {
|
||||
// Add both hostname and sanitized label (alias) for matching
|
||||
managedHostnameSet.add(host.hostname.toLowerCase());
|
||||
if (host.label) {
|
||||
managedHostnameSet.add(host.label.replace(/\s/g, "").toLowerCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use mergeWithExistingSshConfig to filter out existing Host blocks
|
||||
// that match our managed hosts, keeping preserved content outside markers
|
||||
const mergedContent = mergeWithExistingSshConfig(
|
||||
existingContent,
|
||||
managedHosts,
|
||||
managedHostnameSet,
|
||||
allHosts,
|
||||
);
|
||||
return mergedContent;
|
||||
}
|
||||
|
||||
// Replace the existing managed block
|
||||
const before = existingContent.substring(0, beginIndex);
|
||||
const after = existingContent.substring(endIndex + MANAGED_BLOCK_END.length);
|
||||
return `${before}${MANAGED_BLOCK_BEGIN}\n${managedContent}${MANAGED_BLOCK_END}${after}`;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const writeSshConfigToFile = useCallback(
|
||||
async (source: ManagedSource, managedHosts: Host[]) => {
|
||||
console.log(`[ManagedSourceSync] writeSshConfigToFile called for ${source.groupName}, hosts:`, managedHosts.length);
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.writeLocalFile) {
|
||||
console.warn("[ManagedSourceSync] writeLocalFile not available");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Read existing file content to preserve non-managed parts
|
||||
const existingContent = await readExistingFileContent(source.filePath);
|
||||
|
||||
// Merge with existing content, preserving non-managed parts and removing duplicates
|
||||
const finalContent = mergeWithExistingContent(
|
||||
existingContent,
|
||||
managedHosts,
|
||||
hosts,
|
||||
);
|
||||
console.log(`[ManagedSourceSync] Final content (${finalContent.length} chars)`);
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const buffer = encoder.encode(finalContent);
|
||||
console.log(`[ManagedSourceSync] Writing to ${source.filePath}`);
|
||||
|
||||
await bridge.writeLocalFile(source.filePath, buffer.buffer as ArrayBuffer);
|
||||
console.log(`[ManagedSourceSync] Write successful`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("[ManagedSourceSync] Failed to write SSH config:", err);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[readExistingFileContent, mergeWithExistingContent, hosts],
|
||||
);
|
||||
|
||||
const syncManagedSource = useCallback(
|
||||
async (source: ManagedSource): Promise<{ sourceId: string; success: boolean }> => {
|
||||
const managedHosts = getManagedHostsForSource(source.id);
|
||||
const success = await writeSshConfigToFile(source, managedHosts);
|
||||
return { sourceId: source.id, success };
|
||||
},
|
||||
[getManagedHostsForSource, writeSshConfigToFile],
|
||||
);
|
||||
|
||||
const unmanageSource = useCallback(
|
||||
(sourceId: string) => {
|
||||
const updatedSources = managedSourcesRef.current.filter((s) => s.id !== sourceId);
|
||||
onUpdateManagedSources(updatedSources);
|
||||
},
|
||||
[onUpdateManagedSources],
|
||||
);
|
||||
|
||||
// Clear the managed block in the SSH config file and then remove the source
|
||||
// This should be called before deleting a managed group to avoid stale entries
|
||||
const clearAndRemoveSource = useCallback(
|
||||
async (source: ManagedSource) => {
|
||||
console.log(`[ManagedSourceSync] Clearing managed block for ${source.groupName}`);
|
||||
// Write empty hosts list to clear the managed block
|
||||
const success = await writeSshConfigToFile(source, []);
|
||||
if (success) {
|
||||
console.log(`[ManagedSourceSync] Managed block cleared, removing source`);
|
||||
}
|
||||
// Remove the source regardless of write success
|
||||
const updatedSources = managedSourcesRef.current.filter((s) => s.id !== source.id);
|
||||
onUpdateManagedSources(updatedSources);
|
||||
return success;
|
||||
},
|
||||
[onUpdateManagedSources, writeSshConfigToFile],
|
||||
);
|
||||
|
||||
// Clear and remove multiple sources atomically to avoid race conditions
|
||||
// when multiple sources are removed concurrently
|
||||
const clearAndRemoveSources = useCallback(
|
||||
async (sources: ManagedSource[]) => {
|
||||
if (sources.length === 0) return;
|
||||
|
||||
console.log(`[ManagedSourceSync] Clearing ${sources.length} managed blocks`);
|
||||
|
||||
// Clear all files in parallel
|
||||
const results = await Promise.all(
|
||||
sources.map(async (source) => {
|
||||
const success = await writeSshConfigToFile(source, []);
|
||||
return { sourceId: source.id, success };
|
||||
})
|
||||
);
|
||||
|
||||
const successCount = results.filter(r => r.success).length;
|
||||
console.log(`[ManagedSourceSync] Cleared ${successCount}/${sources.length} managed blocks`);
|
||||
|
||||
// Remove all sources atomically in a single update
|
||||
const sourceIdsToRemove = new Set(sources.map(s => s.id));
|
||||
const updatedSources = managedSourcesRef.current.filter(
|
||||
(s) => !sourceIdsToRemove.has(s.id)
|
||||
);
|
||||
onUpdateManagedSources(updatedSources);
|
||||
},
|
||||
[onUpdateManagedSources, writeSshConfigToFile],
|
||||
);
|
||||
|
||||
const pendingSyncRef = useRef(false);
|
||||
const checkAndSyncRef = useRef<() => void>(() => {});
|
||||
|
||||
const checkAndSync = useCallback(() => {
|
||||
if (managedSources.length === 0) {
|
||||
// Still update previousHostsRef so we have a baseline when sources are added
|
||||
previousHostsRef.current = hosts;
|
||||
return;
|
||||
}
|
||||
|
||||
const prevHosts = previousHostsRef.current;
|
||||
previousHostsRef.current = hosts;
|
||||
|
||||
// On initial sync (prevHosts empty), sync all sources that have managed hosts
|
||||
const isInitialSync = prevHosts.length === 0;
|
||||
|
||||
const changedSourceIds = new Set<string>();
|
||||
|
||||
if (isInitialSync) {
|
||||
// Initial sync: sync all sources that have hosts
|
||||
for (const source of managedSources) {
|
||||
const currManaged = hosts.filter((h) => h.managedSourceId === source.id);
|
||||
if (currManaged.length > 0) {
|
||||
changedSourceIds.add(source.id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Build maps for all hosts (for jump host lookup)
|
||||
const prevHostMap = new Map<string, Host>(prevHosts.map((h) => [h.id, h]));
|
||||
const currHostMap = new Map<string, Host>(hosts.map((h) => [h.id, h]));
|
||||
|
||||
// Helper to check if a host's SSH-relevant fields changed
|
||||
const hostChanged = (prevHost: Host | undefined, currHost: Host | undefined): boolean => {
|
||||
if (!prevHost || !currHost) return prevHost !== currHost;
|
||||
return (
|
||||
prevHost.hostname !== currHost.hostname ||
|
||||
prevHost.port !== currHost.port ||
|
||||
prevHost.username !== currHost.username ||
|
||||
prevHost.label !== currHost.label
|
||||
);
|
||||
};
|
||||
|
||||
for (const source of managedSources) {
|
||||
const prevManaged = prevHosts.filter((h) => h.managedSourceId === source.id);
|
||||
const currManaged = hosts.filter((h) => h.managedSourceId === source.id);
|
||||
|
||||
console.log(`[ManagedSourceSync] Source ${source.groupName}: prev=${prevManaged.length}, curr=${currManaged.length}`);
|
||||
|
||||
if (prevManaged.length !== currManaged.length) {
|
||||
changedSourceIds.add(source.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const prevManagedMap = new Map<string, Host>(prevManaged.map((h) => [h.id, h]));
|
||||
let sourceChanged = false;
|
||||
|
||||
for (const curr of currManaged) {
|
||||
const prev = prevManagedMap.get(curr.id);
|
||||
if (!prev) {
|
||||
sourceChanged = true;
|
||||
break;
|
||||
}
|
||||
// Compare hostChain arrays for ProxyJump changes
|
||||
const prevChain = prev.hostChain?.hostIds || [];
|
||||
const currChain = curr.hostChain?.hostIds || [];
|
||||
const chainChanged =
|
||||
prevChain.length !== currChain.length ||
|
||||
prevChain.some((id, i) => id !== currChain[i]);
|
||||
|
||||
const hasChanged =
|
||||
prev.hostname !== curr.hostname ||
|
||||
prev.port !== curr.port ||
|
||||
prev.username !== curr.username ||
|
||||
prev.label !== curr.label ||
|
||||
prev.group !== curr.group ||
|
||||
prev.protocol !== curr.protocol ||
|
||||
chainChanged;
|
||||
if (hasChanged) {
|
||||
sourceChanged = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if any referenced jump hosts changed (even if outside this managed source)
|
||||
for (const jumpHostId of currChain) {
|
||||
const prevJumpHost = prevHostMap.get(jumpHostId);
|
||||
const currJumpHost = currHostMap.get(jumpHostId);
|
||||
if (hostChanged(prevJumpHost, currJumpHost)) {
|
||||
sourceChanged = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (sourceChanged) break;
|
||||
}
|
||||
|
||||
if (sourceChanged) {
|
||||
changedSourceIds.add(source.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (changedSourceIds.size > 0) {
|
||||
console.log(`[ManagedSourceSync] Syncing sources:`, Array.from(changedSourceIds));
|
||||
syncInProgressRef.current = true;
|
||||
|
||||
Promise.all(
|
||||
managedSources
|
||||
.filter((s) => changedSourceIds.has(s.id))
|
||||
.map(syncManagedSource),
|
||||
).then((results) => {
|
||||
// Batch update lastSyncedAt for all successful syncs to avoid race conditions
|
||||
const successfulSourceIds = new Set(
|
||||
results.filter(r => r.success).map(r => r.sourceId)
|
||||
);
|
||||
|
||||
if (successfulSourceIds.size > 0) {
|
||||
const currentSources = managedSourcesRef.current;
|
||||
const now = Date.now();
|
||||
const updatedSources = currentSources.map((s) =>
|
||||
successfulSourceIds.has(s.id) ? { ...s, lastSyncedAt: now } : s,
|
||||
);
|
||||
onUpdateManagedSources(updatedSources);
|
||||
}
|
||||
}).finally(() => {
|
||||
syncInProgressRef.current = false;
|
||||
// Check if there were changes during sync that need to be processed
|
||||
// Use ref to get the latest checkAndSync to avoid stale closure
|
||||
if (pendingSyncRef.current) {
|
||||
pendingSyncRef.current = false;
|
||||
checkAndSyncRef.current();
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [hosts, managedSources, syncManagedSource, onUpdateManagedSources]);
|
||||
|
||||
// Keep ref updated with the latest checkAndSync
|
||||
checkAndSyncRef.current = checkAndSync;
|
||||
|
||||
useEffect(() => {
|
||||
if (syncInProgressRef.current) {
|
||||
// Mark that we need to re-sync after current sync completes
|
||||
pendingSyncRef.current = true;
|
||||
return;
|
||||
}
|
||||
checkAndSync();
|
||||
}, [hosts, managedSources, checkAndSync]);
|
||||
|
||||
return {
|
||||
syncManagedSource,
|
||||
unmanageSource,
|
||||
clearAndRemoveSource,
|
||||
clearAndRemoveSources,
|
||||
getManagedHostsForSource,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ STORAGE_KEY_UI_FONT_FAMILY,
|
||||
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
|
||||
STORAGE_KEY_SFTP_AUTO_SYNC,
|
||||
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
|
||||
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
|
||||
STORAGE_KEY_SESSION_LOGS_ENABLED,
|
||||
STORAGE_KEY_SESSION_LOGS_DIR,
|
||||
STORAGE_KEY_SESSION_LOGS_FORMAT,
|
||||
@@ -49,6 +50,7 @@ const DEFAULT_HOTKEY_SCHEME: HotkeyScheme =
|
||||
const DEFAULT_SFTP_DOUBLE_CLICK_BEHAVIOR: 'open' | 'transfer' = 'open';
|
||||
const DEFAULT_SFTP_AUTO_SYNC = false;
|
||||
const DEFAULT_SFTP_SHOW_HIDDEN_FILES = false;
|
||||
const DEFAULT_SFTP_USE_COMPRESSED_UPLOAD = true;
|
||||
|
||||
// Session Logs defaults
|
||||
const DEFAULT_SESSION_LOGS_ENABLED = false;
|
||||
@@ -196,6 +198,13 @@ export const useSettingsState = () => {
|
||||
const stored = readStoredString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES);
|
||||
return stored === 'true' ? true : DEFAULT_SFTP_SHOW_HIDDEN_FILES;
|
||||
});
|
||||
const [sftpUseCompressedUpload, setSftpUseCompressedUpload] = useState<boolean>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD);
|
||||
// 兼容旧的设置值
|
||||
if (stored === 'true' || stored === 'enabled' || stored === 'ask') return true;
|
||||
if (stored === 'false' || stored === 'disabled') return false;
|
||||
return DEFAULT_SFTP_USE_COMPRESSED_UPLOAD;
|
||||
});
|
||||
|
||||
// Session Logs Settings
|
||||
const [sessionLogsEnabled, setSessionLogsEnabled] = useState<boolean>(() => {
|
||||
@@ -467,11 +476,18 @@ export const useSettingsState = () => {
|
||||
setSftpShowHiddenFiles(newValue);
|
||||
}
|
||||
}
|
||||
// Sync SFTP compressed upload setting from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true' || e.newValue === 'enabled';
|
||||
if (newValue !== sftpUseCompressedUpload) {
|
||||
setSftpUseCompressedUpload(newValue);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
return () => window.removeEventListener('storage', handleStorageChange);
|
||||
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles]);
|
||||
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
|
||||
@@ -540,6 +556,12 @@ export const useSettingsState = () => {
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, sftpShowHiddenFiles);
|
||||
}, [sftpShowHiddenFiles, notifySettingsChanged]);
|
||||
|
||||
// Persist SFTP compressed upload setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, sftpUseCompressedUpload ? 'true' : 'false');
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, sftpUseCompressedUpload);
|
||||
}, [sftpUseCompressedUpload, notifySettingsChanged]);
|
||||
|
||||
// Persist Session Logs settings
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_ENABLED, sessionLogsEnabled ? 'true' : 'false');
|
||||
@@ -670,6 +692,8 @@ export const useSettingsState = () => {
|
||||
setSftpAutoSync,
|
||||
sftpShowHiddenFiles,
|
||||
setSftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload,
|
||||
setSftpUseCompressedUpload,
|
||||
availableFonts,
|
||||
// Session Logs
|
||||
sessionLogsEnabled,
|
||||
|
||||
@@ -188,6 +188,15 @@ export const useSftpBackend = () => {
|
||||
return bridge.selectApplication();
|
||||
}, []);
|
||||
|
||||
const showSaveDialog = useCallback(async (
|
||||
defaultPath: string,
|
||||
filters?: Array<{ name: string; extensions: string[] }>
|
||||
) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.showSaveDialog) return null;
|
||||
return bridge.showSaveDialog(defaultPath, filters);
|
||||
}, []);
|
||||
|
||||
const downloadSftpToTempAndOpen = useCallback(async (
|
||||
sftpId: string,
|
||||
remotePath: string,
|
||||
@@ -268,6 +277,7 @@ export const useSftpBackend = () => {
|
||||
cancelSftpUpload,
|
||||
onTransferProgress,
|
||||
selectApplication,
|
||||
showSaveDialog,
|
||||
downloadSftpToTempAndOpen,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -61,6 +61,11 @@ export const useSftpState = (
|
||||
// SFTP session refs
|
||||
const sftpSessionsRef = useRef<Map<string, string>>(new Map()); // connectionId -> sftpId
|
||||
|
||||
// Getter for sftpId from connectionId (for stream transfers)
|
||||
const getSftpIdForConnection = useCallback((connectionId: string) => {
|
||||
return sftpSessionsRef.current.get(connectionId);
|
||||
}, []);
|
||||
|
||||
// Directory listing cache (connectionId + path)
|
||||
const DIR_CACHE_TTL_MS = 10_000;
|
||||
const dirCacheRef = useRef<
|
||||
@@ -274,11 +279,14 @@ export const useSftpState = (
|
||||
cancelExternalUpload,
|
||||
selectApplication,
|
||||
startTransfer,
|
||||
addExternalUpload,
|
||||
updateExternalUpload,
|
||||
cancelTransfer,
|
||||
retryTransfer,
|
||||
clearCompletedTransfers,
|
||||
dismissTransfer,
|
||||
resolveConflict,
|
||||
getSftpIdForConnection,
|
||||
});
|
||||
methodsRef.current = {
|
||||
getFilteredFiles,
|
||||
@@ -315,11 +323,14 @@ export const useSftpState = (
|
||||
cancelExternalUpload,
|
||||
selectApplication,
|
||||
startTransfer,
|
||||
addExternalUpload,
|
||||
updateExternalUpload,
|
||||
cancelTransfer,
|
||||
retryTransfer,
|
||||
clearCompletedTransfers,
|
||||
dismissTransfer,
|
||||
resolveConflict,
|
||||
getSftpIdForConnection,
|
||||
};
|
||||
|
||||
// Create stable method wrappers that call through methodsRef
|
||||
@@ -360,11 +371,14 @@ export const useSftpState = (
|
||||
cancelExternalUpload: () => methodsRef.current.cancelExternalUpload(),
|
||||
selectApplication: () => methodsRef.current.selectApplication(),
|
||||
startTransfer: (...args: Parameters<typeof startTransfer>) => methodsRef.current.startTransfer(...args),
|
||||
addExternalUpload: (...args: Parameters<typeof addExternalUpload>) => methodsRef.current.addExternalUpload(...args),
|
||||
updateExternalUpload: (...args: Parameters<typeof updateExternalUpload>) => methodsRef.current.updateExternalUpload(...args),
|
||||
cancelTransfer: (...args: Parameters<typeof cancelTransfer>) => methodsRef.current.cancelTransfer(...args),
|
||||
retryTransfer: (...args: Parameters<typeof retryTransfer>) => methodsRef.current.retryTransfer(...args),
|
||||
clearCompletedTransfers: () => methodsRef.current.clearCompletedTransfers(),
|
||||
dismissTransfer: (...args: Parameters<typeof dismissTransfer>) => methodsRef.current.dismissTransfer(...args),
|
||||
resolveConflict: (...args: Parameters<typeof resolveConflict>) => methodsRef.current.resolveConflict(...args),
|
||||
getSftpIdForConnection: (...args: Parameters<typeof getSftpIdForConnection>) => methodsRef.current.getSftpIdForConnection(...args),
|
||||
}), []); // Empty deps - these wrappers never change
|
||||
|
||||
// Return object with stable method references but reactive state
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
|
||||
export type ViewMode = "grid" | "list";
|
||||
export type ViewMode = "grid" | "list" | "tree";
|
||||
|
||||
const isViewMode = (value: string | null): value is ViewMode =>
|
||||
value === "grid" || value === "list";
|
||||
value === "grid" || value === "list" || value === "tree";
|
||||
|
||||
export const useStoredViewMode = (
|
||||
storageKey: string,
|
||||
|
||||
47
application/state/useTreeExpandedState.ts
Normal file
47
application/state/useTreeExpandedState.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
|
||||
export const useTreeExpandedState = (storageKey: string) => {
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => {
|
||||
const stored = localStorageAdapter.readString(storageKey);
|
||||
if (stored) {
|
||||
try {
|
||||
const paths = JSON.parse(stored) as string[];
|
||||
return new Set(paths);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
return new Set();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const pathsArray = Array.from(expandedPaths);
|
||||
localStorageAdapter.writeString(storageKey, JSON.stringify(pathsArray));
|
||||
}, [storageKey, expandedPaths]);
|
||||
|
||||
const togglePath = (path: string) => {
|
||||
const newExpanded = new Set(expandedPaths);
|
||||
if (newExpanded.has(path)) {
|
||||
newExpanded.delete(path);
|
||||
} else {
|
||||
newExpanded.add(path);
|
||||
}
|
||||
setExpandedPaths(newExpanded);
|
||||
};
|
||||
|
||||
const expandAll = (allPaths: string[]) => {
|
||||
setExpandedPaths(new Set(allPaths));
|
||||
};
|
||||
|
||||
const collapseAll = () => {
|
||||
setExpandedPaths(new Set());
|
||||
};
|
||||
|
||||
return {
|
||||
expandedPaths,
|
||||
togglePath,
|
||||
expandAll,
|
||||
collapseAll,
|
||||
};
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Identity,
|
||||
KeyCategory,
|
||||
KnownHost,
|
||||
ManagedSource,
|
||||
ShellHistoryEntry,
|
||||
Snippet,
|
||||
SSHKey,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
STORAGE_KEY_KEYS,
|
||||
STORAGE_KEY_KNOWN_HOSTS,
|
||||
STORAGE_KEY_LEGACY_KEYS,
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
STORAGE_KEY_SHELL_HISTORY,
|
||||
STORAGE_KEY_SNIPPET_PACKAGES,
|
||||
STORAGE_KEY_SNIPPETS,
|
||||
@@ -95,6 +97,7 @@ export const useVaultState = () => {
|
||||
const [knownHosts, setKnownHosts] = useState<KnownHost[]>([]);
|
||||
const [shellHistory, setShellHistory] = useState<ShellHistoryEntry[]>([]);
|
||||
const [connectionLogs, setConnectionLogs] = useState<ConnectionLog[]>([]);
|
||||
const [managedSources, setManagedSources] = useState<ManagedSource[]>([]);
|
||||
|
||||
const updateHosts = useCallback((data: Host[]) => {
|
||||
const cleaned = data.map(sanitizeHost);
|
||||
@@ -132,6 +135,11 @@ export const useVaultState = () => {
|
||||
localStorageAdapter.write(STORAGE_KEY_KNOWN_HOSTS, data);
|
||||
}, []);
|
||||
|
||||
const updateManagedSources = useCallback((data: ManagedSource[]) => {
|
||||
setManagedSources(data);
|
||||
localStorageAdapter.write(STORAGE_KEY_MANAGED_SOURCES, data);
|
||||
}, []);
|
||||
|
||||
const clearVaultData = useCallback(() => {
|
||||
updateHosts([]);
|
||||
updateKeys([]);
|
||||
@@ -140,6 +148,7 @@ export const useVaultState = () => {
|
||||
updateSnippetPackages([]);
|
||||
updateCustomGroups([]);
|
||||
updateKnownHosts([]);
|
||||
updateManagedSources([]);
|
||||
localStorageAdapter.remove(STORAGE_KEY_LEGACY_KEYS);
|
||||
}, [
|
||||
updateHosts,
|
||||
@@ -149,6 +158,7 @@ export const useVaultState = () => {
|
||||
updateSnippetPackages,
|
||||
updateCustomGroups,
|
||||
updateKnownHosts,
|
||||
updateManagedSources,
|
||||
]);
|
||||
|
||||
const addShellHistoryEntry = useCallback(
|
||||
@@ -339,6 +349,12 @@ export const useVaultState = () => {
|
||||
STORAGE_KEY_CONNECTION_LOGS,
|
||||
);
|
||||
if (savedConnectionLogs) setConnectionLogs(savedConnectionLogs);
|
||||
|
||||
// Load managed sources
|
||||
const savedManagedSources = localStorageAdapter.read<ManagedSource[]>(
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
);
|
||||
if (savedManagedSources) setManagedSources(savedManagedSources);
|
||||
}, [updateHosts, updateSnippets]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -407,6 +423,12 @@ export const useVaultState = () => {
|
||||
if (key === STORAGE_KEY_CONNECTION_LOGS) {
|
||||
const next = safeParse<ConnectionLog[]>(event.newValue) ?? [];
|
||||
setConnectionLogs(next);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === STORAGE_KEY_MANAGED_SOURCES) {
|
||||
const next = safeParse<ManagedSource[]>(event.newValue) ?? [];
|
||||
setManagedSources(next);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -474,6 +496,7 @@ export const useVaultState = () => {
|
||||
knownHosts,
|
||||
shellHistory,
|
||||
connectionLogs,
|
||||
managedSources,
|
||||
updateHosts,
|
||||
updateKeys,
|
||||
updateIdentities,
|
||||
@@ -481,6 +504,7 @@ export const useVaultState = () => {
|
||||
updateSnippetPackages,
|
||||
updateCustomGroups,
|
||||
updateKnownHosts,
|
||||
updateManagedSources,
|
||||
addShellHistoryEntry,
|
||||
clearShellHistory,
|
||||
addConnectionLog,
|
||||
|
||||
@@ -16,6 +16,7 @@ interface GroupTreeItemProps {
|
||||
onEditGroup: (path: string) => void;
|
||||
onNewHost: (path: string) => void;
|
||||
onNewSubfolder: (path: string) => void;
|
||||
isManagedGroup?: (path: string) => boolean;
|
||||
}
|
||||
|
||||
export const GroupTreeItem: React.FC<GroupTreeItemProps> = ({
|
||||
|
||||
@@ -2,6 +2,8 @@ import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FolderLock,
|
||||
FolderPlus,
|
||||
Forward,
|
||||
@@ -27,7 +29,7 @@ import { useApplicationBackend } from "../application/state/useApplicationBacken
|
||||
import { TERMINAL_THEMES } from "../infrastructure/config/terminalThemes";
|
||||
import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "../infrastructure/config/fonts";
|
||||
import { cn } from "../lib/utils";
|
||||
import { EnvVar, Host, Identity, ProxyConfig, SSHKey } from "../types";
|
||||
import { EnvVar, Host, Identity, ManagedSource, ProxyConfig, SSHKey } from "../types";
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
import ThemeSelectPanel from "./ThemeSelectPanel";
|
||||
import {
|
||||
@@ -41,6 +43,7 @@ import { Switch } from "./ui/switch";
|
||||
import { Card } from "./ui/card";
|
||||
import { Combobox, ComboboxOption, MultiCombobox } from "./ui/combobox";
|
||||
import { Input } from "./ui/input";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
||||
@@ -68,6 +71,7 @@ interface HostDetailsPanelProps {
|
||||
availableKeys: SSHKey[];
|
||||
identities: Identity[];
|
||||
groups: string[];
|
||||
managedSources?: ManagedSource[];
|
||||
allTags?: string[]; // All available tags for autocomplete
|
||||
allHosts?: Host[]; // All hosts for chain selection
|
||||
defaultGroup?: string | null; // Default group for new hosts (from current navigation)
|
||||
@@ -82,6 +86,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
availableKeys,
|
||||
identities,
|
||||
groups,
|
||||
managedSources = [],
|
||||
allTags = [],
|
||||
allHosts = [],
|
||||
defaultGroup,
|
||||
@@ -123,6 +128,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 +172,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
}
|
||||
setForm(updatedData);
|
||||
setGroupInputValue(initialData.group || "");
|
||||
// Reset password visibility when host changes for privacy
|
||||
setShowPassword(false);
|
||||
}
|
||||
}, [initialData]);
|
||||
|
||||
@@ -244,14 +254,51 @@ 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
|
||||
let finalLabel = form.label?.trim() || form.hostname;
|
||||
const finalGroup = groupInputValue.trim() || form.group || "";
|
||||
|
||||
// Find the most specific (deepest) managed source that matches the group path
|
||||
// This handles nested managed groups correctly by preferring exact matches
|
||||
// and longer paths over shorter prefix matches
|
||||
const targetManagedSource = managedSources
|
||||
.filter(s => finalGroup === s.groupName || finalGroup.startsWith(s.groupName + "/"))
|
||||
.sort((a, b) => b.groupName.length - a.groupName.length)[0];
|
||||
|
||||
// Only SSH hosts can be managed (SSH config only supports SSH protocol)
|
||||
const canBeManaged = !form.protocol || form.protocol === "ssh";
|
||||
|
||||
// Strip spaces from label only if host can be managed and is in a managed group
|
||||
// (SSH config requires no spaces in Host alias)
|
||||
if (targetManagedSource && canBeManaged) {
|
||||
finalLabel = finalLabel.replace(/\s/g, '');
|
||||
}
|
||||
|
||||
// Determine managedSourceId:
|
||||
// - Only SSH hosts can be managed (SSH config only supports SSH protocol)
|
||||
// - If we found a matching managed source, use its id
|
||||
// - If managedSources was not provided (empty array) and host already has managedSourceId, preserve it
|
||||
// - Otherwise, clear it (host is not in a managed group)
|
||||
let finalManagedSourceId: string | undefined;
|
||||
if (targetManagedSource && canBeManaged) {
|
||||
finalManagedSourceId = targetManagedSource.id;
|
||||
} else if (managedSources.length === 0 && form.managedSourceId && canBeManaged) {
|
||||
// managedSources not provided, preserve existing value
|
||||
finalManagedSourceId = form.managedSourceId;
|
||||
} else {
|
||||
finalManagedSourceId = undefined;
|
||||
}
|
||||
|
||||
const cleaned: Host = {
|
||||
...form,
|
||||
group: groupInputValue.trim() || form.group,
|
||||
label: finalLabel,
|
||||
group: finalGroup,
|
||||
tags: form.tags || [],
|
||||
port: form.port || 22,
|
||||
// Clear password if savePassword is explicitly set to false
|
||||
password: form.savePassword === false ? undefined : form.password,
|
||||
managedSourceId: finalManagedSourceId,
|
||||
};
|
||||
onSave(cleaned);
|
||||
};
|
||||
@@ -501,7 +548,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} />
|
||||
@@ -509,32 +556,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
}
|
||||
>
|
||||
<AsidePanelContent>
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">
|
||||
{t("hostDetails.section.address")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<DistroAvatar
|
||||
host={form as Host}
|
||||
fallback={
|
||||
form.label?.slice(0, 2).toUpperCase() ||
|
||||
form.hostname?.slice(0, 2).toUpperCase() ||
|
||||
"H"
|
||||
}
|
||||
className="h-10 w-10"
|
||||
/>
|
||||
<Input
|
||||
placeholder={t("hostDetails.hostname.placeholder")}
|
||||
value={form.hostname}
|
||||
onChange={(e) => update("hostname", e.target.value)}
|
||||
className="h-10 flex-1"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings2 size={14} className="text-muted-foreground" />
|
||||
@@ -545,7 +566,21 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
<Input
|
||||
placeholder={t("hostDetails.label.placeholder")}
|
||||
value={form.label}
|
||||
onChange={(e) => update("label", e.target.value)}
|
||||
onChange={(e) => {
|
||||
let value = e.target.value;
|
||||
// Only strip spaces if the TARGET group belongs to a managed source
|
||||
// (don't use form.managedSourceId as it reflects old state before group change)
|
||||
const targetGroup = groupInputValue.trim() || form.group || "";
|
||||
const willBeManaged = managedSources.some(s =>
|
||||
targetGroup === s.groupName || targetGroup.startsWith(s.groupName + "/")
|
||||
);
|
||||
// Also check protocol - only SSH hosts can be managed
|
||||
const canBeManaged = !form.protocol || form.protocol === "ssh";
|
||||
if (willBeManaged && canBeManaged) {
|
||||
value = value.replace(/\s/g, '');
|
||||
}
|
||||
update("label", value);
|
||||
}}
|
||||
className="h-10"
|
||||
/>
|
||||
|
||||
@@ -591,6 +626,32 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">
|
||||
{t("hostDetails.section.address")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<DistroAvatar
|
||||
host={form as Host}
|
||||
fallback={
|
||||
form.label?.slice(0, 2).toUpperCase() ||
|
||||
form.hostname?.slice(0, 2).toUpperCase() ||
|
||||
"H"
|
||||
}
|
||||
className="h-10 w-10"
|
||||
/>
|
||||
<Input
|
||||
placeholder={t("hostDetails.hostname.placeholder")}
|
||||
value={form.hostname}
|
||||
onChange={(e) => update("hostname", e.target.value)}
|
||||
className="h-10 flex-1"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound size={14} className="text-muted-foreground" />
|
||||
@@ -800,13 +861,23 @@ 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 */}
|
||||
@@ -1314,11 +1385,12 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
<TerminalSquare size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">{t("hostDetails.startupCommand")}</p>
|
||||
</div>
|
||||
<Input
|
||||
<Textarea
|
||||
placeholder={t("hostDetails.startupCommand.placeholder")}
|
||||
value={form.startupCommand || ""}
|
||||
onChange={(e) => update("startupCommand", e.target.value)}
|
||||
className="h-9"
|
||||
className="min-h-[80px] font-mono text-sm"
|
||||
rows={3}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.startupCommand.help")}
|
||||
@@ -1460,7 +1532,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>
|
||||
|
||||
501
components/HostTreeView.tsx
Normal file
501
components/HostTreeView.tsx
Normal file
@@ -0,0 +1,501 @@
|
||||
import { ChevronRight, FileSymlink, Folder, FolderOpen, Monitor, Server, Expand, Minimize2 } from 'lucide-react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useTreeExpandedState } from '../application/state/useTreeExpandedState';
|
||||
import { sanitizeHost } from '../domain/host';
|
||||
import { STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED } from '../infrastructure/config/storageKeys';
|
||||
import { cn } from '../lib/utils';
|
||||
import { GroupNode, Host } from '../types';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from './ui/context-menu';
|
||||
import { DistroAvatar } from './DistroAvatar';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
interface HostTreeViewProps {
|
||||
groupTree: GroupNode[];
|
||||
hosts: Host[];
|
||||
sortMode?: 'az' | 'za' | 'newest' | 'oldest' | 'group';
|
||||
expandedPaths?: Set<string>;
|
||||
onTogglePath?: (path: string) => void;
|
||||
onExpandAll?: (paths: string[]) => void;
|
||||
onCollapseAll?: () => void;
|
||||
onConnect: (host: Host) => void;
|
||||
onEditHost: (host: Host) => void;
|
||||
onDuplicateHost: (host: Host) => void;
|
||||
onDeleteHost: (host: Host) => void;
|
||||
onCopyCredentials: (host: Host) => void;
|
||||
onNewHost: (groupPath?: string) => void;
|
||||
onNewGroup: (parentPath?: string) => void;
|
||||
onEditGroup: (groupPath: string) => void;
|
||||
onDeleteGroup: (groupPath: string) => void;
|
||||
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
|
||||
moveGroup: (sourcePath: string, targetPath: string) => void;
|
||||
managedGroupPaths?: Set<string>;
|
||||
onUnmanageGroup?: (groupPath: string) => void;
|
||||
}
|
||||
|
||||
interface TreeNodeProps {
|
||||
node: GroupNode;
|
||||
depth: number;
|
||||
sortMode: 'az' | 'za' | 'newest' | 'oldest' | 'group';
|
||||
expandedPaths: Set<string>;
|
||||
onToggle: (path: string) => void;
|
||||
onConnect: (host: Host) => void;
|
||||
onEditHost: (host: Host) => void;
|
||||
onDuplicateHost: (host: Host) => void;
|
||||
onDeleteHost: (host: Host) => void;
|
||||
onCopyCredentials: (host: Host) => void;
|
||||
onNewHost: (groupPath?: string) => void;
|
||||
onNewGroup: (parentPath?: string) => void;
|
||||
onEditGroup: (groupPath: string) => void;
|
||||
onDeleteGroup: (groupPath: string) => void;
|
||||
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
|
||||
moveGroup: (sourcePath: string, targetPath: string) => void;
|
||||
managedGroupPaths?: Set<string>;
|
||||
onUnmanageGroup?: (groupPath: string) => void;
|
||||
}
|
||||
|
||||
const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
node,
|
||||
depth,
|
||||
sortMode,
|
||||
expandedPaths,
|
||||
onToggle,
|
||||
onConnect,
|
||||
onEditHost,
|
||||
onDuplicateHost,
|
||||
onDeleteHost,
|
||||
onCopyCredentials,
|
||||
onNewHost,
|
||||
onNewGroup,
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
moveHostToGroup,
|
||||
moveGroup,
|
||||
managedGroupPaths,
|
||||
onUnmanageGroup,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isExpanded = expandedPaths.has(node.path);
|
||||
const hasChildren = node.children && Object.keys(node.children).length > 0;
|
||||
const paddingLeft = `${depth * 20 + 12}px`;
|
||||
const isManaged = managedGroupPaths?.has(node.path) ?? false;
|
||||
|
||||
const childNodes = useMemo(() => {
|
||||
if (!node.children) return [];
|
||||
const nodes = Object.values(node.children) as unknown as GroupNode[];
|
||||
return nodes.sort((a, b) => {
|
||||
switch (sortMode) {
|
||||
case 'za':
|
||||
return b.name.localeCompare(a.name);
|
||||
case 'newest':
|
||||
case 'oldest':
|
||||
// For groups, fall back to name sorting since groups don't have creation dates
|
||||
return a.name.localeCompare(b.name);
|
||||
case 'az':
|
||||
default:
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
});
|
||||
}, [node.children, sortMode]);
|
||||
|
||||
const sortedHosts = useMemo(() => {
|
||||
return [...node.hosts].sort((a, b) => {
|
||||
switch (sortMode) {
|
||||
case 'az':
|
||||
return a.label.localeCompare(b.label);
|
||||
case 'za':
|
||||
return b.label.localeCompare(a.label);
|
||||
case 'newest':
|
||||
return (b.createdAt || 0) - (a.createdAt || 0);
|
||||
case 'oldest':
|
||||
return (a.createdAt || 0) - (b.createdAt || 0);
|
||||
default:
|
||||
return a.label.localeCompare(b.label);
|
||||
}
|
||||
});
|
||||
}, [node.hosts, sortMode]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Group Node */}
|
||||
<Collapsible open={isExpanded} onOpenChange={() => onToggle(node.path)}>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center py-2 pr-3 text-sm font-medium cursor-pointer transition-colors select-none group hover:bg-secondary/60 rounded-lg",
|
||||
)}
|
||||
style={{ paddingLeft }}
|
||||
draggable
|
||||
onDragStart={(e) => e.dataTransfer.setData("group-path", node.path)}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const hostId = e.dataTransfer.getData("host-id");
|
||||
const groupPath = e.dataTransfer.getData("group-path");
|
||||
if (hostId) moveHostToGroup(hostId, node.path);
|
||||
if (groupPath) moveGroup(groupPath, node.path);
|
||||
}}
|
||||
>
|
||||
<div className="mr-2 flex-shrink-0 w-4 h-4 flex items-center justify-center">
|
||||
{(hasChildren || node.hosts.length > 0) && (
|
||||
<div className={cn("transition-transform duration-200", isExpanded ? "rotate-90" : "")}>
|
||||
<ChevronRight size={14} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mr-3 text-primary/80 group-hover:text-primary transition-colors">
|
||||
{isExpanded ? <FolderOpen size={18} /> : <Folder size={18} />}
|
||||
</div>
|
||||
<span className="truncate flex-1 font-semibold">{node.name}</span>
|
||||
{isManaged && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-medium px-1.5 py-0.5 rounded bg-primary/15 text-primary shrink-0 mr-1.5">
|
||||
<FileSymlink size={10} />
|
||||
Managed
|
||||
</span>
|
||||
)}
|
||||
{(node.hosts.length > 0 || hasChildren) && (
|
||||
<span className="text-xs opacity-70 bg-background/50 px-2 py-0.5 rounded-full border border-border">
|
||||
{node.hosts.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => onNewHost(node.path)}>
|
||||
<Server className="mr-2 h-4 w-4" /> {t("vault.hosts.newHost")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onNewGroup(node.path)}>
|
||||
<Folder className="mr-2 h-4 w-4" /> {t("vault.hosts.newGroup")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onEditGroup(node.path)}>
|
||||
<FolderOpen className="mr-2 h-4 w-4" /> {t("vault.groups.rename")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => onDeleteGroup(node.path)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<FolderOpen className="mr-2 h-4 w-4" /> {t("vault.groups.delete")}
|
||||
</ContextMenuItem>
|
||||
{isManaged && onUnmanageGroup && (
|
||||
<ContextMenuItem onClick={() => onUnmanageGroup(node.path)}>
|
||||
<FileSymlink className="mr-2 h-4 w-4" /> {t("vault.managedSource.unmanage")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
<CollapsibleContent>
|
||||
{/* Child Groups */}
|
||||
{childNodes.map((child) => (
|
||||
<TreeNode
|
||||
key={child.path}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
sortMode={sortMode}
|
||||
expandedPaths={expandedPaths}
|
||||
onToggle={onToggle}
|
||||
onConnect={onConnect}
|
||||
onEditHost={onEditHost}
|
||||
onDuplicateHost={onDuplicateHost}
|
||||
onDeleteHost={onDeleteHost}
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
onNewHost={onNewHost}
|
||||
onNewGroup={onNewGroup}
|
||||
onEditGroup={onEditGroup}
|
||||
onDeleteGroup={onDeleteGroup}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
moveGroup={moveGroup}
|
||||
managedGroupPaths={managedGroupPaths}
|
||||
onUnmanageGroup={onUnmanageGroup}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Hosts in this group */}
|
||||
{sortedHosts.map((host) => (
|
||||
<HostTreeItem
|
||||
key={host.id}
|
||||
host={host}
|
||||
depth={depth + 1}
|
||||
onConnect={onConnect}
|
||||
onEditHost={onEditHost}
|
||||
onDuplicateHost={onDuplicateHost}
|
||||
onDeleteHost={onDeleteHost}
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
/>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface HostTreeItemProps {
|
||||
host: Host;
|
||||
depth: number;
|
||||
onConnect: (host: Host) => void;
|
||||
onEditHost: (host: Host) => void;
|
||||
onDuplicateHost: (host: Host) => void;
|
||||
onDeleteHost: (host: Host) => void;
|
||||
onCopyCredentials: (host: Host) => void;
|
||||
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
|
||||
}
|
||||
|
||||
const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
host,
|
||||
depth,
|
||||
onConnect,
|
||||
onEditHost,
|
||||
onDuplicateHost,
|
||||
onDeleteHost,
|
||||
onCopyCredentials,
|
||||
moveHostToGroup: _moveHostToGroup,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const paddingLeft = `${depth * 20 + 12}px`;
|
||||
const safeHost = sanitizeHost(host);
|
||||
const tags = host.tags || [];
|
||||
const isTelnet = host.protocol === 'telnet';
|
||||
const displayUsername = isTelnet
|
||||
? (host.telnetUsername?.trim() || host.username?.trim() || '')
|
||||
: (host.username?.trim() || '');
|
||||
const displayPort = isTelnet
|
||||
? (host.telnetPort ?? host.port ?? 23)
|
||||
: (host.port ?? 22);
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>
|
||||
<div
|
||||
className="flex items-center py-2 pr-3 text-sm cursor-pointer transition-colors select-none group hover:bg-secondary/40 rounded-lg"
|
||||
style={{ paddingLeft }}
|
||||
draggable
|
||||
onDragStart={(e) => e.dataTransfer.setData("host-id", host.id)}
|
||||
onClick={() => onConnect(safeHost)}
|
||||
>
|
||||
<div className="mr-2 flex-shrink-0 w-4 h-4" />
|
||||
<div className="mr-3 flex-shrink-0">
|
||||
<DistroAvatar host={host} fallback={(host.os || "L")[0].toUpperCase()} size="sm" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{host.label}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{displayUsername}@{host.hostname}:{displayPort}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{host.protocol && host.protocol !== 'ssh' && (
|
||||
<span className="text-xs px-1.5 py-0.5 bg-primary/10 text-primary rounded">
|
||||
{host.protocol.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
{tags.length > 0 && (
|
||||
<span className="text-xs opacity-60">
|
||||
{tags.slice(0, 2).join(', ')}
|
||||
{tags.length > 2 && '...'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => onConnect(safeHost)}>
|
||||
<Monitor className="mr-2 h-4 w-4" /> {t("vault.hosts.connect")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onEditHost(host)}>
|
||||
<Server className="mr-2 h-4 w-4" /> {t("action.edit")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onDuplicateHost(host)}>
|
||||
<Server className="mr-2 h-4 w-4" /> {t("action.duplicate")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onCopyCredentials(host)}>
|
||||
<Server className="mr-2 h-4 w-4" /> {t("vault.hosts.copyCredentials")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => onDeleteHost(host)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Server className="mr-2 h-4 w-4" /> {t("action.delete")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export const HostTreeView: React.FC<HostTreeViewProps> = ({
|
||||
groupTree,
|
||||
hosts,
|
||||
sortMode = 'az',
|
||||
expandedPaths: externalExpandedPaths,
|
||||
onTogglePath: externalOnTogglePath,
|
||||
onExpandAll: externalOnExpandAll,
|
||||
onCollapseAll: externalOnCollapseAll,
|
||||
onConnect,
|
||||
onEditHost,
|
||||
onDuplicateHost,
|
||||
onDeleteHost,
|
||||
onCopyCredentials,
|
||||
onNewHost,
|
||||
onNewGroup,
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
moveHostToGroup,
|
||||
moveGroup,
|
||||
managedGroupPaths,
|
||||
onUnmanageGroup,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
// Use external state if provided, otherwise use local persistent state
|
||||
const localTreeState = useTreeExpandedState(STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED);
|
||||
|
||||
const expandedPaths = externalExpandedPaths || localTreeState.expandedPaths;
|
||||
const togglePath = externalOnTogglePath || localTreeState.togglePath;
|
||||
const expandAll = externalOnExpandAll || localTreeState.expandAll;
|
||||
const collapseAll = externalOnCollapseAll || localTreeState.collapseAll;
|
||||
|
||||
// Get all possible group paths for expand/collapse all functionality
|
||||
const getAllGroupPaths = (nodes: GroupNode[]): string[] => {
|
||||
const paths: string[] = [];
|
||||
const traverse = (nodeList: GroupNode[]) => {
|
||||
nodeList.forEach(node => {
|
||||
paths.push(node.path);
|
||||
if (node.children) {
|
||||
traverse(Object.values(node.children) as GroupNode[]);
|
||||
}
|
||||
});
|
||||
};
|
||||
traverse(nodes);
|
||||
return paths;
|
||||
};
|
||||
|
||||
const allGroupPaths = useMemo(() => getAllGroupPaths(groupTree), [groupTree]);
|
||||
|
||||
const handleExpandAll = () => {
|
||||
expandAll(allGroupPaths);
|
||||
};
|
||||
|
||||
const handleCollapseAll = () => {
|
||||
collapseAll();
|
||||
};
|
||||
|
||||
// Get ungrouped hosts (hosts without a group or with empty group) and sort them
|
||||
const ungroupedHosts = useMemo(() => {
|
||||
const hosts_without_group = hosts.filter(host => !host.group || host.group === '');
|
||||
return hosts_without_group.sort((a, b) => {
|
||||
switch (sortMode) {
|
||||
case 'az':
|
||||
return a.label.localeCompare(b.label);
|
||||
case 'za':
|
||||
return b.label.localeCompare(a.label);
|
||||
case 'newest':
|
||||
return (b.createdAt || 0) - (a.createdAt || 0);
|
||||
case 'oldest':
|
||||
return (a.createdAt || 0) - (b.createdAt || 0);
|
||||
default:
|
||||
return a.label.localeCompare(b.label);
|
||||
}
|
||||
});
|
||||
}, [hosts, sortMode]);
|
||||
|
||||
// Sort group tree based on sort mode
|
||||
const sortedGroupTree = useMemo(() => {
|
||||
return [...groupTree].sort((a, b) => {
|
||||
switch (sortMode) {
|
||||
case 'za':
|
||||
return b.name.localeCompare(a.name);
|
||||
case 'newest':
|
||||
case 'oldest':
|
||||
// For groups, fall back to name sorting since groups don't have creation dates
|
||||
return a.name.localeCompare(b.name);
|
||||
case 'az':
|
||||
default:
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
});
|
||||
}, [groupTree, sortMode]);
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{/* Expand/Collapse controls */}
|
||||
{groupTree.length > 0 && (
|
||||
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-border/30">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleExpandAll}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<Expand size={12} className="mr-1" />
|
||||
{t("vault.tree.expandAll")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCollapseAll}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<Minimize2 size={12} className="mr-1" />
|
||||
{t("vault.tree.collapseAll")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Group tree */}
|
||||
{sortedGroupTree.map((node) => (
|
||||
<TreeNode
|
||||
key={node.path}
|
||||
node={node}
|
||||
depth={0}
|
||||
sortMode={sortMode}
|
||||
expandedPaths={expandedPaths}
|
||||
onToggle={togglePath}
|
||||
onConnect={onConnect}
|
||||
onEditHost={onEditHost}
|
||||
onDuplicateHost={onDuplicateHost}
|
||||
onDeleteHost={onDeleteHost}
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
onNewHost={onNewHost}
|
||||
onNewGroup={onNewGroup}
|
||||
onEditGroup={onEditGroup}
|
||||
onDeleteGroup={onDeleteGroup}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
moveGroup={moveGroup}
|
||||
managedGroupPaths={managedGroupPaths}
|
||||
onUnmanageGroup={onUnmanageGroup}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Ungrouped hosts at root level */}
|
||||
{ungroupedHosts.map((host) => (
|
||||
<HostTreeItem
|
||||
key={host.id}
|
||||
host={host}
|
||||
depth={0}
|
||||
onConnect={onConnect}
|
||||
onEditHost={onEditHost}
|
||||
onDuplicateHost={onDuplicateHost}
|
||||
onDeleteHost={onDeleteHost}
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Empty state */}
|
||||
{ungroupedHosts.length === 0 && groupTree.length === 0 && (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Server size={48} className="mx-auto mb-4 opacity-50" />
|
||||
<p className="text-sm">{t("vault.hosts.empty")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -23,6 +23,7 @@ import { STORAGE_KEY_VAULT_KEYS_VIEW_MODE } from "../infrastructure/config/stora
|
||||
import { logger } from "../lib/logger";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Host, Identity, KeyType, SSHKey } from "../types";
|
||||
import { ManagedSource } from "../domain/models";
|
||||
import { useKeychainBackend } from "../application/state/useKeychainBackend";
|
||||
import SelectHostPanel from "./SelectHostPanel";
|
||||
import {
|
||||
@@ -68,6 +69,7 @@ interface KeychainManagerProps {
|
||||
identities?: Identity[];
|
||||
hosts?: Host[];
|
||||
customGroups?: string[];
|
||||
managedSources?: ManagedSource[];
|
||||
onSave: (key: SSHKey) => void;
|
||||
onUpdate: (key: SSHKey) => void;
|
||||
onDelete: (id: string) => void;
|
||||
@@ -83,6 +85,7 @@ const KeychainManager: React.FC<KeychainManagerProps> = ({
|
||||
identities = [],
|
||||
hosts = [],
|
||||
customGroups = [],
|
||||
managedSources = [],
|
||||
onSave,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
@@ -1282,6 +1285,7 @@ echo $3 >> "$FILE"`);
|
||||
onBack={() => setShowHostSelector(false)}
|
||||
onContinue={() => setShowHostSelector(false)}
|
||||
availableKeys={keys}
|
||||
managedSources={managedSources}
|
||||
onSaveHost={onSaveHost}
|
||||
onCreateGroup={onCreateGroup}
|
||||
/>
|
||||
|
||||
169
components/PassphraseModal.tsx
Normal file
169
components/PassphraseModal.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Passphrase Modal
|
||||
* Modal for requesting passphrase for encrypted SSH keys
|
||||
*/
|
||||
import { Eye, EyeOff, KeyRound, Loader2 } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "./ui/dialog";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
|
||||
export interface PassphraseRequest {
|
||||
requestId: string;
|
||||
keyPath: string;
|
||||
keyName: string;
|
||||
hostname?: string;
|
||||
}
|
||||
|
||||
interface PassphraseModalProps {
|
||||
request: PassphraseRequest | null;
|
||||
onSubmit: (requestId: string, passphrase: string) => void;
|
||||
onCancel: (requestId: string) => void;
|
||||
onSkip?: (requestId: string) => void;
|
||||
}
|
||||
|
||||
export const PassphraseModal: React.FC<PassphraseModalProps> = ({
|
||||
request,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
onSkip,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [passphrase, setPassphrase] = useState("");
|
||||
const [showPassphrase, setShowPassphrase] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Reset state when request changes
|
||||
useEffect(() => {
|
||||
if (request) {
|
||||
setPassphrase("");
|
||||
setShowPassphrase(false);
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [request]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!request || isSubmitting || !passphrase) return;
|
||||
setIsSubmitting(true);
|
||||
onSubmit(request.requestId, passphrase);
|
||||
}, [request, passphrase, onSubmit, isSubmitting]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (!request) return;
|
||||
onCancel(request.requestId);
|
||||
}, [request, onCancel]);
|
||||
|
||||
const handleSkip = useCallback(() => {
|
||||
if (!request || !onSkip) return;
|
||||
onSkip(request.requestId);
|
||||
}, [request, onSkip]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !isSubmitting && passphrase) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
},
|
||||
[handleSubmit, isSubmitting, passphrase]
|
||||
);
|
||||
|
||||
if (!request) return null;
|
||||
|
||||
const keyDisplayName = request.keyName || request.keyPath.split("/").pop() || "SSH Key";
|
||||
|
||||
return (
|
||||
<Dialog open={!!request} onOpenChange={(open) => !open && handleCancel()}>
|
||||
<DialogContent className="sm:max-w-[425px]" hideCloseButton>
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<KeyRound className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle>{t("passphrase.title")}</DialogTitle>
|
||||
<DialogDescription className="mt-1">
|
||||
{request.hostname
|
||||
? t("passphrase.descWithHost", { keyName: keyDisplayName, hostname: request.hostname })
|
||||
: t("passphrase.desc", { keyName: keyDisplayName })}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="passphrase-input">
|
||||
{t("passphrase.label")}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="passphrase-input"
|
||||
type={showPassphrase ? "text" : "password"}
|
||||
value={passphrase}
|
||||
onChange={(e) => setPassphrase(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder=""
|
||||
className="pr-10"
|
||||
autoFocus
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground disabled:opacity-50 p-1"
|
||||
onClick={() => setShowPassphrase(!showPassphrase)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{showPassphrase ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("passphrase.keyPath")}: <code className="text-xs">{request.keyPath}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleCancel}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
{onSkip && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleSkip}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("passphrase.skip")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button onClick={handleSubmit} disabled={isSubmitting || !passphrase}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t("passphrase.unlocking")}
|
||||
</>
|
||||
) : (
|
||||
t("passphrase.unlock")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default PassphraseModal;
|
||||
@@ -15,6 +15,7 @@ import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { usePortForwardingState } from "../application/state/usePortForwardingState";
|
||||
import {
|
||||
Host,
|
||||
ManagedSource,
|
||||
PortForwardingRule,
|
||||
PortForwardingType,
|
||||
SSHKey,
|
||||
@@ -64,6 +65,7 @@ interface PortForwardingProps {
|
||||
keys: SSHKey[];
|
||||
identities?: import('../domain/models').Identity[];
|
||||
customGroups: string[];
|
||||
managedSources?: ManagedSource[];
|
||||
onNewHost?: () => void;
|
||||
onSaveHost?: (host: Host) => void;
|
||||
onCreateGroup?: (groupPath: string) => void;
|
||||
@@ -74,6 +76,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
keys,
|
||||
identities = [],
|
||||
customGroups: _customGroups,
|
||||
managedSources = [],
|
||||
onNewHost: _onNewHost,
|
||||
onSaveHost,
|
||||
onCreateGroup: _onCreateGroup,
|
||||
@@ -844,6 +847,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
onContinue={() => setShowHostSelector(false)}
|
||||
availableKeys={keys}
|
||||
identities={identities}
|
||||
managedSources={managedSources}
|
||||
onSaveHost={onSaveHost}
|
||||
onCreateGroup={_onCreateGroup}
|
||||
/>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useSettingsState } from "../application/state/useSettingsState";
|
||||
import { useSftpModalTransfers } from "./sftp-modal/hooks/useSftpModalTransfers";
|
||||
import { Host, RemoteFile, SftpFilenameEncoding } from "../types";
|
||||
import { filterHiddenFiles } from "./sftp";
|
||||
import { DropEntry } from "../lib/sftpFileUtils";
|
||||
import FileOpenerDialog from "./FileOpenerDialog";
|
||||
import TextEditorModal from "./TextEditorModal";
|
||||
import { SftpModalFileList } from "./sftp-modal/SftpModalFileList";
|
||||
@@ -45,6 +46,8 @@ interface SFTPModalProps {
|
||||
onClose: () => void;
|
||||
/** Initial path to open in SFTP. If not accessible, falls back to home directory. */
|
||||
initialPath?: string;
|
||||
/** Initial entries to upload when SFTP modal opens. Used for drag-and-drop to terminal. */
|
||||
initialEntriesToUpload?: DropEntry[];
|
||||
}
|
||||
|
||||
const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
@@ -53,6 +56,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
initialPath,
|
||||
initialEntriesToUpload,
|
||||
}) => {
|
||||
const {
|
||||
openSftp,
|
||||
@@ -76,15 +80,19 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
selectApplication,
|
||||
downloadSftpToTempAndOpen,
|
||||
cancelSftpUpload,
|
||||
startStreamTransfer,
|
||||
cancelTransfer,
|
||||
showSaveDialog,
|
||||
} = useSftpBackend();
|
||||
const { t, resolvedLocale } = useI18n();
|
||||
const { sftpAutoSync, sftpShowHiddenFiles } = useSettingsState();
|
||||
const { t } = useI18n();
|
||||
const { sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload } = useSettingsState();
|
||||
const isLocalSession = host.protocol === "local";
|
||||
const [filenameEncoding, setFilenameEncoding] = useState<SftpFilenameEncoding>(
|
||||
host.sftpEncoding ?? "auto"
|
||||
);
|
||||
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const folderInputRef = useRef<HTMLInputElement>(null);
|
||||
const navigatingRef = useRef(false);
|
||||
const clearSelection = useCallback(() => setSelectedFiles(new Set()), []);
|
||||
|
||||
@@ -345,10 +353,13 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
uploadTasks,
|
||||
dragActive,
|
||||
handleDownload,
|
||||
handleUploadEntries,
|
||||
handleFileSelect,
|
||||
handleFolderSelect,
|
||||
handleDrag,
|
||||
handleDrop,
|
||||
cancelUpload,
|
||||
cancelTask,
|
||||
dismissTask,
|
||||
} = useSftpModalTransfers({
|
||||
currentPath,
|
||||
@@ -365,8 +376,12 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
mkdirLocal,
|
||||
mkdirSftp: mkdirSftpWithEncoding,
|
||||
cancelSftpUpload,
|
||||
startStreamTransfer,
|
||||
cancelTransfer,
|
||||
showSaveDialog,
|
||||
setLoading,
|
||||
t,
|
||||
useCompressedUpload: sftpUseCompressedUpload,
|
||||
});
|
||||
|
||||
const handleClose = async () => {
|
||||
@@ -374,6 +389,43 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Handle initial entries to upload (from drag-and-drop to terminal)
|
||||
const initialUploadTriggeredRef = useRef(false);
|
||||
const prevLoadingRef = useRef(loading);
|
||||
const prevEntriesRef = useRef<DropEntry[] | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
// Detect when loading transitions from true to false (initial load complete)
|
||||
const wasLoading = prevLoadingRef.current;
|
||||
prevLoadingRef.current = loading;
|
||||
const justFinishedLoading = wasLoading && !loading;
|
||||
|
||||
// Reset the flag when initialEntriesToUpload is cleared
|
||||
if (!initialEntriesToUpload || initialEntriesToUpload.length === 0) {
|
||||
initialUploadTriggeredRef.current = false;
|
||||
prevEntriesRef.current = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset the flag when new entries arrive (different reference = new drop)
|
||||
if (initialEntriesToUpload !== prevEntriesRef.current) {
|
||||
initialUploadTriggeredRef.current = false;
|
||||
prevEntriesRef.current = initialEntriesToUpload;
|
||||
}
|
||||
|
||||
// Prevent duplicate uploads
|
||||
if (initialUploadTriggeredRef.current) return;
|
||||
|
||||
// Wait for SFTP connection to be established
|
||||
// Trigger when: modal is open AND loading just finished (works for empty directories too)
|
||||
if (!open || loading) return;
|
||||
if (!justFinishedLoading) return;
|
||||
|
||||
initialUploadTriggeredRef.current = true;
|
||||
|
||||
// Trigger upload with full DropEntry data (preserves directory structure)
|
||||
handleUploadEntries(initialEntriesToUpload);
|
||||
}, [initialEntriesToUpload, open, loading, handleUploadEntries]);
|
||||
|
||||
// Display files with parent entry (like SftpView)
|
||||
const displayFiles = useMemo(() => {
|
||||
// Filter hidden files using utility function
|
||||
@@ -522,12 +574,15 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
onBreadcrumbSelect={(index) => setCurrentPath(breadcrumbPathAtForIndex(index))}
|
||||
onRootSelect={() => setCurrentPath(rootPath)}
|
||||
inputRef={inputRef}
|
||||
folderInputRef={folderInputRef}
|
||||
pathInputRef={pathInputRef}
|
||||
uploading={uploading}
|
||||
onTriggerUpload={() => inputRef.current?.click()}
|
||||
onTriggerFolderUpload={() => folderInputRef.current?.click()}
|
||||
onCreateFolder={handleCreateFolder}
|
||||
onCreateFile={handleCreateFile}
|
||||
onFileSelect={handleFileSelect}
|
||||
onFolderSelect={handleFolderSelect}
|
||||
/>
|
||||
|
||||
<SftpModalFileList
|
||||
@@ -540,7 +595,6 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
loading={loading}
|
||||
loadingTextContent={loadingTextContent}
|
||||
reconnecting={reconnecting}
|
||||
resolvedLocale={resolvedLocale}
|
||||
columnWidths={columnWidths}
|
||||
sortField={sortField}
|
||||
sortOrder={sortOrder}
|
||||
@@ -549,6 +603,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
visibleRows={visibleRows}
|
||||
fileListRef={fileListRef}
|
||||
inputRef={inputRef}
|
||||
folderInputRef={folderInputRef}
|
||||
handleSort={handleSort}
|
||||
handleResizeStart={handleResizeStart}
|
||||
handleFileListScroll={handleFileListScroll}
|
||||
@@ -573,7 +628,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
formatDate={formatDate}
|
||||
/>
|
||||
|
||||
<SftpModalUploadTasks tasks={uploadTasks} t={t} onCancel={cancelUpload} onDismiss={dismissTask} />
|
||||
<SftpModalUploadTasks tasks={uploadTasks} t={t} onCancel={cancelUpload} onCancelTask={cancelTask} onDismiss={dismissTask} />
|
||||
|
||||
<SftpModalFooter
|
||||
t={t}
|
||||
|
||||
@@ -11,6 +11,7 @@ import React, { useMemo, useState } from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { Host, SSHKey } from "../types";
|
||||
import { ManagedSource } from "../domain/models";
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
import HostDetailsPanel from "./HostDetailsPanel";
|
||||
import { Button } from "./ui/button";
|
||||
@@ -31,6 +32,7 @@ interface SelectHostPanelProps {
|
||||
// Props for inline host creation
|
||||
availableKeys?: SSHKey[];
|
||||
identities?: import('../domain/models').Identity[];
|
||||
managedSources?: ManagedSource[];
|
||||
onSaveHost?: (host: Host) => void;
|
||||
onCreateGroup?: (groupPath: string) => void;
|
||||
title?: string;
|
||||
@@ -49,6 +51,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
onNewHost,
|
||||
availableKeys = [],
|
||||
identities = [],
|
||||
managedSources = [],
|
||||
onSaveHost,
|
||||
onCreateGroup,
|
||||
title,
|
||||
@@ -407,6 +410,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
availableKeys={availableKeys}
|
||||
identities={identities}
|
||||
groups={customGroups}
|
||||
managedSources={managedSources}
|
||||
allHosts={hosts}
|
||||
onSave={(host) => {
|
||||
onSaveHost(host);
|
||||
|
||||
@@ -18,6 +18,7 @@ import React, { memo, useLayoutEffect, useMemo, useRef } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useIsSftpActive } from "../application/state/activeTabStore";
|
||||
import { useSftpState } from "../application/state/useSftpState";
|
||||
import { useSftpBackend } from "../application/state/useSftpBackend";
|
||||
import { useSettingsState } from "../application/state/useSettingsState";
|
||||
import { logger } from "../lib/logger";
|
||||
import { useRenderTracker } from "../lib/useRenderTracker";
|
||||
@@ -67,6 +68,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
|
||||
|
||||
const sftp = useSftpState(hosts, keys, identities, fileWatchHandlers);
|
||||
|
||||
// Get stream transfer functions for optimized downloads
|
||||
const { showSaveDialog, startStreamTransfer } = useSftpBackend();
|
||||
|
||||
// Store sftp in a ref so callbacks can access the latest instance
|
||||
// without needing to re-create when sftp changes
|
||||
const sftpRef = useRef(sftp);
|
||||
@@ -130,6 +134,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
|
||||
getOpenerForFileRef,
|
||||
setOpenerForExtension,
|
||||
t,
|
||||
showSaveDialog,
|
||||
startStreamTransfer,
|
||||
getSftpIdForConnection: sftp.getSftpIdForConnection,
|
||||
});
|
||||
|
||||
const visibleTransfers = useMemo(
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useStoredViewMode } from '../application/state/useStoredViewMode';
|
||||
import { STORAGE_KEY_VAULT_SNIPPETS_VIEW_MODE } from '../infrastructure/config/storageKeys';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Host, ShellHistoryEntry, Snippet, SSHKey } from '../types';
|
||||
import { ManagedSource } from '../domain/models';
|
||||
import { DistroAvatar } from './DistroAvatar';
|
||||
import SelectHostPanel from './SelectHostPanel';
|
||||
import { AsidePanel, AsidePanelContent } from './ui/aside-panel';
|
||||
@@ -30,6 +31,7 @@ interface SnippetsManagerProps {
|
||||
onRunSnippet?: (snippet: Snippet, targetHosts: Host[]) => void;
|
||||
// Props for inline host creation
|
||||
availableKeys?: SSHKey[];
|
||||
managedSources?: ManagedSource[];
|
||||
onSaveHost?: (host: Host) => void;
|
||||
onCreateGroup?: (groupPath: string) => void;
|
||||
}
|
||||
@@ -49,6 +51,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
onPackagesChange,
|
||||
onRunSnippet,
|
||||
availableKeys = [],
|
||||
managedSources = [],
|
||||
onSaveHost,
|
||||
onCreateGroup,
|
||||
}) => {
|
||||
@@ -67,6 +70,12 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
const [newPackageName, setNewPackageName] = useState('');
|
||||
const [isPackageDialogOpen, setIsPackageDialogOpen] = useState(false);
|
||||
|
||||
// Rename package state
|
||||
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
|
||||
const [renamingPackagePath, setRenamingPackagePath] = useState<string | null>(null);
|
||||
const [renamePackageName, setRenamePackageName] = useState('');
|
||||
const [renameError, setRenameError] = useState('');
|
||||
|
||||
// Search, sort, and view mode state
|
||||
const [search, setSearch] = useState('');
|
||||
const [viewMode, setViewMode] = useStoredViewMode(
|
||||
@@ -144,23 +153,60 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
|
||||
const displayedPackages = useMemo(() => {
|
||||
if (!selectedPackage) {
|
||||
const roots = packages
|
||||
// Separate absolute paths (starting with /) from relative paths
|
||||
const absolutePaths = packages.filter(p => p.startsWith('/'));
|
||||
const relativePaths = packages.filter(p => !p.startsWith('/'));
|
||||
|
||||
const results: { name: string; path: string; count: number }[] = [];
|
||||
|
||||
// Process relative paths (traditional behavior)
|
||||
const relativeRoots = relativePaths
|
||||
.map((p) => p.split('/')[0])
|
||||
.filter(Boolean);
|
||||
return Array.from(new Set(roots)).map((name) => {
|
||||
const path = name;
|
||||
const count = snippets.filter((s) => (s.package || '') === path).length;
|
||||
return { name, path, count };
|
||||
.filter((name): name is string => Boolean(name) && name.length > 0);
|
||||
|
||||
Array.from(new Set(relativeRoots)).forEach((name: string) => {
|
||||
const path: string = name;
|
||||
const count = snippets.filter((s) => {
|
||||
const pkg = s.package || '';
|
||||
return pkg === path || pkg.startsWith(path + '/');
|
||||
}).length;
|
||||
results.push({ name, path, count });
|
||||
});
|
||||
|
||||
// Process absolute paths - show them as separate roots with "/" prefix
|
||||
const absoluteRoots = absolutePaths
|
||||
.map((p) => {
|
||||
const cleanPath = p.substring(1); // Remove leading slash
|
||||
const firstSegment = cleanPath.split('/')[0];
|
||||
return firstSegment;
|
||||
})
|
||||
.filter((name): name is string => Boolean(name) && name.length > 0);
|
||||
|
||||
Array.from(new Set(absoluteRoots)).forEach((name: string) => {
|
||||
const path: string = `/${name}`;
|
||||
const displayName: string = `/${name}`; // Show with leading slash to distinguish
|
||||
const count = snippets.filter((s) => {
|
||||
const pkg = s.package || '';
|
||||
return pkg === path || pkg.startsWith(path + '/');
|
||||
}).length;
|
||||
results.push({ name: displayName, path, count });
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
const prefix = selectedPackage + '/';
|
||||
const children = packages
|
||||
.filter((p) => p.startsWith(prefix))
|
||||
.map((p) => p.replace(prefix, '').split('/')[0])
|
||||
.filter(Boolean);
|
||||
.filter((name): name is string => Boolean(name) && name.length > 0);
|
||||
return Array.from(new Set(children)).map((name) => {
|
||||
const path = `${selectedPackage}/${name}`;
|
||||
const count = snippets.filter((s) => (s.package || '') === path).length;
|
||||
// Count snippets in this package AND all nested packages
|
||||
const count = snippets.filter((s) => {
|
||||
const pkg = s.package || '';
|
||||
return pkg === path || pkg.startsWith(path + '/');
|
||||
}).length;
|
||||
return { name, path, count };
|
||||
});
|
||||
}, [packages, selectedPackage, snippets]);
|
||||
@@ -191,28 +237,76 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
|
||||
const breadcrumb = useMemo(() => {
|
||||
if (!selectedPackage) return [];
|
||||
const isAbsolute = selectedPackage.startsWith('/');
|
||||
const parts = selectedPackage.split('/').filter(Boolean);
|
||||
return parts.map((name, idx) => ({ name, path: parts.slice(0, idx + 1).join('/') }));
|
||||
return parts.map((name, idx) => {
|
||||
const pathSegments = parts.slice(0, idx + 1);
|
||||
const path = isAbsolute ? `/${pathSegments.join('/')}` : pathSegments.join('/');
|
||||
return { name, path };
|
||||
});
|
||||
}, [selectedPackage]);
|
||||
|
||||
const createPackage = () => {
|
||||
const name = newPackageName.trim();
|
||||
if (!name) return;
|
||||
const full = selectedPackage ? `${selectedPackage}/${name}` : name;
|
||||
if (!packages.includes(full)) onPackagesChange([...packages, full]);
|
||||
|
||||
// Allow leading slash and validate the rest - allow hyphens anywhere in package names
|
||||
if (!/^\/?([\w-]+(\/[\w-]+)*)\/?$/.test(name)) {
|
||||
// Could add toast notification here for invalid characters
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalize path construction to avoid double slashes
|
||||
let full: string;
|
||||
if (selectedPackage) {
|
||||
// Strip leading slash from name when we're inside a package to avoid double slashes
|
||||
const normalizedName = name.startsWith('/') ? name.substring(1) : name;
|
||||
full = `${selectedPackage}/${normalizedName}`;
|
||||
} else {
|
||||
// At root level, preserve the leading slash if user intended it
|
||||
full = name;
|
||||
}
|
||||
|
||||
// Strip trailing slash to ensure consistent path handling
|
||||
if (full.endsWith('/')) {
|
||||
full = full.slice(0, -1);
|
||||
}
|
||||
|
||||
// Check for duplicate package names (case-insensitive)
|
||||
const existingPackage = packages.find(p => p.toLowerCase() === full.toLowerCase());
|
||||
if (existingPackage) {
|
||||
// Could add toast notification here for duplicate package
|
||||
return;
|
||||
}
|
||||
|
||||
onPackagesChange([...packages, full]);
|
||||
setNewPackageName('');
|
||||
setIsPackageDialogOpen(false);
|
||||
};
|
||||
|
||||
const deletePackage = (path: string) => {
|
||||
// Remove the package and all its children
|
||||
const keep = packages.filter((p) => !(p === path || p.startsWith(path + '/')));
|
||||
|
||||
// Move all snippets from deleted packages to root
|
||||
const updatedSnippets = snippets.map((s) => {
|
||||
if (!s.package) return s;
|
||||
if (s.package === path || s.package.startsWith(path + '/')) return { ...s, package: '' };
|
||||
if (s.package === path || s.package.startsWith(path + '/')) {
|
||||
return { ...s, package: '' };
|
||||
}
|
||||
return s;
|
||||
});
|
||||
|
||||
// Update packages first, then save snippets
|
||||
onPackagesChange(keep);
|
||||
updatedSnippets.forEach(onSave);
|
||||
|
||||
// Only save snippets that were actually modified
|
||||
const modifiedSnippets = updatedSnippets.filter((s, index) =>
|
||||
s.package !== snippets[index].package
|
||||
);
|
||||
modifiedSnippets.forEach(onSave);
|
||||
|
||||
// Reset selected package if it was deleted
|
||||
if (selectedPackage && (selectedPackage === path || selectedPackage.startsWith(path + '/'))) {
|
||||
setSelectedPackage(null);
|
||||
}
|
||||
@@ -220,24 +314,125 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
|
||||
const movePackage = (source: string, target: string | null) => {
|
||||
const name = source.split('/').pop() || '';
|
||||
const newPath = target ? `${target}/${name}` : name;
|
||||
const isAbsolute = source.startsWith('/');
|
||||
const newPath = target ? `${target}/${name}` : (isAbsolute ? `/${name}` : name);
|
||||
if (newPath === source || newPath.startsWith(source + '/')) return;
|
||||
|
||||
// Check if target path already exists
|
||||
if (packages.includes(newPath)) return;
|
||||
|
||||
const updatedPackages = packages.map((p) => {
|
||||
if (p === source) return newPath;
|
||||
if (p.startsWith(source + '/')) return p.replace(source, newPath);
|
||||
// Use more precise replacement to avoid substring issues
|
||||
if (p.startsWith(source + '/')) {
|
||||
return newPath + p.substring(source.length);
|
||||
}
|
||||
return p;
|
||||
});
|
||||
|
||||
const updatedSnippets = snippets.map((s) => {
|
||||
if (!s.package) return s;
|
||||
if (s.package === source) return { ...s, package: newPath };
|
||||
if (s.package.startsWith(source + '/')) return { ...s, package: s.package.replace(source, newPath) };
|
||||
// Use more precise replacement to avoid substring issues
|
||||
if (s.package.startsWith(source + '/')) {
|
||||
return { ...s, package: newPath + s.package.substring(source.length) };
|
||||
}
|
||||
return s;
|
||||
});
|
||||
|
||||
onPackagesChange(Array.from(new Set(updatedPackages)));
|
||||
updatedSnippets.forEach(onSave);
|
||||
if (selectedPackage === source) setSelectedPackage(newPath);
|
||||
};
|
||||
|
||||
const openRenameDialog = (path: string) => {
|
||||
const name = path.split('/').pop() || '';
|
||||
setRenamingPackagePath(path);
|
||||
setRenamePackageName(name);
|
||||
setRenameError('');
|
||||
setIsRenameDialogOpen(true);
|
||||
};
|
||||
|
||||
const renamePackage = () => {
|
||||
if (!renamingPackagePath) return;
|
||||
|
||||
const newName = renamePackageName.trim();
|
||||
|
||||
// Validate: empty name
|
||||
if (!newName) {
|
||||
setRenameError(t('snippets.renameDialog.error.empty'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate: same rules as createPackage - only allow letters, numbers, hyphens, underscores
|
||||
// Since we're renaming a single segment (no slashes allowed), use the segment-level pattern
|
||||
if (!/^[\w-]+$/.test(newName)) {
|
||||
setRenameError(t('snippets.renameDialog.error.invalidChars'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Build new path
|
||||
const parts = renamingPackagePath.split('/');
|
||||
parts[parts.length - 1] = newName;
|
||||
const newPath = parts.join('/');
|
||||
|
||||
// Validate: same name
|
||||
if (newPath === renamingPackagePath) {
|
||||
setIsRenameDialogOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate: duplicate (case-insensitive)
|
||||
const existingPackage = packages.find(p => p.toLowerCase() === newPath.toLowerCase());
|
||||
if (existingPackage) {
|
||||
setRenameError(t('snippets.renameDialog.error.duplicate'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Update all packages with this path or nested under it
|
||||
const updatedPackages = packages.map((p) => {
|
||||
if (p === renamingPackagePath) return newPath;
|
||||
if (p.startsWith(renamingPackagePath + '/')) {
|
||||
return newPath + p.substring(renamingPackagePath.length);
|
||||
}
|
||||
return p;
|
||||
});
|
||||
|
||||
// Update all snippets with this package or nested under it
|
||||
const updatedSnippets = snippets.map((s) => {
|
||||
if (!s.package) return s;
|
||||
if (s.package === renamingPackagePath) return { ...s, package: newPath };
|
||||
if (s.package.startsWith(renamingPackagePath + '/')) {
|
||||
return { ...s, package: newPath + s.package.substring(renamingPackagePath.length) };
|
||||
}
|
||||
return s;
|
||||
});
|
||||
|
||||
onPackagesChange(Array.from(new Set(updatedPackages)));
|
||||
updatedSnippets.forEach(onSave);
|
||||
|
||||
// Update selected package if it was renamed
|
||||
if (selectedPackage === renamingPackagePath) {
|
||||
setSelectedPackage(newPath);
|
||||
} else if (selectedPackage?.startsWith(renamingPackagePath + '/')) {
|
||||
setSelectedPackage(newPath + selectedPackage.substring(renamingPackagePath.length));
|
||||
}
|
||||
|
||||
// Update editingSnippet.package if it's in the renamed package (fixes stale state when editing)
|
||||
if (editingSnippet.package) {
|
||||
if (editingSnippet.package === renamingPackagePath) {
|
||||
setEditingSnippet(prev => ({ ...prev, package: newPath }));
|
||||
} else if (editingSnippet.package.startsWith(renamingPackagePath + '/')) {
|
||||
setEditingSnippet(prev => ({
|
||||
...prev,
|
||||
package: newPath + prev.package!.substring(renamingPackagePath.length)
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
setIsRenameDialogOpen(false);
|
||||
};
|
||||
|
||||
const moveSnippet = (id: string, pkg: string | null) => {
|
||||
const sn = snippets.find((s) => s.id === id);
|
||||
if (!sn) return;
|
||||
@@ -246,11 +441,36 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
|
||||
// Package options for Combobox
|
||||
const packageOptions: ComboboxOption[] = useMemo(() => {
|
||||
return packages.map(p => ({
|
||||
value: p,
|
||||
label: p.includes('/') ? p.split('/').pop()! : p,
|
||||
sublabel: p.includes('/') ? p : undefined,
|
||||
}));
|
||||
// Generate all possible parent paths for each package
|
||||
const allPaths = new Set<string>();
|
||||
|
||||
packages.forEach(pkg => {
|
||||
// Add the full package path
|
||||
allPaths.add(pkg);
|
||||
|
||||
// Add all parent paths
|
||||
const parts = pkg.split('/').filter(Boolean);
|
||||
const isAbsolute = pkg.startsWith('/');
|
||||
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
const parentPath = (isAbsolute ? '/' : '') + parts.slice(0, i).join('/');
|
||||
allPaths.add(parentPath);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(allPaths)
|
||||
.sort((a, b) => {
|
||||
// Sort by depth first (shorter paths first), then alphabetically
|
||||
const depthA = (a.match(/\//g) || []).length;
|
||||
const depthB = (b.match(/\//g) || []).length;
|
||||
if (depthA !== depthB) return depthA - depthB;
|
||||
return a.localeCompare(b);
|
||||
})
|
||||
.map(p => ({
|
||||
value: p,
|
||||
label: p.includes('/') ? p.split('/').pop()! : p,
|
||||
sublabel: p.includes('/') ? p : undefined,
|
||||
}));
|
||||
}, [packages]);
|
||||
|
||||
// Shell history lazy loading
|
||||
@@ -310,6 +530,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
onBack={handleTargetPickerBack}
|
||||
onContinue={handleTargetPickerBack}
|
||||
availableKeys={availableKeys}
|
||||
managedSources={managedSources}
|
||||
onSaveHost={onSaveHost}
|
||||
onCreateGroup={onCreateGroup}
|
||||
title={t('snippets.targets.add')}
|
||||
@@ -354,7 +575,13 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
<Combobox
|
||||
options={packageOptions}
|
||||
value={editingSnippet.package || selectedPackage || ''}
|
||||
onValueChange={(val) => setEditingSnippet({ ...editingSnippet, package: val })}
|
||||
onValueChange={(val) => {
|
||||
setEditingSnippet({ ...editingSnippet, package: val });
|
||||
// If selecting an implicit parent path, persist it to packages
|
||||
if (val && !packages.includes(val)) {
|
||||
onPackagesChange([...packages, val]);
|
||||
}
|
||||
}}
|
||||
placeholder={t('snippets.field.packagePlaceholder')}
|
||||
allowCreate={true}
|
||||
onCreateNew={(val) => {
|
||||
@@ -624,6 +851,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => setSelectedPackage(pkg.path)}>{t('action.open')}</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => openRenameDialog(pkg.path)}>{t('common.rename')}</ContextMenuItem>
|
||||
<ContextMenuItem className="text-destructive" onClick={() => deletePackage(pkg.path)}>{t('action.delete')}</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
@@ -729,6 +957,8 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
value={newPackageName}
|
||||
onChange={(e) => setNewPackageName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && createPackage()}
|
||||
pattern="^/?([\w-]+(/[\w-]+)*)?/?$"
|
||||
title="Package names can contain letters, numbers, hyphens, underscores, and forward slashes. Can optionally start with /"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">{t('snippets.packageDialog.hint')}</p>
|
||||
</div>
|
||||
@@ -742,6 +972,40 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rename Package Dialog */}
|
||||
{isRenameDialogOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<Card className="w-full max-w-sm p-4 space-y-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{t('snippets.renameDialog.title')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('snippets.renameDialog.currentPath', { path: renamingPackagePath })}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('field.name')}</Label>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder={t('snippets.renameDialog.placeholder')}
|
||||
value={renamePackageName}
|
||||
onChange={(e) => {
|
||||
setRenamePackageName(e.target.value);
|
||||
setRenameError('');
|
||||
}}
|
||||
onKeyDown={(e) => e.key === 'Enter' && renamePackage()}
|
||||
/>
|
||||
{renameError && (
|
||||
<p className="text-[11px] text-destructive">{renameError}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => setIsRenameDialogOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={renamePackage}>{t('common.rename')}</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right Panel */}
|
||||
{renderRightPanel()}
|
||||
</div>
|
||||
|
||||
@@ -42,6 +42,57 @@ import { useTerminalSearch } from "./terminal/hooks/useTerminalSearch";
|
||||
import { useTerminalContextActions } from "./terminal/hooks/useTerminalContextActions";
|
||||
import { useTerminalAuthState } from "./terminal/hooks/useTerminalAuthState";
|
||||
import { useServerStats } from "./terminal/hooks/useServerStats";
|
||||
import { extractDropEntries, getPathForFile, DropEntry } from "../lib/sftpFileUtils";
|
||||
|
||||
/**
|
||||
* Extract unique root paths from drop entries for local terminal path insertion.
|
||||
* For nested files, extracts the root folder path; for single files, uses the full path.
|
||||
* Paths with spaces are quoted.
|
||||
*/
|
||||
function extractRootPathsFromDropEntries(dropEntries: DropEntry[]): string[] {
|
||||
const paths: string[] = [];
|
||||
const seenPaths = new Set<string>();
|
||||
|
||||
for (const entry of dropEntries) {
|
||||
if (!entry.file) continue;
|
||||
|
||||
const fullPath = getPathForFile(entry.file);
|
||||
if (!fullPath) continue;
|
||||
|
||||
const pathParts = entry.relativePath.split('/');
|
||||
|
||||
if (pathParts.length > 1) {
|
||||
// Nested file in a folder - extract the root folder path
|
||||
const rootFolderName = pathParts[0];
|
||||
const separator = fullPath.includes('\\') ? '\\' : '/';
|
||||
|
||||
// Find the position of the root folder name in the full path
|
||||
const rootFolderIndex = fullPath.lastIndexOf(separator + rootFolderName + separator);
|
||||
const altRootFolderIndex = fullPath.lastIndexOf(separator + rootFolderName);
|
||||
const folderStartIndex = rootFolderIndex !== -1
|
||||
? rootFolderIndex + 1
|
||||
: (altRootFolderIndex !== -1 ? altRootFolderIndex + 1 : -1);
|
||||
|
||||
if (folderStartIndex !== -1) {
|
||||
const folderEndIndex = folderStartIndex + rootFolderName.length;
|
||||
const folderPath = fullPath.substring(0, folderEndIndex);
|
||||
|
||||
if (!seenPaths.has(folderPath)) {
|
||||
paths.push(folderPath.includes(' ') ? `"${folderPath}"` : folderPath);
|
||||
seenPaths.add(folderPath);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Single file (not in a folder)
|
||||
if (!seenPaths.has(fullPath)) {
|
||||
paths.push(fullPath.includes(' ') ? `"${fullPath}"` : fullPath);
|
||||
seenPaths.add(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
interface TerminalProps {
|
||||
host: Host;
|
||||
@@ -211,6 +262,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
currentHostLabel: string;
|
||||
} | null>(null);
|
||||
|
||||
// Drag and drop state
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||||
const dragCounterRef = useRef(0);
|
||||
const [pendingUploadEntries, setPendingUploadEntries] = useState<DropEntry[]>([]);
|
||||
|
||||
const terminalSearch = useTerminalSearch({ searchAddonRef, termRef });
|
||||
const {
|
||||
isSearchOpen,
|
||||
@@ -223,6 +279,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
handleCloseSearch,
|
||||
} = terminalSearch;
|
||||
|
||||
// Check if this is a local or serial connection (doesn't need connection dialog during connecting)
|
||||
const isLocalConnection = host.protocol === "local";
|
||||
const isSerialConnection = host.protocol === "serial";
|
||||
|
||||
// Server stats (CPU, Memory, Disk) for Linux servers
|
||||
const { stats: serverStats } = useServerStats({
|
||||
sessionId,
|
||||
@@ -450,8 +510,11 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
useEffect(() => {
|
||||
if (status !== "connecting" || auth.needsAuth) return;
|
||||
|
||||
// Local terminal and serial connections don't need timeout/progress UI
|
||||
if (isLocalConnection || isSerialConnection) return;
|
||||
|
||||
// Only show SSH-specific scripted logs for SSH connections
|
||||
const isSSH = host.protocol !== "serial" && host.protocol !== "local" && host.protocol !== "telnet" && host.hostname !== "localhost";
|
||||
const isSSH = host.protocol !== "telnet";
|
||||
|
||||
let stepTimer: ReturnType<typeof setInterval> | undefined;
|
||||
if (isSSH) {
|
||||
@@ -883,6 +946,95 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Drag and drop handlers
|
||||
const handleDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounterRef.current++;
|
||||
if (e.dataTransfer.types.includes('Files')) {
|
||||
setIsDraggingOver(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.dataTransfer.types.includes('Files')) {
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounterRef.current--;
|
||||
if (dragCounterRef.current === 0) {
|
||||
setIsDraggingOver(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounterRef.current = 0;
|
||||
setIsDraggingOver(false);
|
||||
|
||||
if (!e.dataTransfer.types.includes('Files')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only handle drops on connected terminals
|
||||
if (status !== 'connected') {
|
||||
toast.error(t("terminal.dragDrop.notConnected"), t("terminal.dragDrop.errorTitle"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const dropEntries = await extractDropEntries(e.dataTransfer);
|
||||
|
||||
if (dropEntries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLocalConnection) {
|
||||
// Local terminal: Insert absolute paths
|
||||
const paths = extractRootPathsFromDropEntries(dropEntries);
|
||||
|
||||
if (paths.length > 0 && termRef.current && sessionRef.current) {
|
||||
const pathsText = paths.join(' ');
|
||||
// Write the paths to the terminal
|
||||
terminalBackend.writeToSession(sessionRef.current, pathsText);
|
||||
termRef.current.focus();
|
||||
}
|
||||
} else {
|
||||
// Remote terminal: Trigger SFTP upload
|
||||
// Get current working directory for SFTP initial path
|
||||
let initialPath: string | undefined = undefined;
|
||||
if (sessionRef.current) {
|
||||
try {
|
||||
const result = await terminalBackend.getSessionPwd(sessionRef.current);
|
||||
if (result.success && result.cwd) {
|
||||
initialPath = result.cwd;
|
||||
}
|
||||
} catch {
|
||||
// Silently fail and open SFTP without initial path
|
||||
}
|
||||
}
|
||||
|
||||
setPendingUploadEntries(dropEntries);
|
||||
// Use flushSync to ensure sftpInitialPath is updated synchronously
|
||||
// before setShowSFTP(true) triggers the modal open
|
||||
flushSync(() => {
|
||||
setSftpInitialPath(initialPath);
|
||||
});
|
||||
setShowSFTP(true);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Failed to handle file drop", error);
|
||||
toast.error(t("terminal.dragDrop.errorMessage"), t("terminal.dragDrop.errorTitle"));
|
||||
}
|
||||
};
|
||||
|
||||
const renderControls = (opts?: { showClose?: boolean }) => (
|
||||
<TerminalToolbar
|
||||
status={status}
|
||||
@@ -919,6 +1071,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
<TerminalContextMenu
|
||||
hasSelection={hasSelection}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
rightClickBehavior={terminalSettings?.rightClickBehavior}
|
||||
onCopy={terminalContextActions.onCopy}
|
||||
onPaste={terminalContextActions.onPaste}
|
||||
@@ -929,7 +1082,34 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onSplitVertical={onSplitVertical}
|
||||
onClose={inWorkspace ? () => onCloseSession?.(sessionId) : undefined}
|
||||
>
|
||||
<div className="relative h-full w-full flex overflow-hidden bg-gradient-to-br from-[#050910] via-[#06101a] to-[#0b1220]">
|
||||
<div
|
||||
className="relative h-full w-full flex overflow-hidden bg-gradient-to-br from-[#050910] via-[#06101a] to-[#0b1220]"
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{/* Drag and drop overlay */}
|
||||
{isDraggingOver && (
|
||||
<div className="absolute inset-0 z-50 bg-blue-600/20 backdrop-blur-sm border-4 border-dashed border-blue-400 pointer-events-none flex items-center justify-center">
|
||||
<div className="bg-background/90 backdrop-blur-md rounded-lg shadow-lg p-6 border border-border">
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-semibold mb-2">
|
||||
{isLocalConnection
|
||||
? t("terminal.dragDrop.localTitle")
|
||||
: t("terminal.dragDrop.remoteTitle")
|
||||
}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{isLocalConnection
|
||||
? t("terminal.dragDrop.localMessage")
|
||||
: t("terminal.dragDrop.remoteMessage")
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute left-0 right-0 top-0 z-20 pointer-events-none">
|
||||
<div
|
||||
className="flex items-center gap-1 px-2 py-0.5 backdrop-blur-md pointer-events-auto min-w-0 border-b-[0.5px]"
|
||||
@@ -1294,7 +1474,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status !== "connected" && !needsHostKeyVerification && (
|
||||
{/* Connection dialog: skip for local/serial during connecting phase, but show on error */}
|
||||
{status !== "connected" && !needsHostKeyVerification && !(
|
||||
(isLocalConnection || isSerialConnection) && status === "connecting"
|
||||
) && (
|
||||
<TerminalConnectionDialog
|
||||
host={host}
|
||||
status={status}
|
||||
@@ -1399,8 +1582,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
};
|
||||
})()}
|
||||
open={showSFTP && status === "connected"}
|
||||
onClose={() => setShowSFTP(false)}
|
||||
onClose={() => {
|
||||
setShowSFTP(false);
|
||||
setPendingUploadEntries([]);
|
||||
}}
|
||||
initialPath={sftpInitialPath}
|
||||
initialEntriesToUpload={pendingUploadEntries}
|
||||
/>
|
||||
</div>
|
||||
</TerminalContextMenu>
|
||||
|
||||
@@ -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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -29,7 +29,7 @@ const getOpenerLabel = (
|
||||
export default function SettingsFileAssociationsTab() {
|
||||
const { t } = useI18n();
|
||||
const { getAllAssociations, removeAssociation, setOpenerForExtension } = useSftpFileAssociations();
|
||||
const { sftpDoubleClickBehavior, setSftpDoubleClickBehavior, sftpAutoSync, setSftpAutoSync, sftpShowHiddenFiles, setSftpShowHiddenFiles } = useSettingsState();
|
||||
const { sftpDoubleClickBehavior, setSftpDoubleClickBehavior, sftpAutoSync, setSftpAutoSync, sftpShowHiddenFiles, setSftpShowHiddenFiles, sftpUseCompressedUpload, setSftpUseCompressedUpload } = useSettingsState();
|
||||
const associations = getAllAssociations();
|
||||
const [editingExtension, setEditingExtension] = useState<string | null>(null);
|
||||
|
||||
@@ -213,6 +213,46 @@ export default function SettingsFileAssociationsTab() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Compressed folder upload section */}
|
||||
<div className="space-y-4">
|
||||
<SectionHeader title={t('settings.sftp.compressedUpload')} />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.sftp.compressedUpload.desc')}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setSftpUseCompressedUpload(!sftpUseCompressedUpload)}
|
||||
className={cn(
|
||||
"w-full text-left p-4 rounded-lg border-2 transition-colors",
|
||||
sftpUseCompressedUpload
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-primary/50 hover:bg-secondary/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={cn(
|
||||
"h-5 w-5 rounded border-2 flex items-center justify-center mt-0.5 shrink-0",
|
||||
sftpUseCompressedUpload
|
||||
? "border-primary bg-primary"
|
||||
: "border-muted-foreground/30"
|
||||
)}>
|
||||
{sftpUseCompressedUpload && (
|
||||
<svg className="h-3 w-3 text-primary-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="font-medium cursor-pointer">
|
||||
{t('settings.sftp.compressedUpload.enable')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.sftp.compressedUpload.enableDesc')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* File associations section */}
|
||||
<div className="space-y-4">
|
||||
<SectionHeader title={t('settings.sftpFileAssociations.title')} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { Download, Edit2, Folder, FolderOpen, Link, Loader2, MoreHorizontal, Plus, RefreshCw, Shield, Trash2, Upload } from "lucide-react";
|
||||
import { Download, Edit2, Folder, FolderOpen, FolderUp, Link, Loader2, MoreHorizontal, Plus, RefreshCw, Shield, Trash2, Upload } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import type { RemoteFile } from "../../types";
|
||||
import { isKnownBinaryFile } from "../../lib/sftpFileUtils";
|
||||
@@ -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";
|
||||
@@ -32,6 +31,7 @@ interface SftpModalFileListProps {
|
||||
visibleRows: VisibleRow[];
|
||||
fileListRef: React.RefObject<HTMLDivElement>;
|
||||
inputRef: React.RefObject<HTMLInputElement>;
|
||||
folderInputRef: React.RefObject<HTMLInputElement>;
|
||||
handleSort: (field: "name" | "size" | "modified") => void;
|
||||
handleResizeStart: (field: string, e: React.MouseEvent) => void;
|
||||
handleFileListScroll: (e: React.UIEvent<HTMLDivElement>) => void;
|
||||
@@ -53,7 +53,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 +66,6 @@ export const SftpModalFileList: React.FC<SftpModalFileListProps> = ({
|
||||
loading,
|
||||
loadingTextContent,
|
||||
reconnecting,
|
||||
resolvedLocale,
|
||||
columnWidths,
|
||||
sortField,
|
||||
sortOrder,
|
||||
@@ -75,6 +74,7 @@ export const SftpModalFileList: React.FC<SftpModalFileListProps> = ({
|
||||
visibleRows,
|
||||
fileListRef,
|
||||
inputRef,
|
||||
folderInputRef,
|
||||
handleSort,
|
||||
handleResizeStart,
|
||||
handleFileListScroll,
|
||||
@@ -279,7 +279,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 && (
|
||||
@@ -400,6 +400,9 @@ export const SftpModalFileList: React.FC<SftpModalFileListProps> = ({
|
||||
<ContextMenuItem onClick={() => inputRef.current?.click()}>
|
||||
<Upload className="h-4 w-4 mr-2" /> {t("sftp.uploadFiles")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => folderInputRef.current?.click()}>
|
||||
<FolderUp className="h-4 w-4 mr-2" /> {t("sftp.uploadFolder")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => loadFiles(currentPath, { force: true })}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" /> {t("sftp.context.refresh")}
|
||||
</ContextMenuItem>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import React from "react";
|
||||
import { ArrowUp, ChevronRight, Home, MoreHorizontal, Plus, RefreshCw, Upload } from "lucide-react";
|
||||
import { ArrowUp, Check, ChevronRight, FilePlus, FolderPlus, FolderUp, Home, Languages, MoreHorizontal, RefreshCw, Upload } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import type { Host, SftpFilenameEncoding } from "../../types";
|
||||
import { DistroAvatar } from "../DistroAvatar";
|
||||
import { Button } from "../ui/button";
|
||||
import { DialogHeader, DialogTitle } from "../ui/dialog";
|
||||
import { Input } from "../ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select";
|
||||
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
|
||||
|
||||
interface BreadcrumbPart {
|
||||
part: string;
|
||||
@@ -40,12 +41,15 @@ interface SftpModalHeaderProps {
|
||||
onBreadcrumbSelect: (index: number) => void;
|
||||
onRootSelect: () => void;
|
||||
inputRef: React.RefObject<HTMLInputElement>;
|
||||
folderInputRef: React.RefObject<HTMLInputElement>;
|
||||
pathInputRef: React.RefObject<HTMLInputElement>;
|
||||
uploading: boolean;
|
||||
onTriggerUpload: () => void;
|
||||
onTriggerFolderUpload: () => void;
|
||||
onCreateFolder: () => void;
|
||||
onCreateFile: () => void;
|
||||
onFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onFolderSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export const SftpModalHeader: React.FC<SftpModalHeaderProps> = ({
|
||||
@@ -75,12 +79,15 @@ export const SftpModalHeader: React.FC<SftpModalHeaderProps> = ({
|
||||
onBreadcrumbSelect,
|
||||
onRootSelect,
|
||||
inputRef,
|
||||
folderInputRef,
|
||||
pathInputRef,
|
||||
uploading,
|
||||
onTriggerUpload,
|
||||
onTriggerFolderUpload,
|
||||
onCreateFolder,
|
||||
onCreateFile,
|
||||
onFileSelect,
|
||||
onFolderSelect,
|
||||
}) => (
|
||||
<>
|
||||
<DialogHeader className="px-4 py-3 border-b border-border/60 flex-shrink-0">
|
||||
@@ -102,49 +109,90 @@ export const SftpModalHeader: React.FC<SftpModalHeaderProps> = ({
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="px-4 py-2 border-b border-border/60 flex items-center gap-2 flex-shrink-0 bg-muted/30">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onUp}
|
||||
disabled={isAtRoot}
|
||||
>
|
||||
<ArrowUp size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onHome}
|
||||
>
|
||||
<Home size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onRefresh}
|
||||
>
|
||||
<RefreshCw
|
||||
size={14}
|
||||
className={cn(isRefreshing && "animate-spin")}
|
||||
/>
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onUp}
|
||||
disabled={isAtRoot}
|
||||
>
|
||||
<ArrowUp size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.nav.up")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onHome}
|
||||
>
|
||||
<Home size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.nav.home")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onRefresh}
|
||||
>
|
||||
<RefreshCw
|
||||
size={14}
|
||||
className={cn(isRefreshing && "animate-spin")}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.nav.refresh")}</TooltipContent>
|
||||
</Tooltip>
|
||||
{showEncoding && (
|
||||
<Select
|
||||
value={filenameEncoding}
|
||||
onValueChange={(value) => onFilenameEncodingChange(value as SftpFilenameEncoding)}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-[130px] text-xs" title={t("sftp.encoding.label")}>
|
||||
<SelectValue placeholder={t("sftp.encoding.label")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{t("sftp.encoding.auto")}</SelectItem>
|
||||
<SelectItem value="utf-8">{t("sftp.encoding.utf8")}</SelectItem>
|
||||
<SelectItem value="gb18030">{t("sftp.encoding.gb18030")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Popover>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
>
|
||||
<Languages size={14} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.encoding.label")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent className="w-36 p-1" align="start">
|
||||
{(["auto", "utf-8", "gb18030"] as const).map((encoding) => (
|
||||
<PopoverClose asChild key={encoding}>
|
||||
<button
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-2 py-1.5 text-sm rounded-sm hover:bg-secondary transition-colors",
|
||||
filenameEncoding === encoding && "bg-secondary"
|
||||
)}
|
||||
onClick={() => onFilenameEncodingChange(encoding)}
|
||||
>
|
||||
<Check
|
||||
size={14}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
filenameEncoding === encoding ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{t(`sftp.encoding.${encoding === "utf-8" ? "utf8" : encoding}`)}
|
||||
</button>
|
||||
</PopoverClose>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-1 text-sm flex-1 min-w-0 overflow-hidden">
|
||||
@@ -214,32 +262,61 @@ export const SftpModalHeader: React.FC<SftpModalHeaderProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={onTriggerUpload}
|
||||
disabled={uploading}
|
||||
>
|
||||
<Upload size={14} className="mr-1.5" /> {t("sftp.upload")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={onCreateFolder}
|
||||
>
|
||||
<Plus size={14} className="mr-1.5" /> {t("sftp.newFolder")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={onCreateFile}
|
||||
>
|
||||
<Plus size={14} className="mr-1.5" /> {t("sftp.newFile")}
|
||||
</Button>
|
||||
<div className="flex items-center gap-1 ml-auto">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onTriggerUpload}
|
||||
disabled={uploading}
|
||||
>
|
||||
<Upload size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.upload")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onTriggerFolderUpload}
|
||||
disabled={uploading}
|
||||
>
|
||||
<FolderUp size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.uploadFolder")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onCreateFolder}
|
||||
>
|
||||
<FolderPlus size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.newFolder")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onCreateFile}
|
||||
>
|
||||
<FilePlus size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("sftp.newFile")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
@@ -247,7 +324,16 @@ export const SftpModalHeader: React.FC<SftpModalHeaderProps> = ({
|
||||
onChange={onFileSelect}
|
||||
multiple
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
ref={folderInputRef}
|
||||
onChange={onFolderSelect}
|
||||
webkitdirectory=""
|
||||
multiple
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,29 +1,66 @@
|
||||
import React from "react";
|
||||
import { Loader2, Upload, X, XCircle } from "lucide-react";
|
||||
import { Download, Loader2, Upload, X, XCircle } from "lucide-react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
interface UploadTask {
|
||||
interface TransferTask {
|
||||
id: string;
|
||||
fileName: string;
|
||||
totalBytes: number;
|
||||
transferredBytes: number;
|
||||
progress: number;
|
||||
speed: number;
|
||||
status: "pending" | "uploading" | "completed" | "failed" | "cancelled";
|
||||
status: "pending" | "uploading" | "downloading" | "completed" | "failed" | "cancelled";
|
||||
error?: string;
|
||||
direction: "upload" | "download";
|
||||
}
|
||||
|
||||
interface SftpModalUploadTasksProps {
|
||||
tasks: UploadTask[];
|
||||
tasks: TransferTask[];
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
onCancel?: () => void;
|
||||
onCancelTask?: (taskId: string) => void;
|
||||
onDismiss?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
export const SftpModalUploadTasks: React.FC<SftpModalUploadTasksProps> = ({ tasks, t, onCancel, onDismiss }) => {
|
||||
export const SftpModalUploadTasks: React.FC<SftpModalUploadTasksProps> = ({ tasks, t, onCancel, onCancelTask, onDismiss }) => {
|
||||
if (tasks.length === 0) return null;
|
||||
|
||||
// Helper function to get localized display name for compressed uploads
|
||||
const getDisplayName = (task: TransferTask) => {
|
||||
// Check for explicit phase marker format: "folderName|phase"
|
||||
// This is the format sent by uploadService.ts for compressed uploads
|
||||
if (task.fileName.includes('|')) {
|
||||
const pipeIndex = task.fileName.lastIndexOf('|');
|
||||
const baseName = task.fileName.substring(0, pipeIndex);
|
||||
const phase = task.fileName.substring(pipeIndex + 1);
|
||||
|
||||
if (phase === 'compressing' || phase === 'extracting' || phase === 'uploading' || phase === 'compressed') {
|
||||
const phaseLabel = t(`sftp.upload.phase.${phase}`);
|
||||
return `${baseName} (${phaseLabel})`;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for exact matches of phase status strings (legacy support)
|
||||
if (task.fileName === t('sftp.upload.compressing') || task.fileName === 'Compressing...' || task.fileName === 'Compressing') {
|
||||
return t('sftp.upload.compressing');
|
||||
}
|
||||
if (task.fileName === t('sftp.upload.extracting') || task.fileName === 'Extracting...' || task.fileName === 'Extracting') {
|
||||
return t('sftp.upload.extracting');
|
||||
}
|
||||
if (task.fileName === t('sftp.upload.scanning') || task.fileName === 'Scanning files...' || task.fileName === 'Scanning files') {
|
||||
return t('sftp.upload.scanning');
|
||||
}
|
||||
|
||||
// Check if this is a compressed upload task (legacy format)
|
||||
if (task.fileName.includes('(compressed)')) {
|
||||
const baseName = task.fileName.replace(' (compressed)', '');
|
||||
return `${baseName} (${t('sftp.upload.compressed')})`;
|
||||
}
|
||||
|
||||
return task.fileName;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-t border-border/60 bg-secondary/50 flex-shrink-0">
|
||||
<div className="max-h-40 overflow-y-auto overflow-x-hidden">
|
||||
@@ -61,14 +98,18 @@ export const SftpModalUploadTasks: React.FC<SftpModalUploadTasksProps> = ({ task
|
||||
className="px-4 py-2.5 flex items-center gap-3 border-b border-border/30 last:border-b-0"
|
||||
>
|
||||
<div className="shrink-0">
|
||||
{task.status === "uploading" && (
|
||||
{(task.status === "uploading" || task.status === "downloading") && (
|
||||
<Loader2 size={14} className="animate-spin text-primary" />
|
||||
)}
|
||||
{task.status === "pending" && (
|
||||
<Upload size={14} className="text-muted-foreground animate-pulse" />
|
||||
task.direction === "download"
|
||||
? <Download size={14} className="text-muted-foreground animate-pulse" />
|
||||
: <Upload size={14} className="text-muted-foreground animate-pulse" />
|
||||
)}
|
||||
{task.status === "completed" && (
|
||||
<Upload size={14} className="text-green-500" />
|
||||
task.direction === "download"
|
||||
? <Download size={14} className="text-green-500" />
|
||||
: <Upload size={14} className="text-green-500" />
|
||||
)}
|
||||
{task.status === "failed" && (
|
||||
<XCircle size={14} className="text-destructive" />
|
||||
@@ -80,20 +121,20 @@ export const SftpModalUploadTasks: React.FC<SftpModalUploadTasksProps> = ({ task
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium truncate">
|
||||
{task.fileName}
|
||||
{getDisplayName(task)}
|
||||
</span>
|
||||
{task.status === "uploading" && task.speed > 0 && (
|
||||
{(task.status === "uploading" || task.status === "downloading") && task.speed > 0 && (
|
||||
<span className="text-[10px] text-primary font-mono shrink-0">
|
||||
{formatSpeed(task.speed)}
|
||||
</span>
|
||||
)}
|
||||
{task.status === "uploading" && remainingStr && (
|
||||
{(task.status === "uploading" || task.status === "downloading") && remainingStr && (
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">
|
||||
{remainingStr}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(task.status === "uploading" || task.status === "pending") && (
|
||||
{(task.status === "uploading" || task.status === "downloading" || task.status === "pending") && (
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 bg-secondary rounded-full overflow-hidden">
|
||||
<div
|
||||
@@ -105,30 +146,30 @@ export const SftpModalUploadTasks: React.FC<SftpModalUploadTasksProps> = ({ task
|
||||
)}
|
||||
style={{
|
||||
width:
|
||||
task.status === "uploading"
|
||||
task.status === "uploading" || task.status === "downloading"
|
||||
? `${task.progress}%`
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground font-mono shrink-0 w-8 text-right">
|
||||
{task.status === "uploading" ? `${Math.round(task.progress)}%` : "..."}
|
||||
{task.status === "uploading" || task.status === "downloading" ? `${Math.round(task.progress)}%` : "..."}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{task.status === "uploading" && task.totalBytes > 0 && (
|
||||
{(task.status === "uploading" || task.status === "downloading") && task.totalBytes > 0 && (
|
||||
<div className="text-[10px] text-muted-foreground mt-0.5 font-mono">
|
||||
{formatBytes(task.transferredBytes)} / {formatBytes(task.totalBytes)}
|
||||
</div>
|
||||
)}
|
||||
{task.status === "completed" && (
|
||||
<div className="text-[10px] text-green-600 mt-0.5">
|
||||
Completed - {formatBytes(task.totalBytes)}
|
||||
{t(task.direction === "download" ? "sftp.download.completed" : "sftp.upload.completed")} - {formatBytes(task.totalBytes)}
|
||||
</div>
|
||||
)}
|
||||
{task.status === "cancelled" && (
|
||||
<div className="text-[10px] text-muted-foreground mt-0.5">
|
||||
Cancelled
|
||||
{t(task.direction === "download" ? "sftp.download.cancelled" : "sftp.upload.cancelled")}
|
||||
</div>
|
||||
)}
|
||||
{task.status === "failed" && task.error && (
|
||||
@@ -143,12 +184,19 @@ export const SftpModalUploadTasks: React.FC<SftpModalUploadTasksProps> = ({ task
|
||||
{t("sftp.task.waiting")}
|
||||
</span>
|
||||
)}
|
||||
{(task.status === "uploading" || task.status === "pending") && onCancel && (
|
||||
{(task.status === "uploading" || task.status === "downloading" || task.status === "pending") && (onCancelTask || onCancel) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-destructive hover:text-destructive"
|
||||
onClick={onCancel}
|
||||
onClick={() => {
|
||||
// For download tasks or when onCancelTask is available, use task-specific cancel
|
||||
if (onCancelTask) {
|
||||
onCancelTask(task.id);
|
||||
} else if (onCancel) {
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
title={t("sftp.action.cancel")}
|
||||
>
|
||||
<X size={12} />
|
||||
|
||||
@@ -5,16 +5,18 @@ import {
|
||||
UploadController,
|
||||
uploadFromDataTransfer,
|
||||
uploadFromFileList,
|
||||
uploadEntriesDirect,
|
||||
UploadBridge,
|
||||
UploadCallbacks,
|
||||
UploadTaskInfo,
|
||||
UploadProgress,
|
||||
} from "../../../lib/uploadService";
|
||||
import { DropEntry } from "../../../lib/sftpFileUtils";
|
||||
|
||||
interface UploadTask {
|
||||
interface TransferTask {
|
||||
id: string;
|
||||
fileName: string;
|
||||
status: "pending" | "uploading" | "completed" | "failed" | "cancelled";
|
||||
status: "pending" | "uploading" | "downloading" | "completed" | "failed" | "cancelled";
|
||||
progress: number;
|
||||
totalBytes: number;
|
||||
transferredBytes: number;
|
||||
@@ -24,8 +26,12 @@ interface UploadTask {
|
||||
isDirectory?: boolean;
|
||||
fileCount?: number;
|
||||
completedCount?: number;
|
||||
direction: "upload" | "download";
|
||||
}
|
||||
|
||||
// Keep UploadTask as alias for backwards compatibility
|
||||
type UploadTask = TransferTask;
|
||||
|
||||
interface UseSftpModalTransfersParams {
|
||||
currentPath: string;
|
||||
isLocalSession: boolean;
|
||||
@@ -43,14 +49,32 @@ interface UseSftpModalTransfersParams {
|
||||
onProgress: (transferred: number, total: number, speed: number) => void,
|
||||
onComplete: () => void,
|
||||
onError: (error: string) => void,
|
||||
) => Promise<boolean>;
|
||||
) => Promise<{ success: boolean; transferId: string; cancelled?: boolean }>;
|
||||
writeSftpBinary: (sftpId: string, path: string, data: ArrayBuffer) => Promise<void>;
|
||||
writeSftp: (sftpId: string, path: string, data: string) => Promise<void>;
|
||||
mkdirLocal: (path: string) => Promise<void>;
|
||||
mkdirSftp: (sftpId: string, path: string) => Promise<void>;
|
||||
cancelSftpUpload?: (taskId: string) => Promise<unknown>;
|
||||
startStreamTransfer?: (
|
||||
options: {
|
||||
transferId: string;
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
sourceType: 'local' | 'sftp';
|
||||
targetType: 'local' | 'sftp';
|
||||
sourceSftpId?: string;
|
||||
targetSftpId?: string;
|
||||
totalBytes?: number;
|
||||
},
|
||||
onProgress?: (transferred: number, total: number, speed: number) => void,
|
||||
onComplete?: () => void,
|
||||
onError?: (error: string) => void
|
||||
) => Promise<{ transferId: string; totalBytes?: number; error?: string }>;
|
||||
cancelTransfer?: (transferId: string) => Promise<void>;
|
||||
showSaveDialog?: (defaultPath: string, filters?: Array<{ name: string; extensions: string[] }>) => Promise<string | null>;
|
||||
setLoading: (loading: boolean) => void;
|
||||
t: (key: string, params?: Record<string, unknown>) => string;
|
||||
useCompressedUpload?: boolean; // Enable compressed folder uploads
|
||||
}
|
||||
|
||||
interface UseSftpModalTransfersResult {
|
||||
@@ -60,10 +84,13 @@ interface UseSftpModalTransfersResult {
|
||||
handleDownload: (file: RemoteFile) => Promise<void>;
|
||||
handleUploadMultiple: (fileList: FileList) => Promise<void>;
|
||||
handleUploadFromDrop: (dataTransfer: DataTransfer) => Promise<void>;
|
||||
handleUploadEntries: (entries: DropEntry[]) => Promise<void>;
|
||||
handleFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
handleFolderSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
handleDrag: (e: React.DragEvent) => void;
|
||||
handleDrop: (e: React.DragEvent) => void;
|
||||
cancelUpload: () => Promise<void>;
|
||||
cancelTask: (taskId: string) => Promise<void>;
|
||||
dismissTask: (taskId: string) => void;
|
||||
}
|
||||
|
||||
@@ -81,8 +108,12 @@ export const useSftpModalTransfers = ({
|
||||
mkdirLocal,
|
||||
mkdirSftp,
|
||||
cancelSftpUpload,
|
||||
startStreamTransfer,
|
||||
cancelTransfer,
|
||||
showSaveDialog,
|
||||
setLoading,
|
||||
t,
|
||||
useCompressedUpload = false,
|
||||
}: UseSftpModalTransfersParams): UseSftpModalTransfersResult => {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadTasks, setUploadTasks] = useState<UploadTask[]>([]);
|
||||
@@ -97,35 +128,6 @@ export const useSftpModalTransfers = ({
|
||||
// Track cancelled transfer IDs to detect cancellation in bridge wrapper
|
||||
const cancelledTransferIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const handleDownload = useCallback(
|
||||
async (file: RemoteFile) => {
|
||||
try {
|
||||
const fullPath = joinPath(currentPath, file.name);
|
||||
setLoading(true);
|
||||
const content = isLocalSession
|
||||
? await readLocalFile(fullPath)
|
||||
: await readSftp(await ensureSftp(), fullPath);
|
||||
const blob = new Blob([content], { type: "application/octet-stream" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = file.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.downloadFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[currentPath, ensureSftp, isLocalSession, joinPath, readLocalFile, readSftp, setLoading, t],
|
||||
);
|
||||
|
||||
// Create upload bridge that adapts the modal's functions to the service interface
|
||||
const createUploadBridge = useMemo((): UploadBridge => {
|
||||
return {
|
||||
@@ -143,8 +145,8 @@ export const useSftpModalTransfers = ({
|
||||
data: ArrayBuffer,
|
||||
taskId: string,
|
||||
onProgress: (transferred: number, total: number, speed: number) => void,
|
||||
_onComplete?: () => void,
|
||||
_onError?: (error: string) => void
|
||||
onComplete?: () => void,
|
||||
onError?: (error: string) => void
|
||||
) => {
|
||||
try {
|
||||
const result = await writeSftpBinaryWithProgress(
|
||||
@@ -153,29 +155,57 @@ export const useSftpModalTransfers = ({
|
||||
data,
|
||||
taskId,
|
||||
onProgress,
|
||||
() => { },
|
||||
() => { }
|
||||
onComplete || (() => { }),
|
||||
onError || (() => { })
|
||||
);
|
||||
|
||||
// Check if this transfer was cancelled
|
||||
const wasCancelled = cancelledTransferIdsRef.current.has(taskId);
|
||||
if (wasCancelled) {
|
||||
cancelledTransferIdsRef.current.delete(taskId);
|
||||
}
|
||||
return { success: result, cancelled: wasCancelled };
|
||||
return { success: result.success, transferId: result.transferId, cancelled: wasCancelled || result.cancelled };
|
||||
} catch (error) {
|
||||
// Check if this was a user-initiated cancellation
|
||||
const wasCancelled = cancelledTransferIdsRef.current.has(taskId);
|
||||
if (wasCancelled) {
|
||||
cancelledTransferIdsRef.current.delete(taskId);
|
||||
return { success: false, cancelled: true };
|
||||
return { success: false, transferId: taskId, cancelled: true };
|
||||
}
|
||||
// Real error - propagate it by re-throwing
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
cancelSftpUpload,
|
||||
startStreamTransfer: startStreamTransfer ? async (
|
||||
options,
|
||||
onProgress,
|
||||
onComplete,
|
||||
onError
|
||||
) => {
|
||||
try {
|
||||
const result = await startStreamTransfer(options, onProgress, onComplete, onError);
|
||||
const wasCancelled = cancelledTransferIdsRef.current.has(options.transferId);
|
||||
if (wasCancelled) {
|
||||
cancelledTransferIdsRef.current.delete(options.transferId);
|
||||
}
|
||||
// Handle case where result might be undefined (bridge not available)
|
||||
if (!result) {
|
||||
return { transferId: options.transferId, error: 'Stream transfer not available' };
|
||||
}
|
||||
return { ...result, cancelled: wasCancelled };
|
||||
} catch (error) {
|
||||
const wasCancelled = cancelledTransferIdsRef.current.has(options.transferId);
|
||||
if (wasCancelled) {
|
||||
cancelledTransferIdsRef.current.delete(options.transferId);
|
||||
return { transferId: options.transferId, cancelled: true };
|
||||
}
|
||||
return { transferId: options.transferId, error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
} : undefined,
|
||||
cancelTransfer,
|
||||
};
|
||||
}, [writeLocalFile, mkdirLocal, mkdirSftp, writeSftpBinary, writeSftpBinaryWithProgress, cancelSftpUpload]);
|
||||
}, [writeLocalFile, mkdirLocal, mkdirSftp, writeSftpBinary, writeSftpBinaryWithProgress, cancelSftpUpload, startStreamTransfer, cancelTransfer]);
|
||||
|
||||
// Create upload callbacks
|
||||
const createUploadCallbacks = useCallback((): UploadCallbacks => {
|
||||
@@ -183,7 +213,7 @@ export const useSftpModalTransfers = ({
|
||||
onScanningStart: (taskId: string) => {
|
||||
const scanningTask: UploadTask = {
|
||||
id: taskId,
|
||||
fileName: "Scanning files...",
|
||||
fileName: t("sftp.upload.scanning"),
|
||||
status: "pending",
|
||||
progress: 0,
|
||||
totalBytes: 0,
|
||||
@@ -191,6 +221,7 @@ export const useSftpModalTransfers = ({
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
isDirectory: true,
|
||||
direction: "upload",
|
||||
};
|
||||
setUploadTasks(prev => [...prev, scanningTask]);
|
||||
},
|
||||
@@ -201,36 +232,35 @@ export const useSftpModalTransfers = ({
|
||||
const uploadTask: UploadTask = {
|
||||
id: task.id,
|
||||
fileName: task.displayName,
|
||||
status: "uploading",
|
||||
status: "pending",
|
||||
progress: 0,
|
||||
totalBytes: task.totalBytes,
|
||||
transferredBytes: 0,
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
isDirectory: task.isDirectory,
|
||||
fileCount: task.fileCount,
|
||||
completedCount: 0,
|
||||
direction: "upload",
|
||||
};
|
||||
// Filter out any pending scanning tasks before adding the real task.
|
||||
// This ensures that even if onScanningEnd's state update hasn't been applied yet
|
||||
// (due to React state batching), the scanning placeholder will still be removed.
|
||||
setUploadTasks(prev => [
|
||||
...prev.filter(t => !(t.status === "pending" && t.fileName === "Scanning files...")),
|
||||
uploadTask
|
||||
]);
|
||||
setUploadTasks(prev => [...prev, uploadTask]);
|
||||
},
|
||||
onTaskProgress: (taskId: string, progress: UploadProgress) => {
|
||||
setUploadTasks(prev =>
|
||||
prev.map(task =>
|
||||
task.id === taskId && task.status === "uploading"
|
||||
? {
|
||||
...task,
|
||||
transferredBytes: progress.transferred,
|
||||
progress: progress.percent,
|
||||
speed: progress.speed,
|
||||
}
|
||||
: task
|
||||
)
|
||||
prev.map(task => {
|
||||
if (task.id !== taskId) return task;
|
||||
|
||||
// Don't update progress if task is already completed, failed, or cancelled
|
||||
if (task.status === "completed" || task.status === "failed" || task.status === "cancelled") {
|
||||
return task;
|
||||
}
|
||||
|
||||
return {
|
||||
...task,
|
||||
status: "uploading" as const,
|
||||
progress: progress.percent,
|
||||
transferredBytes: progress.transferred,
|
||||
speed: progress.speed,
|
||||
};
|
||||
})
|
||||
);
|
||||
},
|
||||
onTaskCompleted: (taskId: string, totalBytes: number) => {
|
||||
@@ -249,24 +279,18 @@ export const useSftpModalTransfers = ({
|
||||
);
|
||||
},
|
||||
onTaskFailed: (taskId: string, error: string) => {
|
||||
// Any error marks the task as failed
|
||||
setUploadTasks(prev =>
|
||||
prev.map(task =>
|
||||
task.id === taskId
|
||||
? {
|
||||
...task,
|
||||
status: "failed" as const,
|
||||
error,
|
||||
speed: 0,
|
||||
error,
|
||||
}
|
||||
: task
|
||||
)
|
||||
);
|
||||
|
||||
// Auto-clear failed tasks after 3 seconds
|
||||
setTimeout(() => {
|
||||
setUploadTasks(prev => prev.filter(t => t.id !== taskId));
|
||||
}, 3000);
|
||||
},
|
||||
onTaskCancelled: (taskId: string) => {
|
||||
setUploadTasks(prev =>
|
||||
@@ -280,65 +304,262 @@ export const useSftpModalTransfers = ({
|
||||
: task
|
||||
)
|
||||
);
|
||||
// Auto-clear cancelled tasks after 2 seconds
|
||||
setTimeout(() => {
|
||||
setUploadTasks(prev => prev.filter(t => t.id !== taskId));
|
||||
}, 2000);
|
||||
},
|
||||
onTaskNameUpdate: (taskId: string, newName: string) => {
|
||||
// Parse the phase format: "folderName|phase"
|
||||
let displayName = newName;
|
||||
if (newName.includes('|')) {
|
||||
const [folderName, phase] = newName.split('|');
|
||||
const phaseLabel = phase === 'compressing' ? t('sftp.upload.phase.compressing')
|
||||
: phase === 'extracting' ? t('sftp.upload.phase.extracting')
|
||||
: phase === 'uploading' ? t('sftp.upload.phase.uploading')
|
||||
: t('sftp.upload.phase.compressed');
|
||||
displayName = `${folderName} (${phaseLabel})`;
|
||||
}
|
||||
setUploadTasks(prev =>
|
||||
prev.map(task =>
|
||||
task.id === taskId
|
||||
? {
|
||||
...task,
|
||||
fileName: displayName,
|
||||
}
|
||||
: task
|
||||
)
|
||||
);
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
// Helper function to perform upload with compression setting from user preference
|
||||
const performUpload = useCallback(async (
|
||||
files: FileList | File[],
|
||||
useCompressed: boolean
|
||||
): Promise<void> => {
|
||||
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,
|
||||
useCompressedUpload: useCompressed,
|
||||
},
|
||||
controller
|
||||
);
|
||||
|
||||
await loadFiles(currentPath, { force: true });
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
|
||||
"SFTP"
|
||||
);
|
||||
} finally {
|
||||
// Upload process is complete - clear uploading state and controller
|
||||
setUploading(false);
|
||||
uploadControllerRef.current = null;
|
||||
cachedSftpIdRef.current = null;
|
||||
}
|
||||
}, [currentPath, createUploadBridge, createUploadCallbacks, ensureSftp, isLocalSession, joinPath, loadFiles, t]);
|
||||
|
||||
const handleDownload = useCallback(
|
||||
async (file: RemoteFile) => {
|
||||
try {
|
||||
const fullPath = joinPath(currentPath, file.name);
|
||||
|
||||
// For local files, use blob download (file is already on local filesystem)
|
||||
if (isLocalSession) {
|
||||
setLoading(true);
|
||||
const content = await readLocalFile(fullPath);
|
||||
const blob = new Blob([content], { type: "application/octet-stream" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = file.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
return;
|
||||
}
|
||||
|
||||
// For remote SFTP files, use streaming download with save dialog
|
||||
if (!showSaveDialog || !startStreamTransfer) {
|
||||
toast.error(t("sftp.error.downloadFailed"), "SFTP");
|
||||
return;
|
||||
}
|
||||
|
||||
// Show save dialog to get target path
|
||||
const targetPath = await showSaveDialog(file.name);
|
||||
if (!targetPath) {
|
||||
// User cancelled the save dialog
|
||||
return;
|
||||
}
|
||||
|
||||
const sftpId = await ensureSftp();
|
||||
const transferId = `download-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const fileSize = typeof file.size === 'number' ? file.size : parseInt(file.size, 10) || 0;
|
||||
|
||||
// Create download task for progress display
|
||||
const downloadTask: TransferTask = {
|
||||
id: transferId,
|
||||
fileName: file.name,
|
||||
status: "downloading",
|
||||
progress: 0,
|
||||
totalBytes: fileSize,
|
||||
transferredBytes: 0,
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
direction: "download",
|
||||
};
|
||||
setUploadTasks(prev => [...prev, downloadTask]);
|
||||
|
||||
// Track if this download was cancelled or error was handled
|
||||
let wasCancelled = false;
|
||||
let errorHandled = false;
|
||||
|
||||
const result = await startStreamTransfer(
|
||||
{
|
||||
transferId,
|
||||
sourcePath: fullPath,
|
||||
targetPath,
|
||||
sourceType: 'sftp',
|
||||
targetType: 'local',
|
||||
sourceSftpId: sftpId,
|
||||
totalBytes: fileSize,
|
||||
},
|
||||
// onProgress
|
||||
(transferred, total, speed) => {
|
||||
setUploadTasks(prev =>
|
||||
prev.map(task =>
|
||||
task.id === transferId
|
||||
? {
|
||||
...task,
|
||||
transferredBytes: transferred,
|
||||
totalBytes: total,
|
||||
progress: total > 0 ? Math.round((transferred / total) * 100) : 0,
|
||||
speed,
|
||||
}
|
||||
: task
|
||||
)
|
||||
);
|
||||
},
|
||||
// onComplete
|
||||
() => {
|
||||
setUploadTasks(prev =>
|
||||
prev.map(task =>
|
||||
task.id === transferId
|
||||
? { ...task, status: "completed" as const, progress: 100 }
|
||||
: task
|
||||
)
|
||||
);
|
||||
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
|
||||
},
|
||||
// onError
|
||||
(error) => {
|
||||
errorHandled = true;
|
||||
// Check if this is a cancellation error
|
||||
if (error.includes('cancelled') || error.includes('canceled')) {
|
||||
wasCancelled = true;
|
||||
setUploadTasks(prev =>
|
||||
prev.map(task =>
|
||||
task.id === transferId
|
||||
? { ...task, status: "cancelled" as const, speed: 0 }
|
||||
: task
|
||||
)
|
||||
);
|
||||
} else {
|
||||
setUploadTasks(prev =>
|
||||
prev.map(task =>
|
||||
task.id === transferId
|
||||
? { ...task, status: "failed" as const, error }
|
||||
: task
|
||||
)
|
||||
);
|
||||
toast.error(error, "SFTP");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Check if bridge doesn't support streaming (returns undefined)
|
||||
if (result === undefined) {
|
||||
// Remove the pending task and show error
|
||||
setUploadTasks(prev => prev.filter(task => task.id !== transferId));
|
||||
toast.error(t("sftp.error.downloadFailed"), "SFTP");
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle result - check for cancellation in result.error as well
|
||||
// (backend may set error without calling onError callback)
|
||||
if (result?.error) {
|
||||
const isCancelError = result.error.includes('cancelled') || result.error.includes('canceled');
|
||||
if (isCancelError) {
|
||||
// Mark as cancelled if not already done by onError
|
||||
if (!wasCancelled) {
|
||||
setUploadTasks(prev =>
|
||||
prev.map(task =>
|
||||
task.id === transferId
|
||||
? { ...task, status: "cancelled" as const, speed: 0 }
|
||||
: task
|
||||
)
|
||||
);
|
||||
}
|
||||
// Don't show error for cancellation
|
||||
return;
|
||||
}
|
||||
// For non-cancel errors, only show toast if onError didn't already handle it
|
||||
if (!errorHandled) {
|
||||
setUploadTasks(prev =>
|
||||
prev.map(task =>
|
||||
task.id === transferId
|
||||
? { ...task, status: "failed" as const, error: result.error }
|
||||
: task
|
||||
)
|
||||
);
|
||||
toast.error(result.error, "SFTP");
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.downloadFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[currentPath, ensureSftp, isLocalSession, joinPath, readLocalFile, setLoading, showSaveDialog, startStreamTransfer, t],
|
||||
);
|
||||
|
||||
|
||||
|
||||
const handleUploadMultiple = useCallback(
|
||||
async (fileList: FileList) => {
|
||||
if (fileList.length === 0) return;
|
||||
|
||||
setUploading(true);
|
||||
|
||||
// Get SFTP ID for remote sessions
|
||||
let sftpId: string | null = null;
|
||||
if (!isLocalSession) {
|
||||
sftpId = await ensureSftp();
|
||||
cachedSftpIdRef.current = sftpId;
|
||||
}
|
||||
|
||||
// Create controller for cancellation
|
||||
const controller = new UploadController();
|
||||
uploadControllerRef.current = controller;
|
||||
|
||||
const callbacks = createUploadCallbacks();
|
||||
|
||||
try {
|
||||
await uploadFromFileList(
|
||||
fileList,
|
||||
{
|
||||
targetPath: currentPath,
|
||||
sftpId,
|
||||
isLocal: isLocalSession,
|
||||
bridge: createUploadBridge,
|
||||
joinPath,
|
||||
callbacks,
|
||||
},
|
||||
controller
|
||||
);
|
||||
|
||||
await loadFiles(currentPath, { force: true });
|
||||
|
||||
// Auto-clear completed tasks after 3 seconds
|
||||
setTimeout(() => {
|
||||
setUploadTasks(prev => prev.filter(t => t.status !== "completed"));
|
||||
}, 3000);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
|
||||
"SFTP"
|
||||
);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
uploadControllerRef.current = null;
|
||||
cachedSftpIdRef.current = null;
|
||||
}
|
||||
// Use compressed upload if enabled in settings (auto-fallback is handled in uploadService)
|
||||
await performUpload(fileList, useCompressedUpload);
|
||||
},
|
||||
[currentPath, createUploadBridge, createUploadCallbacks, ensureSftp, isLocalSession, joinPath, loadFiles, t],
|
||||
[performUpload, useCompressedUpload],
|
||||
);
|
||||
|
||||
const handleUploadFromDrop = useCallback(
|
||||
@@ -368,38 +589,121 @@ export const useSftpModalTransfers = ({
|
||||
bridge: createUploadBridge,
|
||||
joinPath,
|
||||
callbacks,
|
||||
useCompressedUpload,
|
||||
},
|
||||
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 {
|
||||
// Upload process is complete - clear uploading state and controller
|
||||
setUploading(false);
|
||||
uploadControllerRef.current = null;
|
||||
cachedSftpIdRef.current = null;
|
||||
}
|
||||
},
|
||||
[currentPath, createUploadBridge, createUploadCallbacks, ensureSftp, isLocalSession, joinPath, loadFiles, t],
|
||||
[currentPath, createUploadBridge, createUploadCallbacks, ensureSftp, isLocalSession, joinPath, loadFiles, t, useCompressedUpload],
|
||||
);
|
||||
|
||||
// Handle upload from DropEntry array (used for drag-and-drop to terminal)
|
||||
const handleUploadEntries = useCallback(
|
||||
async (entries: DropEntry[]) => {
|
||||
if (entries.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 uploadEntriesDirect(
|
||||
entries,
|
||||
{
|
||||
targetPath: currentPath,
|
||||
sftpId,
|
||||
isLocal: isLocalSession,
|
||||
bridge: createUploadBridge,
|
||||
joinPath,
|
||||
callbacks,
|
||||
useCompressedUpload,
|
||||
},
|
||||
controller
|
||||
);
|
||||
|
||||
await loadFiles(currentPath, { force: true });
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
|
||||
"SFTP"
|
||||
);
|
||||
} finally {
|
||||
// Upload process is complete - clear uploading state and controller
|
||||
setUploading(false);
|
||||
uploadControllerRef.current = null;
|
||||
cachedSftpIdRef.current = null;
|
||||
}
|
||||
},
|
||||
[currentPath, createUploadBridge, createUploadCallbacks, ensureSftp, isLocalSession, joinPath, loadFiles, t, useCompressedUpload],
|
||||
);
|
||||
|
||||
// Handle upload from File array (used by file input after copying files)
|
||||
const handleUploadFromFiles = useCallback(
|
||||
async (files: File[]) => {
|
||||
if (files.length === 0) return;
|
||||
|
||||
// Use compressed upload if enabled in settings (auto-fallback is handled in uploadService)
|
||||
await performUpload(files, useCompressedUpload);
|
||||
},
|
||||
[performUpload, useCompressedUpload],
|
||||
);
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
void handleUploadMultiple(e.target.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 files 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 handleFolderSelect = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
// 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 folder again
|
||||
e.target.value = "";
|
||||
// Now start the upload with the copied files
|
||||
void handleUploadFromFiles(files);
|
||||
} else {
|
||||
e.target.value = "";
|
||||
}
|
||||
},
|
||||
[handleUploadFromFiles],
|
||||
);
|
||||
|
||||
const handleDrag = useCallback((e: React.DragEvent) => {
|
||||
@@ -438,7 +742,9 @@ export const useSftpModalTransfers = ({
|
||||
// Always clear all uploading/pending tasks immediately, even without controller
|
||||
setUploadTasks(prev => {
|
||||
const hasActiveTasks = prev.some(t => t.status === "uploading" || t.status === "pending");
|
||||
if (!hasActiveTasks) return prev;
|
||||
if (!hasActiveTasks) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
return prev.map(task =>
|
||||
task.status === "uploading" || task.status === "pending"
|
||||
@@ -447,15 +753,60 @@ export const useSftpModalTransfers = ({
|
||||
);
|
||||
});
|
||||
|
||||
// Auto-clear cancelled tasks after 2 seconds
|
||||
setTimeout(() => {
|
||||
setUploadTasks(prev => prev.filter(t => t.status !== "cancelled"));
|
||||
}, 2000);
|
||||
|
||||
// Also reset uploading state
|
||||
setUploading(false);
|
||||
}, []);
|
||||
|
||||
// Cancel a specific task (works for both uploads and downloads)
|
||||
const cancelTask = useCallback(async (taskId: string) => {
|
||||
// Find the task to determine its type
|
||||
const task = uploadTasks.find(t => t.id === taskId);
|
||||
if (!task) return;
|
||||
|
||||
if (task.direction === "download") {
|
||||
// For download tasks, cancel only this specific transfer
|
||||
if (cancelTransfer) {
|
||||
try {
|
||||
await cancelTransfer(taskId);
|
||||
} catch (e) {
|
||||
// Ignore cancellation errors
|
||||
}
|
||||
}
|
||||
// Mark task as cancelled
|
||||
setUploadTasks(prev =>
|
||||
prev.map(t =>
|
||||
t.id === taskId
|
||||
? { ...t, status: "cancelled" as const, speed: 0 }
|
||||
: t
|
||||
)
|
||||
);
|
||||
} else {
|
||||
// For upload tasks, cancel the entire upload batch
|
||||
// because controller.cancel() cancels all active uploads
|
||||
const controller = uploadControllerRef.current;
|
||||
if (controller) {
|
||||
// Mark all active transfer IDs as cancelled before calling cancel
|
||||
const activeIds = controller.getActiveTransferIds();
|
||||
for (const id of activeIds) {
|
||||
cancelledTransferIdsRef.current.add(id);
|
||||
}
|
||||
await controller.cancel();
|
||||
}
|
||||
|
||||
// Mark ALL uploading/pending tasks as cancelled (not just the clicked one)
|
||||
setUploadTasks(prev =>
|
||||
prev.map(t =>
|
||||
t.status === "uploading" || t.status === "pending"
|
||||
? { ...t, status: "cancelled" as const, speed: 0 }
|
||||
: t
|
||||
)
|
||||
);
|
||||
|
||||
// Reset uploading state
|
||||
setUploading(false);
|
||||
}
|
||||
}, [uploadTasks, cancelTransfer]);
|
||||
|
||||
const dismissTask = useCallback((taskId: string) => {
|
||||
setUploadTasks(prev => prev.filter(t => t.id !== taskId));
|
||||
}, []);
|
||||
@@ -467,10 +818,13 @@ export const useSftpModalTransfers = ({
|
||||
handleDownload,
|
||||
handleUploadMultiple,
|
||||
handleUploadFromDrop,
|
||||
handleUploadEntries,
|
||||
handleFileSelect,
|
||||
handleFolderSelect,
|
||||
handleDrag,
|
||||
handleDrop,
|
||||
cancelUpload,
|
||||
cancelTask,
|
||||
dismissTask,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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())}`;
|
||||
};
|
||||
|
||||
@@ -20,6 +20,23 @@ interface UseSftpViewFileOpsParams {
|
||||
systemApp?: SystemAppInfo,
|
||||
) => void;
|
||||
t: (key: string, vars?: Record<string, string | number>) => string;
|
||||
showSaveDialog?: (defaultPath: string, filters?: Array<{ name: string; extensions: string[] }>) => Promise<string | null>;
|
||||
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 }>;
|
||||
getSftpIdForConnection?: (connectionId: string) => string | undefined;
|
||||
}
|
||||
|
||||
interface UseSftpViewFileOpsResult {
|
||||
@@ -88,6 +105,9 @@ export const useSftpViewFileOps = ({
|
||||
getOpenerForFileRef,
|
||||
setOpenerForExtension,
|
||||
t,
|
||||
showSaveDialog,
|
||||
startStreamTransfer,
|
||||
getSftpIdForConnection,
|
||||
}: UseSftpViewFileOpsParams): UseSftpViewFileOpsResult => {
|
||||
const [permissionsState, setPermissionsState] = useState<{
|
||||
file: SftpFileEntry;
|
||||
@@ -328,19 +348,130 @@ export const useSftpViewFileOps = ({
|
||||
const fullPath = sftpRef.current.joinPath(pane.connection.currentPath, file.name);
|
||||
|
||||
try {
|
||||
const content = await sftpRef.current.readBinaryFile(side, fullPath);
|
||||
// For local files, use blob download
|
||||
if (pane.connection.isLocal) {
|
||||
const content = await sftpRef.current.readBinaryFile(side, fullPath);
|
||||
|
||||
const blob = new Blob([content], { type: "application/octet-stream" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = file.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
const blob = new Blob([content], { type: "application/octet-stream" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = file.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
|
||||
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
|
||||
return;
|
||||
}
|
||||
|
||||
// For remote SFTP files, use streaming download with save dialog
|
||||
if (!showSaveDialog || !startStreamTransfer || !getSftpIdForConnection) {
|
||||
toast.error(t("sftp.error.downloadFailed"), "SFTP");
|
||||
return;
|
||||
}
|
||||
|
||||
const sftpId = getSftpIdForConnection(pane.connection.id);
|
||||
if (!sftpId) {
|
||||
throw new Error("SFTP session not found");
|
||||
}
|
||||
|
||||
// Show save dialog to get target path
|
||||
const targetPath = await showSaveDialog(file.name);
|
||||
if (!targetPath) {
|
||||
// User cancelled
|
||||
return;
|
||||
}
|
||||
|
||||
const transferId = `download-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const fileSize = typeof file.size === 'string' ? parseInt(file.size, 10) || 0 : (file.size || 0);
|
||||
|
||||
// Add download task to transfer queue for progress display
|
||||
sftpRef.current.addExternalUpload({
|
||||
id: transferId,
|
||||
fileName: file.name,
|
||||
sourcePath: fullPath,
|
||||
targetPath,
|
||||
sourceConnectionId: pane.connection.id,
|
||||
targetConnectionId: 'local',
|
||||
direction: 'download',
|
||||
status: 'transferring',
|
||||
totalBytes: fileSize,
|
||||
transferredBytes: 0,
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
isDirectory: false,
|
||||
});
|
||||
|
||||
// Track if error was already handled by callback
|
||||
let errorHandled = false;
|
||||
|
||||
const result = await startStreamTransfer(
|
||||
{
|
||||
transferId,
|
||||
sourcePath: fullPath,
|
||||
targetPath,
|
||||
sourceType: 'sftp',
|
||||
targetType: 'local',
|
||||
sourceSftpId: sftpId,
|
||||
totalBytes: fileSize,
|
||||
},
|
||||
(transferred, total, speed) => {
|
||||
// Update transfer progress in the queue
|
||||
sftpRef.current.updateExternalUpload(transferId, {
|
||||
transferredBytes: transferred,
|
||||
totalBytes: total,
|
||||
speed,
|
||||
});
|
||||
},
|
||||
() => {
|
||||
// Mark as completed
|
||||
sftpRef.current.updateExternalUpload(transferId, {
|
||||
status: 'completed',
|
||||
transferredBytes: fileSize,
|
||||
endTime: Date.now(),
|
||||
});
|
||||
toast.success(`${t("sftp.context.download")}: ${file.name}`, "SFTP");
|
||||
},
|
||||
(error) => {
|
||||
errorHandled = true;
|
||||
// Check if this is a cancellation - don't show error toast for cancellations
|
||||
const isCancelError = error.includes('cancelled') || error.includes('canceled');
|
||||
sftpRef.current.updateExternalUpload(transferId, {
|
||||
status: isCancelError ? 'cancelled' : 'failed',
|
||||
error: isCancelError ? undefined : error,
|
||||
endTime: Date.now(),
|
||||
});
|
||||
if (!isCancelError) {
|
||||
toast.error(error, "SFTP");
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Check if bridge doesn't support streaming (returns undefined)
|
||||
if (result === undefined) {
|
||||
sftpRef.current.updateExternalUpload(transferId, {
|
||||
status: 'failed',
|
||||
error: t("sftp.error.downloadFailed"),
|
||||
endTime: Date.now(),
|
||||
});
|
||||
toast.error(t("sftp.error.downloadFailed"), "SFTP");
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle error from result only if onError callback wasn't called
|
||||
if (result?.error && !errorHandled) {
|
||||
const isCancelError = result.error.includes('cancelled') || result.error.includes('canceled');
|
||||
sftpRef.current.updateExternalUpload(transferId, {
|
||||
status: isCancelError ? 'cancelled' : 'failed',
|
||||
error: isCancelError ? undefined : result.error,
|
||||
endTime: Date.now(),
|
||||
});
|
||||
if (!isCancelError) {
|
||||
toast.error(result.error, "SFTP");
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error("[SftpView] Failed to download file:", e);
|
||||
toast.error(
|
||||
@@ -349,7 +480,7 @@ export const useSftpViewFileOps = ({
|
||||
);
|
||||
}
|
||||
},
|
||||
[sftpRef, t],
|
||||
[sftpRef, t, showSaveDialog, startStreamTransfer, getSftpIdForConnection],
|
||||
);
|
||||
|
||||
const onDownloadFileLeft = useCallback(
|
||||
|
||||
@@ -19,6 +19,23 @@ interface UseSftpViewPaneCallbacksParams {
|
||||
systemApp?: SystemAppInfo,
|
||||
) => void;
|
||||
t: (key: string, vars?: Record<string, string | number>) => string;
|
||||
showSaveDialog?: (defaultPath: string, filters?: Array<{ name: string; extensions: string[] }>) => Promise<string | null>;
|
||||
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 }>;
|
||||
getSftpIdForConnection?: (connectionId: string) => string | undefined;
|
||||
}
|
||||
|
||||
export const useSftpViewPaneCallbacks = ({
|
||||
@@ -28,6 +45,9 @@ export const useSftpViewPaneCallbacks = ({
|
||||
getOpenerForFileRef,
|
||||
setOpenerForExtension,
|
||||
t,
|
||||
showSaveDialog,
|
||||
startStreamTransfer,
|
||||
getSftpIdForConnection,
|
||||
}: UseSftpViewPaneCallbacksParams) => {
|
||||
const paneActions = useSftpViewPaneActions({ sftpRef });
|
||||
const fileOps = useSftpViewFileOps({
|
||||
@@ -37,6 +57,9 @@ export const useSftpViewPaneCallbacks = ({
|
||||
getOpenerForFileRef,
|
||||
setOpenerForExtension,
|
||||
t,
|
||||
showSaveDialog,
|
||||
startStreamTransfer,
|
||||
getSftpIdForConnection,
|
||||
});
|
||||
|
||||
/* eslint-disable react-hooks/exhaustive-deps -- Handlers use refs, so they are stable */
|
||||
|
||||
@@ -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())}`;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -34,6 +34,29 @@ export interface TerminalConnectionDialogProps {
|
||||
progressProps: Omit<TerminalConnectionProgressProps, 'status' | 'error' | 'showLogs' | '_setShowLogs'>;
|
||||
}
|
||||
|
||||
// Helper to get protocol display info
|
||||
const getProtocolInfo = (host: Host): { i18nKey: string; showPort: boolean; port: number } => {
|
||||
// Check moshEnabled first since mosh uses protocol: "ssh" with moshEnabled: true
|
||||
if (host.moshEnabled) {
|
||||
return { i18nKey: 'terminal.connection.protocol.mosh', showPort: true, port: host.port || 22 };
|
||||
}
|
||||
const protocol = host.protocol || 'ssh';
|
||||
switch (protocol) {
|
||||
case 'local':
|
||||
return { i18nKey: 'terminal.connection.protocol.local', showPort: false, port: 0 };
|
||||
case 'telnet':
|
||||
// Telnet uses telnetPort, not port (which is SSH port)
|
||||
return { i18nKey: 'terminal.connection.protocol.telnet', showPort: true, port: host.telnetPort ?? host.port ?? 23 };
|
||||
case 'mosh':
|
||||
return { i18nKey: 'terminal.connection.protocol.mosh', showPort: true, port: host.port || 22 };
|
||||
case 'serial':
|
||||
return { i18nKey: 'terminal.connection.protocol.serial', showPort: false, port: 0 };
|
||||
case 'ssh':
|
||||
default:
|
||||
return { i18nKey: 'terminal.connection.protocol.ssh', showPort: true, port: host.port || 22 };
|
||||
}
|
||||
};
|
||||
|
||||
export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> = ({
|
||||
host,
|
||||
status,
|
||||
@@ -50,6 +73,7 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
|
||||
const { t } = useI18n();
|
||||
const hasError = Boolean(error);
|
||||
const isConnecting = status === 'connecting';
|
||||
const protocolInfo = getProtocolInfo(host);
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
@@ -75,14 +99,14 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
|
||||
<span>{chainProgress.currentHostLabel}</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground font-mono">
|
||||
SSH {host.hostname}:{host.port || 22}
|
||||
{t(protocolInfo.i18nKey)} {protocolInfo.showPort ? `${host.hostname}:${protocolInfo.port}` : host.hostname}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-sm font-semibold">{host.label}</div>
|
||||
<div className="text-[11px] text-muted-foreground font-mono">
|
||||
SSH {host.hostname}:{host.port || 22}
|
||||
{t(protocolInfo.i18nKey)} {protocolInfo.showPort ? `${host.hostname}:${protocolInfo.port}` : host.hostname}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ export class KeywordHighlighter implements IDisposable {
|
||||
private debounceTimer: NodeJS.Timeout | null = null;
|
||||
private enabled: boolean = false;
|
||||
private disposables: IDisposable[] = [];
|
||||
private lastViewportY: number = -1;
|
||||
|
||||
constructor(term: XTerm) {
|
||||
this.term = term;
|
||||
@@ -42,7 +43,16 @@ export class KeywordHighlighter implements IDisposable {
|
||||
this.triggerRefresh();
|
||||
}),
|
||||
// Also refresh on resize as viewport content changes
|
||||
this.term.onResize(() => this.triggerRefresh())
|
||||
this.term.onResize(() => this.triggerRefresh()),
|
||||
// onRender fires after each render cycle - catch scrolls that onScroll might miss
|
||||
this.term.onRender(() => {
|
||||
// Only trigger refresh if viewport position changed
|
||||
const currentViewportY = this.term.buffer.active?.viewportY ?? 0;
|
||||
if (currentViewportY !== this.lastViewportY) {
|
||||
this.lastViewportY = currentViewportY;
|
||||
this.triggerRefresh();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
const PopoverAnchor = PopoverPrimitive.Anchor
|
||||
|
||||
const PopoverClose = PopoverPrimitive.Close
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
@@ -68,4 +70,4 @@ const PopoverContent = React.forwardRef<
|
||||
})
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverAnchor, PopoverContent, PopoverTrigger }
|
||||
export { Popover, PopoverAnchor, PopoverClose, PopoverContent, PopoverTrigger }
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { Calendar,CalendarClock,Check,ChevronDown,ChevronUp,SortAsc,SortDesc } from 'lucide-react';
|
||||
import { Calendar,CalendarClock,Check,ChevronDown,ChevronUp,FolderTree,SortAsc,SortDesc } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import { Button } from './button';
|
||||
import { Dropdown,DropdownContent,DropdownTrigger } from './dropdown';
|
||||
|
||||
export type SortMode = 'az' | 'za' | 'newest' | 'oldest';
|
||||
export type SortMode = 'az' | 'za' | 'newest' | 'oldest' | 'group';
|
||||
|
||||
const SORT_OPTIONS: Record<SortMode, { labelKey: string; icon: React.ReactElement; triggerIcon: React.ReactElement }> = {
|
||||
az: { labelKey: 'sort.az', icon: <SortAsc className="w-4 h-4 shrink-0" />, triggerIcon: <SortAsc className="w-4 h-4" /> },
|
||||
za: { labelKey: 'sort.za', icon: <SortDesc className="w-4 h-4 shrink-0" />, triggerIcon: <SortDesc className="w-4 h-4" /> },
|
||||
newest: { labelKey: 'sort.newest', icon: <Calendar className="w-4 h-4 shrink-0" />, triggerIcon: <Calendar className="w-4 h-4" /> },
|
||||
oldest: { labelKey: 'sort.oldest', icon: <CalendarClock className="w-4 h-4 shrink-0" />, triggerIcon: <CalendarClock className="w-4 h-4" /> },
|
||||
group: { labelKey: 'sort.group', icon: <FolderTree className="w-4 h-4 shrink-0" />, triggerIcon: <FolderTree className="w-4 h-4" /> },
|
||||
};
|
||||
|
||||
interface SortDropdownProps {
|
||||
|
||||
30
components/ui/tooltip.tsx
Normal file
30
components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-[999999] overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useCallback, useRef } from "react";
|
||||
import React, { useCallback, useRef, useState } from "react";
|
||||
import { FileSymlink, Import } from "lucide-react";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import { getVaultCsvTemplate } from "../../domain/vaultImport";
|
||||
import type { VaultImportFormat } from "../../domain/vaultImport";
|
||||
@@ -51,10 +52,15 @@ const OPTIONS: ImportOption[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export type ImportOptions = {
|
||||
managed?: boolean;
|
||||
filePath?: string;
|
||||
};
|
||||
|
||||
export type ImportVaultDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onFileSelected: (format: VaultImportFormat, file: File) => void;
|
||||
onFileSelected: (format: VaultImportFormat, file: File, options?: ImportOptions) => void;
|
||||
};
|
||||
|
||||
export const ImportVaultDialog: React.FC<ImportVaultDialogProps> = ({
|
||||
@@ -65,6 +71,8 @@ export const ImportVaultDialog: React.FC<ImportVaultDialogProps> = ({
|
||||
const { t } = useI18n();
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const pendingFormatRef = useRef<VaultImportFormat | null>(null);
|
||||
const pendingOptionsRef = useRef<ImportOptions | undefined>(undefined);
|
||||
const [showManagedChoice, setShowManagedChoice] = useState(false);
|
||||
|
||||
const downloadCsvTemplate = useCallback(() => {
|
||||
const csv = getVaultCsvTemplate();
|
||||
@@ -78,10 +86,11 @@ export const ImportVaultDialog: React.FC<ImportVaultDialogProps> = ({
|
||||
}, []);
|
||||
|
||||
const pickFile = useCallback(
|
||||
(format: VaultImportFormat, accept: string) => {
|
||||
(format: VaultImportFormat, accept: string, options?: ImportOptions) => {
|
||||
const input = fileInputRef.current;
|
||||
if (!input) return;
|
||||
pendingFormatRef.current = format;
|
||||
pendingOptionsRef.current = options;
|
||||
input.accept = accept;
|
||||
input.value = "";
|
||||
input.click();
|
||||
@@ -89,19 +98,50 @@ export const ImportVaultDialog: React.FC<ImportVaultDialogProps> = ({
|
||||
[],
|
||||
);
|
||||
|
||||
const handleFormatClick = useCallback(
|
||||
(opt: ImportOption) => {
|
||||
if (opt.format === "ssh_config") {
|
||||
setShowManagedChoice(true);
|
||||
} else {
|
||||
pickFile(opt.format, opt.accept);
|
||||
}
|
||||
},
|
||||
[pickFile],
|
||||
);
|
||||
|
||||
const handleManagedChoice = useCallback(
|
||||
(managed: boolean) => {
|
||||
setShowManagedChoice(false);
|
||||
pickFile("ssh_config", "*", { managed });
|
||||
},
|
||||
[pickFile],
|
||||
);
|
||||
|
||||
const onChangeFile = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
const format = pendingFormatRef.current;
|
||||
const options = pendingOptionsRef.current;
|
||||
if (!file || !format) return;
|
||||
onFileSelected(format, file);
|
||||
onFileSelected(format, file, options);
|
||||
e.target.value = "";
|
||||
pendingOptionsRef.current = undefined;
|
||||
},
|
||||
[onFileSelected],
|
||||
);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(newOpen: boolean) => {
|
||||
if (!newOpen) {
|
||||
setShowManagedChoice(false);
|
||||
}
|
||||
onOpenChange(newOpen);
|
||||
},
|
||||
[onOpenChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader className="text-center sm:text-center">
|
||||
<div className="mx-auto h-14 w-14 rounded-2xl bg-muted/60 border border-border/60 flex items-center justify-center">
|
||||
@@ -113,7 +153,9 @@ export const ImportVaultDialog: React.FC<ImportVaultDialogProps> = ({
|
||||
</div>
|
||||
<DialogTitle className="text-xl">{t("vault.import.title")}</DialogTitle>
|
||||
<DialogDescription className="mx-auto max-w-xl">
|
||||
{t("vault.import.desc")}
|
||||
{showManagedChoice
|
||||
? t("vault.import.sshConfig.chooseMode")
|
||||
: t("vault.import.desc")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -125,51 +167,108 @@ export const ImportVaultDialog: React.FC<ImportVaultDialogProps> = ({
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="text-sm font-medium text-center text-muted-foreground">
|
||||
{t("vault.import.chooseFormat")}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3">
|
||||
{OPTIONS.map((opt) => (
|
||||
{showManagedChoice ? (
|
||||
<>
|
||||
<div className="text-sm font-medium text-center text-muted-foreground">
|
||||
{t("vault.import.sshConfig.modeQuestion")}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"group rounded-2xl border border-border/60 bg-background",
|
||||
"px-4 py-6 hover:bg-muted/30 hover:border-border transition-colors",
|
||||
"flex flex-col items-center gap-3",
|
||||
)}
|
||||
onClick={() => handleManagedChoice(false)}
|
||||
>
|
||||
<div className="h-12 w-12 rounded-xl bg-muted/60 flex items-center justify-center">
|
||||
<Import className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{t("vault.import.sshConfig.importOnly")}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
{t("vault.import.sshConfig.importOnlyDesc")}
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"group rounded-2xl border border-primary/60 bg-primary/5",
|
||||
"px-4 py-6 hover:bg-primary/10 hover:border-primary transition-colors",
|
||||
"flex flex-col items-center gap-3",
|
||||
)}
|
||||
onClick={() => handleManagedChoice(true)}
|
||||
>
|
||||
<div className="h-12 w-12 rounded-xl bg-primary/10 flex items-center justify-center">
|
||||
<FileSymlink className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{t("vault.import.sshConfig.managed")}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
{t("vault.import.sshConfig.managedDesc")}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
key={opt.format}
|
||||
type="button"
|
||||
className={cn(
|
||||
"group rounded-2xl border border-border/60 bg-background",
|
||||
"px-3 py-4 hover:bg-muted/30 hover:border-border transition-colors",
|
||||
"flex flex-col items-center gap-3",
|
||||
)}
|
||||
onClick={() => pickFile(opt.format, opt.accept)}
|
||||
onClick={() => setShowManagedChoice(false)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<div className="h-16 flex items-center justify-center">
|
||||
<img
|
||||
src={opt.iconSrc}
|
||||
alt=""
|
||||
className={cn(
|
||||
"max-h-12 w-14 object-contain",
|
||||
opt.format === "mobaxterm" && "w-16",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{opt.label}
|
||||
</div>
|
||||
{t("common.back")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-sm font-medium text-center text-muted-foreground">
|
||||
{t("vault.import.chooseFormat")}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 pt-2 border-t border-border/60">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("vault.import.csv.tip")}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={downloadCsvTemplate}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
{t("vault.import.csv.downloadTemplate")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3">
|
||||
{OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.format}
|
||||
type="button"
|
||||
className={cn(
|
||||
"group rounded-2xl border border-border/60 bg-background",
|
||||
"px-3 py-4 hover:bg-muted/30 hover:border-border transition-colors",
|
||||
"flex flex-col items-center gap-3",
|
||||
)}
|
||||
onClick={() => handleFormatClick(opt)}
|
||||
>
|
||||
<div className="h-16 flex items-center justify-center">
|
||||
<img
|
||||
src={opt.iconSrc}
|
||||
alt=""
|
||||
className={cn(
|
||||
"max-h-12 w-14 object-contain",
|
||||
opt.format === "mobaxterm" && "w-16",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{opt.label}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 pt-2 border-t border-border/60">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("vault.import.csv.tip")}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={downloadCsvTemplate}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
{t("vault.import.csv.downloadTemplate")}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -94,6 +94,8 @@ export interface Host {
|
||||
// SFTP specific configuration
|
||||
sftpSudo?: boolean; // Use sudo for SFTP operations (requires password)
|
||||
sftpEncoding?: SftpFilenameEncoding; // Filename encoding for SFTP operations
|
||||
// Managed source: if this host is managed by an external file (e.g., ~/.ssh/config)
|
||||
managedSourceId?: string; // Reference to ManagedSource.id
|
||||
}
|
||||
|
||||
export type KeyType = 'RSA' | 'ECDSA' | 'ED25519';
|
||||
@@ -309,7 +311,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
|
||||
@@ -320,11 +322,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' },
|
||||
];
|
||||
|
||||
@@ -654,3 +656,15 @@ export interface SessionLogsSettings {
|
||||
directory: string; // Base directory for logs
|
||||
format: SessionLogFormat; // Log file format
|
||||
}
|
||||
|
||||
// Managed Source - external file that manages a group of hosts (e.g., ~/.ssh/config)
|
||||
export type ManagedSourceType = 'ssh_config';
|
||||
|
||||
export interface ManagedSource {
|
||||
id: string;
|
||||
type: ManagedSourceType;
|
||||
filePath: string;
|
||||
groupName: string;
|
||||
lastSyncedAt: number;
|
||||
lastFileHash?: string;
|
||||
}
|
||||
|
||||
222
domain/sshConfigSerializer.ts
Normal file
222
domain/sshConfigSerializer.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { Host } from "./models";
|
||||
|
||||
const DEFAULT_SSH_PORT = 22;
|
||||
const MANAGED_BLOCK_BEGIN = "# BEGIN NETCATTY MANAGED - DO NOT EDIT THIS BLOCK";
|
||||
const MANAGED_BLOCK_END = "# END NETCATTY MANAGED";
|
||||
|
||||
/**
|
||||
* Check if a string is an IPv6 address
|
||||
*/
|
||||
const isIPv6 = (hostname: string): boolean => {
|
||||
// IPv6 addresses contain colons and may be wrapped in brackets
|
||||
return hostname.includes(':') && !hostname.startsWith('[');
|
||||
};
|
||||
|
||||
/**
|
||||
* Serialize a single jump host to ProxyJump format
|
||||
* Format: [user@]host[:port]
|
||||
* @param host - The jump host to serialize
|
||||
* @param managedHostIds - Set of host IDs that have Host blocks in the managed config
|
||||
*/
|
||||
const serializeJumpHost = (host: Host, managedHostIds: Set<string>): string => {
|
||||
let result = "";
|
||||
if (host.username) {
|
||||
result += `${host.username}@`;
|
||||
}
|
||||
|
||||
// Only use label as alias if this jump host is in the managed hosts (has a Host block)
|
||||
// and sanitize it by removing spaces. Otherwise use hostname directly.
|
||||
let hostPart: string;
|
||||
if (managedHostIds.has(host.id) && host.label) {
|
||||
// Use sanitized label (same as the Host block alias)
|
||||
hostPart = host.label.replace(/\s/g, '') || host.hostname;
|
||||
} else {
|
||||
// Jump host is outside managed config, use hostname directly
|
||||
hostPart = host.hostname;
|
||||
}
|
||||
|
||||
// For IPv6 addresses, always wrap in brackets to disambiguate colons
|
||||
// OpenSSH requires brackets for IPv6 in ProxyJump regardless of port
|
||||
if (isIPv6(hostPart)) {
|
||||
result += `[${hostPart}]`;
|
||||
if (host.port && host.port !== DEFAULT_SSH_PORT) {
|
||||
result += `:${host.port}`;
|
||||
}
|
||||
} else {
|
||||
result += hostPart;
|
||||
if (host.port && host.port !== DEFAULT_SSH_PORT) {
|
||||
result += `:${host.port}`;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build ProxyJump directive from hostChain
|
||||
* @param host - The host with hostChain
|
||||
* @param allHosts - All hosts to look up jump host details
|
||||
* @param managedHostIds - Set of host IDs that have Host blocks in the managed config
|
||||
* @returns ProxyJump value string or null if chain is empty/invalid
|
||||
*/
|
||||
const buildProxyJumpValue = (
|
||||
host: Host,
|
||||
allHosts: Host[],
|
||||
managedHostIds: Set<string>,
|
||||
): string | null => {
|
||||
if (!host.hostChain?.hostIds || host.hostChain.hostIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hostMap = new Map(allHosts.map(h => [h.id, h]));
|
||||
const jumpParts: string[] = [];
|
||||
|
||||
for (const jumpHostId of host.hostChain.hostIds) {
|
||||
const jumpHost = hostMap.get(jumpHostId);
|
||||
if (jumpHost) {
|
||||
jumpParts.push(serializeJumpHost(jumpHost, managedHostIds));
|
||||
}
|
||||
}
|
||||
|
||||
return jumpParts.length > 0 ? jumpParts.join(",") : null;
|
||||
};
|
||||
|
||||
export const serializeHostsToSshConfig = (hosts: Host[], allHosts?: Host[]): string => {
|
||||
const blocks: string[] = [];
|
||||
// Use provided allHosts for jump host lookup, or fall back to hosts array
|
||||
const hostsForLookup = allHosts || hosts;
|
||||
|
||||
// Build set of managed host IDs (SSH hosts that will have Host blocks)
|
||||
const managedHostIds = new Set(
|
||||
hosts
|
||||
.filter(h => !h.protocol || h.protocol === "ssh")
|
||||
.map(h => h.id)
|
||||
);
|
||||
|
||||
for (const host of hosts) {
|
||||
if (host.protocol && host.protocol !== "ssh") continue;
|
||||
|
||||
const lines: string[] = [];
|
||||
// Sanitize alias by removing spaces (SSH config doesn't allow spaces in Host patterns)
|
||||
const alias = (host.label?.replace(/\s/g, '') || host.hostname);
|
||||
lines.push(`Host ${alias}`);
|
||||
|
||||
if (host.hostname !== alias) {
|
||||
lines.push(` HostName ${host.hostname}`);
|
||||
}
|
||||
|
||||
if (host.username) {
|
||||
lines.push(` User ${host.username}`);
|
||||
}
|
||||
|
||||
if (host.port && host.port !== DEFAULT_SSH_PORT) {
|
||||
lines.push(` Port ${host.port}`);
|
||||
}
|
||||
|
||||
// Serialize ProxyJump if host has a chain
|
||||
const proxyJumpValue = buildProxyJumpValue(host, hostsForLookup, managedHostIds);
|
||||
if (proxyJumpValue) {
|
||||
lines.push(` ProxyJump ${proxyJumpValue}`);
|
||||
}
|
||||
|
||||
blocks.push(lines.join("\n"));
|
||||
}
|
||||
|
||||
return blocks.join("\n\n") + "\n";
|
||||
};
|
||||
|
||||
export const mergeWithExistingSshConfig = (
|
||||
existingContent: string,
|
||||
managedHosts: Host[],
|
||||
managedHostnameSet: Set<string>,
|
||||
allHosts?: Host[],
|
||||
): string => {
|
||||
const lines = existingContent.split(/\r?\n/);
|
||||
const preservedBlocks: string[] = [];
|
||||
// Track preamble lines (comments/blank lines before first Host/Match block)
|
||||
let preambleLines: string[] = [];
|
||||
let seenFirstBlock = false;
|
||||
let currentBlock: string[] = [];
|
||||
let currentHostPatterns: string[] = [];
|
||||
let isMatchBlock = false; // Track if current block is a Match block (always preserve)
|
||||
|
||||
const flush = () => {
|
||||
if (currentBlock.length > 0) {
|
||||
// Match blocks are always preserved (we don't manage them)
|
||||
if (isMatchBlock) {
|
||||
preservedBlocks.push(currentBlock.join("\n"));
|
||||
} else {
|
||||
// Filter out managed patterns from the Host line, keep non-managed ones
|
||||
const nonManagedPatterns = currentHostPatterns.filter(
|
||||
(p) => !managedHostnameSet.has(p.toLowerCase())
|
||||
);
|
||||
|
||||
if (nonManagedPatterns.length === currentHostPatterns.length) {
|
||||
// No managed patterns - preserve the entire block as-is
|
||||
preservedBlocks.push(currentBlock.join("\n"));
|
||||
} else if (nonManagedPatterns.length > 0) {
|
||||
// Some patterns are managed, some are not - rewrite Host line with only non-managed patterns
|
||||
const newHostLine = `Host ${nonManagedPatterns.join(" ")}`;
|
||||
const restOfBlock = currentBlock.slice(1); // Everything after Host line
|
||||
preservedBlocks.push([newHostLine, ...restOfBlock].join("\n"));
|
||||
}
|
||||
// If all patterns are managed (nonManagedPatterns.length === 0), drop the entire block
|
||||
}
|
||||
|
||||
currentBlock = [];
|
||||
currentHostPatterns = [];
|
||||
isMatchBlock = false;
|
||||
}
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.replace(/#.*/, "").trim();
|
||||
|
||||
const tokens = trimmed.split(/\s+/).filter(Boolean);
|
||||
const keyword = tokens[0]?.toLowerCase();
|
||||
|
||||
if (keyword === "host") {
|
||||
flush();
|
||||
seenFirstBlock = true;
|
||||
currentHostPatterns = tokens.slice(1);
|
||||
currentBlock.push(line);
|
||||
} else if (keyword === "match") {
|
||||
flush();
|
||||
seenFirstBlock = true;
|
||||
isMatchBlock = true;
|
||||
currentBlock.push(line);
|
||||
} else if (!seenFirstBlock) {
|
||||
// Preserve preamble lines (comments, blank lines before first block)
|
||||
preambleLines.push(line);
|
||||
} else if (currentBlock.length > 0) {
|
||||
// Inside a block - add to current block
|
||||
currentBlock.push(line);
|
||||
} else {
|
||||
// Between blocks (comments/blank lines after a block ended)
|
||||
// These will be included with the next block or preserved separately
|
||||
currentBlock.push(line);
|
||||
}
|
||||
}
|
||||
flush();
|
||||
|
||||
const managedContent = serializeHostsToSshConfig(managedHosts, allHosts);
|
||||
const managedBlock = `${MANAGED_BLOCK_BEGIN}\n${managedContent}${MANAGED_BLOCK_END}\n`;
|
||||
const preserved = preservedBlocks.join("\n\n");
|
||||
|
||||
// Build final output: preamble + preserved blocks + managed block
|
||||
const parts: string[] = [];
|
||||
|
||||
// Add preamble if it has content (trim trailing empty lines but keep structure)
|
||||
const preamble = preambleLines.join("\n");
|
||||
if (preamble.trim()) {
|
||||
parts.push(preamble);
|
||||
}
|
||||
|
||||
if (preserved.trim()) {
|
||||
parts.push(preserved);
|
||||
}
|
||||
|
||||
parts.push(managedBlock);
|
||||
|
||||
return parts.join("\n\n");
|
||||
};
|
||||
@@ -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']
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
556
electron/bridges/compressUploadBridge.cjs
Normal file
556
electron/bridges/compressUploadBridge.cjs
Normal file
@@ -0,0 +1,556 @@
|
||||
/**
|
||||
* Compress Upload Bridge - Handles folder compression and upload
|
||||
*
|
||||
* Compresses folders locally using tar, uploads the archive, then extracts on remote server
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { spawn } = require("node:child_process");
|
||||
const { getTempFilePath } = require("./tempDirBridge.cjs");
|
||||
|
||||
/**
|
||||
* Escape shell arguments to prevent injection attacks
|
||||
* Wraps arguments in single quotes and escapes any existing single quotes
|
||||
*/
|
||||
function escapeShellArg(arg) {
|
||||
// Replace single quotes with '\'' (end quote, escaped quote, start quote)
|
||||
return "'" + arg.replace(/'/g, "'\\''") + "'";
|
||||
}
|
||||
|
||||
// Shared references
|
||||
let sftpClients = null;
|
||||
let transferBridge = null;
|
||||
|
||||
// Active compress operations
|
||||
const activeCompressions = new Map();
|
||||
|
||||
/**
|
||||
* Initialize the compress upload bridge with dependencies
|
||||
*/
|
||||
function init(deps) {
|
||||
sftpClients = deps.sftpClients;
|
||||
transferBridge = deps.transferBridge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tar command is available on the system
|
||||
*/
|
||||
async function checkTarAvailable() {
|
||||
return new Promise((resolve) => {
|
||||
const tar = spawn('tar', ['--version'], { stdio: 'ignore' });
|
||||
tar.on('close', (code) => {
|
||||
resolve(code === 0);
|
||||
});
|
||||
tar.on('error', () => {
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if tar command is available on remote server
|
||||
*/
|
||||
async function checkRemoteTarAvailable(sftpId) {
|
||||
try {
|
||||
const client = sftpClients.get(sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
// Try to execute tar --version via SSH
|
||||
const sshClient = client.client; // Get underlying SSH2 client
|
||||
if (!sshClient) throw new Error("SSH client not available");
|
||||
|
||||
return new Promise((resolve) => {
|
||||
sshClient.exec('tar --version', (err, stream) => {
|
||||
if (err) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let hasOutput = false;
|
||||
stream.on('data', () => {
|
||||
hasOutput = true;
|
||||
});
|
||||
|
||||
stream.on('close', (code) => {
|
||||
resolve(code === 0 && hasOutput);
|
||||
});
|
||||
|
||||
stream.on('error', () => {
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compress a folder using tar
|
||||
*/
|
||||
async function compressFolder(folderPath, outputPath, compressionId, sendProgress) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const compression = activeCompressions.get(compressionId);
|
||||
if (!compression) {
|
||||
reject(new Error('Compression cancelled'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Use tar with gzip compression, excluding macOS resource fork files
|
||||
// -czf: create, gzip, file
|
||||
// -C: change to directory (so we don't include the full path in archive)
|
||||
// --exclude='._*': exclude macOS resource fork files
|
||||
// --exclude='.DS_Store': exclude macOS folder metadata files
|
||||
const folderName = path.basename(folderPath);
|
||||
const parentDir = path.dirname(folderPath);
|
||||
|
||||
const tar = spawn('tar', [
|
||||
'-czf', outputPath,
|
||||
'-C', parentDir,
|
||||
'--exclude=._*',
|
||||
'--exclude=.DS_Store',
|
||||
'--exclude=.Spotlight-V100',
|
||||
'--exclude=.Trashes',
|
||||
folderName
|
||||
], {
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
compression.process = tar;
|
||||
let stderr = '';
|
||||
|
||||
// Monitor progress by checking output file size periodically
|
||||
const progressInterval = setInterval(async () => {
|
||||
if (compression.cancelled) {
|
||||
clearInterval(progressInterval);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fs.promises.stat(outputPath);
|
||||
// We don't know the final size, so we'll show indeterminate progress
|
||||
sendProgress(stat.size, 0); // 0 means indeterminate
|
||||
} catch {
|
||||
// File doesn't exist yet, ignore
|
||||
}
|
||||
}, 500);
|
||||
|
||||
tar.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
tar.on('close', (code) => {
|
||||
clearInterval(progressInterval);
|
||||
|
||||
if (compression.cancelled) {
|
||||
// Clean up output file if cancelled
|
||||
fs.promises.unlink(outputPath).catch(() => {});
|
||||
reject(new Error('Compression cancelled'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Tar compression failed: ${stderr}`));
|
||||
}
|
||||
});
|
||||
|
||||
tar.on('error', (err) => {
|
||||
clearInterval(progressInterval);
|
||||
reject(new Error(`Failed to start tar: ${err.message}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract archive on remote server
|
||||
* @param {string} sftpId - SFTP session ID
|
||||
* @param {string} archivePath - Path to the archive on remote server
|
||||
* @param {string} targetDir - Target directory for extraction
|
||||
* @param {number} [archiveSize] - Size of the archive in bytes (optional, for timeout calculation)
|
||||
*/
|
||||
async function extractRemoteArchive(sftpId, archivePath, targetDir, archiveSize) {
|
||||
const client = sftpClients.get(sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
const sshClient = client.client;
|
||||
if (!sshClient) throw new Error("SSH client not available");
|
||||
|
||||
// Calculate timeout based on archive size
|
||||
// Base: 60 seconds minimum
|
||||
// Add 30 seconds per 10MB of archive size
|
||||
// Maximum: 10 minutes to prevent excessively long waits
|
||||
const baseTimeout = 60000; // 60 seconds minimum
|
||||
const maxTimeout = 600000; // 10 minutes maximum
|
||||
const sizeBasedTimeout = archiveSize ? Math.ceil(archiveSize / (10 * 1024 * 1024)) * 30000 : 0;
|
||||
const extractionTimeout = Math.min(maxTimeout, Math.max(baseTimeout, baseTimeout + sizeBasedTimeout));
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// Create target directory, extract, then always clean up the archive
|
||||
// Use && for tar success, then always try cleanup regardless of tar result
|
||||
// Also exclude any ._* files that might have been included despite our compression exclusions
|
||||
// Properly escape shell arguments to prevent injection attacks
|
||||
const escapedTargetDir = escapeShellArg(targetDir);
|
||||
const escapedArchivePath = escapeShellArg(archivePath);
|
||||
const command = `mkdir -p ${escapedTargetDir} && cd ${escapedTargetDir} && tar -xzf ${escapedArchivePath} --exclude='._*' --exclude='.DS_Store' && rm -f ${escapedArchivePath} || (rm -f ${escapedArchivePath}; exit 1)`;
|
||||
|
||||
sshClient.exec(command, (err, stream) => {
|
||||
if (err) {
|
||||
reject(new Error(`Failed to execute extraction command: ${err.message}`));
|
||||
return;
|
||||
}
|
||||
|
||||
let stderr = '';
|
||||
let resolved = false;
|
||||
|
||||
stream.on('data', () => {
|
||||
// stdout not needed, just consume the data
|
||||
});
|
||||
|
||||
stream.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
stream.on('close', (code) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
||||
// The command uses `;` and `||` so cleanup should always run
|
||||
// We only care about the tar extraction success (first part of command)
|
||||
// The rm commands are just cleanup and their failure doesn't matter
|
||||
|
||||
// For most cases, code 0 means success
|
||||
// If code is not 0, check if it's just cleanup failure
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
// Check if the error is from tar extraction or just cleanup
|
||||
// If stderr contains tar errors, it's a real extraction failure
|
||||
if (stderr.includes('tar:') || stderr.includes('gzip:') || stderr.includes('Cannot open:') || stderr.includes('not found in archive')) {
|
||||
reject(new Error(`Remote extraction failed: ${stderr || 'Tar extraction error'}`));
|
||||
} else {
|
||||
// Likely just cleanup failure - consider it successful if no tar-specific errors
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('error', (err) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
|
||||
clearTimeout(timeout);
|
||||
reject(new Error(`Stream error: ${err.message}`));
|
||||
});
|
||||
|
||||
// Add timeout to prevent hanging (uses dynamic timeout based on archive size)
|
||||
const timeout = setTimeout(() => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
|
||||
reject(new Error(`Remote extraction timed out after ${extractionTimeout / 1000} seconds`));
|
||||
}, extractionTimeout);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start compressed folder upload
|
||||
*/
|
||||
async function startCompressedUpload(event, payload) {
|
||||
const {
|
||||
compressionId,
|
||||
folderPath,
|
||||
targetPath,
|
||||
sftpId,
|
||||
folderName
|
||||
} = payload;
|
||||
const sender = event.sender;
|
||||
|
||||
// Register compression for cancellation
|
||||
const compression = { cancelled: false, process: null };
|
||||
activeCompressions.set(compressionId, compression);
|
||||
|
||||
const sendProgress = (phase, transferred, total) => {
|
||||
if (compression.cancelled) return;
|
||||
sender.send("netcatty:compress:progress", {
|
||||
compressionId,
|
||||
phase,
|
||||
transferred,
|
||||
total
|
||||
});
|
||||
};
|
||||
|
||||
const sendComplete = () => {
|
||||
// Send final 100% progress before completion
|
||||
if (!compression.cancelled) {
|
||||
sender.send("netcatty:compress:progress", {
|
||||
compressionId,
|
||||
phase: 'extracting',
|
||||
transferred: 100,
|
||||
total: 100
|
||||
});
|
||||
}
|
||||
activeCompressions.delete(compressionId);
|
||||
sender.send("netcatty:compress:complete", { compressionId });
|
||||
};
|
||||
|
||||
const sendError = (error) => {
|
||||
activeCompressions.delete(compressionId);
|
||||
sender.send("netcatty:compress:error", {
|
||||
compressionId,
|
||||
error: error.message || String(error)
|
||||
});
|
||||
};
|
||||
|
||||
// Declare tempArchivePath in outer scope for cleanup access
|
||||
let tempArchivePath = null;
|
||||
|
||||
try {
|
||||
// Check if tar is available locally and remotely
|
||||
const localTarAvailable = await checkTarAvailable();
|
||||
if (!localTarAvailable) {
|
||||
throw new Error("tar command not available on local system. Please install tar.");
|
||||
}
|
||||
|
||||
const remoteTarAvailable = await checkRemoteTarAvailable(sftpId);
|
||||
if (!remoteTarAvailable) {
|
||||
throw new Error("tar command not available on remote server. Please install tar on the remote system.");
|
||||
}
|
||||
|
||||
// Phase 1: Compression (0-30%)
|
||||
sendProgress('compressing', 0, 100);
|
||||
|
||||
tempArchivePath = getTempFilePath(`${folderName}.tar.gz`);
|
||||
|
||||
await compressFolder(folderPath, tempArchivePath, compressionId, (transferred) => {
|
||||
// Show compression progress (0-30%)
|
||||
sendProgress('compressing', Math.min(30, transferred / 1024 / 1024), 100);
|
||||
});
|
||||
|
||||
if (compression.cancelled) {
|
||||
try {
|
||||
await fs.promises.unlink(tempArchivePath);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
throw new Error('Upload cancelled');
|
||||
}
|
||||
|
||||
// Get compressed file size
|
||||
const stat = await fs.promises.stat(tempArchivePath);
|
||||
const compressedSize = stat.size;
|
||||
|
||||
sendProgress('compressing', 30, 100);
|
||||
|
||||
// Phase 2: Upload (30-90%)
|
||||
sendProgress('uploading', 30, 100);
|
||||
|
||||
const remoteArchivePath = `${targetPath}/${folderName}.tar.gz`;
|
||||
|
||||
// Use existing transfer bridge for upload with progress
|
||||
const transferId = `compress-${compressionId}`;
|
||||
|
||||
// Progress callback to map upload progress to 30-90%
|
||||
const onUploadProgress = (transferred, total, _speed) => {
|
||||
if (compression.cancelled) return;
|
||||
const uploadProgress = Math.min(60, (transferred / total) * 60);
|
||||
sendProgress('uploading', 30 + uploadProgress, 100);
|
||||
};
|
||||
|
||||
// Start the transfer with progress callback
|
||||
await transferBridge.startTransfer(event, {
|
||||
transferId,
|
||||
sourcePath: tempArchivePath,
|
||||
targetPath: remoteArchivePath,
|
||||
sourceType: 'local',
|
||||
targetType: 'sftp',
|
||||
targetSftpId: sftpId,
|
||||
totalBytes: compressedSize
|
||||
}, onUploadProgress);
|
||||
|
||||
if (compression.cancelled) {
|
||||
await fs.promises.unlink(tempArchivePath).catch(() => {});
|
||||
throw new Error('Upload cancelled');
|
||||
}
|
||||
|
||||
// Upload completed, update to 90%
|
||||
sendProgress('uploading', 90, 100);
|
||||
|
||||
// Phase 3: Extraction (90-100%)
|
||||
sendProgress('extracting', 90, 100);
|
||||
|
||||
await extractRemoteArchive(sftpId, remoteArchivePath, targetPath, compressedSize);
|
||||
|
||||
// Update progress to 95% after extraction
|
||||
sendProgress('extracting', 95, 100);
|
||||
|
||||
// Perform cleanup operations asynchronously without blocking completion
|
||||
// Note: These cleanup operations are best-effort; if the SFTP session closes before
|
||||
// cleanup completes, errors will be silently ignored
|
||||
setImmediate(async () => {
|
||||
// Additional cleanup: remove any ._* files that might have been extracted
|
||||
try {
|
||||
const client = sftpClients.get(sftpId);
|
||||
// Check both that client exists and connection is still open
|
||||
if (client && client.client && client.client.writable !== false) {
|
||||
const cleanupCommand = `find ${escapeShellArg(targetPath)} -name "._*" -type f -delete 2>/dev/null || true`;
|
||||
client.client.exec(cleanupCommand, (err, stream) => {
|
||||
if (err) {
|
||||
// Silently ignore - session may have closed
|
||||
return;
|
||||
}
|
||||
|
||||
stream.on('close', () => {
|
||||
// Cleanup completed
|
||||
});
|
||||
|
||||
stream.on('error', () => {
|
||||
// Silently ignore cleanup errors
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore cleanup errors
|
||||
}
|
||||
|
||||
// Additional cleanup attempt - ensure remote archive is removed
|
||||
try {
|
||||
const client = sftpClients.get(sftpId);
|
||||
if (client && client.client && client.client.writable !== false) {
|
||||
client.client.exec(`rm -f ${escapeShellArg(remoteArchivePath)}`, (err, stream) => {
|
||||
if (err) {
|
||||
// Silently ignore - session may have closed
|
||||
return;
|
||||
}
|
||||
|
||||
stream.on('close', () => {
|
||||
// Cleanup completed
|
||||
});
|
||||
|
||||
stream.on('error', () => {
|
||||
// Silently ignore cleanup errors
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore cleanup errors
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up local temp file
|
||||
try {
|
||||
await fs.promises.unlink(tempArchivePath);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
|
||||
// Check if cancelled during extraction before reporting completion
|
||||
if (compression.cancelled) {
|
||||
sender.send("netcatty:compress:cancelled", { compressionId });
|
||||
return { compressionId, cancelled: true };
|
||||
}
|
||||
|
||||
sendComplete();
|
||||
|
||||
return { compressionId, success: true };
|
||||
} catch (err) {
|
||||
// Clean up local temp file if it exists
|
||||
if (tempArchivePath) {
|
||||
try {
|
||||
await fs.promises.unlink(tempArchivePath);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
if (err.message === 'Upload cancelled' || err.message === 'Compression cancelled' || err.message === 'Transfer cancelled') {
|
||||
activeCompressions.delete(compressionId);
|
||||
sender.send("netcatty:compress:cancelled", { compressionId });
|
||||
} else {
|
||||
sendError(err.message || 'Unknown error occurred');
|
||||
}
|
||||
return { compressionId, error: err.message };
|
||||
} finally {
|
||||
// Always clean up the active compression entry
|
||||
activeCompressions.delete(compressionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a compression operation
|
||||
*/
|
||||
async function cancelCompression(event, payload) {
|
||||
const { compressionId } = payload;
|
||||
const compression = activeCompressions.get(compressionId);
|
||||
|
||||
if (compression) {
|
||||
compression.cancelled = true;
|
||||
|
||||
// Kill the tar process if running
|
||||
if (compression.process) {
|
||||
try {
|
||||
compression.process.kill('SIGTERM');
|
||||
} catch {
|
||||
// Ignore errors when killing process
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel the associated transfer if it's running
|
||||
const transferId = `compress-${compressionId}`;
|
||||
if (transferBridge && transferBridge.cancelTransfer) {
|
||||
try {
|
||||
await transferBridge.cancelTransfer(event, { transferId });
|
||||
} catch {
|
||||
// Ignore errors when cancelling transfer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if compressed upload is supported (tar available on both local and remote)
|
||||
*/
|
||||
async function checkCompressedUploadSupport(event, payload) {
|
||||
const { sftpId } = payload;
|
||||
|
||||
try {
|
||||
const localSupport = await checkTarAvailable();
|
||||
const remoteSupport = await checkRemoteTarAvailable(sftpId);
|
||||
|
||||
return {
|
||||
supported: localSupport && remoteSupport,
|
||||
localTar: localSupport,
|
||||
remoteTar: remoteSupport
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
supported: false,
|
||||
localTar: false,
|
||||
remoteTar: false,
|
||||
error: err.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register IPC handlers
|
||||
*/
|
||||
function registerHandlers(ipcMain) {
|
||||
ipcMain.handle("netcatty:compress:start", startCompressedUpload);
|
||||
ipcMain.handle("netcatty:compress:cancel", cancelCompression);
|
||||
ipcMain.handle("netcatty:compress:checkSupport", checkCompressedUploadSupport);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init,
|
||||
registerHandlers,
|
||||
checkTarAvailable,
|
||||
checkRemoteTarAvailable,
|
||||
};
|
||||
@@ -6,19 +6,23 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
const { execSync } = require("node:child_process");
|
||||
const { exec } = require("node:child_process");
|
||||
const { promisify } = require("node:util");
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
/**
|
||||
* Check if a file is hidden on Windows using the attrib command
|
||||
* Returns true if the file has the hidden attribute set
|
||||
* Uses async exec to avoid blocking the main process
|
||||
*/
|
||||
function isWindowsHiddenFile(filePath) {
|
||||
async function isWindowsHiddenFile(filePath) {
|
||||
if (process.platform !== "win32") return false;
|
||||
try {
|
||||
const output = execSync(`attrib "${filePath}"`, { encoding: "utf8" });
|
||||
const { stdout } = await execAsync(`attrib "${filePath}"`);
|
||||
// attrib output format: " H R filename" where H = hidden, R = read-only, etc.
|
||||
// The attributes appear in the first ~10 characters before the path
|
||||
const attrPart = output.substring(0, output.indexOf(filePath)).toUpperCase();
|
||||
const attrPart = stdout.substring(0, stdout.indexOf(filePath)).toUpperCase();
|
||||
return attrPart.includes("H");
|
||||
} catch (err) {
|
||||
console.warn(`Could not check hidden attribute for ${filePath}:`, err.message);
|
||||
@@ -67,7 +71,7 @@ async function listLocalDir(event, payload) {
|
||||
}
|
||||
|
||||
// Check for Windows hidden attribute
|
||||
const hidden = isWindows ? isWindowsHiddenFile(fullPath) : false;
|
||||
const hidden = isWindows ? await isWindowsHiddenFile(fullPath) : false;
|
||||
|
||||
result[i] = {
|
||||
name: entry.name,
|
||||
@@ -86,7 +90,7 @@ async function listLocalDir(event, payload) {
|
||||
const lstat = await fs.promises.lstat(fullPath);
|
||||
if (lstat.isSymbolicLink()) {
|
||||
// Broken symlink
|
||||
const hidden = isWindows ? isWindowsHiddenFile(fullPath) : false;
|
||||
const hidden = isWindows ? await isWindowsHiddenFile(fullPath) : false;
|
||||
result[i] = {
|
||||
name: brokenEntry.name,
|
||||
type: "symlink",
|
||||
|
||||
141
electron/bridges/passphraseHandler.cjs
Normal file
141
electron/bridges/passphraseHandler.cjs
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Passphrase Handler - Handles passphrase requests for encrypted SSH keys
|
||||
* This module provides a mechanism to request passphrase input from the user
|
||||
* when encountering encrypted default SSH keys in ~/.ssh
|
||||
*/
|
||||
|
||||
// Passphrase request pending map
|
||||
// Map of requestId -> { resolveCallback, rejectCallback, webContentsId, keyPath, createdAt, timeoutId }
|
||||
const passphraseRequests = new Map();
|
||||
|
||||
// TTL for abandoned requests (2 minutes)
|
||||
const REQUEST_TTL_MS = 2 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Generate a unique request ID for passphrase requests
|
||||
*/
|
||||
function generateRequestId(prefix = 'pp') {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request passphrase from user via IPC
|
||||
* @param {Object} sender - Electron webContents sender
|
||||
* @param {string} keyPath - Path to the encrypted key
|
||||
* @param {string} keyName - Name of the key (e.g., id_rsa)
|
||||
* @param {string} [hostname] - Optional hostname for context
|
||||
* @returns {Promise<{ passphrase?: string, cancelled?: boolean, skipped?: boolean } | null>}
|
||||
*/
|
||||
function requestPassphrase(sender, keyPath, keyName, hostname) {
|
||||
return new Promise((resolve) => {
|
||||
if (!sender || sender.isDestroyed()) {
|
||||
console.warn('[Passphrase] Sender is destroyed, cannot request passphrase');
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = generateRequestId();
|
||||
|
||||
// Set up TTL timeout to clean up abandoned requests
|
||||
const timeoutId = setTimeout(() => {
|
||||
const pending = passphraseRequests.get(requestId);
|
||||
if (pending) {
|
||||
console.warn(`[Passphrase] Request ${requestId} timed out after ${REQUEST_TTL_MS / 1000}s`);
|
||||
passphraseRequests.delete(requestId);
|
||||
|
||||
// Notify renderer to close the modal
|
||||
try {
|
||||
if (!sender.isDestroyed()) {
|
||||
sender.send('netcatty:passphrase-timeout', { requestId });
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[Passphrase] Failed to send timeout notification:', err.message);
|
||||
}
|
||||
|
||||
resolve(null);
|
||||
}
|
||||
}, REQUEST_TTL_MS);
|
||||
|
||||
passphraseRequests.set(requestId, {
|
||||
resolveCallback: resolve,
|
||||
webContentsId: sender.id,
|
||||
keyPath,
|
||||
keyName,
|
||||
createdAt: Date.now(),
|
||||
timeoutId,
|
||||
});
|
||||
|
||||
console.log(`[Passphrase] Requesting passphrase for ${keyName} (${requestId})`);
|
||||
|
||||
try {
|
||||
sender.send('netcatty:passphrase-request', {
|
||||
requestId,
|
||||
keyPath,
|
||||
keyName,
|
||||
hostname,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Passphrase] Failed to send passphrase request:', err);
|
||||
passphraseRequests.delete(requestId);
|
||||
clearTimeout(timeoutId);
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle passphrase response from renderer
|
||||
*/
|
||||
function handleResponse(_event, payload) {
|
||||
const { requestId, passphrase, cancelled, skipped } = payload;
|
||||
const pending = passphraseRequests.get(requestId);
|
||||
|
||||
if (!pending) {
|
||||
console.warn(`[Passphrase] No pending request for ${requestId}`);
|
||||
return { success: false, error: 'Request not found' };
|
||||
}
|
||||
|
||||
// Clear the TTL timeout
|
||||
if (pending.timeoutId) {
|
||||
clearTimeout(pending.timeoutId);
|
||||
}
|
||||
|
||||
passphraseRequests.delete(requestId);
|
||||
|
||||
if (cancelled) {
|
||||
// User clicked Cancel - stop the entire passphrase flow
|
||||
console.log(`[Passphrase] Request ${requestId} cancelled by user`);
|
||||
pending.resolveCallback({ cancelled: true });
|
||||
} else if (skipped) {
|
||||
// User clicked Skip - skip this key but continue with others
|
||||
console.log(`[Passphrase] Request ${requestId} skipped by user`);
|
||||
pending.resolveCallback({ skipped: true });
|
||||
} else {
|
||||
console.log(`[Passphrase] Received passphrase for ${requestId}`);
|
||||
pending.resolveCallback({ passphrase: passphrase || null });
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Register IPC handler for passphrase responses
|
||||
*/
|
||||
function registerHandler(ipcMain) {
|
||||
ipcMain.handle('netcatty:passphrase:respond', handleResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending requests (for debugging)
|
||||
*/
|
||||
function getRequests() {
|
||||
return passphraseRequests;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateRequestId,
|
||||
requestPassphrase,
|
||||
handleResponse,
|
||||
registerHandler,
|
||||
getRequests,
|
||||
};
|
||||
@@ -6,6 +6,11 @@
|
||||
const net = require("node:net");
|
||||
const { Client: SSHClient } = require("ssh2");
|
||||
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
const {
|
||||
buildAuthHandler,
|
||||
createKeyboardInteractiveHandler,
|
||||
applyAuthToConnOpts,
|
||||
} = require("./sshAuthHelper.cjs");
|
||||
|
||||
// Active port forwarding tunnels
|
||||
const portForwardingTunnels = new Map();
|
||||
@@ -38,6 +43,7 @@ async function startPortForward(event, payload) {
|
||||
username,
|
||||
password,
|
||||
privateKey,
|
||||
passphrase,
|
||||
} = payload;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -63,59 +69,31 @@ async function startPortForward(event, payload) {
|
||||
if (privateKey) {
|
||||
connectOpts.privateKey = privateKey;
|
||||
}
|
||||
if (passphrase) {
|
||||
connectOpts.passphrase = passphrase;
|
||||
}
|
||||
if (password) {
|
||||
connectOpts.password = password;
|
||||
}
|
||||
|
||||
// Build auth handler with keyboard-interactive support
|
||||
const authMethods = [];
|
||||
if (privateKey) authMethods.push("publickey");
|
||||
if (password) authMethods.push("password");
|
||||
authMethods.push("keyboard-interactive");
|
||||
connectOpts.authHandler = authMethods;
|
||||
// Build auth handler using shared helper
|
||||
const authConfig = buildAuthHandler({
|
||||
privateKey,
|
||||
password,
|
||||
passphrase,
|
||||
username: connectOpts.username,
|
||||
logPrefix: "[PortForward]",
|
||||
});
|
||||
applyAuthToConnOpts(connectOpts, authConfig);
|
||||
|
||||
// Handle keyboard-interactive authentication (2FA/MFA)
|
||||
conn.on("keyboard-interactive", (name, instructions, instructionsLang, prompts, finish) => {
|
||||
console.log(`[PortForward] ${hostname} keyboard-interactive auth requested`, {
|
||||
name,
|
||||
instructions,
|
||||
promptCount: prompts?.length || 0,
|
||||
prompts: prompts?.map(p => ({ prompt: p.prompt, echo: p.echo })),
|
||||
});
|
||||
|
||||
// If there are no prompts, just call finish with empty array
|
||||
if (!prompts || prompts.length === 0) {
|
||||
console.log(`[PortForward] No prompts, finishing keyboard-interactive`);
|
||||
finish([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward ALL prompts to user - no auto-fill to avoid semantic detection issues
|
||||
// (Prompt text is admin-customizable and may not contain expected keywords)
|
||||
const requestId = keyboardInteractiveHandler.generateRequestId('pf');
|
||||
|
||||
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
|
||||
console.log(`[PortForward] Received user responses, finishing keyboard-interactive`);
|
||||
finish(userResponses);
|
||||
}, sender.id, tunnelId);
|
||||
|
||||
const promptsData = prompts.map((p) => ({
|
||||
prompt: p.prompt,
|
||||
echo: p.echo,
|
||||
}));
|
||||
|
||||
console.log(`[PortForward] Showing modal for ${promptsData.length} prompts`);
|
||||
|
||||
safeSend(sender, "netcatty:keyboard-interactive", {
|
||||
requestId,
|
||||
sessionId: tunnelId,
|
||||
name: name || "",
|
||||
instructions: instructions || "",
|
||||
prompts: promptsData,
|
||||
hostname: hostname,
|
||||
savedPassword: password || null,
|
||||
});
|
||||
});
|
||||
conn.on("keyboard-interactive", createKeyboardInteractiveHandler({
|
||||
sender,
|
||||
sessionId: tunnelId,
|
||||
hostname,
|
||||
password,
|
||||
logPrefix: "[PortForward]",
|
||||
}));
|
||||
|
||||
|
||||
conn.on('ready', () => {
|
||||
|
||||
@@ -23,6 +23,12 @@ const { NetcattyAgent } = require("./netcattyAgent.cjs");
|
||||
const fileWatcherBridge = require("./fileWatcherBridge.cjs");
|
||||
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
const { createProxySocket } = require("./proxyUtils.cjs");
|
||||
const {
|
||||
buildAuthHandler,
|
||||
createKeyboardInteractiveHandler,
|
||||
applyAuthToConnOpts,
|
||||
safeSend: authSafeSend,
|
||||
} = require("./sshAuthHelper.cjs");
|
||||
|
||||
// SFTP clients storage - shared reference passed from main
|
||||
let sftpClients = null;
|
||||
@@ -258,7 +264,8 @@ function init(deps) {
|
||||
/**
|
||||
* Connect through a chain of jump hosts for SFTP
|
||||
*/
|
||||
async function connectThroughChainForSftp(event, options, jumpHosts, targetHost, targetPort) {
|
||||
async function connectThroughChainForSftp(event, options, jumpHosts, targetHost, targetPort, connId) {
|
||||
const sender = event.sender;
|
||||
const connections = [];
|
||||
let currentSocket = null;
|
||||
|
||||
@@ -282,7 +289,7 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
host: jump.hostname,
|
||||
port: jump.port || 22,
|
||||
username: jump.username || 'root',
|
||||
readyTimeout: 20000,
|
||||
readyTimeout: 120000, // 2 minutes to allow for keyboard-interactive (2FA/MFA)
|
||||
keepaliveInterval: 10000,
|
||||
keepaliveCountMax: 3,
|
||||
// Enable keyboard-interactive authentication (required for 2FA/MFA)
|
||||
@@ -318,11 +325,18 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
|
||||
if (jump.password) connOpts.password = jump.password;
|
||||
|
||||
if (authAgent) {
|
||||
const order = ["agent"];
|
||||
if (connOpts.password) order.push("password");
|
||||
connOpts.authHandler = order;
|
||||
}
|
||||
// Build auth handler using shared helper
|
||||
// Pass unlocked encrypted keys from options so jump hosts can use them for retry
|
||||
const authConfig = buildAuthHandler({
|
||||
privateKey: connOpts.privateKey,
|
||||
password: connOpts.password,
|
||||
passphrase: connOpts.passphrase,
|
||||
agent: connOpts.agent,
|
||||
username: connOpts.username,
|
||||
logPrefix: `[SFTP Chain] Hop ${i + 1}`,
|
||||
unlockedEncryptedKeys: options._unlockedEncryptedKeys || [],
|
||||
});
|
||||
applyAuthToConnOpts(connOpts, authConfig);
|
||||
|
||||
// If first hop and proxy is configured, connect through proxy
|
||||
if (isFirst && options.proxy) {
|
||||
@@ -351,6 +365,14 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
console.error(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: ${hopLabel} timeout`);
|
||||
reject(new Error(`Connection timeout to ${hopLabel}`));
|
||||
});
|
||||
// Handle keyboard-interactive authentication for jump hosts (2FA/MFA)
|
||||
conn.on('keyboard-interactive', createKeyboardInteractiveHandler({
|
||||
sender,
|
||||
sessionId: connId,
|
||||
hostname: hopLabel,
|
||||
password: jump.password,
|
||||
logPrefix: `[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}`,
|
||||
}));
|
||||
conn.connect(connOpts);
|
||||
});
|
||||
|
||||
@@ -648,7 +670,8 @@ async function openSftp(event, options) {
|
||||
options,
|
||||
jumpHosts,
|
||||
options.hostname,
|
||||
options.port || 22
|
||||
options.port || 22,
|
||||
connId
|
||||
);
|
||||
connectionSocket = chainResult.socket;
|
||||
chainConnections = chainResult.connections;
|
||||
@@ -700,78 +723,29 @@ async function openSftp(event, options) {
|
||||
|
||||
if (options.password) connectOpts.password = options.password;
|
||||
|
||||
if (authAgent) {
|
||||
const order = ["agent"];
|
||||
if (connectOpts.password) order.push("password");
|
||||
connectOpts.authHandler = order;
|
||||
} else if (options.privateKey && connectOpts.password) {
|
||||
// Prefer key auth when both key and password are present (password still needed for sudo)
|
||||
connectOpts.authHandler = ["publickey", "password"];
|
||||
}
|
||||
|
||||
// Add keyboard-interactive authentication support
|
||||
// ssh2-sftp-client exposes the underlying ssh2 Client through its `on` method
|
||||
const kiHandler = (name, instructions, instructionsLang, prompts, finish) => {
|
||||
console.log(`[SFTP] ${options.hostname} keyboard-interactive auth requested`, {
|
||||
name,
|
||||
instructions,
|
||||
promptCount: prompts?.length || 0,
|
||||
prompts: prompts?.map(p => ({ prompt: p.prompt, echo: p.echo })),
|
||||
});
|
||||
|
||||
// If there are no prompts, just call finish with empty array
|
||||
if (!prompts || prompts.length === 0) {
|
||||
console.log(`[SFTP] No prompts, finishing keyboard-interactive`);
|
||||
finish([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward ALL prompts to user - no auto-fill to avoid semantic detection issues
|
||||
// (Prompt text is admin-customizable and may not contain expected keywords)
|
||||
const requestId = keyboardInteractiveHandler.generateRequestId('sftp');
|
||||
|
||||
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
|
||||
console.log(`[SFTP] Received user responses, finishing keyboard-interactive`);
|
||||
finish(userResponses);
|
||||
}, event.sender.id, connId);
|
||||
|
||||
const promptsData = prompts.map((p) => ({
|
||||
prompt: p.prompt,
|
||||
echo: p.echo,
|
||||
}));
|
||||
|
||||
console.log(`[SFTP] Showing modal for ${promptsData.length} prompts`);
|
||||
|
||||
safeSend(event.sender, "netcatty:keyboard-interactive", {
|
||||
requestId,
|
||||
sessionId: connId,
|
||||
name: name || "",
|
||||
instructions: instructions || "",
|
||||
prompts: promptsData,
|
||||
hostname: options.hostname,
|
||||
savedPassword: options.password || null,
|
||||
});
|
||||
};
|
||||
// Build auth handler using shared helper
|
||||
const authConfig = buildAuthHandler({
|
||||
privateKey: connectOpts.privateKey,
|
||||
password: connectOpts.password,
|
||||
passphrase: connectOpts.passphrase,
|
||||
agent: connectOpts.agent,
|
||||
username: connectOpts.username,
|
||||
logPrefix: "[SFTP]",
|
||||
});
|
||||
applyAuthToConnOpts(connectOpts, authConfig);
|
||||
|
||||
// Create keyboard-interactive handler using shared helper
|
||||
const kiHandler = createKeyboardInteractiveHandler({
|
||||
sender: event.sender,
|
||||
sessionId: connId,
|
||||
hostname: options.hostname,
|
||||
password: options.password,
|
||||
logPrefix: "[SFTP]",
|
||||
});
|
||||
|
||||
// Add keyboard-interactive listener BEFORE connecting
|
||||
client.on("keyboard-interactive", kiHandler);
|
||||
|
||||
// Enable keyboard-interactive authentication in authHandler
|
||||
if (connectOpts.authHandler) {
|
||||
// Add keyboard-interactive after the existing methods
|
||||
if (!connectOpts.authHandler.includes("keyboard-interactive")) {
|
||||
connectOpts.authHandler.push("keyboard-interactive");
|
||||
}
|
||||
} else {
|
||||
// Create authHandler with keyboard-interactive support
|
||||
const authMethods = [];
|
||||
if (connectOpts.privateKey) authMethods.push("publickey");
|
||||
if (connectOpts.password) authMethods.push("password");
|
||||
authMethods.push("keyboard-interactive");
|
||||
connectOpts.authHandler = authMethods;
|
||||
}
|
||||
|
||||
// Increase timeout to allow for keyboard-interactive auth
|
||||
connectOpts.readyTimeout = 120000; // 2 minutes for 2FA input
|
||||
|
||||
@@ -1038,6 +1012,11 @@ async function writeSftpBinaryWithProgress(event, payload) {
|
||||
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
|
||||
const encodedPath = encodePath(remotePath, encoding);
|
||||
|
||||
// Extract callback functions from payload
|
||||
const onProgress = payload.onProgress;
|
||||
const onComplete = payload.onComplete;
|
||||
const onError = payload.onError;
|
||||
|
||||
// Optimize: Use Buffer.isBuffer to avoid unnecessary copy if already a Buffer
|
||||
// For ArrayBuffer from renderer, we still need to convert but use a more efficient method
|
||||
const buffer = Buffer.isBuffer(content) ? content : Buffer.from(content);
|
||||
@@ -1085,13 +1064,22 @@ async function writeSftpBinaryWithProgress(event, payload) {
|
||||
const isComplete = transferredBytes >= totalBytes;
|
||||
|
||||
if (isComplete || timeSinceLastProgress >= PROGRESS_THROTTLE_MS || bytesSinceLastProgress >= PROGRESS_THROTTLE_BYTES) {
|
||||
const contents = electronModule.webContents.fromId(event.sender.id);
|
||||
contents?.send("netcatty:upload:progress", {
|
||||
transferId,
|
||||
transferred: transferredBytes,
|
||||
totalBytes,
|
||||
speed,
|
||||
});
|
||||
// Call the progress callback if provided, otherwise send IPC event
|
||||
if (typeof onProgress === 'function') {
|
||||
try {
|
||||
onProgress(transferredBytes, totalBytes, speed);
|
||||
} catch (err) {
|
||||
console.warn('[SFTP] Progress callback error:', err);
|
||||
}
|
||||
} else {
|
||||
const contents = electronModule.webContents.fromId(event.sender.id);
|
||||
contents?.send("netcatty:upload:progress", {
|
||||
transferId,
|
||||
transferred: transferredBytes,
|
||||
totalBytes,
|
||||
speed,
|
||||
});
|
||||
}
|
||||
lastProgressSentTime = now;
|
||||
lastProgressSentBytes = transferredBytes;
|
||||
}
|
||||
@@ -1109,22 +1097,40 @@ async function writeSftpBinaryWithProgress(event, payload) {
|
||||
try {
|
||||
await client.put(readableStream, encodedPath);
|
||||
|
||||
const contents = electronModule.webContents.fromId(event.sender.id);
|
||||
contents?.send("netcatty:upload:complete", { transferId });
|
||||
// Call the complete callback if provided, otherwise send IPC event
|
||||
if (typeof onComplete === 'function') {
|
||||
try {
|
||||
onComplete();
|
||||
} catch (err) {
|
||||
console.warn('[SFTP] Complete callback error:', err);
|
||||
}
|
||||
} else {
|
||||
const contents = electronModule.webContents.fromId(event.sender.id);
|
||||
contents?.send("netcatty:upload:complete", { transferId });
|
||||
}
|
||||
|
||||
return { success: true, transferId };
|
||||
} catch (err) {
|
||||
const contents = electronModule.webContents.fromId(event.sender.id);
|
||||
|
||||
// Check if this upload was cancelled - the error might not be exactly "Upload cancelled"
|
||||
// when stream is destroyed, SFTP server may return different errors like "Write stream error"
|
||||
const uploadState = activeSftpUploads.get(transferId);
|
||||
if (uploadState?.cancelled || err.message === "Upload cancelled") {
|
||||
const contents = electronModule.webContents.fromId(event.sender.id);
|
||||
contents?.send("netcatty:upload:cancelled", { transferId });
|
||||
return { success: false, transferId, cancelled: true };
|
||||
}
|
||||
|
||||
contents?.send("netcatty:upload:error", { transferId, error: err.message });
|
||||
// Call the error callback if provided, otherwise send IPC event
|
||||
if (typeof onError === 'function') {
|
||||
try {
|
||||
onError(err.message);
|
||||
} catch (callbackErr) {
|
||||
console.warn('[SFTP] Error callback error:', callbackErr);
|
||||
}
|
||||
} else {
|
||||
const contents = electronModule.webContents.fromId(event.sender.id);
|
||||
contents?.send("netcatty:upload:error", { transferId, error: err.message });
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
// Cleanup
|
||||
|
||||
533
electron/bridges/sshAuthHelper.cjs
Normal file
533
electron/bridges/sshAuthHelper.cjs
Normal file
@@ -0,0 +1,533 @@
|
||||
/**
|
||||
* SSH Authentication Helper - Shared authentication logic for SSH connections
|
||||
* Used by sshBridge, sftpBridge, and portForwardingBridge
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
const passphraseHandler = require("./passphraseHandler.cjs");
|
||||
|
||||
// Default SSH key names in priority order
|
||||
const DEFAULT_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
||||
|
||||
/**
|
||||
* Check if an SSH private key is encrypted (requires passphrase)
|
||||
* @param {string} keyContent - The content of the private key file
|
||||
* @returns {boolean} - True if the key is encrypted
|
||||
*/
|
||||
function isKeyEncrypted(keyContent) {
|
||||
if (!keyContent || typeof keyContent !== "string") return false;
|
||||
|
||||
// Check for PKCS#8 encrypted format (-----BEGIN ENCRYPTED PRIVATE KEY-----)
|
||||
if (keyContent.includes("-----BEGIN ENCRYPTED PRIVATE KEY-----")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for legacy PEM format encryption (e.g., RSA PRIVATE KEY with encryption)
|
||||
if (keyContent.includes("Proc-Type:") && keyContent.includes("ENCRYPTED")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for DEK-Info header (legacy PEM encryption indicator)
|
||||
if (keyContent.includes("DEK-Info:")) return true;
|
||||
|
||||
// Check for OpenSSH format keys
|
||||
if (keyContent.includes("-----BEGIN OPENSSH PRIVATE KEY-----")) {
|
||||
try {
|
||||
// Extract the base64 content between the markers
|
||||
const base64Match = keyContent.match(
|
||||
/-----BEGIN OPENSSH PRIVATE KEY-----\s*([\s\S]*?)\s*-----END OPENSSH PRIVATE KEY-----/
|
||||
);
|
||||
if (base64Match) {
|
||||
const base64Content = base64Match[1].replace(/\s/g, "");
|
||||
const keyBuffer = Buffer.from(base64Content, "base64");
|
||||
|
||||
// OpenSSH key format: "openssh-key-v1\0" followed by cipher name
|
||||
// If ciphername is "none", the key is not encrypted
|
||||
const authMagic = "openssh-key-v1\0";
|
||||
if (keyBuffer.toString("ascii", 0, authMagic.length) === authMagic) {
|
||||
// After magic, read ciphername (length-prefixed string)
|
||||
let offset = authMagic.length;
|
||||
const cipherNameLen = keyBuffer.readUInt32BE(offset);
|
||||
offset += 4;
|
||||
const cipherName = keyBuffer.toString("ascii", offset, offset + cipherNameLen);
|
||||
return cipherName !== "none";
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If parsing fails, assume it might be encrypted to be safe
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find default SSH private key from user's ~/.ssh directory
|
||||
* Skips encrypted keys that require a passphrase
|
||||
* @returns {{ privateKey: string, keyPath: string, keyName: string } | null}
|
||||
*/
|
||||
function findDefaultPrivateKey() {
|
||||
const sshDir = path.join(os.homedir(), ".ssh");
|
||||
for (const name of DEFAULT_KEY_NAMES) {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
if (fs.existsSync(keyPath)) {
|
||||
try {
|
||||
const privateKey = fs.readFileSync(keyPath, "utf8");
|
||||
if (isKeyEncrypted(privateKey)) {
|
||||
continue;
|
||||
}
|
||||
return { privateKey, keyPath, keyName: name };
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find ALL default SSH private keys from user's ~/.ssh directory
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.includeEncrypted=false] - If true, include encrypted keys with isEncrypted flag
|
||||
* @returns {Array<{ privateKey: string, keyPath: string, keyName: string, isEncrypted?: boolean }>}
|
||||
*/
|
||||
function findAllDefaultPrivateKeys(options = {}) {
|
||||
const { includeEncrypted = false } = options;
|
||||
const sshDir = path.join(os.homedir(), ".ssh");
|
||||
const keys = [];
|
||||
for (const name of DEFAULT_KEY_NAMES) {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
if (fs.existsSync(keyPath)) {
|
||||
try {
|
||||
const privateKey = fs.readFileSync(keyPath, "utf8");
|
||||
const encrypted = isKeyEncrypted(privateKey);
|
||||
if (encrypted && !includeEncrypted) {
|
||||
continue; // Skip encrypted keys when not including them
|
||||
}
|
||||
keys.push({
|
||||
privateKey,
|
||||
keyPath,
|
||||
keyName: name,
|
||||
...(includeEncrypted ? { isEncrypted: encrypted } : {})
|
||||
});
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ssh-agent socket path based on platform
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function getSshAgentSocket() {
|
||||
if (process.platform === "win32") {
|
||||
return "\\\\.\\pipe\\openssh-ssh-agent";
|
||||
}
|
||||
return process.env.SSH_AUTH_SOCK || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build authentication handler with default key fallback support
|
||||
* @param {Object} options
|
||||
* @param {string} [options.privateKey] - Explicitly configured private key
|
||||
* @param {string} [options.password] - Password for authentication
|
||||
* @param {string} [options.passphrase] - Passphrase for encrypted private key
|
||||
* @param {Object} [options.agent] - SSH agent (NetcattyAgent or socket path)
|
||||
* @param {string} options.username - SSH username
|
||||
* @param {string} [options.logPrefix] - Log prefix for debugging
|
||||
* @returns {{ authHandler: Function|Array, privateKey: string|null, agent: string|Object|null, usedDefaultKeys: boolean }}
|
||||
* @param {Array} [options.unlockedEncryptedKeys] - Array of unlocked encrypted keys with passphrases
|
||||
*/
|
||||
function buildAuthHandler(options) {
|
||||
const { privateKey, password, passphrase, agent, username, logPrefix = "[SSH]", unlockedEncryptedKeys = [] } = options;
|
||||
|
||||
// Determine what type of explicit auth the user configured
|
||||
const hasExplicitKey = !!privateKey;
|
||||
const hasExplicitPassword = !!password;
|
||||
const hasExplicitAgent = !!agent;
|
||||
const hasExplicitAuth = hasExplicitKey || hasExplicitPassword || hasExplicitAgent;
|
||||
|
||||
// Determine if this is a password-only or key-only connection
|
||||
const isPasswordOnly = hasExplicitPassword && !hasExplicitKey && !hasExplicitAgent;
|
||||
const isKeyOnly = hasExplicitKey && !hasExplicitAgent;
|
||||
|
||||
const sshAgentSocket = getSshAgentSocket();
|
||||
const defaultKeys = findAllDefaultPrivateKeys();
|
||||
|
||||
// Only use system ssh-agent BEFORE user's auth when:
|
||||
// - User explicitly configured agent, OR
|
||||
// - No explicit auth is configured (pure fallback mode)
|
||||
// When user configured key/password, system agent should only be used AFTER as fallback
|
||||
const useAgentFirst = hasExplicitAgent || !hasExplicitAuth;
|
||||
|
||||
// Determine effective agent
|
||||
const effectiveAgent = agent || (useAgentFirst ? sshAgentSocket : null);
|
||||
|
||||
// Determine effective privateKey (user-provided takes priority)
|
||||
const effectivePrivateKey = privateKey || (!hasExplicitAuth && defaultKeys.length > 0 ? defaultKeys[0].privateKey : null);
|
||||
|
||||
// Determine fallback keys (keys to try after user's primary auth fails)
|
||||
// - If user provided a key: all default keys are fallbacks
|
||||
// - If no explicit auth: first default key is primary, rest are fallbacks
|
||||
// - If password-only or agent-only: all default keys are fallbacks (tried after primary)
|
||||
const fallbackKeys = hasExplicitKey
|
||||
? defaultKeys
|
||||
: !hasExplicitAuth
|
||||
? defaultKeys.slice(1)
|
||||
: defaultKeys;
|
||||
|
||||
// Check if we need dynamic handler (have fallback options)
|
||||
const hasFallbackOptions = fallbackKeys.length > 0 ||
|
||||
(!hasExplicitAgent && sshAgentSocket) ||
|
||||
(isPasswordOnly && defaultKeys.length > 0);
|
||||
|
||||
// If only simple auth methods and no fallback keys needed, use array-based handler
|
||||
if (hasExplicitAuth && !hasFallbackOptions) {
|
||||
const authMethods = [];
|
||||
if (effectiveAgent) authMethods.push("agent");
|
||||
if (privateKey) authMethods.push("publickey");
|
||||
if (password) authMethods.push("password");
|
||||
authMethods.push("keyboard-interactive");
|
||||
|
||||
return {
|
||||
authHandler: authMethods,
|
||||
privateKey: effectivePrivateKey,
|
||||
agent: effectiveAgent,
|
||||
usedDefaultKeys: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Build comprehensive authMethods array with all auth options
|
||||
// Order depends on what user explicitly configured:
|
||||
// - Password-only: password -> agent -> default keys -> keyboard-interactive
|
||||
// - Key-only: user key -> password -> agent -> default keys -> keyboard-interactive
|
||||
// - Agent configured: agent -> user key -> password -> default keys -> keyboard-interactive
|
||||
// - No explicit auth: agent -> default keys -> keyboard-interactive
|
||||
const authMethods = [];
|
||||
|
||||
if (isPasswordOnly) {
|
||||
// Password-only: password first, then fallbacks
|
||||
authMethods.push({ type: "password", id: "password" });
|
||||
|
||||
// Add agent and default keys AFTER password as fallback
|
||||
if (sshAgentSocket) {
|
||||
authMethods.push({ type: "agent", id: "agent" });
|
||||
}
|
||||
for (const keyInfo of defaultKeys) {
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: keyInfo.privateKey,
|
||||
id: `publickey-default-${keyInfo.keyName}`
|
||||
});
|
||||
}
|
||||
} else if (isKeyOnly) {
|
||||
// Key-only: user key first, then password (if any), then agent/default keys as fallback
|
||||
|
||||
// 1. User-provided key first
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: privateKey,
|
||||
passphrase: passphrase,
|
||||
id: "publickey-user"
|
||||
});
|
||||
|
||||
// 2. Password (if configured alongside key)
|
||||
if (password) {
|
||||
authMethods.push({ type: "password", id: "password" });
|
||||
}
|
||||
|
||||
// 3. System agent as fallback (AFTER user's key)
|
||||
if (sshAgentSocket) {
|
||||
authMethods.push({ type: "agent", id: "agent" });
|
||||
}
|
||||
|
||||
// 4. Default keys as fallback
|
||||
for (const keyInfo of fallbackKeys) {
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: keyInfo.privateKey,
|
||||
id: `publickey-default-${keyInfo.keyName}`
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Agent configured or no explicit auth: agent -> user key -> password -> default keys
|
||||
|
||||
// 1. Agent (user-provided or system)
|
||||
if (effectiveAgent) {
|
||||
authMethods.push({ type: "agent", id: "agent" });
|
||||
}
|
||||
|
||||
// 2. User-provided key
|
||||
if (privateKey) {
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: privateKey,
|
||||
passphrase: passphrase,
|
||||
id: "publickey-user"
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Password (if configured)
|
||||
if (password) {
|
||||
authMethods.push({ type: "password", id: "password" });
|
||||
}
|
||||
|
||||
// 4. Default keys as fallback
|
||||
for (const keyInfo of fallbackKeys) {
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: keyInfo.privateKey,
|
||||
id: `publickey-default-${keyInfo.keyName}`
|
||||
});
|
||||
}
|
||||
|
||||
// 5. If no user key provided, add first default key at the beginning (after agent)
|
||||
if (!privateKey && defaultKeys.length > 0) {
|
||||
const insertIndex = effectiveAgent ? 1 : 0;
|
||||
authMethods.splice(insertIndex, 0, {
|
||||
type: "publickey",
|
||||
key: defaultKeys[0].privateKey,
|
||||
id: `publickey-default-${defaultKeys[0].keyName}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add unlocked encrypted default keys (user provided passphrases for these)
|
||||
for (const keyInfo of unlockedEncryptedKeys) {
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: keyInfo.privateKey,
|
||||
passphrase: keyInfo.passphrase,
|
||||
id: `publickey-encrypted-${keyInfo.keyName}`
|
||||
});
|
||||
}
|
||||
|
||||
// Keyboard-interactive as last resort
|
||||
authMethods.push({ type: "keyboard-interactive", id: "keyboard-interactive" });
|
||||
|
||||
console.log(`${logPrefix} Auth methods configured`, {
|
||||
isPasswordOnly,
|
||||
hasUserKey: !!privateKey,
|
||||
hasPassword: !!password,
|
||||
hasAgent: !!effectiveAgent,
|
||||
methodCount: authMethods.length,
|
||||
methods: authMethods.map(m => m.id),
|
||||
});
|
||||
|
||||
// Use dynamic authHandler to try all keys
|
||||
let authIndex = 0;
|
||||
const attemptedMethodIds = new Set();
|
||||
|
||||
const authHandler = (methodsLeft, partialSuccess, callback) => {
|
||||
const availableMethods = methodsLeft || ["publickey", "password", "keyboard-interactive", "agent"];
|
||||
|
||||
while (authIndex < authMethods.length) {
|
||||
const method = authMethods[authIndex];
|
||||
authIndex++;
|
||||
|
||||
if (attemptedMethodIds.has(method.id)) continue;
|
||||
attemptedMethodIds.add(method.id);
|
||||
|
||||
if (method.type === "agent" && (availableMethods.includes("publickey") || availableMethods.includes("agent"))) {
|
||||
console.log(`${logPrefix} Trying agent auth`);
|
||||
return callback("agent");
|
||||
} else if (method.type === "publickey" && availableMethods.includes("publickey")) {
|
||||
console.log(`${logPrefix} Trying publickey auth:`, method.id);
|
||||
const pubkeyAuth = {
|
||||
type: "publickey",
|
||||
username,
|
||||
key: method.key,
|
||||
};
|
||||
if (method.passphrase) {
|
||||
pubkeyAuth.passphrase = method.passphrase;
|
||||
}
|
||||
return callback(pubkeyAuth);
|
||||
} else if (method.type === "password" && availableMethods.includes("password")) {
|
||||
console.log(`${logPrefix} Trying password auth`);
|
||||
return callback({
|
||||
type: "password",
|
||||
username,
|
||||
password,
|
||||
});
|
||||
} else if (method.type === "keyboard-interactive" && availableMethods.includes("keyboard-interactive")) {
|
||||
return callback("keyboard-interactive");
|
||||
}
|
||||
}
|
||||
return callback(false);
|
||||
};
|
||||
|
||||
// Determine the agent to return - if authMethods includes agent, we need to provide the socket
|
||||
// even if effectiveAgent is null (for fallback scenarios)
|
||||
const hasAgentInMethods = authMethods.some(m => m.type === "agent");
|
||||
const returnAgent = effectiveAgent || (hasAgentInMethods ? sshAgentSocket : null);
|
||||
|
||||
return {
|
||||
authHandler,
|
||||
privateKey: effectivePrivateKey,
|
||||
agent: returnAgent,
|
||||
usedDefaultKeys: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a keyboard-interactive event handler
|
||||
* @param {Object} options
|
||||
* @param {Object} options.sender - Electron webContents sender
|
||||
* @param {string} options.sessionId - Session/connection ID
|
||||
* @param {string} options.hostname - Host being connected to
|
||||
* @param {string} [options.password] - Saved password for fill button
|
||||
* @param {string} [options.logPrefix] - Log prefix for debugging
|
||||
* @returns {Function} - Event handler for 'keyboard-interactive' event
|
||||
*/
|
||||
function createKeyboardInteractiveHandler(options) {
|
||||
const { sender, sessionId, hostname, password, logPrefix = "[SSH]" } = options;
|
||||
|
||||
return (name, instructions, instructionsLang, prompts, finish) => {
|
||||
console.log(`${logPrefix} ${hostname} keyboard-interactive auth requested`, {
|
||||
name,
|
||||
instructions,
|
||||
promptCount: prompts?.length || 0,
|
||||
});
|
||||
|
||||
// If there are no prompts, just call finish with empty array
|
||||
if (!prompts || prompts.length === 0) {
|
||||
console.log(`${logPrefix} No prompts, finishing keyboard-interactive`);
|
||||
finish([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward prompts to user via IPC
|
||||
const requestId = keyboardInteractiveHandler.generateRequestId('ssh');
|
||||
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
|
||||
console.log(`${logPrefix} Received user responses, finishing keyboard-interactive`);
|
||||
finish(userResponses);
|
||||
}, sender.id, sessionId);
|
||||
|
||||
const promptsData = prompts.map((p) => ({
|
||||
prompt: p.prompt,
|
||||
echo: p.echo,
|
||||
}));
|
||||
|
||||
console.log(`${logPrefix} Showing modal for ${promptsData.length} prompts`);
|
||||
|
||||
safeSend(sender, "netcatty:keyboard-interactive", {
|
||||
requestId,
|
||||
sessionId,
|
||||
name: name || hostname,
|
||||
instructions: instructions || "",
|
||||
prompts: promptsData,
|
||||
hostname: hostname,
|
||||
savedPassword: password || null,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to renderer safely
|
||||
*/
|
||||
function safeSend(sender, channel, payload) {
|
||||
try {
|
||||
if (!sender || sender.isDestroyed()) return;
|
||||
sender.send(channel, payload);
|
||||
} catch {
|
||||
// Ignore destroyed webContents during shutdown.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply auth configuration to connection options
|
||||
* Convenience function that combines buildAuthHandler results with connOpts
|
||||
* @param {Object} connOpts - SSH connection options to modify
|
||||
* @param {Object} authConfig - Auth configuration from buildAuthHandler
|
||||
*/
|
||||
function applyAuthToConnOpts(connOpts, authConfig) {
|
||||
connOpts.authHandler = authConfig.authHandler;
|
||||
if (authConfig.privateKey) {
|
||||
connOpts.privateKey = authConfig.privateKey;
|
||||
}
|
||||
if (authConfig.agent) {
|
||||
connOpts.agent = authConfig.agent;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request passphrases for encrypted default keys
|
||||
* Shows a modal for each encrypted key and collects passphrases
|
||||
* @param {Object} sender - Electron webContents sender
|
||||
* @param {string} [hostname] - Optional hostname for context
|
||||
* @returns {Promise<{ keys: Array<{ privateKey: string, keyPath: string, keyName: string, passphrase: string }>, cancelled: boolean }>}
|
||||
*/
|
||||
async function requestPassphrasesForEncryptedKeys(sender, hostname) {
|
||||
const allKeys = findAllDefaultPrivateKeys({ includeEncrypted: true });
|
||||
const encryptedKeys = allKeys.filter(k => k.isEncrypted);
|
||||
|
||||
if (encryptedKeys.length === 0) {
|
||||
return { keys: [], cancelled: false };
|
||||
}
|
||||
|
||||
console.log(`[SSHAuth] Found ${encryptedKeys.length} encrypted default key(s), requesting passphrases`);
|
||||
|
||||
const unlockedKeys = [];
|
||||
let wasCancelled = false;
|
||||
|
||||
for (const keyInfo of encryptedKeys) {
|
||||
const result = await passphraseHandler.requestPassphrase(
|
||||
sender,
|
||||
keyInfo.keyPath,
|
||||
keyInfo.keyName,
|
||||
hostname
|
||||
);
|
||||
|
||||
// Handle different response types
|
||||
if (!result) {
|
||||
// Timeout or error - continue with next key
|
||||
console.log(`[SSHAuth] No response for ${keyInfo.keyName}, continuing...`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result.cancelled) {
|
||||
// User clicked Cancel - stop the entire flow
|
||||
console.log(`[SSHAuth] User cancelled passphrase flow at ${keyInfo.keyName}`);
|
||||
wasCancelled = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (result.skipped) {
|
||||
// User clicked Skip - continue with next key
|
||||
console.log(`[SSHAuth] User skipped passphrase for ${keyInfo.keyName}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result.passphrase) {
|
||||
// User provided passphrase
|
||||
unlockedKeys.push({
|
||||
privateKey: keyInfo.privateKey,
|
||||
keyPath: keyInfo.keyPath,
|
||||
keyName: keyInfo.keyName,
|
||||
passphrase: result.passphrase,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { keys: unlockedKeys, cancelled: wasCancelled };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_KEY_NAMES,
|
||||
isKeyEncrypted,
|
||||
findDefaultPrivateKey,
|
||||
findAllDefaultPrivateKeys,
|
||||
getSshAgentSocket,
|
||||
buildAuthHandler,
|
||||
createKeyboardInteractiveHandler,
|
||||
applyAuthToConnOpts,
|
||||
safeSend,
|
||||
requestPassphrasesForEncryptedKeys,
|
||||
};
|
||||
@@ -11,7 +11,16 @@ const { exec } = require("node:child_process");
|
||||
const { Client: SSHClient, utils: sshUtils } = require("ssh2");
|
||||
const { NetcattyAgent } = require("./netcattyAgent.cjs");
|
||||
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
const passphraseHandler = require("./passphraseHandler.cjs");
|
||||
const { createProxySocket } = require("./proxyUtils.cjs");
|
||||
const {
|
||||
buildAuthHandler,
|
||||
createKeyboardInteractiveHandler,
|
||||
applyAuthToConnOpts,
|
||||
safeSend: authSafeSend,
|
||||
requestPassphrasesForEncryptedKeys,
|
||||
findAllDefaultPrivateKeys: findAllDefaultPrivateKeysFromHelper,
|
||||
} = require("./sshAuthHelper.cjs");
|
||||
|
||||
// Default SSH key names in priority order
|
||||
const DEFAULT_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
||||
@@ -71,14 +80,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,9 +103,40 @@ function findDefaultPrivateKey() {
|
||||
}
|
||||
}
|
||||
}
|
||||
log("No suitable default SSH key found");
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find ALL default SSH private keys from user's ~/.ssh directory
|
||||
* Returns all non-encrypted keys for fallback authentication
|
||||
* @returns {Array<{ privateKey: string, keyPath: string, keyName: string }>}
|
||||
*/
|
||||
function findAllDefaultPrivateKeys() {
|
||||
const sshDir = path.join(os.homedir(), ".ssh");
|
||||
const keys = [];
|
||||
log("Searching for ALL default SSH keys", { sshDir, keyNames: DEFAULT_KEY_NAMES });
|
||||
for (const name of DEFAULT_KEY_NAMES) {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
if (fs.existsSync(keyPath)) {
|
||||
try {
|
||||
const privateKey = fs.readFileSync(keyPath, "utf8");
|
||||
const encrypted = isKeyEncrypted(privateKey);
|
||||
if (!encrypted) {
|
||||
keys.push({ privateKey, keyPath, keyName: name });
|
||||
log("Found default key for fallback", { keyPath, keyName: name });
|
||||
} else {
|
||||
log("Skipping encrypted key", { keyPath, keyName: name });
|
||||
}
|
||||
} catch (e) {
|
||||
log("Failed to read key", { keyPath, error: e.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
log("Found default SSH keys", { count: keys.length, keyNames: keys.map(k => k.keyName) });
|
||||
return keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Windows SSH Agent service is running
|
||||
* @returns {Promise<{ running: boolean, startupType: string | null, error: string | null }>}
|
||||
@@ -124,13 +168,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) {
|
||||
@@ -162,7 +238,7 @@ function init(deps) {
|
||||
/**
|
||||
* Connect through a chain of jump hosts
|
||||
*/
|
||||
async function connectThroughChain(event, options, jumpHosts, targetHost, targetPort) {
|
||||
async function connectThroughChain(event, options, jumpHosts, targetHost, targetPort, sessionId) {
|
||||
const sender = event.sender;
|
||||
const connections = [];
|
||||
let currentSocket = null;
|
||||
@@ -192,7 +268,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
host: jump.hostname,
|
||||
port: jump.port || 22,
|
||||
username: jump.username || 'root',
|
||||
readyTimeout: 20000, // Reduced from 60s for faster failure detection
|
||||
readyTimeout: 120000, // 2 minutes to allow for keyboard-interactive (2FA/MFA)
|
||||
// Use user-configured keepalive interval from options (in seconds -> convert to ms)
|
||||
// If 0 or not provided, use 10000ms as default
|
||||
keepaliveInterval: options.keepaliveInterval && options.keepaliveInterval > 0 ? options.keepaliveInterval * 1000 : 10000,
|
||||
@@ -208,7 +284,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
},
|
||||
};
|
||||
|
||||
// Auth - support agent (certificate), key, and password fallback
|
||||
// Auth - support agent (certificate), key, password, and default key fallback
|
||||
const hasCertificate =
|
||||
typeof jump.certificate === "string" && jump.certificate.trim().length > 0;
|
||||
|
||||
@@ -232,11 +308,18 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
|
||||
if (jump.password) connOpts.password = jump.password;
|
||||
|
||||
if (authAgent) {
|
||||
const order = ["agent"];
|
||||
if (connOpts.password) order.push("password");
|
||||
connOpts.authHandler = order;
|
||||
}
|
||||
// Build auth handler using shared helper
|
||||
// Pass unlocked encrypted keys from options so jump hosts can use them for retry
|
||||
const authConfig = buildAuthHandler({
|
||||
privateKey: connOpts.privateKey,
|
||||
password: connOpts.password,
|
||||
passphrase: connOpts.passphrase,
|
||||
agent: connOpts.agent,
|
||||
username: connOpts.username,
|
||||
logPrefix: `[Chain] Hop ${i + 1}`,
|
||||
unlockedEncryptedKeys: options._unlockedEncryptedKeys || [],
|
||||
});
|
||||
applyAuthToConnOpts(connOpts, authConfig);
|
||||
|
||||
// If first hop and proxy is configured, connect through proxy
|
||||
if (isFirst && options.proxy) {
|
||||
@@ -267,6 +350,14 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
console.error(`[Chain] Hop ${i + 1}/${totalHops}: ${hopLabel} timeout`);
|
||||
reject(new Error(`Connection timeout to ${hopLabel}`));
|
||||
});
|
||||
// Handle keyboard-interactive authentication for jump hosts (2FA/MFA)
|
||||
conn.on('keyboard-interactive', createKeyboardInteractiveHandler({
|
||||
sender,
|
||||
sessionId,
|
||||
hostname: hopLabel,
|
||||
password: jump.password,
|
||||
logPrefix: `[Chain] Hop ${i + 1}/${totalHops}`,
|
||||
}));
|
||||
console.log(`[Chain] Hop ${i + 1}/${totalHops}: Connecting to ${hopLabel}...`);
|
||||
conn.connect(connOpts);
|
||||
});
|
||||
@@ -408,21 +499,63 @@ 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 keys for fallback authentication
|
||||
// This allows fallback even when password auth fails
|
||||
let defaultKeyInfo = null;
|
||||
let allDefaultKeys = [];
|
||||
let usedDefaultKeyAsPrimary = false;
|
||||
const defaultKey = findDefaultPrivateKey();
|
||||
if (defaultKey) {
|
||||
defaultKeyInfo = defaultKey;
|
||||
log("Found default SSH key for fallback", { keyPath: defaultKey.keyPath, keyName: defaultKey.keyName });
|
||||
}
|
||||
// Also find ALL default keys for comprehensive fallback
|
||||
allDefaultKeys = findAllDefaultPrivateKeys();
|
||||
|
||||
// Use unlocked encrypted keys if provided (from retry after auth failure)
|
||||
// These are passed via _unlockedEncryptedKeys from startSSHSessionWrapper
|
||||
const unlockedEncryptedKeys = options._unlockedEncryptedKeys || [];
|
||||
if (unlockedEncryptedKeys.length > 0) {
|
||||
log("Using unlocked encrypted keys from retry", {
|
||||
count: unlockedEncryptedKeys.length,
|
||||
keyNames: unlockedEncryptedKeys.map(k => k.keyName)
|
||||
});
|
||||
}
|
||||
|
||||
// If no primary auth method configured, try ssh-agent first, then ALL default keys
|
||||
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;
|
||||
// First, try to use ssh-agent if available (this is what regular SSH does)
|
||||
const sshAgentSocket = process.platform === "win32"
|
||||
? "\\\\.\\pipe\\openssh-ssh-agent"
|
||||
: process.env.SSH_AUTH_SOCK;
|
||||
|
||||
if (sshAgentSocket) {
|
||||
log("No auth method configured, trying ssh-agent first", { agentSocket: sshAgentSocket });
|
||||
connectOpts.agent = sshAgentSocket;
|
||||
}
|
||||
|
||||
// Mark that we need to try all default keys (handled in authMethods below)
|
||||
if (allDefaultKeys.length > 0) {
|
||||
log("Will try all default SSH keys as fallback", { count: allDefaultKeys.length, keyNames: allDefaultKeys.map(k => k.keyName) });
|
||||
// Set first key for connectOpts.privateKey (required for ssh2 to allow publickey auth)
|
||||
connectOpts.privateKey = allDefaultKeys[0].privateKey;
|
||||
usedDefaultKeyAsPrimary = true;
|
||||
} 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 +568,248 @@ 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 && !usedDefaultKeyAsPrimary) {
|
||||
authMethods.push({ type: "publickey", key: connectOpts.privateKey, passphrase: connectOpts.passphrase, id: "publickey-user" });
|
||||
}
|
||||
|
||||
// Then try agent if configured (try agent before password since it's usually faster)
|
||||
if (connectOpts.agent) {
|
||||
authMethods.push({ type: "agent", id: "agent" });
|
||||
}
|
||||
|
||||
// Then try password if available (explicit user choice)
|
||||
if (connectOpts.password) {
|
||||
authMethods.push({ type: "password", id: "password" });
|
||||
}
|
||||
|
||||
// Then try ALL default SSH keys as fallback (not just the first one!)
|
||||
// This is critical because different servers may have different keys in authorized_keys
|
||||
if (usedDefaultKeyAsPrimary && allDefaultKeys.length > 0) {
|
||||
for (const keyInfo of allDefaultKeys) {
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: keyInfo.privateKey,
|
||||
isDefault: true,
|
||||
id: `publickey-default-${keyInfo.keyName}`
|
||||
});
|
||||
}
|
||||
} else if (defaultKeyInfo && !options.privateKey && !usedDefaultKeyAsPrimary) {
|
||||
// Single default key fallback (when user has configured other auth methods)
|
||||
authMethods.push({ type: "publickey", key: defaultKeyInfo.privateKey, isDefault: true, id: "publickey-default" });
|
||||
}
|
||||
|
||||
// Add unlocked encrypted default keys (user provided passphrases for these)
|
||||
for (const keyInfo of unlockedEncryptedKeys) {
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: keyInfo.privateKey,
|
||||
passphrase: keyInfo.passphrase,
|
||||
isDefault: true,
|
||||
id: `publickey-encrypted-${keyInfo.keyName}`
|
||||
});
|
||||
}
|
||||
|
||||
// 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,
|
||||
usedDefaultKeyAsPrimary
|
||||
});
|
||||
|
||||
// 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
|
||||
@@ -450,7 +819,8 @@ async function startSSHSession(event, options) {
|
||||
options,
|
||||
jumpHosts,
|
||||
options.hostname,
|
||||
options.port || 22
|
||||
options.port || 22,
|
||||
sessionId
|
||||
);
|
||||
connectionSocket = chainResult.socket;
|
||||
chainConnections = chainResult.connections;
|
||||
@@ -476,6 +846,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 +963,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,23 +1050,39 @@ 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
|
||||
// This path is taken when usedDefaultKeyAsPrimary=true (only keyboard-interactive in authMethods)
|
||||
// Using array format is more reliable - ssh2 uses connectOpts credentials directly
|
||||
const authMethods = [];
|
||||
// Try agent FIRST (this is what regular SSH does - it checks ssh-agent before key files)
|
||||
if (connectOpts.agent) authMethods.push("agent");
|
||||
if (connectOpts.privateKey) authMethods.push("publickey");
|
||||
if (connectOpts.password) authMethods.push("password");
|
||||
authMethods.push("keyboard-interactive");
|
||||
connectOpts.authHandler = authMethods;
|
||||
log("Using simple array authHandler", { authMethods, usedDefaultKeyAsPrimary });
|
||||
}
|
||||
// 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
|
||||
|
||||
// Enable debug logging for ssh2 to diagnose auth issues
|
||||
connectOpts.debug = (msg) => {
|
||||
// Only log auth-related messages to avoid noise
|
||||
if (msg.includes('Auth') || msg.includes('auth') || msg.includes('publickey') || msg.includes('keyboard')) {
|
||||
log("ssh2 debug", { msg });
|
||||
}
|
||||
};
|
||||
|
||||
console.log(`${logPrefix} Connecting to ${options.hostname}...`);
|
||||
conn.connect(connectOpts);
|
||||
});
|
||||
@@ -858,6 +1254,57 @@ async function startSSHSessionWrapper(event, options) {
|
||||
err.level === 'client-authentication';
|
||||
|
||||
if (isAuthError) {
|
||||
// Check if there are encrypted default keys we haven't tried yet
|
||||
// Only offer retry if no unlocked keys were provided in this attempt
|
||||
if (!options._unlockedEncryptedKeys || options._unlockedEncryptedKeys.length === 0) {
|
||||
const allKeysWithEncrypted = findAllDefaultPrivateKeysFromHelper({ includeEncrypted: true });
|
||||
const encryptedKeys = allKeysWithEncrypted.filter(k => k.isEncrypted);
|
||||
|
||||
if (encryptedKeys.length > 0) {
|
||||
console.log('[SSH] Auth failed, found encrypted default keys. Requesting passphrases for retry...');
|
||||
|
||||
// Request passphrases from user
|
||||
const passphraseResult = await requestPassphrasesForEncryptedKeys(
|
||||
event.sender,
|
||||
options.hostname
|
||||
);
|
||||
|
||||
// If user cancelled, don't retry even if some keys were unlocked
|
||||
if (passphraseResult.cancelled) {
|
||||
console.log('[SSH] User cancelled passphrase flow, not retrying');
|
||||
} else if (passphraseResult.keys.length > 0) {
|
||||
console.log('[SSH] User unlocked keys, retrying connection...', {
|
||||
count: passphraseResult.keys.length,
|
||||
keyNames: passphraseResult.keys.map(k => k.keyName)
|
||||
});
|
||||
|
||||
// Retry connection with unlocked keys
|
||||
// Wrap in try-catch to ensure consistent error handling for retry failures
|
||||
try {
|
||||
return await startSSHSession(event, {
|
||||
...options,
|
||||
_unlockedEncryptedKeys: passphraseResult.keys,
|
||||
});
|
||||
} catch (retryErr) {
|
||||
// Re-wrap retry errors the same way as initial errors
|
||||
const isRetryAuthError = retryErr.message?.toLowerCase().includes('authentication') ||
|
||||
retryErr.message?.toLowerCase().includes('auth') ||
|
||||
retryErr.level === 'client-authentication';
|
||||
|
||||
if (isRetryAuthError) {
|
||||
const authError = new Error(retryErr.message);
|
||||
authError.level = 'client-authentication';
|
||||
authError.isAuthError = true;
|
||||
throw authError;
|
||||
}
|
||||
throw retryErr;
|
||||
}
|
||||
} else {
|
||||
console.log('[SSH] User did not unlock any keys, not retrying');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-throw with a clean error to avoid Electron printing full stack trace
|
||||
// The frontend will handle this as a normal auth failure for fallback
|
||||
const authError = new Error(err.message);
|
||||
@@ -1281,6 +1728,8 @@ function registerHandlers(ipcMain) {
|
||||
});
|
||||
// Register the shared keyboard-interactive response handler
|
||||
keyboardInteractiveHandler.registerHandler(ipcMain);
|
||||
// Register the passphrase response handler
|
||||
passphraseHandler.registerHandler(ipcMain);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
@@ -1294,4 +1743,8 @@ module.exports = {
|
||||
generateKeyPair,
|
||||
checkWindowsSshAgent,
|
||||
findDefaultPrivateKey,
|
||||
findAllDefaultPrivateKeys,
|
||||
isKeyEncrypted,
|
||||
findAllDefaultPrivateKeys,
|
||||
isKeyEncrypted,
|
||||
};
|
||||
|
||||
@@ -24,9 +24,144 @@ function init(deps) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a file transfer
|
||||
* Upload a local file to SFTP using streams (supports cancellation)
|
||||
*/
|
||||
async function startTransfer(event, payload) {
|
||||
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
|
||||
* @param {object} event - IPC event
|
||||
* @param {object} payload - Transfer configuration
|
||||
* @param {function} [onProgress] - Optional progress callback (transferred, total, speed)
|
||||
*/
|
||||
async function startTransfer(event, payload, onProgress) {
|
||||
const {
|
||||
transferId,
|
||||
sourcePath,
|
||||
@@ -40,17 +175,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 +194,28 @@ async function startTransfer(event, payload) {
|
||||
lastTime = now;
|
||||
lastTransferred = transferred;
|
||||
}
|
||||
|
||||
|
||||
// Call optional progress callback if provided
|
||||
if (onProgress) {
|
||||
onProgress(transferred, total, speed);
|
||||
}
|
||||
|
||||
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 +229,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 +372,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 };
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ const cloudSyncBridge = require("./bridges/cloudSyncBridge.cjs");
|
||||
const fileWatcherBridge = require("./bridges/fileWatcherBridge.cjs");
|
||||
const tempDirBridge = require("./bridges/tempDirBridge.cjs");
|
||||
const sessionLogsBridge = require("./bridges/sessionLogsBridge.cjs");
|
||||
const compressUploadBridge = require("./bridges/compressUploadBridge.cjs");
|
||||
const windowManager = require("./bridges/windowManager.cjs");
|
||||
|
||||
// GPU settings
|
||||
@@ -363,6 +364,12 @@ const registerBridges = (win) => {
|
||||
transferBridge.init(deps);
|
||||
terminalBridge.init(deps);
|
||||
fileWatcherBridge.init(deps);
|
||||
|
||||
// Initialize compress upload bridge with transferBridge dependency
|
||||
compressUploadBridge.init({
|
||||
...deps,
|
||||
transferBridge,
|
||||
});
|
||||
|
||||
// Initialize temp directory (synchronously)
|
||||
tempDirBridge.ensureTempDir();
|
||||
@@ -382,6 +389,7 @@ const registerBridges = (win) => {
|
||||
fileWatcherBridge.registerHandlers(ipcMain);
|
||||
tempDirBridge.registerHandlers(ipcMain, shell);
|
||||
sessionLogsBridge.registerHandlers(ipcMain);
|
||||
compressUploadBridge.registerHandlers(ipcMain);
|
||||
|
||||
// Settings window handler
|
||||
ipcMain.handle("netcatty:settings:open", async () => {
|
||||
@@ -551,6 +559,22 @@ const registerBridges = (win) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Show save file dialog and return selected path
|
||||
ipcMain.handle("netcatty:showSaveDialog", async (_event, { defaultPath, filters }) => {
|
||||
const { dialog } = electronModule;
|
||||
|
||||
const result = await dialog.showSaveDialog({
|
||||
defaultPath,
|
||||
filters: filters || [{ name: "All Files", extensions: ["*"] }],
|
||||
});
|
||||
|
||||
if (result.canceled || !result.filePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.filePath;
|
||||
});
|
||||
|
||||
// Download SFTP file to temp and return local path
|
||||
ipcMain.handle("netcatty:sftp:downloadToTemp", async (_event, { sftpId, remotePath, fileName, encoding }) => {
|
||||
console.log(`[Main] Downloading SFTP file to temp:`);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { ipcRenderer, contextBridge } = require("electron");
|
||||
const { ipcRenderer, contextBridge, webUtils } = require("electron");
|
||||
|
||||
const dataListeners = new Map();
|
||||
const exitListeners = new Map();
|
||||
@@ -10,6 +10,8 @@ const authFailedListeners = new Map();
|
||||
const languageChangeListeners = new Set();
|
||||
const fullscreenChangeListeners = new Set();
|
||||
const keyboardInteractiveListeners = new Set();
|
||||
const passphraseListeners = new Set();
|
||||
const passphraseTimeoutListeners = new Set();
|
||||
|
||||
ipcRenderer.on("netcatty:data", (_event, payload) => {
|
||||
const set = dataListeners.get(payload.sessionId);
|
||||
@@ -98,6 +100,28 @@ ipcRenderer.on("netcatty:keyboard-interactive", (_event, payload) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Passphrase request events for encrypted SSH keys
|
||||
ipcRenderer.on("netcatty:passphrase-request", (_event, payload) => {
|
||||
passphraseListeners.forEach((cb) => {
|
||||
try {
|
||||
cb(payload);
|
||||
} catch (err) {
|
||||
console.error("Passphrase request callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Passphrase timeout events (request expired)
|
||||
ipcRenderer.on("netcatty:passphrase-timeout", (_event, payload) => {
|
||||
passphraseTimeoutListeners.forEach((cb) => {
|
||||
try {
|
||||
cb(payload);
|
||||
} catch (err) {
|
||||
console.error("Passphrase timeout callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Transfer progress events
|
||||
ipcRenderer.on("netcatty:transfer:progress", (_event, payload) => {
|
||||
const cb = transferProgressListeners.get(payload.transferId);
|
||||
@@ -152,6 +176,11 @@ const uploadProgressListeners = new Map();
|
||||
const uploadCompleteListeners = new Map();
|
||||
const uploadErrorListeners = new Map();
|
||||
|
||||
// Compress upload listeners
|
||||
const compressProgressListeners = new Map();
|
||||
const compressCompleteListeners = new Map();
|
||||
const compressErrorListeners = new Map();
|
||||
|
||||
ipcRenderer.on("netcatty:upload:progress", (_event, payload) => {
|
||||
const cb = uploadProgressListeners.get(payload.transferId);
|
||||
if (cb) {
|
||||
@@ -193,6 +222,55 @@ ipcRenderer.on("netcatty:upload:error", (_event, payload) => {
|
||||
uploadErrorListeners.delete(payload.transferId);
|
||||
});
|
||||
|
||||
// Compress upload events
|
||||
ipcRenderer.on("netcatty:compress:progress", (_event, payload) => {
|
||||
const cb = compressProgressListeners.get(payload.compressionId);
|
||||
if (cb) {
|
||||
try {
|
||||
cb(payload.phase, payload.transferred, payload.total);
|
||||
} catch (err) {
|
||||
console.error("Compress progress callback failed", err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:compress:complete", (_event, payload) => {
|
||||
const cb = compressCompleteListeners.get(payload.compressionId);
|
||||
if (cb) {
|
||||
try {
|
||||
cb();
|
||||
} catch (err) {
|
||||
console.error("Compress complete callback failed", err);
|
||||
}
|
||||
}
|
||||
// Cleanup listeners
|
||||
compressProgressListeners.delete(payload.compressionId);
|
||||
compressCompleteListeners.delete(payload.compressionId);
|
||||
compressErrorListeners.delete(payload.compressionId);
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:compress:error", (_event, payload) => {
|
||||
const cb = compressErrorListeners.get(payload.compressionId);
|
||||
if (cb) {
|
||||
try {
|
||||
cb(payload.error);
|
||||
} catch (err) {
|
||||
console.error("Compress error callback failed", err);
|
||||
}
|
||||
}
|
||||
// Cleanup listeners
|
||||
compressProgressListeners.delete(payload.compressionId);
|
||||
compressCompleteListeners.delete(payload.compressionId);
|
||||
compressErrorListeners.delete(payload.compressionId);
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:compress:cancelled", (_event, payload) => {
|
||||
// Just cleanup listeners, the UI already knows it's cancelled
|
||||
compressProgressListeners.delete(payload.compressionId);
|
||||
compressCompleteListeners.delete(payload.compressionId);
|
||||
compressErrorListeners.delete(payload.compressionId);
|
||||
});
|
||||
|
||||
// Port forwarding status listeners
|
||||
const portForwardStatusListeners = new Map();
|
||||
|
||||
@@ -318,6 +396,29 @@ const api = {
|
||||
cancelled,
|
||||
});
|
||||
},
|
||||
// Passphrase request for encrypted SSH keys
|
||||
onPassphraseRequest: (cb) => {
|
||||
passphraseListeners.add(cb);
|
||||
return () => passphraseListeners.delete(cb);
|
||||
},
|
||||
respondPassphrase: async (requestId, passphrase, cancelled = false) => {
|
||||
return ipcRenderer.invoke("netcatty:passphrase:respond", {
|
||||
requestId,
|
||||
passphrase,
|
||||
cancelled,
|
||||
});
|
||||
},
|
||||
respondPassphraseSkip: async (requestId) => {
|
||||
return ipcRenderer.invoke("netcatty:passphrase:respond", {
|
||||
requestId,
|
||||
passphrase: '',
|
||||
skipped: true,
|
||||
});
|
||||
},
|
||||
onPassphraseTimeout: (cb) => {
|
||||
passphraseTimeoutListeners.add(cb);
|
||||
return () => passphraseTimeoutListeners.delete(cb);
|
||||
},
|
||||
openSftp: async (options) => {
|
||||
const result = await ipcRenderer.invoke("netcatty:sftp:open", options);
|
||||
return result.sftpId;
|
||||
@@ -440,6 +541,26 @@ const api = {
|
||||
transferErrorListeners.delete(transferId);
|
||||
return ipcRenderer.invoke("netcatty:transfer:cancel", { transferId });
|
||||
},
|
||||
// Compressed folder upload
|
||||
startCompressedUpload: async (options, onProgress, onComplete, onError) => {
|
||||
const { compressionId } = options;
|
||||
// Register callbacks
|
||||
if (onProgress) compressProgressListeners.set(compressionId, onProgress);
|
||||
if (onComplete) compressCompleteListeners.set(compressionId, onComplete);
|
||||
if (onError) compressErrorListeners.set(compressionId, onError);
|
||||
|
||||
return ipcRenderer.invoke("netcatty:compress:start", options);
|
||||
},
|
||||
cancelCompressedUpload: async (compressionId) => {
|
||||
// Cleanup listeners
|
||||
compressProgressListeners.delete(compressionId);
|
||||
compressCompleteListeners.delete(compressionId);
|
||||
compressErrorListeners.delete(compressionId);
|
||||
return ipcRenderer.invoke("netcatty:compress:cancel", { compressionId });
|
||||
},
|
||||
checkCompressedUploadSupport: async (sftpId) => {
|
||||
return ipcRenderer.invoke("netcatty:compress:checkSupport", { sftpId });
|
||||
},
|
||||
// Window controls for custom title bar
|
||||
windowMinimize: () => ipcRenderer.invoke("netcatty:window:minimize"),
|
||||
windowMaximize: () => ipcRenderer.invoke("netcatty:window:maximize"),
|
||||
@@ -584,7 +705,11 @@ const api = {
|
||||
ipcRenderer.invoke("netcatty:openWithApplication", { filePath, appPath }),
|
||||
downloadSftpToTemp: (sftpId, remotePath, fileName, encoding) =>
|
||||
ipcRenderer.invoke("netcatty:sftp:downloadToTemp", { sftpId, remotePath, fileName, encoding }),
|
||||
|
||||
|
||||
// Save dialog for file downloads
|
||||
showSaveDialog: (defaultPath, filters) =>
|
||||
ipcRenderer.invoke("netcatty:showSaveDialog", { defaultPath, filters }),
|
||||
|
||||
// File watcher for auto-sync feature
|
||||
startFileWatch: (localPath, remotePath, sftpId, encoding) =>
|
||||
ipcRenderer.invoke("netcatty:filewatch:start", { localPath, remotePath, sftpId, encoding }),
|
||||
@@ -626,6 +751,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}"],
|
||||
|
||||
58
global.d.ts
vendored
58
global.d.ts
vendored
@@ -2,6 +2,15 @@ import type { RemoteFile, SftpFilenameEncoding } from "./types";
|
||||
import type { S3Config, SMBConfig, SyncedFile, WebDAVConfig } from "./domain/sync";
|
||||
|
||||
declare global {
|
||||
// Extend HTMLInputElement to support webkitdirectory attribute
|
||||
namespace JSX {
|
||||
interface IntrinsicElements {
|
||||
input: React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement> & {
|
||||
webkitdirectory?: string;
|
||||
}, HTMLInputElement>;
|
||||
}
|
||||
}
|
||||
|
||||
// Proxy configuration for SSH connections
|
||||
interface NetcattyProxyConfig {
|
||||
type: 'http' | 'socks5';
|
||||
@@ -245,6 +254,27 @@ declare global {
|
||||
cancelled?: boolean
|
||||
): Promise<{ success: boolean; error?: string }>;
|
||||
|
||||
// Passphrase request for encrypted SSH keys
|
||||
onPassphraseRequest?(
|
||||
cb: (request: {
|
||||
requestId: string;
|
||||
keyPath: string;
|
||||
keyName: string;
|
||||
hostname?: string;
|
||||
}) => void
|
||||
): () => void;
|
||||
respondPassphrase?(
|
||||
requestId: string,
|
||||
passphrase: string,
|
||||
cancelled?: boolean
|
||||
): Promise<{ success: boolean; error?: string }>;
|
||||
respondPassphraseSkip?(
|
||||
requestId: string
|
||||
): Promise<{ success: boolean; error?: string }>;
|
||||
onPassphraseTimeout?(
|
||||
cb: (event: { requestId: string }) => void
|
||||
): () => void;
|
||||
|
||||
// SFTP operations
|
||||
openSftp(options: NetcattySSHOptions): Promise<string>;
|
||||
listSftp(sftpId: string, path: string, encoding?: SftpFilenameEncoding): Promise<RemoteFile[]>;
|
||||
@@ -278,6 +308,28 @@ declare global {
|
||||
uploadFile?(sftpId: string, localPath: string, remotePath: string, transferId: string): Promise<void>;
|
||||
downloadFile?(sftpId: string, remotePath: string, localPath: string, transferId: string): Promise<void>;
|
||||
cancelTransfer?(transferId: string): Promise<void>;
|
||||
|
||||
// Compressed folder upload
|
||||
startCompressedUpload?(
|
||||
options: {
|
||||
compressionId: string;
|
||||
folderPath: string;
|
||||
targetPath: string;
|
||||
sftpId: string;
|
||||
folderName: string;
|
||||
},
|
||||
onProgress?: (phase: string, transferred: number, total: number) => void,
|
||||
onComplete?: () => void,
|
||||
onError?: (error: string) => void
|
||||
): Promise<{ compressionId: string; success?: boolean; error?: string }>;
|
||||
cancelCompressedUpload?(compressionId: string): Promise<{ success: boolean }>;
|
||||
checkCompressedUploadSupport?(sftpId: string): Promise<{
|
||||
supported: boolean;
|
||||
localTar: boolean;
|
||||
remoteTar: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
|
||||
onTransferProgress?(transferId: string, cb: (progress: SftpTransferProgress) => void): () => void;
|
||||
|
||||
// Streaming transfer with real progress and cancellation
|
||||
@@ -484,6 +536,9 @@ declare global {
|
||||
openWithApplication?(filePath: string, appPath: string): Promise<boolean>;
|
||||
downloadSftpToTemp?(sftpId: string, remotePath: string, fileName: string, encoding?: SftpFilenameEncoding): Promise<string>;
|
||||
|
||||
// Save dialog for file downloads
|
||||
showSaveDialog?(defaultPath: string, filters?: Array<{ name: string; extensions: string[] }>): Promise<string | null>;
|
||||
|
||||
// File watcher for auto-sync feature
|
||||
startFileWatch?(localPath: string, remotePath: string, sftpId: string, encoding?: SftpFilenameEncoding): Promise<{ watchId: string }>;
|
||||
stopFileWatch?(watchId: string, cleanupTempFile?: boolean): Promise<{ success: boolean }>;
|
||||
@@ -520,6 +575,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 {
|
||||
|
||||
@@ -28,6 +28,7 @@ export const STORAGE_KEY_SHELL_HISTORY = 'netcatty_shell_history_v1';
|
||||
export const STORAGE_KEY_CONNECTION_LOGS = 'netcatty_connection_logs_v1';
|
||||
export const STORAGE_KEY_IDENTITIES = 'netcatty_identities_v1';
|
||||
export const STORAGE_KEY_VAULT_HOSTS_VIEW_MODE = 'netcatty_vault_hosts_view_mode_v1';
|
||||
export const STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED = 'netcatty_vault_hosts_tree_expanded_v1';
|
||||
export const STORAGE_KEY_VAULT_KEYS_VIEW_MODE = 'netcatty_vault_keys_view_mode_v1';
|
||||
export const STORAGE_KEY_VAULT_SNIPPETS_VIEW_MODE = 'netcatty_vault_snippets_view_mode_v1';
|
||||
export const STORAGE_KEY_VAULT_KNOWN_HOSTS_VIEW_MODE = 'netcatty_vault_known_hosts_view_mode_v1';
|
||||
@@ -43,6 +44,7 @@ export const STORAGE_KEY_SFTP_FILE_ASSOCIATIONS = 'netcatty_sftp_file_associatio
|
||||
export const STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR = 'netcatty_sftp_double_click_behavior_v1';
|
||||
export const STORAGE_KEY_SFTP_AUTO_SYNC = 'netcatty_sftp_auto_sync_v1';
|
||||
export const STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES = 'netcatty_sftp_show_hidden_files_v1';
|
||||
export const STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD = 'netcatty_sftp_use_compressed_upload_v1';
|
||||
|
||||
// Session Logs Settings
|
||||
export const STORAGE_KEY_SESSION_LOGS_ENABLED = 'netcatty_session_logs_enabled_v1';
|
||||
@@ -51,3 +53,6 @@ export const STORAGE_KEY_SESSION_LOGS_FORMAT = 'netcatty_session_logs_format_v1'
|
||||
|
||||
// Archived legacy key records that are no longer supported by the app (e.g. biometric/WebAuthn/FIDO2 experiments).
|
||||
export const STORAGE_KEY_LEGACY_KEYS = 'netcatty_legacy_keys_v1';
|
||||
|
||||
// Managed Sources - external files that manage groups of hosts (e.g., ~/.ssh/config)
|
||||
export const STORAGE_KEY_MANAGED_SOURCES = 'netcatty_managed_sources_v1';
|
||||
|
||||
87
infrastructure/services/compressUploadService.ts
Normal file
87
infrastructure/services/compressUploadService.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Compressed Upload Service
|
||||
*
|
||||
* Provides compressed folder upload functionality using tar compression
|
||||
*/
|
||||
|
||||
import { netcattyBridge } from "./netcattyBridge";
|
||||
|
||||
export interface CompressUploadOptions {
|
||||
compressionId: string;
|
||||
folderPath: string;
|
||||
targetPath: string;
|
||||
sftpId: string;
|
||||
folderName: string;
|
||||
}
|
||||
|
||||
export interface CompressUploadProgress {
|
||||
phase: 'compressing' | 'uploading' | 'extracting';
|
||||
transferred: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface CompressUploadSupport {
|
||||
supported: boolean;
|
||||
localTar: boolean;
|
||||
remoteTar: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type CompressUploadProgressCallback = (phase: string, transferred: number, total: number) => void;
|
||||
export type CompressUploadCompleteCallback = () => void;
|
||||
export type CompressUploadErrorCallback = (error: string) => void;
|
||||
|
||||
/**
|
||||
* Start a compressed folder upload
|
||||
*/
|
||||
export async function startCompressedUpload(
|
||||
options: CompressUploadOptions,
|
||||
onProgress?: CompressUploadProgressCallback,
|
||||
onComplete?: CompressUploadCompleteCallback,
|
||||
onError?: CompressUploadErrorCallback
|
||||
): Promise<{ compressionId: string; success?: boolean; error?: string }> {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.startCompressedUpload) {
|
||||
throw new Error("Compressed upload not available");
|
||||
}
|
||||
|
||||
try {
|
||||
return await bridge.startCompressedUpload(options, onProgress, onComplete, onError);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
return {
|
||||
compressionId: options.compressionId,
|
||||
success: false,
|
||||
error: errorMessage
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a compressed upload
|
||||
*/
|
||||
export async function cancelCompressedUpload(compressionId: string): Promise<{ success: boolean }> {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.cancelCompressedUpload) {
|
||||
throw new Error("Compressed upload not available");
|
||||
}
|
||||
|
||||
return bridge.cancelCompressedUpload(compressionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if compressed upload is supported for a given SFTP session
|
||||
*/
|
||||
export async function checkCompressedUploadSupport(sftpId: string): Promise<CompressUploadSupport> {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.checkCompressedUploadSupport) {
|
||||
return {
|
||||
supported: false,
|
||||
localTar: false,
|
||||
remoteTar: false,
|
||||
error: "Compressed upload not available"
|
||||
};
|
||||
}
|
||||
|
||||
return bridge.checkCompressedUploadSupport(sftpId);
|
||||
}
|
||||
@@ -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
|
||||
@@ -55,6 +55,8 @@ export interface UploadCallbacks {
|
||||
onScanningStart?: (taskId: string) => void;
|
||||
/** Called when scanning ends */
|
||||
onScanningEnd?: (taskId: string) => void;
|
||||
/** Called when task name needs to be updated (for phase changes) */
|
||||
onTaskNameUpdate?: (taskId: string, newName: string) => void;
|
||||
}
|
||||
|
||||
export interface UploadBridge {
|
||||
@@ -72,6 +74,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 {
|
||||
@@ -87,6 +106,8 @@ export interface UploadConfig {
|
||||
joinPath: (base: string, name: string) => string;
|
||||
/** Callbacks for progress updates */
|
||||
callbacks?: UploadCallbacks;
|
||||
/** Use compressed upload for folders (requires tar on both local and remote) */
|
||||
useCompressedUpload?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -142,6 +163,7 @@ export function sortEntries(entries: DropEntry[]): DropEntry[] {
|
||||
export class UploadController {
|
||||
private cancelled = false;
|
||||
private activeFileTransferIds = new Set<string>();
|
||||
private activeCompressionIds = new Set<string>();
|
||||
private currentTransferId = "";
|
||||
private bridge: UploadBridge | null = null;
|
||||
|
||||
@@ -151,15 +173,30 @@ export class UploadController {
|
||||
async cancel(): Promise<void> {
|
||||
this.cancelled = true;
|
||||
|
||||
if (!this.bridge?.cancelSftpUpload) {
|
||||
return;
|
||||
// Cancel all active compressed uploads
|
||||
const activeCompressionIds = Array.from(this.activeCompressionIds);
|
||||
for (const compressionId of activeCompressionIds) {
|
||||
try {
|
||||
// Import and call cancelCompressedUpload
|
||||
const { cancelCompressedUpload } = await import('../infrastructure/services/compressUploadService');
|
||||
await cancelCompressedUpload(compressionId);
|
||||
} catch {
|
||||
// Ignore cancel errors
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel all active file uploads
|
||||
const activeIds = Array.from(this.activeFileTransferIds);
|
||||
for (const transferId of activeIds) {
|
||||
try {
|
||||
await this.bridge.cancelSftpUpload(transferId);
|
||||
// Try cancelTransfer first (for stream transfers)
|
||||
if (this.bridge?.cancelTransfer) {
|
||||
await this.bridge.cancelTransfer(transferId);
|
||||
}
|
||||
// Also try cancelSftpUpload (for legacy uploads)
|
||||
if (this.bridge?.cancelSftpUpload) {
|
||||
await this.bridge.cancelSftpUpload(transferId);
|
||||
}
|
||||
} catch {
|
||||
// Ignore cancel errors
|
||||
}
|
||||
@@ -168,7 +205,12 @@ 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);
|
||||
if (this.bridge?.cancelTransfer) {
|
||||
await this.bridge.cancelTransfer(this.currentTransferId);
|
||||
}
|
||||
if (this.bridge?.cancelSftpUpload) {
|
||||
await this.bridge.cancelSftpUpload(this.currentTransferId);
|
||||
}
|
||||
} catch {
|
||||
// Ignore cancel errors
|
||||
}
|
||||
@@ -190,7 +232,9 @@ export class UploadController {
|
||||
if (this.currentTransferId && !ids.includes(this.currentTransferId)) {
|
||||
ids.push(this.currentTransferId);
|
||||
}
|
||||
return ids;
|
||||
// Also include compression IDs
|
||||
const compressionIds = Array.from(this.activeCompressionIds);
|
||||
return [...ids, ...compressionIds];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -199,6 +243,7 @@ export class UploadController {
|
||||
reset(): void {
|
||||
this.cancelled = false;
|
||||
this.activeFileTransferIds.clear();
|
||||
this.activeCompressionIds.clear();
|
||||
this.currentTransferId = "";
|
||||
}
|
||||
|
||||
@@ -233,6 +278,20 @@ export class UploadController {
|
||||
clearCurrentTransfer(): void {
|
||||
this.currentTransferId = "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a compression ID
|
||||
*/
|
||||
addActiveCompression(id: string): void {
|
||||
this.activeCompressionIds.add(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a tracked compression ID
|
||||
*/
|
||||
removeActiveCompression(id: string): void {
|
||||
this.activeCompressionIds.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -252,7 +311,7 @@ export async function uploadFromDataTransfer(
|
||||
config: UploadConfig,
|
||||
controller?: UploadController
|
||||
): Promise<UploadResult[]> {
|
||||
const { targetPath, sftpId, isLocal, bridge, joinPath, callbacks } = config;
|
||||
const { targetPath, sftpId, isLocal, bridge, joinPath, callbacks, useCompressedUpload } = config;
|
||||
|
||||
// Reset controller if provided
|
||||
if (controller) {
|
||||
@@ -275,35 +334,141 @@ export async function uploadFromDataTransfer(
|
||||
return [];
|
||||
}
|
||||
|
||||
// Check if this is a folder upload and compressed upload is enabled
|
||||
if (useCompressedUpload && !isLocal && sftpId) {
|
||||
const rootFolders = detectRootFolders(entries);
|
||||
const folderEntries = Array.from(rootFolders.entries()).filter(([key]) => !key.startsWith("__file__"));
|
||||
const standaloneFileEntries = Array.from(rootFolders.entries()).filter(([key]) => key.startsWith("__file__"));
|
||||
|
||||
if (folderEntries.length > 0) {
|
||||
try {
|
||||
const compressedResults = await uploadFoldersCompressed(folderEntries, entries, targetPath, sftpId, callbacks, controller);
|
||||
|
||||
// Check if any folders failed due to lack of compression support
|
||||
const failedFolders = compressedResults.filter(result =>
|
||||
!result.success && result.error === "Compressed upload not supported - fallback needed"
|
||||
);
|
||||
const successfulFolders = compressedResults.filter(result =>
|
||||
result.success || result.error !== "Compressed upload not supported - fallback needed"
|
||||
);
|
||||
|
||||
let fallbackResults: UploadResult[] = [];
|
||||
if (failedFolders.length > 0) {
|
||||
// Get entries only for failed folders, not already successful ones
|
||||
const failedFolderNames = new Set(failedFolders.map(f => f.fileName));
|
||||
const failedFolderEntries = entries.filter(entry => {
|
||||
const topFolder = entry.relativePath.split('/')[0];
|
||||
return failedFolderNames.has(topFolder);
|
||||
});
|
||||
|
||||
if (failedFolderEntries.length > 0) {
|
||||
fallbackResults = await uploadEntries(failedFolderEntries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
|
||||
}
|
||||
}
|
||||
|
||||
// Upload standalone files using regular upload if any exist
|
||||
let standaloneResults: UploadResult[] = [];
|
||||
if (standaloneFileEntries.length > 0) {
|
||||
const standaloneEntries = standaloneFileEntries.flatMap(([, entries]) => entries);
|
||||
standaloneResults = await uploadEntries(standaloneEntries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
|
||||
}
|
||||
|
||||
// Combine results: successful compressed + fallback results + standalone files
|
||||
return [...successfulFolders, ...fallbackResults, ...standaloneResults];
|
||||
} catch {
|
||||
// Fall back to regular upload
|
||||
return uploadEntries(entries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return uploadEntries(entries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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[]> {
|
||||
const { targetPath, sftpId, isLocal, bridge, joinPath, callbacks } = config;
|
||||
const { targetPath, sftpId, isLocal, bridge, joinPath, callbacks, useCompressedUpload } = config;
|
||||
|
||||
if (controller) {
|
||||
controller.reset();
|
||||
controller.setBridge(bridge);
|
||||
}
|
||||
|
||||
// Convert FileList to DropEntry array (simple files, no folders)
|
||||
const entries: DropEntry[] = Array.from(fileList).map(file => ({
|
||||
file,
|
||||
relativePath: file.name,
|
||||
isDirectory: false,
|
||||
}));
|
||||
// Convert FileList to DropEntry array
|
||||
// Use webkitRelativePath for folder uploads, fallback to file.name for regular file uploads
|
||||
const entries: DropEntry[] = Array.from(fileList).map(file => {
|
||||
const localPath = getPathForFile(file);
|
||||
// Use webkitRelativePath if available (folder upload), otherwise use file.name (regular file upload)
|
||||
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name;
|
||||
if (localPath) {
|
||||
// Set the path property on the file for stream transfer
|
||||
(file as File & { path?: string }).path = localPath;
|
||||
}
|
||||
return {
|
||||
file,
|
||||
relativePath,
|
||||
isDirectory: false,
|
||||
};
|
||||
});
|
||||
|
||||
if (entries.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Check if this is a folder upload and compressed upload is enabled
|
||||
if (useCompressedUpload && !isLocal && sftpId) {
|
||||
const rootFolders = detectRootFolders(entries);
|
||||
const folderEntries = Array.from(rootFolders.entries()).filter(([key]) => !key.startsWith("__file__"));
|
||||
const standaloneFileEntries = Array.from(rootFolders.entries()).filter(([key]) => key.startsWith("__file__"));
|
||||
|
||||
if (folderEntries.length > 0) {
|
||||
try {
|
||||
const compressedResults = await uploadFoldersCompressed(folderEntries, entries, targetPath, sftpId, callbacks, controller);
|
||||
|
||||
// Check if any folders failed due to lack of compression support
|
||||
const failedFolders = compressedResults.filter(result =>
|
||||
!result.success && result.error === "Compressed upload not supported - fallback needed"
|
||||
);
|
||||
const successfulFolders = compressedResults.filter(result =>
|
||||
result.success || result.error !== "Compressed upload not supported - fallback needed"
|
||||
);
|
||||
|
||||
let fallbackResults: UploadResult[] = [];
|
||||
if (failedFolders.length > 0) {
|
||||
// Get entries only for failed folders, not already successful ones
|
||||
const failedFolderNames = new Set(failedFolders.map(f => f.fileName));
|
||||
const failedFolderEntries = entries.filter(entry => {
|
||||
const topFolder = entry.relativePath.split('/')[0];
|
||||
return failedFolderNames.has(topFolder);
|
||||
});
|
||||
|
||||
if (failedFolderEntries.length > 0) {
|
||||
fallbackResults = await uploadEntries(failedFolderEntries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
|
||||
}
|
||||
}
|
||||
|
||||
// Upload standalone files using regular upload if any exist
|
||||
let standaloneResults: UploadResult[] = [];
|
||||
if (standaloneFileEntries.length > 0) {
|
||||
const standaloneEntries = standaloneFileEntries.flatMap(([, entries]) => entries);
|
||||
standaloneResults = await uploadEntries(standaloneEntries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
|
||||
}
|
||||
|
||||
// Combine results: successful compressed + fallback results + standalone files
|
||||
return [...successfulFolders, ...fallbackResults, ...standaloneResults];
|
||||
} catch {
|
||||
// Fall back to regular upload
|
||||
return uploadEntries(entries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return uploadEntries(entries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
|
||||
}
|
||||
|
||||
@@ -470,99 +635,198 @@ 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;
|
||||
// Use stream transfer if available and we have a local file path (avoids loading file into memory)
|
||||
if (localFilePath && bridge.startStreamTransfer && sftpId && !isLocal) {
|
||||
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;
|
||||
const onProgress = (transferred: number, total: number, speed: number) => {
|
||||
if (controller?.isCancelled()) return;
|
||||
|
||||
pendingProgressUpdate = { transferred, total, speed };
|
||||
pendingProgressUpdate = { transferred, total, speed };
|
||||
|
||||
if (!rafScheduled) {
|
||||
rafScheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
rafScheduled = false;
|
||||
const update = pendingProgressUpdate;
|
||||
pendingProgressUpdate = null;
|
||||
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,
|
||||
if (update && !controller?.isCancelled() && callbacks?.onTaskProgress) {
|
||||
if (bundleTaskId) {
|
||||
const progress = bundleProgress.get(bundleTaskId);
|
||||
if (progress) {
|
||||
// For bundled tasks, only update the current file's progress
|
||||
// Don't add to completedFilesBytes until the file is fully completed
|
||||
const newTransferred = progress.completedFilesBytes + update.transferred;
|
||||
progress.transferredBytes = newTransferred;
|
||||
progress.currentSpeed = update.speed;
|
||||
const percent = progress.totalBytes > 0 ? (newTransferred / progress.totalBytes) * 100 : 0;
|
||||
// Ensure progress doesn't exceed 99.9% until all files are completed
|
||||
const displayPercent = progress.completedCount >= progress.fileCount ? percent : Math.min(percent, 99.9);
|
||||
callbacks.onTaskProgress(bundleTaskId, {
|
||||
transferred: newTransferred,
|
||||
total: progress.totalBytes,
|
||||
speed: update.speed,
|
||||
percent: update.total > 0 ? (update.transferred / update.total) * 100 : 0,
|
||||
percent: displayPercent,
|
||||
});
|
||||
}
|
||||
} 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)
|
||||
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;
|
||||
const percent = progress.totalBytes > 0 ? (newTransferred / progress.totalBytes) * 100 : 0;
|
||||
// Ensure progress doesn't show 100% until all files are completed
|
||||
const displayPercent = progress.completedCount >= progress.fileCount ? percent : Math.min(percent, 99.9);
|
||||
callbacks.onTaskProgress(bundleTaskId, {
|
||||
transferred: newTransferred,
|
||||
total: progress.totalBytes,
|
||||
speed: update.speed,
|
||||
percent: displayPercent,
|
||||
});
|
||||
}
|
||||
} 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,
|
||||
() => {
|
||||
// File upload completed successfully
|
||||
},
|
||||
(error) => {
|
||||
// File upload failed - error is handled by the caller
|
||||
void error;
|
||||
}
|
||||
);
|
||||
} 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");
|
||||
}
|
||||
}
|
||||
|
||||
// File processing completed (both stream transfer and fallback paths)
|
||||
controller?.clearCurrentTransfer();
|
||||
results.push({ fileName: entry.relativePath, success: true });
|
||||
|
||||
@@ -572,16 +836,28 @@ async function uploadEntries(
|
||||
if (progress) {
|
||||
progress.completedCount++;
|
||||
progress.completedFilesBytes += fileTotalBytes;
|
||||
// Set transferredBytes to completedFilesBytes to avoid double counting
|
||||
progress.transferredBytes = progress.completedFilesBytes;
|
||||
|
||||
if (progress.completedCount >= progress.fileCount) {
|
||||
// All files completed - set final progress to 100% and mark as completed
|
||||
callbacks?.onTaskProgress?.(bundleTaskId, {
|
||||
transferred: progress.totalBytes,
|
||||
total: progress.totalBytes,
|
||||
speed: 0,
|
||||
percent: 100,
|
||||
});
|
||||
// Call completion callback synchronously
|
||||
callbacks?.onTaskCompleted?.(bundleTaskId, progress.totalBytes);
|
||||
} else if (callbacks?.onTaskProgress) {
|
||||
const percent = progress.totalBytes > 0 ? (progress.completedFilesBytes / progress.totalBytes) * 100 : 0;
|
||||
// Ensure progress doesn't exceed 99.9% until all files are completed
|
||||
const displayPercent = Math.min(percent, 99.9);
|
||||
callbacks.onTaskProgress(bundleTaskId, {
|
||||
transferred: progress.completedFilesBytes,
|
||||
total: progress.totalBytes,
|
||||
speed: 0,
|
||||
percent: progress.totalBytes > 0 ? (progress.completedFilesBytes / progress.totalBytes) * 100 : 0,
|
||||
percent: displayPercent,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -650,3 +926,226 @@ export async function uploadEntriesDirect(
|
||||
|
||||
return uploadEntries(entries, targetPath, sftpId, isLocal, bridge, joinPath, callbacks, controller);
|
||||
}
|
||||
/**
|
||||
* Upload folders using compression
|
||||
*/
|
||||
async function uploadFoldersCompressed(
|
||||
folderEntries: Array<[string, DropEntry[]]>,
|
||||
allEntries: DropEntry[],
|
||||
targetPath: string,
|
||||
sftpId: string,
|
||||
callbacks?: UploadCallbacks,
|
||||
controller?: UploadController
|
||||
): Promise<UploadResult[]> {
|
||||
const results: UploadResult[] = [];
|
||||
|
||||
// Import the compressed upload service
|
||||
const { startCompressedUpload, checkCompressedUploadSupport } = await import('../infrastructure/services/compressUploadService');
|
||||
|
||||
for (const [folderName, entries] of folderEntries) {
|
||||
if (controller?.isCancelled()) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Get the local folder path from the first file in the folder
|
||||
const firstFile = entries.find(e => e.file);
|
||||
if (!firstFile?.file) {
|
||||
// Empty folder - mark for fallback to regular upload which will create the directory
|
||||
results.push({ fileName: folderName, success: false, error: "Compressed upload not supported - fallback needed" });
|
||||
continue;
|
||||
}
|
||||
|
||||
const localFilePath = getPathForFile(firstFile.file);
|
||||
if (!localFilePath) {
|
||||
results.push({ fileName: folderName, success: false, error: "Could not get local file path" });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract folder path from the first file path
|
||||
// Use DropEntry.relativePath which works for both file input and drag-drop scenarios
|
||||
// For file input: webkitRelativePath is set (e.g., "folder/subdir/file.txt")
|
||||
// For drag-drop: DropEntry.relativePath contains the correct path from extractDropEntries
|
||||
const relativePath = firstFile.relativePath || (firstFile.file as File & { webkitRelativePath?: string }).webkitRelativePath || firstFile.file.name;
|
||||
|
||||
// Normalize path separators for cross-platform compatibility
|
||||
const normalizePathSeparators = (path: string) => path.replace(/\\/g, '/');
|
||||
const normalizedLocalPath = normalizePathSeparators(localFilePath);
|
||||
const normalizedRelativePath = normalizePathSeparators(relativePath);
|
||||
|
||||
// Calculate the root folder path by removing the full relativePath from localFilePath
|
||||
// For example: if localFilePath is "/Users/rice/Downloads/110-temp/insideServer/subdir/file.txt"
|
||||
// and relativePath is "insideServer/subdir/file.txt", we want "/Users/rice/Downloads/110-temp/insideServer"
|
||||
let folderPath = localFilePath;
|
||||
if (normalizedRelativePath && normalizedLocalPath.endsWith(normalizedRelativePath)) {
|
||||
// Remove the relativePath from the end to get the base directory
|
||||
const basePath = localFilePath.substring(0, localFilePath.length - relativePath.length);
|
||||
// Remove trailing slash/backslash if present
|
||||
const cleanBasePath = basePath.replace(/[/\\]$/, '');
|
||||
// Add the folder name to get the actual folder path
|
||||
folderPath = cleanBasePath + (cleanBasePath ? (localFilePath.includes('\\') ? '\\' : '/') : '') + folderName;
|
||||
} else {
|
||||
// Fallback: try to extract based on folder name with normalized separators
|
||||
const normalizedFolderPattern1 = '/' + folderName + '/';
|
||||
const normalizedFolderPattern2 = '\\' + folderName + '\\';
|
||||
const folderIndex1 = normalizedLocalPath.lastIndexOf(normalizedFolderPattern1);
|
||||
const folderIndex2 = localFilePath.lastIndexOf(normalizedFolderPattern2);
|
||||
const folderIndex = Math.max(folderIndex1, folderIndex2);
|
||||
|
||||
if (folderIndex >= 0) {
|
||||
folderPath = localFilePath.substring(0, folderIndex + folderName.length + 1);
|
||||
} else {
|
||||
// Last resort: remove just the filename (original logic)
|
||||
const pathParts = normalizedRelativePath.split('/');
|
||||
if (pathParts.length > 1) {
|
||||
const fileName = pathParts[pathParts.length - 1];
|
||||
if (normalizedLocalPath.endsWith(fileName)) {
|
||||
folderPath = localFilePath.substring(0, localFilePath.length - fileName.length - 1);
|
||||
}
|
||||
} else {
|
||||
// Single file, get its parent directory
|
||||
const lastSlash = Math.max(localFilePath.lastIndexOf('/'), localFilePath.lastIndexOf('\\'));
|
||||
if (lastSlash > 0) {
|
||||
folderPath = localFilePath.substring(0, lastSlash);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let taskId: string | null = null; // Declare taskId outside try block for error handling
|
||||
|
||||
try {
|
||||
// Check if compressed upload is supported
|
||||
const support = await checkCompressedUploadSupport(sftpId);
|
||||
if (!support.supported) {
|
||||
// Fall back to regular upload for this folder
|
||||
results.push({
|
||||
fileName: folderName,
|
||||
success: false,
|
||||
error: "Compressed upload not supported - fallback needed"
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const compressionId = crypto.randomUUID();
|
||||
|
||||
// Check for cancellation before starting
|
||||
if (controller?.isCancelled()) {
|
||||
results.push({ fileName: folderName, success: false, cancelled: true });
|
||||
break;
|
||||
}
|
||||
|
||||
// Register compression ID with controller for cancellation support
|
||||
controller?.addActiveCompression(compressionId);
|
||||
|
||||
// Create a task for this folder compression
|
||||
const totalBytes = entries.reduce((sum, entry) => sum + (entry.file?.size || 0), 0);
|
||||
taskId = compressionId;
|
||||
|
||||
if (callbacks?.onTaskCreated) {
|
||||
callbacks.onTaskCreated({
|
||||
id: taskId,
|
||||
fileName: folderName,
|
||||
displayName: `${folderName} (compressed)`,
|
||||
isDirectory: true,
|
||||
totalBytes,
|
||||
transferredBytes: 0,
|
||||
speed: 0,
|
||||
fileCount: entries.length,
|
||||
completedCount: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Start compressed upload
|
||||
const result = await startCompressedUpload(
|
||||
{
|
||||
compressionId,
|
||||
folderPath,
|
||||
targetPath,
|
||||
sftpId,
|
||||
folderName,
|
||||
},
|
||||
(phase, transferred, total) => {
|
||||
// Check for cancellation during progress updates
|
||||
if (controller?.isCancelled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (callbacks?.onTaskProgress) {
|
||||
// Map compression progress to actual file bytes
|
||||
const progressPercent = total > 0 ? (transferred / total) * 100 : 0;
|
||||
const mappedTransferred = Math.floor((progressPercent / 100) * totalBytes);
|
||||
|
||||
callbacks.onTaskProgress(taskId, {
|
||||
transferred: mappedTransferred,
|
||||
total: totalBytes,
|
||||
speed: 0, // Speed is handled by the compression service
|
||||
percent: progressPercent,
|
||||
});
|
||||
}
|
||||
|
||||
// Update task name based on phase
|
||||
if (callbacks?.onTaskNameUpdate) {
|
||||
// Pass phase identifier for UI layer to handle i18n
|
||||
// Format: "folderName|phase" where phase is: compressing, extracting, uploading, or compressed
|
||||
const phaseKey = phase === 'compressing' ? 'compressing'
|
||||
: phase === 'extracting' ? 'extracting'
|
||||
: phase === 'uploading' ? 'uploading'
|
||||
: 'compressed';
|
||||
callbacks.onTaskNameUpdate(taskId, `${folderName}|${phaseKey}`);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
// Remove compression ID from controller
|
||||
controller?.removeActiveCompression(compressionId);
|
||||
// Mark task as completed immediately
|
||||
if (callbacks?.onTaskCompleted) {
|
||||
callbacks.onTaskCompleted(taskId, totalBytes);
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
// Remove compression ID from controller on error
|
||||
controller?.removeActiveCompression(compressionId);
|
||||
if (callbacks?.onTaskFailed) {
|
||||
callbacks.onTaskFailed(taskId, error);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
results.push({ fileName: folderName, success: true });
|
||||
} else if (result.error?.includes('cancelled') || controller?.isCancelled()) {
|
||||
// Handle cancellation
|
||||
results.push({ fileName: folderName, success: false, cancelled: true });
|
||||
if (callbacks?.onTaskCancelled) {
|
||||
callbacks.onTaskCancelled(taskId);
|
||||
}
|
||||
} else {
|
||||
results.push({ fileName: folderName, success: false, error: result.error });
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
|
||||
// Remove compression ID from controller on error
|
||||
if (taskId) {
|
||||
controller?.removeActiveCompression(taskId);
|
||||
}
|
||||
|
||||
// Check if this was a cancellation
|
||||
if (controller?.isCancelled() || errorMessage.includes('cancelled')) {
|
||||
results.push({ fileName: folderName, success: false, cancelled: true });
|
||||
if (callbacks?.onTaskCancelled && taskId) {
|
||||
callbacks.onTaskCancelled(taskId);
|
||||
}
|
||||
} else {
|
||||
results.push({ fileName: folderName, success: false, error: errorMessage });
|
||||
// Only call onTaskFailed if we have a valid taskId (task was created) and it's not a cancellation
|
||||
if (callbacks?.onTaskFailed && taskId) {
|
||||
callbacks.onTaskFailed(taskId, errorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
85
package-lock.json
generated
85
package-lock.json
generated
@@ -24,6 +24,7 @@
|
||||
"@radix-ui/react-select": "2.2.6",
|
||||
"@radix-ui/react-slot": "1.2.4",
|
||||
"@radix-ui/react-tabs": "1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-search": "^0.15.0",
|
||||
"@xterm/addon-serialize": "^0.13.0",
|
||||
@@ -1007,6 +1008,7 @@
|
||||
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.28.6",
|
||||
"@babel/generator": "^7.28.6",
|
||||
@@ -1653,7 +1655,6 @@
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cross-dirname": "^0.1.0",
|
||||
"debug": "^4.3.4",
|
||||
@@ -1675,7 +1676,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
@@ -1692,7 +1692,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
@@ -1707,7 +1706,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
@@ -3620,6 +3618,58 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
|
||||
"integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.3",
|
||||
"@radix-ui/react-compose-refs": "1.1.2",
|
||||
"@radix-ui/react-context": "1.1.2",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.11",
|
||||
"@radix-ui/react-id": "1.1.1",
|
||||
"@radix-ui/react-popper": "1.2.8",
|
||||
"@radix-ui/react-portal": "1.1.9",
|
||||
"@radix-ui/react-presence": "1.1.5",
|
||||
"@radix-ui/react-primitive": "2.1.3",
|
||||
"@radix-ui/react-slot": "1.2.3",
|
||||
"@radix-ui/react-use-controllable-state": "1.2.2",
|
||||
"@radix-ui/react-visually-hidden": "1.2.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||
@@ -5621,6 +5671,7 @@
|
||||
"integrity": "sha512-eEXsVvLPu8Z4PkFibtuFJLJOTAV/nPdgtSjkGoPpddpFk3/ym2oy97jynY6ic2m6+nc5M8SE1e9v/mHKsulcJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.53.0",
|
||||
@@ -5650,6 +5701,7 @@
|
||||
"integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.53.0",
|
||||
"@typescript-eslint/types": "8.53.0",
|
||||
@@ -5928,7 +5980,8 @@
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@yarnpkg/lockfile": {
|
||||
"version": "1.1.0",
|
||||
@@ -5960,6 +6013,7 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -5992,6 +6046,7 @@
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -6399,6 +6454,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -7058,8 +7114,7 @@
|
||||
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/cross-env": {
|
||||
"version": "10.1.0",
|
||||
@@ -7299,6 +7354,7 @@
|
||||
"integrity": "sha512-ce4Ogns4VMeisIuCSK0C62umG0lFy012jd8LMZ6w/veHUeX4fqfDrGe+HTWALAEwK6JwKP+dhPvizhArSOsFbg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "26.4.0",
|
||||
"builder-util": "26.3.4",
|
||||
@@ -7624,7 +7680,6 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@electron/asar": "^3.2.1",
|
||||
"debug": "^4.1.1",
|
||||
@@ -7645,7 +7700,6 @@
|
||||
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.2",
|
||||
"jsonfile": "^4.0.0",
|
||||
@@ -7870,6 +7924,7 @@
|
||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -10152,6 +10207,7 @@
|
||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
|
||||
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"dompurify": "3.2.7",
|
||||
"marked": "14.0.0"
|
||||
@@ -10777,6 +10833,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -10835,7 +10892,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"commander": "^9.4.0"
|
||||
},
|
||||
@@ -10853,7 +10909,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^12.20.0 || >=14"
|
||||
}
|
||||
@@ -10954,6 +11009,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -10963,6 +11019,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -11891,7 +11948,6 @@
|
||||
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mkdirp": "^0.5.1",
|
||||
"rimraf": "~2.6.2"
|
||||
@@ -11955,7 +12011,6 @@
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
@@ -11970,7 +12025,6 @@
|
||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
@@ -12132,6 +12186,7 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -12334,6 +12389,7 @@
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -12688,6 +12744,7 @@
|
||||
"integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"@radix-ui/react-select": "2.2.6",
|
||||
"@radix-ui/react-slot": "1.2.4",
|
||||
"@radix-ui/react-tabs": "1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/addon-search": "^0.15.0",
|
||||
"@xterm/addon-serialize": "^0.13.0",
|
||||
|
||||
Reference in New Issue
Block a user