Compare commits
81 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
554bc3d2ab | ||
|
|
951a89e91e | ||
|
|
339e34e722 | ||
|
|
fe1a5ca0e5 | ||
|
|
3e89a65b39 | ||
|
|
090aae1833 | ||
|
|
8810b3cf0f | ||
|
|
087ce0f3b1 | ||
|
|
14e07741ae | ||
|
|
fe9b1b1011 | ||
|
|
7941aa6d08 | ||
|
|
b3d9908814 | ||
|
|
1006fa1da0 | ||
|
|
721b9596f5 | ||
|
|
b3fbc0972d | ||
|
|
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 |
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(npm run lint:*)",
|
||||
"Bash(npm run build:*)",
|
||||
"Bash(gh pr view:*)",
|
||||
"Bash(gh pr list:*)",
|
||||
"Bash(gh api:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(gh issue view:*)",
|
||||
"Bash(npm run dev:*)",
|
||||
"Bash(git checkout:*)",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(sftp\\): bundle folder uploads and improve cancel/delete operations\n\n- Bundle folder uploads as single tasks showing aggregate progress\n- Add unique file transfer IDs for proper cancellation tracking\n- Fix cancel button to call cancelExternalUpload for external uploads\n- Improve backend cancel detection using cancelled flag instead of error message\n- Use SSH exec with rm -rf for fast folder deletion on remote servers\n- Add FolderUp icon for folder upload tasks in transfer queue\n- Add i18n key for upload cancelled message\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git push:*)",
|
||||
"Bash(gh pr create --title \"feat\\(sftp\\): bundle folder uploads and improve cancel/delete operations\" --body \"$\\(cat <<''EOF''\n## Summary\n\n- **Bundle folder uploads as single tasks** - When uploading a folder from computer, show it as one aggregated task with total progress instead of individual files\n- **Fix cancel upload** - Properly cancel external uploads by calling the correct cancel function and using unique file transfer IDs for backend tracking\n- **Fast folder deletion** - Use SSH exec with `rm -rf` command for remote folder deletion instead of slow recursive SFTP rmdir\n- **UI improvements** - Add FolderUp icon for folder upload tasks, add cancelled status toast message\n\n## Changes\n\n### Bundle folder uploads\n- Added `detectRootFolders` helper to group entries by root folder\n- Create single bundled task per folder with aggregate byte count\n- Track progress across all files in the bundle\n\n### Fix cancel upload\n- Each file now uses unique `fileTransferId` for backend cancellation tracking\n- Added `activeFileTransferIdsRef` to track all active uploads\n- Modified `cancelExternalUpload` to cancel all active file uploads\n- Backend now checks `uploadState.cancelled` flag instead of just error message\n- Frontend catch block checks `cancelUploadRef.current` to break out of loop\n\n### Fast folder deletion\n- Added `execSshCommand` helper function in sftpBridge.cjs\n- Uses `client.client` \\(underlying ssh2 Client\\) to execute `rm -rf` command\n- Falls back to SFTP rmdir if SSH exec fails\n\n## Test plan\n- [ ] Drag a folder from computer to SFTP pane - should show as single task with aggregate progress\n- [ ] Click cancel button during folder upload - should stop immediately without errors\n- [ ] Delete a large folder on remote server - should complete quickly using rm -rf\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\nEOF\n\\)\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -33,3 +33,6 @@ coverage
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Claude Code local settings
|
||||
/.claude/settings.local.json
|
||||
|
||||
53
App.tsx
53
App.tsx
@@ -1,7 +1,9 @@
|
||||
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 { usePortForwardingState } from './application/state/usePortForwardingState';
|
||||
import { useSessionState } from './application/state/useSessionState';
|
||||
import { useSettingsState } from './application/state/useSettingsState';
|
||||
import { useUpdateCheck } from './application/state/useUpdateCheck';
|
||||
@@ -85,6 +87,9 @@ const LazyProtocolSelectDialog = lazy(() => import('./components/ProtocolSelectD
|
||||
const LazyQuickSwitcher = lazy(() =>
|
||||
import('./components/QuickSwitcher').then((m) => ({ default: m.QuickSwitcher })),
|
||||
);
|
||||
const LazyCreateWorkspaceDialog = lazy(() =>
|
||||
import('./components/CreateWorkspaceDialog').then((m) => ({ default: m.CreateWorkspaceDialog })),
|
||||
);
|
||||
|
||||
const IS_DEV = import.meta.env.DEV;
|
||||
const HOTKEY_DEBUG =
|
||||
@@ -149,6 +154,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const { t } = useI18n();
|
||||
|
||||
const [isQuickSwitcherOpen, setIsQuickSwitcherOpen] = useState(false);
|
||||
const [isCreateWorkspaceOpen, setIsCreateWorkspaceOpen] = useState(false);
|
||||
const [quickSearch, setQuickSearch] = useState('');
|
||||
// Protocol selection dialog state for QuickSwitcher
|
||||
const [protocolSelectHost, setProtocolSelectHost] = useState<Host | null>(null);
|
||||
@@ -187,6 +193,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
knownHosts,
|
||||
shellHistory,
|
||||
connectionLogs,
|
||||
managedSources,
|
||||
updateHosts,
|
||||
updateKeys,
|
||||
updateIdentities,
|
||||
@@ -194,6 +201,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
updateSnippetPackages,
|
||||
updateCustomGroups,
|
||||
updateKnownHosts,
|
||||
updateManagedSources,
|
||||
addShellHistoryEntry,
|
||||
addConnectionLog,
|
||||
updateConnectionLog,
|
||||
@@ -229,6 +237,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
closeSession,
|
||||
closeWorkspace,
|
||||
updateSessionStatus,
|
||||
createWorkspaceWithHosts,
|
||||
createWorkspaceFromSessions,
|
||||
addSessionToWorkspace,
|
||||
updateSplitSizes,
|
||||
@@ -251,6 +260,20 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
// isMacClient is used for window controls styling
|
||||
const isMacClient = typeof navigator !== 'undefined' && /Mac|Macintosh/.test(navigator.userAgent);
|
||||
|
||||
// Get port forwarding rules and import function
|
||||
const { rules: portForwardingRules, importRules: importPortForwardingRules } = usePortForwardingState();
|
||||
|
||||
const portForwardingRulesForSync = useMemo(
|
||||
() =>
|
||||
portForwardingRules.map((rule) => ({
|
||||
...rule,
|
||||
status: "inactive",
|
||||
error: undefined,
|
||||
lastUsedAt: undefined,
|
||||
})),
|
||||
[portForwardingRules],
|
||||
);
|
||||
|
||||
// Auto-sync hook for cloud sync
|
||||
const { syncNow: handleSyncNow } = useAutoSync({
|
||||
hosts,
|
||||
@@ -258,7 +281,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
identities,
|
||||
snippets,
|
||||
customGroups,
|
||||
portForwardingRules: undefined, // TODO: Add port forwarding rules from usePortForwardingState
|
||||
portForwardingRules: portForwardingRulesForSync,
|
||||
knownHosts,
|
||||
onApplyPayload: (payload) => {
|
||||
importDataFromString(JSON.stringify({
|
||||
@@ -268,9 +291,19 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
snippets: payload.snippets,
|
||||
customGroups: payload.customGroups,
|
||||
}));
|
||||
|
||||
if (payload.portForwardingRules) {
|
||||
importPortForwardingRules(payload.portForwardingRules);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const { clearAndRemoveSource, clearAndRemoveSources, unmanageSource } = useManagedSourceSync({
|
||||
hosts,
|
||||
managedSources,
|
||||
onUpdateManagedSources: updateManagedSources,
|
||||
});
|
||||
|
||||
const handleSyncNowManual = useCallback(() => {
|
||||
return handleSyncNow({ trigger: 'manual' });
|
||||
}, [handleSyncNow]);
|
||||
@@ -963,6 +996,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
knownHosts={knownHosts}
|
||||
shellHistory={shellHistory}
|
||||
connectionLogs={connectionLogs}
|
||||
managedSources={managedSources}
|
||||
sessions={sessions}
|
||||
onOpenSettings={handleOpenSettings}
|
||||
onOpenQuickSwitcher={handleOpenQuickSwitcher}
|
||||
@@ -977,6 +1011,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}
|
||||
@@ -1067,8 +1105,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
setQuickSearch('');
|
||||
}}
|
||||
onCreateWorkspace={() => {
|
||||
// TODO: Implement workspace creation
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setIsCreateWorkspaceOpen(true);
|
||||
}}
|
||||
onClose={() => {
|
||||
setIsQuickSwitcherOpen(false);
|
||||
@@ -1133,6 +1171,17 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{isCreateWorkspaceOpen && (
|
||||
<Suspense fallback={null}>
|
||||
<LazyCreateWorkspaceDialog
|
||||
isOpen={isCreateWorkspaceOpen}
|
||||
onClose={() => setIsCreateWorkspaceOpen(false)}
|
||||
hosts={hosts}
|
||||
onCreate={createWorkspaceWithHosts}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{/* Protocol Select Dialog for QuickSwitcher */}
|
||||
{protocolSelectHost && (
|
||||
<Suspense fallback={null}>
|
||||
|
||||
@@ -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}',
|
||||
@@ -47,11 +48,14 @@ const en: Messages = {
|
||||
// Dialogs / prompts
|
||||
'confirm.deleteHost': 'Delete Host "{name}"?',
|
||||
'confirm.deleteIdentity': 'Delete Identity "{name}"?',
|
||||
'dialog.createWorkspace.title': 'Create Workspace',
|
||||
'dialog.renameWorkspace.title': 'Rename workspace',
|
||||
'dialog.renameSession.title': 'Rename session',
|
||||
'field.name': 'Name',
|
||||
'field.selectHosts': 'Select Hosts',
|
||||
'placeholder.workspaceName': 'Workspace name',
|
||||
'placeholder.sessionName': 'Session name',
|
||||
'placeholder.searchHosts': 'Search hosts...',
|
||||
'toast.settingsUnavailable': 'Settings window is unavailable on this platform.',
|
||||
|
||||
// Settings shell
|
||||
@@ -315,6 +319,9 @@ const en: Messages = {
|
||||
'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',
|
||||
@@ -322,6 +329,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',
|
||||
|
||||
@@ -344,6 +354,12 @@ const en: Messages = {
|
||||
'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',
|
||||
@@ -360,6 +376,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...',
|
||||
@@ -452,8 +480,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',
|
||||
@@ -539,6 +571,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',
|
||||
@@ -603,10 +641,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',
|
||||
@@ -622,6 +669,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',
|
||||
@@ -824,6 +877,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)',
|
||||
@@ -854,6 +914,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',
|
||||
|
||||
@@ -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}',
|
||||
@@ -186,6 +187,9 @@ const zhCN: Messages = {
|
||||
'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': '父级',
|
||||
@@ -193,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} 个在线',
|
||||
|
||||
@@ -215,6 +222,12 @@ const zhCN: Messages = {
|
||||
'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',
|
||||
@@ -229,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': '搜索已知主机...',
|
||||
@@ -305,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': '打开',
|
||||
@@ -542,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)',
|
||||
@@ -573,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': '字体',
|
||||
@@ -793,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': '编辑',
|
||||
@@ -857,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': '连接已断开,正在尝试重新连接',
|
||||
@@ -876,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': '选择主题',
|
||||
|
||||
@@ -358,8 +358,8 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
manager.setAutoSync(enabled, intervalMinutes);
|
||||
}, []);
|
||||
|
||||
const setDeviceName = useCallback((_name: string) => {
|
||||
// TODO: Add setDeviceName to CloudSyncManager if needed
|
||||
const setDeviceName = useCallback((name: string) => {
|
||||
manager.setDeviceName(name);
|
||||
}, []);
|
||||
|
||||
// ========== Utilities ==========
|
||||
|
||||
@@ -15,6 +15,8 @@ export const useKeychainBackend = () => {
|
||||
privateKey?: string;
|
||||
command: string;
|
||||
timeout?: number;
|
||||
enableKeyboardInteractive?: boolean;
|
||||
sessionId?: string;
|
||||
}) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.execCommand) throw new Error("execCommand unavailable");
|
||||
|
||||
383
application/state/useManagedSourceSync.ts
Normal file
383
application/state/useManagedSourceSync.ts
Normal file
@@ -0,0 +1,383 @@
|
||||
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]));
|
||||
|
||||
// Index hosts by managedSourceId to avoid O(N*M) lookups
|
||||
const prevHostsBySource = new Map<string, Host[]>();
|
||||
for (const h of prevHosts) {
|
||||
if (h.managedSourceId) {
|
||||
let list = prevHostsBySource.get(h.managedSourceId);
|
||||
if (!list) {
|
||||
list = [];
|
||||
prevHostsBySource.set(h.managedSourceId, list);
|
||||
}
|
||||
list.push(h);
|
||||
}
|
||||
}
|
||||
|
||||
const currHostsBySource = new Map<string, Host[]>();
|
||||
for (const h of hosts) {
|
||||
if (h.managedSourceId) {
|
||||
let list = currHostsBySource.get(h.managedSourceId);
|
||||
if (!list) {
|
||||
list = [];
|
||||
currHostsBySource.set(h.managedSourceId, list);
|
||||
}
|
||||
list.push(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 = prevHostsBySource.get(source.id) || [];
|
||||
const currManaged = currHostsBySource.get(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,
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Host, PortForwardingRule } from "../../domain/models";
|
||||
import {
|
||||
STORAGE_KEY_PF_PREFER_FORM_MODE,
|
||||
@@ -9,7 +9,6 @@ import { localStorageAdapter } from "../../infrastructure/persistence/localStora
|
||||
import {
|
||||
clearReconnectTimer,
|
||||
getActiveConnection,
|
||||
getActiveRuleIds,
|
||||
startPortForward,
|
||||
stopPortForward,
|
||||
syncWithBackend,
|
||||
@@ -40,6 +39,7 @@ export interface UsePortForwardingStateResult {
|
||||
updateRule: (id: string, updates: Partial<PortForwardingRule>) => void;
|
||||
deleteRule: (id: string) => void;
|
||||
duplicateRule: (id: string) => void;
|
||||
importRules: (rules: PortForwardingRule[]) => void;
|
||||
|
||||
setRuleStatus: (
|
||||
id: string,
|
||||
@@ -63,8 +63,58 @@ export interface UsePortForwardingStateResult {
|
||||
selectedRule: PortForwardingRule | undefined;
|
||||
}
|
||||
|
||||
// Global Store State
|
||||
let globalRules: PortForwardingRule[] = [];
|
||||
let isInitialized = false;
|
||||
const listeners = new Set<(rules: PortForwardingRule[]) => void>();
|
||||
|
||||
// Store Actions
|
||||
const notifyListeners = () => {
|
||||
listeners.forEach((listener) => listener(globalRules));
|
||||
};
|
||||
|
||||
const setGlobalRules = (newRules: PortForwardingRule[]) => {
|
||||
globalRules = newRules;
|
||||
notifyListeners();
|
||||
localStorageAdapter.write(STORAGE_KEY_PORT_FORWARDING, newRules);
|
||||
};
|
||||
|
||||
const normalizeRulesWithConnections = (rules: PortForwardingRule[]) => {
|
||||
return rules.map((rule) => {
|
||||
const connection = getActiveConnection(rule.id);
|
||||
if (connection) {
|
||||
return {
|
||||
...rule,
|
||||
status: connection.status,
|
||||
error: connection.error,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...rule,
|
||||
status: "inactive",
|
||||
error: undefined,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// Initialization Logic
|
||||
const initializeStore = async () => {
|
||||
if (isInitialized) return;
|
||||
isInitialized = true;
|
||||
|
||||
await syncWithBackend();
|
||||
|
||||
const saved = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
);
|
||||
if (saved && Array.isArray(saved)) {
|
||||
setGlobalRules(normalizeRulesWithConnections(saved));
|
||||
}
|
||||
};
|
||||
|
||||
export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
const [rules, setRules] = useState<PortForwardingRule[]>([]);
|
||||
const [rules, setRules] = useState<PortForwardingRule[]>(globalRules);
|
||||
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useStoredViewMode(
|
||||
STORAGE_KEY_PF_VIEW_MODE,
|
||||
@@ -76,49 +126,31 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
return localStorageAdapter.readBoolean(STORAGE_KEY_PF_PREFER_FORM_MODE) ?? false;
|
||||
});
|
||||
|
||||
// Track if sync has been executed for this component instance
|
||||
const syncExecutedRef = useRef(false);
|
||||
|
||||
const setPreferFormMode = useCallback((prefer: boolean) => {
|
||||
setPreferFormModeState(prefer);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_PF_PREFER_FORM_MODE, prefer);
|
||||
}, []);
|
||||
|
||||
// Load rules from storage on mount and sync with backend
|
||||
// Initialize store on mount (only once globally)
|
||||
useEffect(() => {
|
||||
const loadAndSync = async () => {
|
||||
// Only sync once per component instance (prevents duplicate calls from React StrictMode)
|
||||
if (!syncExecutedRef.current) {
|
||||
syncExecutedRef.current = true;
|
||||
await syncWithBackend();
|
||||
}
|
||||
void initializeStore();
|
||||
}, []);
|
||||
|
||||
const saved = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
);
|
||||
if (saved && Array.isArray(saved)) {
|
||||
// Sync status with active connections in the service layer
|
||||
const _activeRuleIds = getActiveRuleIds();
|
||||
const withSyncedStatus = saved.map((r) => {
|
||||
const conn = getActiveConnection(r.id);
|
||||
if (conn) {
|
||||
// This rule has an active connection, preserve its status
|
||||
return { ...r, status: conn.status, error: conn.error };
|
||||
}
|
||||
// No active connection, reset to inactive
|
||||
return { ...r, status: "inactive" as const, error: undefined };
|
||||
});
|
||||
setRules(withSyncedStatus);
|
||||
}
|
||||
// Subscribe to global store
|
||||
useEffect(() => {
|
||||
// If global state was updated before we subscribed (e.g. init finished), update local state
|
||||
if (rules !== globalRules) {
|
||||
setRules(globalRules);
|
||||
}
|
||||
|
||||
const listener = (newRules: PortForwardingRule[]) => {
|
||||
setRules(newRules);
|
||||
};
|
||||
|
||||
void loadAndSync();
|
||||
}, []);
|
||||
|
||||
// Persist rules to storage whenever they change
|
||||
const persistRules = useCallback((updatedRules: PortForwardingRule[]) => {
|
||||
localStorageAdapter.write(STORAGE_KEY_PORT_FORWARDING, updatedRules);
|
||||
}, []);
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}, [rules]);
|
||||
|
||||
const addRule = useCallback(
|
||||
(
|
||||
@@ -130,47 +162,38 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
createdAt: Date.now(),
|
||||
status: "inactive",
|
||||
};
|
||||
setRules((prev) => {
|
||||
const updated = [...prev, newRule];
|
||||
persistRules(updated);
|
||||
return updated;
|
||||
});
|
||||
const updated = [...globalRules, newRule];
|
||||
setGlobalRules(updated);
|
||||
setSelectedRuleId(newRule.id);
|
||||
return newRule;
|
||||
},
|
||||
[persistRules],
|
||||
[],
|
||||
);
|
||||
|
||||
const updateRule = useCallback(
|
||||
(id: string, updates: Partial<PortForwardingRule>) => {
|
||||
setRules((prev) => {
|
||||
const updated = prev.map((r) =>
|
||||
r.id === id ? { ...r, ...updates } : r,
|
||||
);
|
||||
persistRules(updated);
|
||||
return updated;
|
||||
});
|
||||
const updated = globalRules.map((r) =>
|
||||
r.id === id ? { ...r, ...updates } : r,
|
||||
);
|
||||
setGlobalRules(updated);
|
||||
},
|
||||
[persistRules],
|
||||
[],
|
||||
);
|
||||
|
||||
const deleteRule = useCallback(
|
||||
(id: string) => {
|
||||
setRules((prev) => {
|
||||
const updated = prev.filter((r) => r.id !== id);
|
||||
persistRules(updated);
|
||||
return updated;
|
||||
});
|
||||
const updated = globalRules.filter((r) => r.id !== id);
|
||||
setGlobalRules(updated);
|
||||
if (selectedRuleId === id) {
|
||||
setSelectedRuleId(null);
|
||||
}
|
||||
},
|
||||
[selectedRuleId, persistRules],
|
||||
[selectedRuleId],
|
||||
);
|
||||
|
||||
const duplicateRule = useCallback(
|
||||
(id: string) => {
|
||||
const original = rules.find((r) => r.id === id);
|
||||
const original = globalRules.find((r) => r.id === id);
|
||||
if (!original) return;
|
||||
|
||||
const copy: PortForwardingRule = {
|
||||
@@ -182,33 +205,31 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
error: undefined,
|
||||
lastUsedAt: undefined,
|
||||
};
|
||||
setRules((prev) => {
|
||||
const updated = [...prev, copy];
|
||||
persistRules(updated);
|
||||
return updated;
|
||||
});
|
||||
const updated = [...globalRules, copy];
|
||||
setGlobalRules(updated);
|
||||
setSelectedRuleId(copy.id);
|
||||
},
|
||||
[rules, persistRules],
|
||||
[],
|
||||
);
|
||||
|
||||
const importRules = useCallback((newRules: PortForwardingRule[]) => {
|
||||
setGlobalRules(normalizeRulesWithConnections(newRules));
|
||||
}, []);
|
||||
|
||||
const setRuleStatus = useCallback(
|
||||
(id: string, status: PortForwardingRule["status"], error?: string) => {
|
||||
setRules((prev) => {
|
||||
const updated = prev.map((r) => {
|
||||
if (r.id !== id) return r;
|
||||
return {
|
||||
...r,
|
||||
status,
|
||||
error,
|
||||
lastUsedAt: status === "active" ? Date.now() : r.lastUsedAt,
|
||||
};
|
||||
});
|
||||
persistRules(updated);
|
||||
return updated;
|
||||
const updated = globalRules.map((r) => {
|
||||
if (r.id !== id) return r;
|
||||
return {
|
||||
...r,
|
||||
status,
|
||||
error,
|
||||
lastUsedAt: status === "active" ? Date.now() : r.lastUsedAt,
|
||||
};
|
||||
});
|
||||
setGlobalRules(updated);
|
||||
},
|
||||
[persistRules],
|
||||
[],
|
||||
);
|
||||
|
||||
const startTunnel = useCallback(
|
||||
@@ -301,6 +322,7 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
updateRule,
|
||||
deleteRule,
|
||||
duplicateRule,
|
||||
importRules,
|
||||
|
||||
setRuleStatus,
|
||||
startTunnel,
|
||||
|
||||
@@ -286,6 +286,69 @@ export const useSessionState = () => {
|
||||
setWorkspaceRenameValue('');
|
||||
}, []);
|
||||
|
||||
const createWorkspaceWithHosts = useCallback((name: string, hosts: Host[]) => {
|
||||
if (hosts.length === 0) return;
|
||||
|
||||
// Create sessions for each host
|
||||
const newSessions: TerminalSession[] = hosts.map(host => {
|
||||
// Handle serial hosts specially
|
||||
if (host.protocol === 'serial') {
|
||||
const serialConfig: SerialConfig = host.serialConfig || {
|
||||
path: host.hostname,
|
||||
baudRate: host.port || 115200,
|
||||
dataBits: 8,
|
||||
stopBits: 1,
|
||||
parity: 'none',
|
||||
flowControl: 'none',
|
||||
localEcho: false,
|
||||
lineMode: false,
|
||||
};
|
||||
|
||||
const portName = serialConfig.path.split('/').pop() || serialConfig.path;
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
hostId: host.id,
|
||||
hostLabel: host.label || `Serial: ${portName}`,
|
||||
hostname: serialConfig.path,
|
||||
username: '',
|
||||
status: 'connecting',
|
||||
protocol: 'serial',
|
||||
serialConfig: serialConfig,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
hostId: host.id,
|
||||
hostLabel: host.label,
|
||||
hostname: host.hostname,
|
||||
username: host.username,
|
||||
status: 'connecting',
|
||||
protocol: host.protocol,
|
||||
port: host.port,
|
||||
moshEnabled: host.moshEnabled,
|
||||
};
|
||||
});
|
||||
|
||||
const sessionIds = newSessions.map(s => s.id);
|
||||
|
||||
// Create workspace
|
||||
const workspace = createWorkspaceFromSessionIds(sessionIds, {
|
||||
title: name,
|
||||
viewMode: 'split',
|
||||
});
|
||||
|
||||
// Assign workspaceId to sessions
|
||||
const sessionsWithWorkspace = newSessions.map(s => ({
|
||||
...s,
|
||||
workspaceId: workspace.id
|
||||
}));
|
||||
|
||||
setSessions(prev => [...prev, ...sessionsWithWorkspace]);
|
||||
setWorkspaces(prev => [...prev, workspace]);
|
||||
setActiveTabId(workspace.id);
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const createWorkspaceFromSessions = useCallback((
|
||||
baseSessionId: string,
|
||||
joiningSessionId: string,
|
||||
@@ -669,6 +732,7 @@ export const useSessionState = () => {
|
||||
closeSession,
|
||||
closeWorkspace,
|
||||
updateSessionStatus,
|
||||
createWorkspaceWithHosts,
|
||||
createWorkspaceFromSessions,
|
||||
addSessionToWorkspace,
|
||||
updateSplitSizes,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
143
components/CreateWorkspaceDialog.tsx
Normal file
143
components/CreateWorkspaceDialog.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { Search } from 'lucide-react';
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { Host } from '../types';
|
||||
import { DistroAvatar } from './DistroAvatar';
|
||||
import { Button } from './ui/button';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
|
||||
interface CreateWorkspaceDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
hosts: Host[];
|
||||
onCreate: (name: string, selectedHosts: Host[]) => void;
|
||||
}
|
||||
|
||||
export const CreateWorkspaceDialog: React.FC<CreateWorkspaceDialogProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
hosts,
|
||||
onCreate,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [name, setName] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedHostIds, setSelectedHostIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const filteredHosts = useMemo(() => {
|
||||
if (!search.trim()) return hosts;
|
||||
const term = search.toLowerCase();
|
||||
return hosts.filter(h =>
|
||||
h.label.toLowerCase().includes(term) ||
|
||||
h.hostname.toLowerCase().includes(term) ||
|
||||
(h.group || '').toLowerCase().includes(term)
|
||||
);
|
||||
}, [hosts, search]);
|
||||
|
||||
const toggleHost = (hostId: string) => {
|
||||
setSelectedHostIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(hostId)) {
|
||||
next.delete(hostId);
|
||||
} else {
|
||||
next.add(hostId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
const selected = hosts.filter(h => selectedHostIds.has(h.id));
|
||||
onCreate(name, selected);
|
||||
onClose();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setName('');
|
||||
setSearch('');
|
||||
setSelectedHostIds(new Set());
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-md flex flex-col max-h-[80vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('dialog.createWorkspace.title', 'Create Workspace')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2 flex-1 flex flex-col min-h-0">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="workspace-name">{t('field.name', 'Name')}</Label>
|
||||
<Input
|
||||
id="workspace-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t('placeholder.workspaceName', 'Workspace Name')}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 flex-1 flex flex-col min-h-0">
|
||||
<Label>{t('field.selectHosts', 'Select Hosts')}</Label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t('placeholder.searchHosts', 'Search hosts...')}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-md flex-1 min-h-[200px]">
|
||||
<ScrollArea className="h-full max-h-[300px]">
|
||||
<div className="p-2 space-y-1">
|
||||
{filteredHosts.length === 0 ? (
|
||||
<div className="text-center py-4 text-sm text-muted-foreground">
|
||||
{t('common.noResults', 'No hosts found')}
|
||||
</div>
|
||||
) : (
|
||||
filteredHosts.map(host => {
|
||||
const isSelected = selectedHostIds.has(host.id);
|
||||
return (
|
||||
<div
|
||||
key={host.id}
|
||||
className={`flex items-center gap-3 p-2 rounded-md cursor-pointer hover:bg-muted/50 ${isSelected ? 'bg-primary/10' : ''}`}
|
||||
onClick={() => toggleHost(host.id)}
|
||||
>
|
||||
<div className={`h-4 w-4 border rounded flex items-center justify-center ${isSelected ? 'bg-primary border-primary' : 'border-muted-foreground'}`}>
|
||||
{isSelected && <div className="h-2 w-2 bg-primary-foreground rounded-sm" />}
|
||||
</div>
|
||||
<DistroAvatar host={host} size="sm" fallback={host.label.slice(0, 2).toUpperCase()} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{host.label}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{host.hostname}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground text-right">
|
||||
{selectedHostIds.size} {t('common.selected', 'selected')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose}>{t('common.cancel', 'Cancel')}</Button>
|
||||
<Button onClick={handleCreate} disabled={!name.trim() || selectedHostIds.size === 0}>
|
||||
{t('common.create', 'Create')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -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> = ({
|
||||
|
||||
@@ -29,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 {
|
||||
@@ -43,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";
|
||||
@@ -70,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)
|
||||
@@ -84,6 +86,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
availableKeys,
|
||||
identities,
|
||||
groups,
|
||||
managedSources = [],
|
||||
allTags = [],
|
||||
allHosts = [],
|
||||
defaultGroup,
|
||||
@@ -253,15 +256,49 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
const handleSubmit = () => {
|
||||
if (!form.hostname) return;
|
||||
// If label is empty, use hostname as label
|
||||
const finalLabel = form.label?.trim() || form.hostname;
|
||||
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,
|
||||
label: finalLabel,
|
||||
group: groupInputValue.trim() || form.group,
|
||||
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);
|
||||
};
|
||||
@@ -519,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" />
|
||||
@@ -555,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"
|
||||
/>
|
||||
|
||||
@@ -601,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" />
|
||||
@@ -1334,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")}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChevronRight, Folder, FolderOpen, Monitor, Server, Expand, Minimize2 } from 'lucide-react';
|
||||
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';
|
||||
@@ -14,7 +14,7 @@ import { Button } from './ui/button';
|
||||
interface HostTreeViewProps {
|
||||
groupTree: GroupNode[];
|
||||
hosts: Host[];
|
||||
sortMode?: 'az' | 'za' | 'newest' | 'oldest';
|
||||
sortMode?: 'az' | 'za' | 'newest' | 'oldest' | 'group';
|
||||
expandedPaths?: Set<string>;
|
||||
onTogglePath?: (path: string) => void;
|
||||
onExpandAll?: (paths: string[]) => void;
|
||||
@@ -30,12 +30,14 @@ interface HostTreeViewProps {
|
||||
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';
|
||||
sortMode: 'az' | 'za' | 'newest' | 'oldest' | 'group';
|
||||
expandedPaths: Set<string>;
|
||||
onToggle: (path: string) => void;
|
||||
onConnect: (host: Host) => void;
|
||||
@@ -49,6 +51,8 @@ interface TreeNodeProps {
|
||||
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> = ({
|
||||
@@ -68,11 +72,14 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
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 [];
|
||||
@@ -147,6 +154,12 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
{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}
|
||||
@@ -171,6 +184,11 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
>
|
||||
<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>
|
||||
|
||||
@@ -195,6 +213,8 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
onDeleteGroup={onDeleteGroup}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
moveGroup={moveGroup}
|
||||
managedGroupPaths={managedGroupPaths}
|
||||
onUnmanageGroup={onUnmanageGroup}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -329,6 +349,8 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
|
||||
onDeleteGroup,
|
||||
moveHostToGroup,
|
||||
moveGroup,
|
||||
managedGroupPaths,
|
||||
onUnmanageGroup,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -447,6 +469,8 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
|
||||
onDeleteGroup={onDeleteGroup}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
moveGroup={moveGroup}
|
||||
managedGroupPaths={managedGroupPaths}
|
||||
onUnmanageGroup={onUnmanageGroup}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -913,6 +916,8 @@ echo $3 >> "$FILE"`);
|
||||
<ImportKeyPanel
|
||||
draftKey={draftKey}
|
||||
setDraftKey={setDraftKey}
|
||||
showPassphrase={showPassphrase}
|
||||
setShowPassphrase={setShowPassphrase}
|
||||
onImport={handleImport}
|
||||
/>
|
||||
)}
|
||||
@@ -1111,6 +1116,8 @@ echo $3 >> "$FILE"`);
|
||||
privateKey: hostPrivateKey,
|
||||
command,
|
||||
timeout: 30000,
|
||||
enableKeyboardInteractive: true,
|
||||
sessionId: `export-key:${exportHost.id}:${panel.key.id}`,
|
||||
});
|
||||
|
||||
// Check result - code 0, null, or undefined with no stderr is success
|
||||
@@ -1282,6 +1289,7 @@ echo $3 >> "$FILE"`);
|
||||
onBack={() => setShowHostSelector(false)}
|
||||
onContinue={() => setShowHostSelector(false)}
|
||||
availableKeys={keys}
|
||||
managedSources={managedSources}
|
||||
onSaveHost={onSaveHost}
|
||||
onCreateGroup={onCreateGroup}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -94,7 +94,6 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
return IS_MAC ? binding.mac : binding.pc;
|
||||
}, [keyBindings]);
|
||||
const quickSwitchKey = getHotkeyLabel('quick-switch');
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -102,7 +101,6 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
// Reset state when opening
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setIsFocused(false);
|
||||
setSelectedIndex(0);
|
||||
// Auto focus the input after a short delay
|
||||
setTimeout(() => {
|
||||
@@ -134,7 +132,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
[sessions]
|
||||
);
|
||||
|
||||
const showCategorized = isFocused || query.trim().length > 0;
|
||||
const showCategorized = query.trim().length > 0;
|
||||
|
||||
// Memoize flat items list and index map
|
||||
const { flatItems, itemIndexMap } = useMemo(() => {
|
||||
@@ -232,7 +230,6 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
onQueryChange(e.target.value);
|
||||
setSelectedIndex(0);
|
||||
}}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t("qs.search.placeholder")}
|
||||
className="flex-1 h-8 border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 px-0 text-sm"
|
||||
|
||||
@@ -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,
|
||||
@@ -78,15 +82,17 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
cancelSftpUpload,
|
||||
startStreamTransfer,
|
||||
cancelTransfer,
|
||||
showSaveDialog,
|
||||
} = useSftpBackend();
|
||||
const { t } = useI18n();
|
||||
const { sftpAutoSync, sftpShowHiddenFiles } = useSettingsState();
|
||||
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()), []);
|
||||
|
||||
@@ -347,10 +353,13 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
uploadTasks,
|
||||
dragActive,
|
||||
handleDownload,
|
||||
handleUploadEntries,
|
||||
handleFileSelect,
|
||||
handleFolderSelect,
|
||||
handleDrag,
|
||||
handleDrop,
|
||||
cancelUpload,
|
||||
cancelTask,
|
||||
dismissTask,
|
||||
} = useSftpModalTransfers({
|
||||
currentPath,
|
||||
@@ -369,8 +378,10 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
cancelSftpUpload,
|
||||
startStreamTransfer,
|
||||
cancelTransfer,
|
||||
showSaveDialog,
|
||||
setLoading,
|
||||
t,
|
||||
useCompressedUpload: sftpUseCompressedUpload,
|
||||
});
|
||||
|
||||
const handleClose = async () => {
|
||||
@@ -378,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
|
||||
@@ -526,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
|
||||
@@ -552,6 +603,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
visibleRows={visibleRows}
|
||||
fileListRef={fileListRef}
|
||||
inputRef={inputRef}
|
||||
folderInputRef={folderInputRef}
|
||||
handleSort={handleSort}
|
||||
handleResizeStart={handleResizeStart}
|
||||
handleFileListScroll={handleFileListScroll}
|
||||
@@ -576,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,
|
||||
@@ -63,21 +66,26 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [showNewHostPanel, setShowNewHostPanel] = useState(false);
|
||||
|
||||
const selectableHosts = useMemo(
|
||||
() => hosts.filter((host) => host.protocol !== "serial"),
|
||||
[hosts]
|
||||
);
|
||||
|
||||
// Get all unique tags from hosts
|
||||
const allTags = useMemo(() => {
|
||||
const tagSet = new Set<string>();
|
||||
hosts.forEach((h) => {
|
||||
selectableHosts.forEach((h) => {
|
||||
if (h.tags) {
|
||||
h.tags.forEach((tag) => tagSet.add(tag));
|
||||
}
|
||||
});
|
||||
return Array.from(tagSet).sort();
|
||||
}, [hosts]);
|
||||
}, [selectableHosts]);
|
||||
|
||||
// Get unique group paths from both hosts and customGroups
|
||||
const allGroupPaths = useMemo(() => {
|
||||
const pathSet = new Set<string>();
|
||||
hosts.forEach((h) => {
|
||||
selectableHosts.forEach((h) => {
|
||||
if (h.group) {
|
||||
// Add all parent paths as well
|
||||
const parts = h.group.split("/");
|
||||
@@ -88,7 +96,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
});
|
||||
customGroups.forEach((g) => pathSet.add(g));
|
||||
return Array.from(pathSet).sort();
|
||||
}, [hosts, customGroups]);
|
||||
}, [selectableHosts, customGroups]);
|
||||
|
||||
// Get groups at current level
|
||||
const groupsWithCounts = useMemo(() => {
|
||||
@@ -102,7 +110,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
const topLevel = path.split("/")[0];
|
||||
if (!seen.has(topLevel)) {
|
||||
seen.add(topLevel);
|
||||
const count = hosts.filter(
|
||||
const count = selectableHosts.filter(
|
||||
(h) =>
|
||||
h.group &&
|
||||
(h.group === topLevel || h.group.startsWith(`${topLevel}/`)),
|
||||
@@ -116,7 +124,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
const fullPath = `${prefix}${nextLevel}`;
|
||||
if (!seen.has(fullPath)) {
|
||||
seen.add(fullPath);
|
||||
const count = hosts.filter(
|
||||
const count = selectableHosts.filter(
|
||||
(h) =>
|
||||
h.group &&
|
||||
(h.group === fullPath || h.group.startsWith(`${fullPath}/`)),
|
||||
@@ -127,11 +135,11 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
});
|
||||
|
||||
return groups;
|
||||
}, [allGroupPaths, currentPath, hosts]);
|
||||
}, [allGroupPaths, currentPath, selectableHosts]);
|
||||
|
||||
// Get hosts at current level with filtering and sorting
|
||||
const filteredHosts = useMemo(() => {
|
||||
let result = hosts;
|
||||
let result = selectableHosts;
|
||||
|
||||
// Filter by current path
|
||||
if (currentPath) {
|
||||
@@ -177,7 +185,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [hosts, currentPath, searchQuery, selectedTags, sortMode]);
|
||||
}, [selectableHosts, currentPath, searchQuery, selectedTags, sortMode]);
|
||||
|
||||
// Build breadcrumb from current path
|
||||
const breadcrumbs = useMemo(() => {
|
||||
@@ -356,7 +364,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium">{host.label}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{host.protocol || "ssh"}, {host.username}
|
||||
{host.username}@{host.hostname}:{host.port || 22}
|
||||
</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
@@ -387,7 +395,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
if (onContinue) {
|
||||
onContinue();
|
||||
} else {
|
||||
const host = hosts.find((h) => selectedHostIds.includes(h.id));
|
||||
const host = selectableHosts.find((h) => selectedHostIds.includes(h.id));
|
||||
if (host) {
|
||||
onSelect(host);
|
||||
}
|
||||
@@ -407,6 +415,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,
|
||||
}) => {
|
||||
@@ -527,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')}
|
||||
|
||||
@@ -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}
|
||||
@@ -930,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]"
|
||||
@@ -1295,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}
|
||||
@@ -1400,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>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import {
|
||||
Activity,
|
||||
BookMarked,
|
||||
CheckSquare,
|
||||
ChevronDown,
|
||||
ClipboardCopy,
|
||||
Copy,
|
||||
Download,
|
||||
Edit2,
|
||||
FileCode,
|
||||
FileSymlink,
|
||||
FolderPlus,
|
||||
FolderTree,
|
||||
Key,
|
||||
@@ -17,10 +19,12 @@ import {
|
||||
Plus,
|
||||
Search,
|
||||
Settings,
|
||||
Square,
|
||||
TerminalSquare,
|
||||
Trash2,
|
||||
Upload,
|
||||
Usb,
|
||||
X,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import React, { Suspense, lazy, memo, useCallback, useEffect, useMemo, useState } from "react";
|
||||
@@ -39,6 +43,7 @@ import {
|
||||
HostProtocol,
|
||||
Identity,
|
||||
KnownHost,
|
||||
ManagedSource,
|
||||
SerialConfig,
|
||||
SSHKey,
|
||||
ShellHistoryEntry,
|
||||
@@ -57,7 +62,7 @@ import { isQuickConnectInput, parseQuickConnectInputWithWarnings } from "../doma
|
||||
import SerialConnectModal from "./SerialConnectModal";
|
||||
import SerialHostDetailsPanel from "./SerialHostDetailsPanel";
|
||||
import SnippetsManager from "./SnippetsManager";
|
||||
import { ImportVaultDialog } from "./vault/ImportVaultDialog";
|
||||
import { ImportVaultDialog, ImportOptions } from "./vault/ImportVaultDialog";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
ContextMenu,
|
||||
@@ -79,6 +84,7 @@ import { Label } from "./ui/label";
|
||||
import { SortDropdown, SortMode } from "./ui/sort-dropdown";
|
||||
import { TagFilterDropdown } from "./ui/tag-filter-dropdown";
|
||||
import { toast } from "./ui/toast";
|
||||
import { Badge } from "./ui/badge";
|
||||
|
||||
const LazyProtocolSelectDialog = lazy(() => import("./ProtocolSelectDialog"));
|
||||
const LazyConnectionLogsManager = lazy(() => import("./ConnectionLogsManager"));
|
||||
@@ -96,6 +102,7 @@ interface VaultViewProps {
|
||||
knownHosts: KnownHost[];
|
||||
shellHistory: ShellHistoryEntry[];
|
||||
connectionLogs: ConnectionLog[];
|
||||
managedSources: ManagedSource[];
|
||||
sessions: TerminalSession[];
|
||||
onOpenSettings: () => void;
|
||||
onOpenQuickSwitcher: () => void;
|
||||
@@ -110,6 +117,10 @@ interface VaultViewProps {
|
||||
onUpdateSnippetPackages: (pkgs: string[]) => void;
|
||||
onUpdateCustomGroups: (groups: string[]) => void;
|
||||
onUpdateKnownHosts: (knownHosts: KnownHost[]) => void;
|
||||
onUpdateManagedSources: (managedSources: ManagedSource[]) => void;
|
||||
onClearAndRemoveManagedSource?: (source: ManagedSource) => Promise<boolean>;
|
||||
onClearAndRemoveManagedSources?: (sources: ManagedSource[]) => Promise<void>;
|
||||
onUnmanageSource?: (sourceId: string) => void;
|
||||
onConvertKnownHost: (knownHost: KnownHost) => void;
|
||||
onToggleConnectionLogSaved: (id: string) => void;
|
||||
onDeleteConnectionLog: (id: string) => void;
|
||||
@@ -131,6 +142,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
knownHosts,
|
||||
shellHistory,
|
||||
connectionLogs,
|
||||
managedSources,
|
||||
sessions,
|
||||
onOpenSettings,
|
||||
onOpenQuickSwitcher,
|
||||
@@ -145,6 +157,10 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
onUpdateSnippetPackages,
|
||||
onUpdateCustomGroups,
|
||||
onUpdateKnownHosts,
|
||||
onUpdateManagedSources,
|
||||
onClearAndRemoveManagedSource,
|
||||
onClearAndRemoveManagedSources,
|
||||
onUnmanageSource,
|
||||
onConvertKnownHost,
|
||||
onToggleConnectionLogSaved,
|
||||
onDeleteConnectionLog,
|
||||
@@ -171,6 +187,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
const [isSerialModalOpen, setIsSerialModalOpen] = useState(false);
|
||||
const [isDeleteGroupOpen, setIsDeleteGroupOpen] = useState(false);
|
||||
const [deleteTargetPath, setDeleteTargetPath] = useState<string | null>(null);
|
||||
const [deleteGroupWithHosts, setDeleteGroupWithHosts] = useState(false);
|
||||
|
||||
// Handle external navigation requests
|
||||
useEffect(() => {
|
||||
@@ -188,6 +205,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
const treeExpandedState = useTreeExpandedState(STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED);
|
||||
const [sortMode, setSortMode] = useState<SortMode>("az");
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [selectedHostIds, setSelectedHostIds] = useState<Set<string>>(new Set());
|
||||
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false);
|
||||
|
||||
// Host panel state (local to hosts section)
|
||||
const [isHostPanelOpen, setIsHostPanelOpen] = useState(false);
|
||||
@@ -402,6 +421,31 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
});
|
||||
}, [identities, t]);
|
||||
|
||||
const toggleHostSelection = useCallback((hostId: string) => {
|
||||
setSelectedHostIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(hostId)) {
|
||||
next.delete(hostId);
|
||||
} else {
|
||||
next.add(hostId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearHostSelection = useCallback(() => {
|
||||
setSelectedHostIds(new Set());
|
||||
setIsMultiSelectMode(false);
|
||||
}, []);
|
||||
|
||||
const deleteSelectedHosts = useCallback(() => {
|
||||
if (selectedHostIds.size === 0) return;
|
||||
const updatedHosts = hosts.filter(h => !selectedHostIds.has(h.id));
|
||||
onUpdateHosts(updatedHosts);
|
||||
clearHostSelection();
|
||||
toast.success(t("vault.hosts.deleteMultiple.success", { count: selectedHostIds.size }));
|
||||
}, [selectedHostIds, hosts, onUpdateHosts, clearHostSelection, t]);
|
||||
|
||||
const readTextFile = useCallback(async (file: File): Promise<string> => {
|
||||
const buf = await file.arrayBuffer();
|
||||
const bytes = new Uint8Array(buf);
|
||||
@@ -430,7 +474,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
}, []);
|
||||
|
||||
const handleImportFileSelected = useCallback(
|
||||
async (format: VaultImportFormat, file: File) => {
|
||||
async (format: VaultImportFormat, file: File, options?: ImportOptions) => {
|
||||
setIsImportOpen(false);
|
||||
|
||||
try {
|
||||
@@ -452,13 +496,111 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
fileName: file.name,
|
||||
});
|
||||
|
||||
const isManaged = format === "ssh_config" && options?.managed === true;
|
||||
const fileBaseName = file.name.replace(/\.[^/.]+$/, "");
|
||||
|
||||
// Generate unique managed group name (check for conflicts with existing sources,
|
||||
// custom groups, and host groups to avoid accidentally merging unrelated hosts)
|
||||
let managedGroupName = `${fileBaseName} - Managed`;
|
||||
if (isManaged) {
|
||||
const existingGroupNames = new Set([
|
||||
...managedSources.map(s => s.groupName),
|
||||
...customGroups,
|
||||
...hosts.map(h => h.group).filter((g): g is string => !!g),
|
||||
]);
|
||||
let suffix = 1;
|
||||
while (existingGroupNames.has(managedGroupName)) {
|
||||
managedGroupName = `${fileBaseName} - Managed (${suffix})`;
|
||||
suffix++;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this file is already managed
|
||||
const bridge = (window as unknown as { netcatty?: { getPathForFile?: (file: File) => string | undefined } }).netcatty;
|
||||
// Try bridge.getPathForFile first, then fall back to file.path (Electron legacy)
|
||||
const filePath = bridge?.getPathForFile?.(file) || (file as File & { path?: string }).path;
|
||||
|
||||
if (isManaged && !filePath) {
|
||||
// Cannot proceed with managed import without a valid file path
|
||||
toast({
|
||||
title: t("vault.import.sshConfig.noFilePath"),
|
||||
description: t("vault.import.sshConfig.noFilePathDesc"),
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isManaged) {
|
||||
const existingSource = managedSources.find(s => s.filePath === filePath);
|
||||
if (existingSource) {
|
||||
toast({
|
||||
title: t("vault.import.sshConfig.alreadyManaged"),
|
||||
description: t("vault.import.sshConfig.alreadyManagedDesc", { group: existingSource.groupName }),
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const makeKey = (h: Host) =>
|
||||
`${(h.protocol ?? "ssh").toLowerCase()}|${h.hostname.toLowerCase()}|${h.port}|${(h.username ?? "").toLowerCase()}`;
|
||||
|
||||
const existingKeys = new Set(hosts.map(makeKey));
|
||||
const newHosts = result.hosts.filter((h) => !existingKeys.has(makeKey(h)));
|
||||
// Filter out duplicates for both managed and non-managed imports
|
||||
let newHosts = result.hosts.filter((h) => !existingKeys.has(makeKey(h)));
|
||||
|
||||
if (newHosts.length > 0) {
|
||||
// For managed imports, also update existing hosts to be managed
|
||||
let updatedExistingHosts: Host[] = [];
|
||||
if (isManaged) {
|
||||
const importedKeys = new Set(result.hosts.map(makeKey));
|
||||
updatedExistingHosts = hosts.filter((h) => importedKeys.has(makeKey(h)));
|
||||
}
|
||||
|
||||
if (isManaged && (newHosts.length > 0 || updatedExistingHosts.length > 0)) {
|
||||
const sourceId = crypto.randomUUID();
|
||||
console.log('[Import] File path resolved:', filePath);
|
||||
const newSource: ManagedSource = {
|
||||
id: sourceId,
|
||||
type: "ssh_config",
|
||||
filePath: filePath,
|
||||
groupName: managedGroupName,
|
||||
lastSyncedAt: Date.now(),
|
||||
};
|
||||
|
||||
newHosts = newHosts.map((h) => ({
|
||||
...h,
|
||||
group: managedGroupName,
|
||||
// Only SSH hosts can be managed (SSH config only supports SSH)
|
||||
managedSourceId: (!h.protocol || h.protocol === "ssh") ? sourceId : undefined,
|
||||
}));
|
||||
|
||||
// Update existing hosts to be managed (move to managed group)
|
||||
const existingHostIds = new Set(updatedExistingHosts.map(h => h.id));
|
||||
const updatedHosts = hosts.map((h) => {
|
||||
if (!existingHostIds.has(h.id)) return h;
|
||||
const canBeManaged = !h.protocol || h.protocol === "ssh";
|
||||
return {
|
||||
...h,
|
||||
group: managedGroupName,
|
||||
managedSourceId: canBeManaged ? sourceId : undefined,
|
||||
// Sanitize label for managed hosts
|
||||
label: canBeManaged && h.label ? h.label.replace(/\s/g, '') : h.label,
|
||||
};
|
||||
});
|
||||
|
||||
onUpdateManagedSources([...managedSources, newSource]);
|
||||
onUpdateHosts([...updatedHosts, ...newHosts].map(sanitizeHost));
|
||||
|
||||
const nextGroups = Array.from(
|
||||
new Set([
|
||||
...customGroups,
|
||||
...result.groups,
|
||||
managedGroupName,
|
||||
...newHosts.map((h) => h.group).filter(Boolean),
|
||||
]),
|
||||
) as string[];
|
||||
onUpdateCustomGroups(nextGroups);
|
||||
} else if (newHosts.length > 0) {
|
||||
onUpdateHosts([...hosts, ...newHosts].map(sanitizeHost));
|
||||
|
||||
const nextGroups = Array.from(
|
||||
@@ -471,11 +613,14 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
onUpdateCustomGroups(nextGroups);
|
||||
}
|
||||
|
||||
// Count total hosts affected (new + converted to managed)
|
||||
const totalAffected = newHosts.length + (isManaged ? updatedExistingHosts.length : 0);
|
||||
|
||||
const skipped = result.stats.skipped;
|
||||
const duplicates = result.stats.duplicates;
|
||||
const hasWarnings = skipped > 0 || duplicates > 0 || result.issues.length > 0;
|
||||
|
||||
if (result.stats.parsed === 0 && newHosts.length === 0) {
|
||||
if (result.stats.parsed === 0 && totalAffected === 0) {
|
||||
toast.error(
|
||||
t("vault.import.toast.noEntries", { format: formatLabel }),
|
||||
t("vault.import.toast.failedTitle"),
|
||||
@@ -483,7 +628,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (newHosts.length === 0) {
|
||||
if (totalAffected === 0) {
|
||||
toast.warning(
|
||||
t("vault.import.toast.noNewHosts", { format: formatLabel }),
|
||||
t("vault.import.toast.completedTitle"),
|
||||
@@ -491,20 +636,27 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const details = t("vault.import.toast.summary", {
|
||||
count: newHosts.length,
|
||||
skipped,
|
||||
duplicates,
|
||||
});
|
||||
|
||||
if (hasWarnings) {
|
||||
const firstIssue = result.issues[0]?.message;
|
||||
toast.warning(
|
||||
firstIssue ? `${details} ${t("vault.import.toast.firstIssue", { issue: firstIssue })}` : details,
|
||||
if (isManaged) {
|
||||
toast.success(
|
||||
t("vault.import.sshConfig.managedSuccess", { count: totalAffected }),
|
||||
t("vault.import.toast.completedTitle"),
|
||||
);
|
||||
} else {
|
||||
toast.success(details, t("vault.import.toast.completedTitle"));
|
||||
const details = t("vault.import.toast.summary", {
|
||||
count: totalAffected,
|
||||
skipped,
|
||||
duplicates,
|
||||
});
|
||||
|
||||
if (hasWarnings) {
|
||||
const firstIssue = result.issues[0]?.message;
|
||||
toast.warning(
|
||||
firstIssue ? `${details} ${t("vault.import.toast.firstIssue", { issue: firstIssue })}` : details,
|
||||
t("vault.import.toast.completedTitle"),
|
||||
);
|
||||
} else {
|
||||
toast.success(details, t("vault.import.toast.completedTitle"));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const message =
|
||||
@@ -515,8 +667,10 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
[
|
||||
customGroups,
|
||||
hosts,
|
||||
managedSources,
|
||||
onUpdateCustomGroups,
|
||||
onUpdateHosts,
|
||||
onUpdateManagedSources,
|
||||
readTextFile,
|
||||
t,
|
||||
],
|
||||
@@ -597,7 +751,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
selectedTags.some((t) => h.tags?.includes(t)),
|
||||
);
|
||||
}
|
||||
// Apply sorting
|
||||
filtered = [...filtered].sort((a, b) => {
|
||||
switch (sortMode) {
|
||||
case "az":
|
||||
@@ -608,6 +761,12 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
return (b.createdAt || 0) - (a.createdAt || 0);
|
||||
case "oldest":
|
||||
return (a.createdAt || 0) - (b.createdAt || 0);
|
||||
case "group": {
|
||||
const groupA = a.group || "";
|
||||
const groupB = b.group || "";
|
||||
const groupCmp = groupA.localeCompare(groupB);
|
||||
return groupCmp !== 0 ? groupCmp : a.label.localeCompare(b.label);
|
||||
}
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
@@ -633,7 +792,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
selectedTags.some((t) => h.tags?.includes(t)),
|
||||
);
|
||||
}
|
||||
// Apply sorting
|
||||
filtered = [...filtered].sort((a, b) => {
|
||||
switch (sortMode) {
|
||||
case "az":
|
||||
@@ -644,6 +802,12 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
return (b.createdAt || 0) - (a.createdAt || 0);
|
||||
case "oldest":
|
||||
return (a.createdAt || 0) - (b.createdAt || 0);
|
||||
case "group": {
|
||||
const groupA = a.group || "";
|
||||
const groupB = b.group || "";
|
||||
const groupCmp = groupA.localeCompare(groupB);
|
||||
return groupCmp !== 0 ? groupCmp : a.label.localeCompare(b.label);
|
||||
}
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
@@ -651,7 +815,26 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
return filtered;
|
||||
}, [hosts, search, selectedTags, sortMode]);
|
||||
|
||||
// Create a separate group tree for tree view that uses filtered hosts
|
||||
const groupedDisplayHosts = useMemo(() => {
|
||||
if (sortMode !== "group") return null;
|
||||
const groups: { name: string; hosts: Host[] }[] = [];
|
||||
const groupMap = new Map<string, Host[]>();
|
||||
|
||||
for (const host of displayedHosts) {
|
||||
const groupName = host.group || "";
|
||||
if (!groupMap.has(groupName)) {
|
||||
groupMap.set(groupName, []);
|
||||
}
|
||||
groupMap.get(groupName)!.push(host);
|
||||
}
|
||||
|
||||
const sortedKeys = [...groupMap.keys()].sort((a, b) => a.localeCompare(b));
|
||||
for (const key of sortedKeys) {
|
||||
groups.push({ name: key, hosts: groupMap.get(key)! });
|
||||
}
|
||||
return groups;
|
||||
}, [displayedHosts, sortMode]);
|
||||
|
||||
const buildTreeViewGroupTree = useMemo<Record<string, GroupNode>>(() => {
|
||||
const root: Record<string, GroupNode> = {};
|
||||
const insertPath = (path: string, host?: Host) => {
|
||||
@@ -852,6 +1035,17 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
return h;
|
||||
});
|
||||
|
||||
// Update managed sources if any match the renamed group path
|
||||
const updatedManagedSources = managedSources.map((s) => {
|
||||
if (s.groupName === renameTargetPath) return { ...s, groupName: nextPath };
|
||||
if (s.groupName.startsWith(renameTargetPath + "/"))
|
||||
return { ...s, groupName: nextPath + s.groupName.slice(renameTargetPath.length) };
|
||||
return s;
|
||||
});
|
||||
if (updatedManagedSources.some((s, i) => s !== managedSources[i])) {
|
||||
onUpdateManagedSources(updatedManagedSources);
|
||||
}
|
||||
|
||||
onUpdateCustomGroups(Array.from(new Set(updatedGroups)));
|
||||
onUpdateHosts(updatedHosts);
|
||||
if (
|
||||
@@ -869,15 +1063,58 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
setIsRenameGroupOpen(false);
|
||||
};
|
||||
|
||||
const deleteGroupPath = (path: string) => {
|
||||
const deleteGroupPath = async (path: string, deleteHosts: boolean = false) => {
|
||||
const keepGroups = customGroups.filter(
|
||||
(g) => !(g === path || g.startsWith(path + "/")),
|
||||
);
|
||||
const keepHosts = hosts.map((h) => {
|
||||
const g = h.group || "";
|
||||
if (g === path || g.startsWith(path + "/")) return { ...h, group: "" };
|
||||
return h;
|
||||
});
|
||||
|
||||
// Find all managed sources under the deleted path (exact match or subgroups)
|
||||
const sourcesToRemove = managedSources.filter(s =>
|
||||
s.groupName === path || s.groupName.startsWith(path + "/")
|
||||
);
|
||||
|
||||
// Clear managed blocks in SSH config files before removing sources
|
||||
// Use batch removal to avoid race conditions when multiple sources are removed
|
||||
if (sourcesToRemove.length > 0 && onClearAndRemoveManagedSources) {
|
||||
await onClearAndRemoveManagedSources(sourcesToRemove);
|
||||
} else if (sourcesToRemove.length > 0 && onClearAndRemoveManagedSource) {
|
||||
// Fallback to single removal (may have race conditions with multiple sources)
|
||||
await Promise.all(sourcesToRemove.map(s => onClearAndRemoveManagedSource(s)));
|
||||
} else if (sourcesToRemove.length > 0) {
|
||||
// Fallback: just remove sources without clearing (if callback not provided)
|
||||
const updatedSources = managedSources.filter(s =>
|
||||
s.groupName !== path && !s.groupName.startsWith(path + "/")
|
||||
);
|
||||
onUpdateManagedSources(updatedSources);
|
||||
}
|
||||
|
||||
// Check if this is a subgroup under a managed group (that won't be deleted)
|
||||
// Use the most specific (deepest) matching managed source
|
||||
const parentManagedSource = managedSources
|
||||
.filter(s => path.startsWith(s.groupName + "/") && s.groupName !== path)
|
||||
.sort((a, b) => b.groupName.length - a.groupName.length)[0];
|
||||
|
||||
let keepHosts: Host[];
|
||||
if (deleteHosts) {
|
||||
keepHosts = hosts.filter((h) => {
|
||||
const g = h.group || "";
|
||||
return !(g === path || g.startsWith(path + "/"));
|
||||
});
|
||||
} else {
|
||||
keepHosts = hosts.map((h) => {
|
||||
const g = h.group || "";
|
||||
if (g === path || g.startsWith(path + "/")) {
|
||||
// If deleting a subgroup under a managed group, keep managedSourceId
|
||||
// so hosts remain managed and sync to the SSH config
|
||||
if (parentManagedSource) {
|
||||
return { ...h, group: "" };
|
||||
}
|
||||
return { ...h, group: "", managedSourceId: undefined };
|
||||
}
|
||||
return h;
|
||||
});
|
||||
}
|
||||
|
||||
onUpdateCustomGroups(keepGroups);
|
||||
onUpdateHosts(keepHosts);
|
||||
if (
|
||||
@@ -904,6 +1141,16 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
return { ...h, group: g.replace(sourcePath, newPath) };
|
||||
return h;
|
||||
});
|
||||
// Update managed sources if any match the moved group path
|
||||
const updatedManagedSources = managedSources.map((s) => {
|
||||
if (s.groupName === sourcePath) return { ...s, groupName: newPath };
|
||||
if (s.groupName.startsWith(sourcePath + "/"))
|
||||
return { ...s, groupName: s.groupName.replace(sourcePath, newPath) };
|
||||
return s;
|
||||
});
|
||||
if (updatedManagedSources.some((s, i) => s !== managedSources[i])) {
|
||||
onUpdateManagedSources(updatedManagedSources);
|
||||
}
|
||||
onUpdateCustomGroups(Array.from(new Set(updatedGroups)));
|
||||
onUpdateHosts(updatedHosts);
|
||||
if (
|
||||
@@ -915,14 +1162,65 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const managedGroupPaths = useMemo(() => {
|
||||
return new Set(managedSources.map(s => s.groupName));
|
||||
}, [managedSources]);
|
||||
|
||||
const moveHostToGroup = (hostId: string, groupPath: string | null) => {
|
||||
const targetGroup = groupPath || "";
|
||||
// Find the most specific (deepest) managed source that matches the target group
|
||||
const targetManagedSource = managedSources
|
||||
.filter(s => targetGroup === s.groupName || targetGroup.startsWith(s.groupName + "/"))
|
||||
.sort((a, b) => b.groupName.length - a.groupName.length)[0];
|
||||
|
||||
onUpdateHosts(
|
||||
hosts.map((h) =>
|
||||
h.id === hostId ? { ...h, group: groupPath || "" } : h,
|
||||
),
|
||||
hosts.map((h) => {
|
||||
if (h.id !== hostId) return h;
|
||||
|
||||
// Only SSH hosts can be managed (SSH config only supports SSH)
|
||||
const canBeManaged = !h.protocol || h.protocol === "ssh";
|
||||
|
||||
// Sanitize label if moving to a managed group (SSH config requires no spaces in Host alias)
|
||||
let label = h.label;
|
||||
if (targetManagedSource && canBeManaged && label) {
|
||||
label = label.replace(/\s/g, '');
|
||||
}
|
||||
|
||||
return {
|
||||
...h,
|
||||
label,
|
||||
group: targetGroup,
|
||||
managedSourceId: (targetManagedSource && canBeManaged) ? targetManagedSource.id : undefined,
|
||||
};
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const handleUnmanageGroup = useCallback((groupPath: string) => {
|
||||
const source = managedSources.find(s => s.groupName === groupPath);
|
||||
if (!source) return;
|
||||
|
||||
// Clear managedSourceId from hosts first
|
||||
const updatedHosts = hosts.map(h =>
|
||||
h.managedSourceId === source.id
|
||||
? { ...h, managedSourceId: undefined }
|
||||
: h
|
||||
);
|
||||
onUpdateHosts(updatedHosts);
|
||||
|
||||
// Remove the source association without modifying the SSH config file
|
||||
// This preserves the user's file contents while stopping sync
|
||||
if (onUnmanageSource) {
|
||||
onUnmanageSource(source.id);
|
||||
} else {
|
||||
// Fallback if onUnmanageSource not available
|
||||
const updatedSources = managedSources.filter(s => s.id !== source.id);
|
||||
onUpdateManagedSources(updatedSources);
|
||||
}
|
||||
|
||||
toast.success(t("vault.managedSource.unmanageSuccess"));
|
||||
}, [managedSources, hosts, onUpdateHosts, onUpdateManagedSources, onUnmanageSource, t]);
|
||||
|
||||
// Component no longer handles visibility - that's done by VaultViewWrapper
|
||||
return (
|
||||
<div className="absolute inset-0 min-h-0 flex">
|
||||
@@ -1113,6 +1411,21 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
onChange={setSortMode}
|
||||
className="h-10 w-10"
|
||||
/>
|
||||
<Button
|
||||
variant={isMultiSelectMode ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => {
|
||||
if (isMultiSelectMode) {
|
||||
clearHostSelection();
|
||||
} else {
|
||||
setIsMultiSelectMode(true);
|
||||
}
|
||||
}}
|
||||
title={t("vault.hosts.multiSelect")}
|
||||
>
|
||||
<CheckSquare size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
{/* New Host split button */}
|
||||
<div className="flex items-center app-no-drag">
|
||||
@@ -1311,8 +1624,14 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
<FolderTree size={20} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold truncate">
|
||||
<div className="text-sm font-semibold truncate flex items-center gap-2">
|
||||
{node.name}
|
||||
{managedGroupPaths.has(node.path) && (
|
||||
<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">
|
||||
<FileSymlink size={10} />
|
||||
Managed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{t("vault.groups.hostsCount", { count: node.hosts.length })}
|
||||
@@ -1343,7 +1662,10 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => deleteGroupPath(node.path)}
|
||||
onClick={() => {
|
||||
setDeleteTargetPath(node.path);
|
||||
setIsDeleteGroupOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> {t("vault.groups.delete")}
|
||||
</ContextMenuItem>
|
||||
@@ -1369,6 +1691,49 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isMultiSelectMode && (
|
||||
<div className="flex items-center gap-2 p-2 bg-secondary/60 rounded-lg border border-border/40">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("vault.hosts.selected", { count: selectedHostIds.size })}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const allIds = new Set(displayedHosts.map(h => h.id));
|
||||
setSelectedHostIds(allIds);
|
||||
}}
|
||||
>
|
||||
{t("vault.hosts.selectAll")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearHostSelection}
|
||||
>
|
||||
{t("vault.hosts.deselectAll")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={selectedHostIds.size === 0}
|
||||
onClick={deleteSelectedHosts}
|
||||
>
|
||||
<Trash2 size={14} className="mr-1" />
|
||||
{t("vault.hosts.deleteSelected", { count: selectedHostIds.size })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={clearHostSelection}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === "tree" ? (
|
||||
<HostTreeView
|
||||
groupTree={treeViewGroupTree}
|
||||
@@ -1406,7 +1771,160 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
}}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
moveGroup={moveGroup}
|
||||
managedGroupPaths={managedGroupPaths}
|
||||
onUnmanageGroup={handleUnmanageGroup}
|
||||
/>
|
||||
) : sortMode === "group" && groupedDisplayHosts ? (
|
||||
<div className="space-y-6">
|
||||
{groupedDisplayHosts.map((group) => (
|
||||
<div key={group.name || "__ungrouped__"}>
|
||||
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-border/40">
|
||||
<FolderTree size={14} className="text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{group.name || t("vault.groups.ungrouped")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground/60">
|
||||
({group.hosts.length})
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
viewMode === "grid"
|
||||
? "grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
: "flex flex-col gap-0",
|
||||
)}
|
||||
>
|
||||
{group.hosts.map((host) => {
|
||||
const safeHost = sanitizeHost(host);
|
||||
const distroBadge = {
|
||||
text: (safeHost.os || "L")[0].toUpperCase(),
|
||||
label: safeHost.distro || safeHost.os || "Linux",
|
||||
};
|
||||
return (
|
||||
<ContextMenu key={host.id}>
|
||||
<ContextMenuTrigger>
|
||||
<div
|
||||
className={cn(
|
||||
"group cursor-pointer",
|
||||
viewMode === "grid"
|
||||
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
|
||||
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors",
|
||||
)}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("host-id", host.id);
|
||||
}}
|
||||
onClick={() => {
|
||||
if (isMultiSelectMode) {
|
||||
toggleHostSelection(host.id);
|
||||
} else {
|
||||
handleHostConnect(safeHost);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 h-full">
|
||||
{isMultiSelectMode && (
|
||||
<div
|
||||
className="shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleHostSelection(host.id);
|
||||
}}
|
||||
>
|
||||
{selectedHostIds.has(host.id) ? (
|
||||
<CheckSquare size={18} className="text-primary" />
|
||||
) : (
|
||||
<Square size={18} className="text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<DistroAvatar
|
||||
host={safeHost}
|
||||
fallback={distroBadge.text}
|
||||
/>
|
||||
<div className="min-w-0 flex flex-col justify-center gap-0.5 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-semibold truncate leading-5">
|
||||
{safeHost.label}
|
||||
</span>
|
||||
{safeHost.managedSourceId && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-4 shrink-0">
|
||||
managed
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground font-mono truncate leading-4">
|
||||
{safeHost.username}@{safeHost.hostname}
|
||||
</div>
|
||||
</div>
|
||||
{viewMode === "list" && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEditHost(host);
|
||||
}}
|
||||
>
|
||||
<Edit2 size={14} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
onClick={() => handleHostConnect(host)}
|
||||
>
|
||||
<Plug className="mr-2 h-4 w-4" /> {t('vault.hosts.connect')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => handleEditHost(host)}
|
||||
>
|
||||
<Edit2 className="mr-2 h-4 w-4" /> {t('action.edit')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => handleDuplicateHost(host)}
|
||||
>
|
||||
<Copy className="mr-2 h-4 w-4" /> {t('action.duplicate')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => handleCopyCredentials(host)}
|
||||
>
|
||||
<ClipboardCopy className="mr-2 h-4 w-4" /> {t('vault.hosts.copyCredentials')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => onDeleteHost(host.id)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> {t('action.delete')}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{groupedDisplayHosts.length === 0 && (
|
||||
<div className="col-span-full flex flex-col items-center justify-center py-24 text-muted-foreground">
|
||||
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
|
||||
<LayoutGrid size={32} className="opacity-60" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
Set up your hosts
|
||||
</h3>
|
||||
<p className="text-sm text-center max-w-sm">
|
||||
Save hosts to quickly connect to your servers, VMs,
|
||||
and containers.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -1436,16 +1954,44 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("host-id", host.id);
|
||||
}}
|
||||
onClick={() => handleHostConnect(safeHost)}
|
||||
onClick={() => {
|
||||
if (isMultiSelectMode) {
|
||||
toggleHostSelection(host.id);
|
||||
} else {
|
||||
handleHostConnect(safeHost);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 h-full">
|
||||
{isMultiSelectMode && (
|
||||
<div
|
||||
className="shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleHostSelection(host.id);
|
||||
}}
|
||||
>
|
||||
{selectedHostIds.has(host.id) ? (
|
||||
<CheckSquare size={18} className="text-primary" />
|
||||
) : (
|
||||
<Square size={18} className="text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<DistroAvatar
|
||||
host={safeHost}
|
||||
fallback={distroBadge.text}
|
||||
/>
|
||||
<div className="min-w-0 flex flex-col justify-center gap-0.5 flex-1">
|
||||
<div className="text-sm font-semibold truncate leading-5">
|
||||
{safeHost.label}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-semibold truncate leading-5">
|
||||
{safeHost.label}
|
||||
</span>
|
||||
{safeHost.managedSourceId && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 h-4 shrink-0">
|
||||
managed
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground font-mono truncate leading-4">
|
||||
{safeHost.username}@{safeHost.hostname}
|
||||
@@ -1542,6 +2088,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
}
|
||||
onRunSnippet={onRunSnippet}
|
||||
availableKeys={keys}
|
||||
managedSources={managedSources}
|
||||
onSaveHost={(host) => onUpdateHosts([...hosts, host])}
|
||||
onCreateGroup={(groupPath) =>
|
||||
onUpdateCustomGroups(
|
||||
@@ -1556,6 +2103,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
identities={identities}
|
||||
hosts={hosts}
|
||||
customGroups={customGroups}
|
||||
managedSources={managedSources}
|
||||
onSave={(k) => onUpdateKeys([...keys, k])}
|
||||
onUpdate={(k) =>
|
||||
onUpdateKeys(
|
||||
@@ -1597,6 +2145,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
keys={keys}
|
||||
identities={identities}
|
||||
customGroups={customGroups}
|
||||
managedSources={managedSources}
|
||||
onSaveHost={(host) => onUpdateHosts([...hosts, host])}
|
||||
onCreateGroup={(groupPath) =>
|
||||
onUpdateCustomGroups(
|
||||
@@ -1640,6 +2189,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
...hosts.map((h) => h.group || "General"),
|
||||
]),
|
||||
)}
|
||||
managedSources={managedSources}
|
||||
allTags={allTags}
|
||||
allHosts={hosts}
|
||||
defaultGroup={editingHost ? undefined : (newHostGroupPath || selectedGroupPath)}
|
||||
@@ -1791,6 +2341,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
setIsDeleteGroupOpen(open);
|
||||
if (!open) {
|
||||
setDeleteTargetPath(null);
|
||||
setDeleteGroupWithHosts(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -1798,15 +2349,30 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("vault.groups.deleteDialogTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("vault.groups.deleteDialog.desc")}
|
||||
{deleteTargetPath && managedGroupPaths.has(deleteTargetPath)
|
||||
? t("vault.groups.deleteDialog.managedDesc")
|
||||
: t("vault.groups.deleteDialog.desc")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<div className="py-4 space-y-4">
|
||||
{deleteTargetPath && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("vault.groups.pathLabel")}:{" "}
|
||||
<span className="font-mono">{deleteTargetPath}</span>
|
||||
</p>
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("vault.groups.pathLabel")}:{" "}
|
||||
<span className="font-mono">{deleteTargetPath}</span>
|
||||
</p>
|
||||
{!managedGroupPaths.has(deleteTargetPath) && (
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={deleteGroupWithHosts}
|
||||
onChange={(e) => setDeleteGroupWithHosts(e.target.checked)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<span>{t("vault.groups.deleteDialog.deleteHosts")}</span>
|
||||
</label>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
@@ -1817,9 +2383,11 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
if (deleteTargetPath) {
|
||||
deleteGroupPath(deleteTargetPath);
|
||||
const isManaged = managedGroupPaths.has(deleteTargetPath);
|
||||
deleteGroupPath(deleteTargetPath, isManaged || deleteGroupWithHosts);
|
||||
}
|
||||
setIsDeleteGroupOpen(false);
|
||||
setDeleteGroupWithHosts(false);
|
||||
}}
|
||||
>
|
||||
{t("common.delete")}
|
||||
@@ -1894,7 +2462,8 @@ const vaultViewAreEqual = (
|
||||
prev.knownHosts === next.knownHosts &&
|
||||
prev.shellHistory === next.shellHistory &&
|
||||
prev.connectionLogs === next.connectionLogs &&
|
||||
prev.sessions === next.sessions;
|
||||
prev.sessions === next.sessions &&
|
||||
prev.managedSources === next.managedSources;
|
||||
|
||||
return isEqual;
|
||||
};
|
||||
|
||||
@@ -97,6 +97,8 @@ export const ExportKeyPanel: React.FC<ExportKeyPanelProps> = ({
|
||||
privateKey: hostPrivateKey,
|
||||
command,
|
||||
timeout: 30000,
|
||||
enableKeyboardInteractive: true,
|
||||
sessionId: `export-key:${exportHost.id}:${keyItem.id}`,
|
||||
});
|
||||
|
||||
// Check result
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Import Key Panel - Import existing SSH key
|
||||
*/
|
||||
|
||||
import { Upload } from 'lucide-react';
|
||||
import { Eye, EyeOff, Upload } from 'lucide-react';
|
||||
import React,{ useCallback,useRef } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { SSHKey } from '../../types';
|
||||
@@ -15,12 +15,16 @@ import { detectKeyType } from './utils';
|
||||
interface ImportKeyPanelProps {
|
||||
draftKey: Partial<SSHKey>;
|
||||
setDraftKey: (key: Partial<SSHKey>) => void;
|
||||
showPassphrase: boolean;
|
||||
setShowPassphrase: (show: boolean) => void;
|
||||
onImport: () => void;
|
||||
}
|
||||
|
||||
export const ImportKeyPanel: React.FC<ImportKeyPanelProps> = ({
|
||||
draftKey,
|
||||
setDraftKey,
|
||||
showPassphrase,
|
||||
setShowPassphrase,
|
||||
onImport,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
@@ -132,6 +136,41 @@ export const ImportKeyPanel: React.FC<ImportKeyPanelProps> = ({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('terminal.auth.passphrase')}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassphrase ? 'text' : 'password'}
|
||||
value={draftKey.passphrase || ''}
|
||||
onChange={e => setDraftKey({ ...draftKey, passphrase: e.target.value })}
|
||||
placeholder={t('keychain.generate.passphrasePlaceholder')}
|
||||
className="pr-10"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8"
|
||||
onClick={() => setShowPassphrase(!showPassphrase)}
|
||||
>
|
||||
{showPassphrase ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="savePassphraseImport"
|
||||
checked={draftKey.savePassphrase || false}
|
||||
onChange={e => setDraftKey({ ...draftKey, savePassphrase: e.target.checked })}
|
||||
className="h-4 w-4 rounded border-border"
|
||||
/>
|
||||
<Label htmlFor="savePassphraseImport" className="text-sm font-normal cursor-pointer">
|
||||
{t('keychain.generate.savePassphrase')}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="border border-dashed border-border/80 rounded-xl p-4 text-center space-y-2 bg-background/60 transition-colors hover:border-primary/50"
|
||||
onDrop={handleDrop}
|
||||
|
||||
@@ -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";
|
||||
@@ -31,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;
|
||||
@@ -73,6 +74,7 @@ export const SftpModalFileList: React.FC<SftpModalFileListProps> = ({
|
||||
visibleRows,
|
||||
fileListRef,
|
||||
inputRef,
|
||||
folderInputRef,
|
||||
handleSort,
|
||||
handleResizeStart,
|
||||
handleFileListScroll,
|
||||
@@ -398,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,7 +49,7 @@ 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>;
|
||||
@@ -65,8 +71,10 @@ interface UseSftpModalTransfersParams {
|
||||
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 {
|
||||
@@ -76,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;
|
||||
}
|
||||
|
||||
@@ -90,7 +101,6 @@ export const useSftpModalTransfers = ({
|
||||
ensureSftp,
|
||||
loadFiles,
|
||||
readLocalFile,
|
||||
readSftp,
|
||||
writeLocalFile,
|
||||
writeSftpBinaryWithProgress,
|
||||
writeSftpBinary,
|
||||
@@ -99,8 +109,10 @@ export const useSftpModalTransfers = ({
|
||||
cancelSftpUpload,
|
||||
startStreamTransfer,
|
||||
cancelTransfer,
|
||||
showSaveDialog,
|
||||
setLoading,
|
||||
t,
|
||||
useCompressedUpload = false,
|
||||
}: UseSftpModalTransfersParams): UseSftpModalTransfersResult => {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadTasks, setUploadTasks] = useState<UploadTask[]>([]);
|
||||
@@ -115,35 +127,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 {
|
||||
@@ -161,8 +144,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(
|
||||
@@ -171,21 +154,22 @@ 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;
|
||||
@@ -228,7 +212,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,
|
||||
@@ -236,6 +220,7 @@ export const useSftpModalTransfers = ({
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
isDirectory: true,
|
||||
direction: "upload",
|
||||
};
|
||||
setUploadTasks(prev => [...prev, scanningTask]);
|
||||
},
|
||||
@@ -246,36 +231,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) => {
|
||||
@@ -294,24 +278,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 =>
|
||||
@@ -325,70 +303,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) => {
|
||||
console.log('[useSftpModalTransfers] handleUploadMultiple called', {
|
||||
length: fileList.length,
|
||||
currentPath,
|
||||
isLocalSession
|
||||
});
|
||||
if (fileList.length === 0) return;
|
||||
|
||||
setUploading(true);
|
||||
|
||||
// Get SFTP ID for remote sessions
|
||||
let sftpId: string | null = null;
|
||||
if (!isLocalSession) {
|
||||
sftpId = await ensureSftp();
|
||||
cachedSftpIdRef.current = sftpId;
|
||||
}
|
||||
|
||||
// Create controller for cancellation
|
||||
const controller = new UploadController();
|
||||
uploadControllerRef.current = controller;
|
||||
|
||||
const callbacks = createUploadCallbacks();
|
||||
|
||||
try {
|
||||
await uploadFromFileList(
|
||||
fileList,
|
||||
{
|
||||
targetPath: currentPath,
|
||||
sftpId,
|
||||
isLocal: isLocalSession,
|
||||
bridge: createUploadBridge,
|
||||
joinPath,
|
||||
callbacks,
|
||||
},
|
||||
controller
|
||||
);
|
||||
|
||||
await loadFiles(currentPath, { force: true });
|
||||
|
||||
// Auto-clear completed tasks after 3 seconds
|
||||
setTimeout(() => {
|
||||
setUploadTasks(prev => prev.filter(t => t.status !== "completed"));
|
||||
}, 3000);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t("sftp.error.uploadFailed"),
|
||||
"SFTP"
|
||||
);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
uploadControllerRef.current = null;
|
||||
cachedSftpIdRef.current = null;
|
||||
}
|
||||
// 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(
|
||||
@@ -418,39 +588,31 @@ 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 File array (used by file input after copying files)
|
||||
const handleUploadFromFiles = useCallback(
|
||||
async (files: File[]) => {
|
||||
console.log('[useSftpModalTransfers] handleUploadFromFiles called', {
|
||||
length: files.length,
|
||||
currentPath,
|
||||
isLocalSession
|
||||
});
|
||||
if (files.length === 0) return;
|
||||
// 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);
|
||||
|
||||
@@ -468,8 +630,8 @@ export const useSftpModalTransfers = ({
|
||||
const callbacks = createUploadCallbacks();
|
||||
|
||||
try {
|
||||
await uploadFromFileList(
|
||||
files,
|
||||
await uploadEntriesDirect(
|
||||
entries,
|
||||
{
|
||||
targetPath: currentPath,
|
||||
sftpId,
|
||||
@@ -477,42 +639,62 @@ 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 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>) => {
|
||||
console.log('[useSftpModalTransfers] handleFileSelect called', {
|
||||
files: e.target.files,
|
||||
length: e.target.files?.length
|
||||
});
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
console.log('[useSftpModalTransfers] Starting upload for', e.target.files.length, 'files');
|
||||
// Copy the files before clearing the input, because clearing the input
|
||||
// will also clear the FileList reference
|
||||
const files = Array.from(e.target.files);
|
||||
// Clear input first to allow selecting the same file again
|
||||
// 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 = "";
|
||||
}
|
||||
},
|
||||
[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);
|
||||
@@ -546,25 +728,22 @@ export const useSftpModalTransfers = ({
|
||||
);
|
||||
|
||||
const cancelUpload = useCallback(async () => {
|
||||
console.log('[useSftpModalTransfers] cancelUpload called');
|
||||
const controller = uploadControllerRef.current;
|
||||
if (controller) {
|
||||
// Mark all active transfer IDs as cancelled before calling cancel
|
||||
const activeIds = controller.getActiveTransferIds();
|
||||
console.log('[useSftpModalTransfers] Active transfer IDs:', activeIds);
|
||||
for (const id of activeIds) {
|
||||
cancelledTransferIdsRef.current.add(id);
|
||||
}
|
||||
await controller.cancel();
|
||||
console.log('[useSftpModalTransfers] controller.cancel() completed');
|
||||
} else {
|
||||
console.log('[useSftpModalTransfers] No controller found');
|
||||
}
|
||||
|
||||
// Always clear all uploading/pending tasks immediately, even without controller
|
||||
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"
|
||||
@@ -573,15 +752,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 {
|
||||
// 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));
|
||||
}, []);
|
||||
@@ -593,10 +817,13 @@ export const useSftpModalTransfers = ({
|
||||
handleDownload,
|
||||
handleUploadMultiple,
|
||||
handleUploadFromDrop,
|
||||
handleUploadEntries,
|
||||
handleFileSelect,
|
||||
handleFolderSelect,
|
||||
handleDrag,
|
||||
handleDrop,
|
||||
cancelUpload,
|
||||
cancelTask,
|
||||
dismissTask,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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';
|
||||
@@ -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");
|
||||
};
|
||||
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,
|
||||
};
|
||||
@@ -10,6 +10,7 @@ const {
|
||||
buildAuthHandler,
|
||||
createKeyboardInteractiveHandler,
|
||||
applyAuthToConnOpts,
|
||||
findAllDefaultPrivateKeys: findAllDefaultPrivateKeysFromHelper,
|
||||
} = require("./sshAuthHelper.cjs");
|
||||
|
||||
// Active port forwarding tunnels
|
||||
@@ -46,55 +47,59 @@ async function startPortForward(event, payload) {
|
||||
passphrase,
|
||||
} = payload;
|
||||
|
||||
const conn = new SSHClient();
|
||||
const sender = event.sender;
|
||||
|
||||
const sendStatus = (status, error = null) => {
|
||||
if (!sender.isDestroyed()) {
|
||||
sender.send("netcatty:portforward:status", { tunnelId, status, error });
|
||||
}
|
||||
};
|
||||
|
||||
const connectOpts = {
|
||||
host: hostname,
|
||||
port: port,
|
||||
username: username || 'root',
|
||||
readyTimeout: 120000, // 2 minutes for 2FA input
|
||||
keepaliveInterval: 10000,
|
||||
// Enable keyboard-interactive authentication (required for 2FA/MFA)
|
||||
tryKeyboard: true,
|
||||
};
|
||||
|
||||
if (privateKey) {
|
||||
connectOpts.privateKey = privateKey;
|
||||
}
|
||||
if (passphrase) {
|
||||
connectOpts.passphrase = passphrase;
|
||||
}
|
||||
if (password) {
|
||||
connectOpts.password = password;
|
||||
}
|
||||
|
||||
// Get default keys
|
||||
const defaultKeys = await findAllDefaultPrivateKeysFromHelper();
|
||||
|
||||
// Build auth handler using shared helper
|
||||
const authConfig = buildAuthHandler({
|
||||
privateKey,
|
||||
password,
|
||||
passphrase,
|
||||
username: connectOpts.username,
|
||||
logPrefix: "[PortForward]",
|
||||
defaultKeys,
|
||||
});
|
||||
applyAuthToConnOpts(connectOpts, authConfig);
|
||||
|
||||
// Handle keyboard-interactive authentication (2FA/MFA)
|
||||
conn.on("keyboard-interactive", createKeyboardInteractiveHandler({
|
||||
sender,
|
||||
sessionId: tunnelId,
|
||||
hostname,
|
||||
password,
|
||||
logPrefix: "[PortForward]",
|
||||
}));
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const conn = new SSHClient();
|
||||
const sender = event.sender;
|
||||
|
||||
const sendStatus = (status, error = null) => {
|
||||
if (!sender.isDestroyed()) {
|
||||
sender.send("netcatty:portforward:status", { tunnelId, status, error });
|
||||
}
|
||||
};
|
||||
|
||||
const connectOpts = {
|
||||
host: hostname,
|
||||
port: port,
|
||||
username: username || 'root',
|
||||
readyTimeout: 120000, // 2 minutes for 2FA input
|
||||
keepaliveInterval: 10000,
|
||||
// Enable keyboard-interactive authentication (required for 2FA/MFA)
|
||||
tryKeyboard: true,
|
||||
};
|
||||
|
||||
if (privateKey) {
|
||||
connectOpts.privateKey = privateKey;
|
||||
}
|
||||
if (passphrase) {
|
||||
connectOpts.passphrase = passphrase;
|
||||
}
|
||||
if (password) {
|
||||
connectOpts.password = password;
|
||||
}
|
||||
|
||||
// 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", createKeyboardInteractiveHandler({
|
||||
sender,
|
||||
sessionId: tunnelId,
|
||||
hostname,
|
||||
password,
|
||||
logPrefix: "[PortForward]",
|
||||
}));
|
||||
|
||||
|
||||
conn.on('ready', () => {
|
||||
console.log(`[PortForward] SSH connection ready for tunnel ${tunnelId}`);
|
||||
|
||||
@@ -28,6 +28,7 @@ const {
|
||||
createKeyboardInteractiveHandler,
|
||||
applyAuthToConnOpts,
|
||||
safeSend: authSafeSend,
|
||||
findAllDefaultPrivateKeys: findAllDefaultPrivateKeysFromHelper,
|
||||
} = require("./sshAuthHelper.cjs");
|
||||
|
||||
// SFTP clients storage - shared reference passed from main
|
||||
@@ -171,6 +172,18 @@ const ensureRemoteDirInternal = async (sftp, dirPath, encoding) => {
|
||||
const normalized = path.posix.normalize(dirPath);
|
||||
if (!normalized || normalized === ".") return;
|
||||
|
||||
// Optimization: Check if the full path already exists to avoid O(N) round trips
|
||||
// This is the common case (e.g. uploading multiple files to the same directory)
|
||||
const encodedFull = encodePath(normalized, encoding);
|
||||
try {
|
||||
const stats = await statAsync(sftp, encodedFull);
|
||||
if (stats.isDirectory()) {
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
// If path doesn't exist or other error, proceed to recursive check
|
||||
}
|
||||
|
||||
const isAbsolute = normalized.startsWith("/");
|
||||
const parts = normalized.split("/").filter(Boolean);
|
||||
let current = isAbsolute ? "/" : "";
|
||||
@@ -325,6 +338,9 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
|
||||
if (jump.password) connOpts.password = jump.password;
|
||||
|
||||
// Get default keys (either from options if pre-fetched, or fetch them now)
|
||||
const defaultKeys = options._defaultKeys || await findAllDefaultPrivateKeysFromHelper();
|
||||
|
||||
// Build auth handler using shared helper
|
||||
// Pass unlocked encrypted keys from options so jump hosts can use them for retry
|
||||
const authConfig = buildAuthHandler({
|
||||
@@ -335,6 +351,7 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
username: connOpts.username,
|
||||
logPrefix: `[SFTP Chain] Hop ${i + 1}`,
|
||||
unlockedEncryptedKeys: options._unlockedEncryptedKeys || [],
|
||||
defaultKeys,
|
||||
});
|
||||
applyAuthToConnOpts(connOpts, authConfig);
|
||||
|
||||
@@ -654,6 +671,9 @@ async function openSftp(event, options) {
|
||||
const client = new SftpClient();
|
||||
const connId = options.sessionId || `${Date.now()}-sftp-${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
// Get default keys early to use for both chain and target
|
||||
const defaultKeys = await findAllDefaultPrivateKeysFromHelper();
|
||||
|
||||
// Check if we need to connect through jump hosts
|
||||
const jumpHosts = options.jumpHosts || [];
|
||||
const hasJumpHosts = jumpHosts.length > 0;
|
||||
@@ -665,6 +685,10 @@ async function openSftp(event, options) {
|
||||
// Handle chain/proxy connections
|
||||
if (hasJumpHosts) {
|
||||
console.log(`[SFTP] Opening connection through ${jumpHosts.length} jump host(s) to ${options.hostname}:${options.port || 22}`);
|
||||
|
||||
// Pass default keys to chain connection
|
||||
options._defaultKeys = defaultKeys;
|
||||
|
||||
const chainResult = await connectThroughChainForSftp(
|
||||
event,
|
||||
options,
|
||||
@@ -731,6 +755,7 @@ async function openSftp(event, options) {
|
||||
agent: connectOpts.agent,
|
||||
username: connectOpts.username,
|
||||
logPrefix: "[SFTP]",
|
||||
defaultKeys,
|
||||
});
|
||||
applyAuthToConnOpts(connectOpts, authConfig);
|
||||
|
||||
@@ -1012,6 +1037,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);
|
||||
@@ -1059,13 +1089,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;
|
||||
}
|
||||
@@ -1083,22 +1122,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
|
||||
|
||||
@@ -68,22 +68,21 @@ const passphraseHandler = require("./passphraseHandler.cjs");
|
||||
/**
|
||||
* 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}
|
||||
* @returns {Promise<{ privateKey: string, keyPath: string, keyName: string } | null>}
|
||||
*/
|
||||
function findDefaultPrivateKey() {
|
||||
async 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 {
|
||||
try {
|
||||
await fs.promises.access(keyPath, fs.constants.F_OK);
|
||||
const privateKey = await fs.promises.readFile(keyPath, "utf8");
|
||||
if (isKeyEncrypted(privateKey)) {
|
||||
continue;
|
||||
}
|
||||
return { privateKey, keyPath, keyName: name };
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -93,33 +92,34 @@ const passphraseHandler = require("./passphraseHandler.cjs");
|
||||
* 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 }>}
|
||||
* @returns {Promise<Array<{ privateKey: string, keyPath: string, keyName: string, isEncrypted?: boolean }>>}
|
||||
*/
|
||||
function findAllDefaultPrivateKeys(options = {}) {
|
||||
async function findAllDefaultPrivateKeys(options = {}) {
|
||||
const { includeEncrypted = false } = options;
|
||||
const sshDir = path.join(os.homedir(), ".ssh");
|
||||
const keys = [];
|
||||
for (const name of DEFAULT_KEY_NAMES) {
|
||||
|
||||
const promises = DEFAULT_KEY_NAMES.map(async (name) => {
|
||||
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;
|
||||
try {
|
||||
await fs.promises.access(keyPath, fs.constants.F_OK);
|
||||
const privateKey = await fs.promises.readFile(keyPath, "utf8");
|
||||
const encrypted = isKeyEncrypted(privateKey);
|
||||
if (encrypted && !includeEncrypted) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
privateKey,
|
||||
keyPath,
|
||||
keyName: name,
|
||||
...(includeEncrypted ? { isEncrypted: encrypted } : {})
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
});
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
return results.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,7 +146,7 @@ function findAllDefaultPrivateKeys(options = {}) {
|
||||
* @param {Array} [options.unlockedEncryptedKeys] - Array of unlocked encrypted keys with passphrases
|
||||
*/
|
||||
function buildAuthHandler(options) {
|
||||
const { privateKey, password, passphrase, agent, username, logPrefix = "[SSH]", unlockedEncryptedKeys = [] } = options;
|
||||
const { privateKey, password, passphrase, agent, username, logPrefix = "[SSH]", unlockedEncryptedKeys = [], defaultKeys = [] } = options;
|
||||
|
||||
// Determine what type of explicit auth the user configured
|
||||
const hasExplicitKey = !!privateKey;
|
||||
@@ -159,7 +159,6 @@ function findAllDefaultPrivateKeys(options = {}) {
|
||||
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
|
||||
@@ -465,7 +464,7 @@ function findAllDefaultPrivateKeys(options = {}) {
|
||||
* @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 allKeys = await findAllDefaultPrivateKeys({ includeEncrypted: true });
|
||||
const encryptedKeys = allKeys.filter(k => k.isEncrypted);
|
||||
|
||||
if (encryptedKeys.length === 0) {
|
||||
|
||||
@@ -76,31 +76,29 @@ function isKeyEncrypted(keyContent) {
|
||||
/**
|
||||
* Find default SSH private key from user's ~/.ssh directory
|
||||
* Skips encrypted keys that require a passphrase to allow password/keyboard-interactive auth
|
||||
* @returns {{ privateKey: string, keyPath: string, keyName: string } | null}
|
||||
* @returns {Promise<{ privateKey: string, keyPath: string, keyName: string } | null>}
|
||||
*/
|
||||
function findDefaultPrivateKey() {
|
||||
async 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
|
||||
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;
|
||||
}
|
||||
log("Found default key", { keyPath, keyName: name });
|
||||
return { privateKey, keyPath, keyName: name };
|
||||
} catch (e) {
|
||||
log("Failed to read default key", { keyPath, error: e.message });
|
||||
try {
|
||||
await fs.promises.access(keyPath, fs.constants.F_OK);
|
||||
const privateKey = await fs.promises.readFile(keyPath, "utf8");
|
||||
// Skip encrypted keys - they require a passphrase and would abort
|
||||
// authentication before password/keyboard-interactive can be tried
|
||||
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;
|
||||
}
|
||||
log("Found default key", { keyPath, keyName: name });
|
||||
return { privateKey, keyPath, keyName: name };
|
||||
} catch (e) {
|
||||
log("Failed to read default key", { keyPath, error: e.message });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
log("No suitable default SSH key found");
|
||||
@@ -110,29 +108,33 @@ function findDefaultPrivateKey() {
|
||||
/**
|
||||
* 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 }>}
|
||||
* @returns {Promise<Array<{ privateKey: string, keyPath: string, keyName: string }>>}
|
||||
*/
|
||||
function findAllDefaultPrivateKeys() {
|
||||
async 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 promises = DEFAULT_KEY_NAMES.map(async (name) => {
|
||||
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 });
|
||||
try {
|
||||
await fs.promises.access(keyPath, fs.constants.F_OK);
|
||||
const privateKey = await fs.promises.readFile(keyPath, "utf8");
|
||||
const encrypted = isKeyEncrypted(privateKey);
|
||||
if (!encrypted) {
|
||||
log("Found default key for fallback", { keyPath, keyName: name });
|
||||
return { privateKey, keyPath, keyName: name };
|
||||
} else {
|
||||
log("Skipping encrypted key", { keyPath, keyName: name });
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
log("Failed to read key", { keyPath, error: e.message });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
const keys = results.filter(Boolean);
|
||||
log("Found default SSH keys", { count: keys.length, keyNames: keys.map(k => k.keyName) });
|
||||
return keys;
|
||||
}
|
||||
@@ -308,6 +310,9 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
|
||||
if (jump.password) connOpts.password = jump.password;
|
||||
|
||||
// Get default keys (either from options if pre-fetched, or fetch them now)
|
||||
const defaultKeys = options._defaultKeys || await findAllDefaultPrivateKeys();
|
||||
|
||||
// Build auth handler using shared helper
|
||||
// Pass unlocked encrypted keys from options so jump hosts can use them for retry
|
||||
const authConfig = buildAuthHandler({
|
||||
@@ -318,6 +323,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
username: connOpts.username,
|
||||
logPrefix: `[Chain] Hop ${i + 1}`,
|
||||
unlockedEncryptedKeys: options._unlockedEncryptedKeys || [],
|
||||
defaultKeys,
|
||||
});
|
||||
applyAuthToConnOpts(connOpts, authConfig);
|
||||
|
||||
@@ -508,13 +514,13 @@ async function startSSHSession(event, options) {
|
||||
let defaultKeyInfo = null;
|
||||
let allDefaultKeys = [];
|
||||
let usedDefaultKeyAsPrimary = false;
|
||||
const defaultKey = findDefaultPrivateKey();
|
||||
const defaultKey = await 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();
|
||||
allDefaultKeys = await findAllDefaultPrivateKeys();
|
||||
|
||||
// Use unlocked encrypted keys if provided (from retry after auth failure)
|
||||
// These are passed via _unlockedEncryptedKeys from startSSHSessionWrapper
|
||||
@@ -814,6 +820,9 @@ async function startSSHSession(event, options) {
|
||||
|
||||
// Handle chain/proxy connections
|
||||
if (hasJumpHosts) {
|
||||
// Pass fetched keys to chain connection to avoid re-reading files
|
||||
options._defaultKeys = allDefaultKeys;
|
||||
|
||||
const chainResult = await connectThroughChain(
|
||||
event,
|
||||
options,
|
||||
@@ -1098,12 +1107,18 @@ async function startSSHSession(event, options) {
|
||||
* Execute a one-off command via SSH
|
||||
*/
|
||||
async function execCommand(event, payload) {
|
||||
const enableKeyboardInteractive = !!payload.enableKeyboardInteractive;
|
||||
const baseTimeoutMs = payload.timeout || 10000;
|
||||
const timeoutMs = enableKeyboardInteractive ? Math.max(baseTimeoutMs, 120000) : baseTimeoutMs;
|
||||
const sender = event.sender;
|
||||
const sessionId = payload.sessionId || `exec-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const defaultKeys = enableKeyboardInteractive ? await findAllDefaultPrivateKeysFromHelper() : [];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const conn = new SSHClient();
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let settled = false;
|
||||
const timeoutMs = payload.timeout || 10000;
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
@@ -1159,7 +1174,7 @@ async function execCommand(event, payload) {
|
||||
host: payload.hostname,
|
||||
port: payload.port || 22,
|
||||
username: payload.username,
|
||||
readyTimeout: timeoutMs,
|
||||
readyTimeout: enableKeyboardInteractive ? Math.max(timeoutMs, 120000) : timeoutMs,
|
||||
keepaliveInterval: 0,
|
||||
};
|
||||
|
||||
@@ -1183,7 +1198,29 @@ async function execCommand(event, payload) {
|
||||
|
||||
if (payload.password) connectOpts.password = payload.password;
|
||||
|
||||
if (authAgent) {
|
||||
if (enableKeyboardInteractive) {
|
||||
connectOpts.tryKeyboard = true;
|
||||
|
||||
const authConfig = buildAuthHandler({
|
||||
privateKey: connectOpts.privateKey,
|
||||
password: connectOpts.password,
|
||||
passphrase: connectOpts.passphrase,
|
||||
agent: connectOpts.agent,
|
||||
username: connectOpts.username,
|
||||
logPrefix: "[SSH Exec]",
|
||||
defaultKeys,
|
||||
});
|
||||
|
||||
applyAuthToConnOpts(connectOpts, authConfig);
|
||||
|
||||
conn.on("keyboard-interactive", createKeyboardInteractiveHandler({
|
||||
sender,
|
||||
sessionId,
|
||||
hostname: payload.hostname,
|
||||
password: payload.password,
|
||||
logPrefix: "[SSH Exec]",
|
||||
}));
|
||||
} else if (authAgent) {
|
||||
const order = ["agent"];
|
||||
if (connectOpts.password) order.push("password");
|
||||
connectOpts.authHandler = order;
|
||||
@@ -1257,7 +1294,7 @@ async function startSSHSessionWrapper(event, options) {
|
||||
// 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 allKeysWithEncrypted = await findAllDefaultPrivateKeysFromHelper({ includeEncrypted: true });
|
||||
const encryptedKeys = allKeysWithEncrypted.filter(k => k.isEncrypted);
|
||||
|
||||
if (encryptedKeys.length > 0) {
|
||||
@@ -1720,8 +1757,11 @@ function registerHandlers(ipcMain) {
|
||||
const keys = [];
|
||||
for (const name of DEFAULT_KEY_NAMES) {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
if (fs.existsSync(keyPath)) {
|
||||
try {
|
||||
await fs.promises.access(keyPath, fs.constants.F_OK);
|
||||
keys.push({ name, path: keyPath });
|
||||
} catch {
|
||||
// ignore missing keys
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
|
||||
@@ -157,8 +157,11 @@ async function downloadWithStreams(remotePath, localPath, client, fileSize, tran
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
async function startTransfer(event, payload, onProgress) {
|
||||
const {
|
||||
transferId,
|
||||
sourcePath,
|
||||
@@ -192,6 +195,11 @@ async function startTransfer(event, payload) {
|
||||
lastTransferred = transferred;
|
||||
}
|
||||
|
||||
// Call optional progress callback if provided
|
||||
if (onProgress) {
|
||||
onProgress(transferred, total, speed);
|
||||
}
|
||||
|
||||
sender.send("netcatty:transfer:progress", { transferId, transferred, speed, totalBytes: total });
|
||||
};
|
||||
|
||||
|
||||
@@ -87,9 +87,9 @@ function loadWindowState() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Save window state to disk
|
||||
* Save window state to disk (synchronous)
|
||||
*/
|
||||
function saveWindowState(state) {
|
||||
function saveWindowStateSync(state) {
|
||||
try {
|
||||
const statePath = getWindowStatePath();
|
||||
if (!statePath) return false;
|
||||
@@ -101,6 +101,47 @@ function saveWindowState(state) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save window state to disk (asynchronous)
|
||||
*/
|
||||
async function saveWindowState(state) {
|
||||
try {
|
||||
const statePath = getWindowStatePath();
|
||||
if (!statePath) return false;
|
||||
await fs.promises.writeFile(statePath, JSON.stringify(state, null, 2), { mode: 0o600 });
|
||||
return true;
|
||||
} catch (err) {
|
||||
debugLog("Failed to save window state:", err?.message || err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let pendingWindowStateWrite = null;
|
||||
let queuedWindowState = null;
|
||||
let windowStateCloseRequested = false;
|
||||
|
||||
async function queueWindowStateSave(state) {
|
||||
if (!state) return false;
|
||||
if (windowStateCloseRequested) {
|
||||
return pendingWindowStateWrite || false;
|
||||
}
|
||||
queuedWindowState = state;
|
||||
if (pendingWindowStateWrite) {
|
||||
return pendingWindowStateWrite;
|
||||
}
|
||||
pendingWindowStateWrite = (async () => {
|
||||
let lastResult = true;
|
||||
while (queuedWindowState) {
|
||||
const nextState = queuedWindowState;
|
||||
queuedWindowState = null;
|
||||
lastResult = await saveWindowState(nextState);
|
||||
}
|
||||
pendingWindowStateWrite = null;
|
||||
return lastResult;
|
||||
})();
|
||||
return pendingWindowStateWrite;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current window bounds state for saving
|
||||
* @param {BrowserWindow} win - The window to get bounds from
|
||||
@@ -589,7 +630,7 @@ async function createWindow(electronModule, options) {
|
||||
if (saveStateTimer) clearTimeout(saveStateTimer);
|
||||
saveStateTimer = setTimeout(() => {
|
||||
const state = getWindowBoundsState(win, lastNormalBounds);
|
||||
if (state) saveWindowState(state);
|
||||
if (state) queueWindowStateSave(state);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
@@ -611,11 +652,33 @@ async function createWindow(electronModule, options) {
|
||||
});
|
||||
|
||||
// Save state when window is about to close
|
||||
win.on("close", () => {
|
||||
win.on("close", (event) => {
|
||||
if (windowStateCloseRequested) {
|
||||
return;
|
||||
}
|
||||
windowStateCloseRequested = true;
|
||||
if (saveStateTimer) clearTimeout(saveStateTimer);
|
||||
const state = getWindowBoundsState(win, lastNormalBounds);
|
||||
if (state) saveWindowState(state);
|
||||
// Close settings window when main window closes
|
||||
if (pendingWindowStateWrite) {
|
||||
event.preventDefault();
|
||||
if (state) queuedWindowState = state;
|
||||
pendingWindowStateWrite
|
||||
.catch(() => {
|
||||
// ignore async write errors before closing
|
||||
})
|
||||
.finally(() => {
|
||||
const finalState = getWindowBoundsState(win, lastNormalBounds);
|
||||
if (finalState) saveWindowStateSync(finalState);
|
||||
closeSettingsWindow();
|
||||
try {
|
||||
win.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (state) saveWindowStateSync(state);
|
||||
closeSettingsWindow();
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -266,22 +267,22 @@ let cloudSyncSessionPassword = null;
|
||||
const CLOUD_SYNC_PASSWORD_FILE = "netcatty_cloud_sync_master_password_v1";
|
||||
|
||||
// Key management helpers
|
||||
const ensureKeyDir = () => {
|
||||
const ensureKeyDir = async () => {
|
||||
try {
|
||||
fs.mkdirSync(keyRoot, { recursive: true, mode: 0o700 });
|
||||
await fs.promises.mkdir(keyRoot, { recursive: true, mode: 0o700 });
|
||||
} catch (err) {
|
||||
console.warn("Unable to ensure key cache dir", err);
|
||||
}
|
||||
};
|
||||
|
||||
const writeKeyToDisk = (keyId, privateKey) => {
|
||||
const writeKeyToDisk = async (keyId, privateKey) => {
|
||||
if (!privateKey) return null;
|
||||
ensureKeyDir();
|
||||
await ensureKeyDir();
|
||||
const filename = `${keyId || "temp"}.pem`;
|
||||
const target = path.join(keyRoot, filename);
|
||||
const normalized = privateKey.endsWith("\n") ? privateKey : `${privateKey}\n`;
|
||||
try {
|
||||
fs.writeFileSync(target, normalized, { mode: 0o600 });
|
||||
await fs.promises.writeFile(target, normalized, { mode: 0o600 });
|
||||
return target;
|
||||
} catch (err) {
|
||||
console.error("Failed to persist private key", err);
|
||||
@@ -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:`);
|
||||
|
||||
@@ -176,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) {
|
||||
@@ -217,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();
|
||||
|
||||
@@ -487,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"),
|
||||
@@ -631,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 }),
|
||||
|
||||
36
global.d.ts
vendored
36
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';
|
||||
@@ -173,6 +182,8 @@ declare global {
|
||||
privateKey?: string;
|
||||
command: string;
|
||||
timeout?: number;
|
||||
enableKeyboardInteractive?: boolean;
|
||||
sessionId?: string;
|
||||
}): Promise<{ stdout: string; stderr: string; code: number | null }>;
|
||||
/** Get current working directory from an active SSH session */
|
||||
getSessionPwd?(sessionId: string): Promise<{ success: boolean; cwd?: string; error?: string }>;
|
||||
@@ -299,6 +310,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
|
||||
@@ -505,6 +538,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 }>;
|
||||
|
||||
@@ -44,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';
|
||||
@@ -52,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';
|
||||
|
||||
@@ -26,11 +26,13 @@ import {
|
||||
type SyncHistoryEntry,
|
||||
type WebDAVConfig,
|
||||
type S3Config,
|
||||
type SyncedFile,
|
||||
SYNC_CONSTANTS,
|
||||
SYNC_STORAGE_KEYS,
|
||||
generateDeviceId,
|
||||
getDefaultDeviceName,
|
||||
} from '../../domain/sync';
|
||||
import packageJson from '../../package.json';
|
||||
import { EncryptionService } from './EncryptionService';
|
||||
import { createAdapter, type CloudAdapter } from './adapters';
|
||||
import type { GitHubAdapter } from './adapters/GitHubAdapter';
|
||||
@@ -795,6 +797,105 @@ export class CloudSyncManager {
|
||||
// Sync Operations
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Helper: Check for conflicts with a specific provider
|
||||
*/
|
||||
private async checkProviderConflict(
|
||||
provider: CloudProvider,
|
||||
adapter: CloudAdapter
|
||||
): Promise<{
|
||||
conflict: boolean;
|
||||
error?: string;
|
||||
remoteFile?: SyncedFile;
|
||||
}> {
|
||||
try {
|
||||
const remoteFile = await adapter.download();
|
||||
|
||||
if (remoteFile) {
|
||||
// Compare versions
|
||||
if (remoteFile.meta.updatedAt > this.state.localUpdatedAt) {
|
||||
return {
|
||||
conflict: true,
|
||||
remoteFile,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { conflict: false };
|
||||
} catch (error) {
|
||||
return { conflict: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Upload encrypted file to a provider
|
||||
*/
|
||||
private async uploadToProvider(
|
||||
provider: CloudProvider,
|
||||
adapter: CloudAdapter,
|
||||
syncedFile: SyncedFile
|
||||
): Promise<SyncResult> {
|
||||
try {
|
||||
await adapter.upload(syncedFile);
|
||||
|
||||
// Update local state (safe to do multiple times if values are same)
|
||||
this.state.localVersion = syncedFile.meta.version;
|
||||
this.state.localUpdatedAt = syncedFile.meta.updatedAt;
|
||||
this.state.remoteVersion = syncedFile.meta.version;
|
||||
this.state.remoteUpdatedAt = syncedFile.meta.updatedAt;
|
||||
this.state.providers[provider].lastSync = Date.now();
|
||||
this.state.providers[provider].lastSyncVersion = syncedFile.meta.version;
|
||||
|
||||
this.saveSyncConfig();
|
||||
this.saveProviderConnection(provider, this.state.providers[provider]);
|
||||
this.notifyStateChange();
|
||||
|
||||
// Add to sync history
|
||||
this.addSyncHistoryEntry({
|
||||
timestamp: Date.now(),
|
||||
provider,
|
||||
action: 'upload',
|
||||
success: true,
|
||||
localVersion: syncedFile.meta.version,
|
||||
remoteVersion: syncedFile.meta.version,
|
||||
deviceName: this.state.deviceName,
|
||||
});
|
||||
|
||||
this.updateProviderStatus(provider, 'connected');
|
||||
|
||||
const result: SyncResult = {
|
||||
success: true,
|
||||
provider,
|
||||
action: 'upload',
|
||||
version: syncedFile.meta.version,
|
||||
};
|
||||
|
||||
this.emit({ type: 'SYNC_COMPLETED', provider, result });
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.updateProviderStatus(provider, 'error', String(error));
|
||||
|
||||
// Add to sync history
|
||||
this.addSyncHistoryEntry({
|
||||
timestamp: Date.now(),
|
||||
provider,
|
||||
action: 'upload',
|
||||
success: false,
|
||||
localVersion: this.state.localVersion,
|
||||
deviceName: this.state.deviceName,
|
||||
error: String(error),
|
||||
});
|
||||
|
||||
this.emit({ type: 'SYNC_ERROR', provider, error: String(error) });
|
||||
|
||||
return {
|
||||
success: false,
|
||||
provider,
|
||||
action: 'none',
|
||||
error: String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build sync payload from current app state
|
||||
*/
|
||||
@@ -855,81 +956,61 @@ export class CloudSyncManager {
|
||||
this.emit({ type: 'SYNC_STARTED', provider });
|
||||
|
||||
try {
|
||||
// Check for remote version first
|
||||
const remoteFile = await adapter.download();
|
||||
// 1. Check for conflict
|
||||
const checkResult = await this.checkProviderConflict(provider, adapter);
|
||||
|
||||
if (remoteFile) {
|
||||
// Compare versions
|
||||
if (remoteFile.meta.updatedAt > this.state.localUpdatedAt) {
|
||||
// Remote is newer - conflict
|
||||
this.state.syncState = 'CONFLICT';
|
||||
this.state.currentConflict = {
|
||||
provider,
|
||||
localVersion: this.state.localVersion,
|
||||
localUpdatedAt: this.state.localUpdatedAt,
|
||||
localDeviceName: this.state.deviceName,
|
||||
remoteVersion: remoteFile.meta.version,
|
||||
remoteUpdatedAt: remoteFile.meta.updatedAt,
|
||||
remoteDeviceName: remoteFile.meta.deviceName,
|
||||
};
|
||||
|
||||
this.emit({ type: 'CONFLICT_DETECTED', conflict: this.state.currentConflict });
|
||||
|
||||
return {
|
||||
success: false,
|
||||
provider,
|
||||
action: 'none',
|
||||
conflictDetected: true,
|
||||
};
|
||||
}
|
||||
if (checkResult.error) {
|
||||
throw new Error(checkResult.error);
|
||||
}
|
||||
|
||||
// Encrypt and upload
|
||||
if (checkResult.conflict && checkResult.remoteFile) {
|
||||
const remoteFile = checkResult.remoteFile;
|
||||
// Remote is newer - conflict
|
||||
this.state.syncState = 'CONFLICT';
|
||||
this.state.currentConflict = {
|
||||
provider,
|
||||
localVersion: this.state.localVersion,
|
||||
localUpdatedAt: this.state.localUpdatedAt,
|
||||
localDeviceName: this.state.deviceName,
|
||||
remoteVersion: remoteFile.meta.version,
|
||||
remoteUpdatedAt: remoteFile.meta.updatedAt,
|
||||
remoteDeviceName: remoteFile.meta.deviceName,
|
||||
};
|
||||
|
||||
this.emit({
|
||||
type: 'CONFLICT_DETECTED',
|
||||
conflict: this.state.currentConflict,
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
provider,
|
||||
action: 'none',
|
||||
conflictDetected: true,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Encrypt
|
||||
const syncedFile = await EncryptionService.encryptPayload(
|
||||
payload,
|
||||
this.masterPassword,
|
||||
this.state.deviceId,
|
||||
this.state.deviceName,
|
||||
'1.0.0', // TODO: Get from package.json
|
||||
packageJson.version,
|
||||
this.state.localVersion
|
||||
);
|
||||
|
||||
await adapter.upload(syncedFile);
|
||||
// 3. Upload
|
||||
const result = await this.uploadToProvider(provider, adapter, syncedFile);
|
||||
|
||||
// Update local state
|
||||
this.state.localVersion = syncedFile.meta.version;
|
||||
this.state.localUpdatedAt = syncedFile.meta.updatedAt;
|
||||
this.state.remoteVersion = syncedFile.meta.version;
|
||||
this.state.remoteUpdatedAt = syncedFile.meta.updatedAt;
|
||||
this.state.providers[provider].lastSync = Date.now();
|
||||
this.state.providers[provider].lastSyncVersion = syncedFile.meta.version;
|
||||
|
||||
this.saveSyncConfig();
|
||||
this.saveProviderConnection(provider, this.state.providers[provider]);
|
||||
this.notifyStateChange(); // Notify UI immediately after version update
|
||||
|
||||
// Add to sync history
|
||||
this.addSyncHistoryEntry({
|
||||
timestamp: Date.now(),
|
||||
provider,
|
||||
action: 'upload',
|
||||
success: true,
|
||||
localVersion: syncedFile.meta.version,
|
||||
remoteVersion: syncedFile.meta.version,
|
||||
deviceName: this.state.deviceName,
|
||||
});
|
||||
|
||||
this.state.syncState = 'IDLE';
|
||||
this.updateProviderStatus(provider, 'connected');
|
||||
|
||||
const result: SyncResult = {
|
||||
success: true,
|
||||
provider,
|
||||
action: 'upload',
|
||||
version: syncedFile.meta.version,
|
||||
};
|
||||
|
||||
this.emit({ type: 'SYNC_COMPLETED', provider, result });
|
||||
if (result.success) {
|
||||
this.state.syncState = 'IDLE';
|
||||
} else {
|
||||
this.state.syncState = 'ERROR';
|
||||
if (result.error) {
|
||||
this.state.lastError = result.error;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
@@ -1050,20 +1131,178 @@ export class CloudSyncManager {
|
||||
return results;
|
||||
}
|
||||
|
||||
if (this.state.securityState !== 'UNLOCKED') {
|
||||
return results; // Or throw? Caller handles it.
|
||||
}
|
||||
|
||||
if (!this.masterPassword) {
|
||||
return results;
|
||||
}
|
||||
|
||||
const connectedProviders = Object.entries(this.state.providers)
|
||||
.filter(([_, conn]) => conn.status === 'connected')
|
||||
.map(([p]) => p as CloudProvider);
|
||||
|
||||
for (const provider of connectedProviders) {
|
||||
const result = await this.syncToProvider(provider, payload);
|
||||
results.set(provider, result);
|
||||
|
||||
// Stop on conflict
|
||||
if (result.conflictDetected) {
|
||||
break;
|
||||
}
|
||||
if (connectedProviders.length === 0) {
|
||||
return results;
|
||||
}
|
||||
|
||||
this.state.syncState = 'SYNCING';
|
||||
|
||||
// 1. Parallel Checks
|
||||
const checkTasks = connectedProviders.map(async (provider) => {
|
||||
try {
|
||||
// We handle connection error here to prevent one provider blocking others
|
||||
const adapter = await this.getConnectedAdapter(provider);
|
||||
this.updateProviderStatus(provider, 'syncing');
|
||||
this.emit({ type: 'SYNC_STARTED', provider });
|
||||
|
||||
const check = await this.checkProviderConflict(provider, adapter);
|
||||
return { provider, adapter, check };
|
||||
} catch (error) {
|
||||
return { provider, error: String(error) };
|
||||
}
|
||||
});
|
||||
|
||||
const checkResults = await Promise.all(checkTasks);
|
||||
|
||||
// 2. Analyze Results & Handle Conflicts
|
||||
const conflict = checkResults.find((r) => !r.error && r.check?.conflict);
|
||||
|
||||
if (conflict && conflict.check?.remoteFile) {
|
||||
const { provider, check } = conflict;
|
||||
const remoteFile = check.remoteFile!;
|
||||
|
||||
this.state.syncState = 'CONFLICT';
|
||||
this.state.currentConflict = {
|
||||
provider: provider as CloudProvider,
|
||||
localVersion: this.state.localVersion,
|
||||
localUpdatedAt: this.state.localUpdatedAt,
|
||||
localDeviceName: this.state.deviceName,
|
||||
remoteVersion: remoteFile.meta.version,
|
||||
remoteUpdatedAt: remoteFile.meta.updatedAt,
|
||||
remoteDeviceName: remoteFile.meta.deviceName,
|
||||
};
|
||||
|
||||
this.emit({
|
||||
type: 'CONFLICT_DETECTED',
|
||||
conflict: this.state.currentConflict,
|
||||
});
|
||||
|
||||
// Populate results
|
||||
for (const r of checkResults) {
|
||||
if (r.error) {
|
||||
results.set(r.provider as CloudProvider, {
|
||||
success: false,
|
||||
provider: r.provider as CloudProvider,
|
||||
action: 'none',
|
||||
error: r.error,
|
||||
});
|
||||
this.updateProviderStatus(r.provider as CloudProvider, 'error', r.error);
|
||||
this.emit({ type: 'SYNC_ERROR', provider: r.provider as CloudProvider, error: r.error });
|
||||
} else if (r.provider === provider) {
|
||||
results.set(provider as CloudProvider, {
|
||||
success: false,
|
||||
provider: provider as CloudProvider,
|
||||
action: 'none',
|
||||
conflictDetected: true,
|
||||
});
|
||||
} else {
|
||||
// Others are reset to connected
|
||||
this.updateProviderStatus(r.provider as CloudProvider, 'connected');
|
||||
results.set(r.provider as CloudProvider, {
|
||||
success: true, // Should we mark as success if skipped?
|
||||
provider: r.provider as CloudProvider,
|
||||
action: 'none',
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// 3. Encrypt Once
|
||||
const validUploads = checkResults.filter(
|
||||
(r) => !r.error && !r.check?.conflict && r.adapter
|
||||
) as { provider: CloudProvider; adapter: CloudAdapter }[];
|
||||
|
||||
if (validUploads.length === 0) {
|
||||
// Process errors if any
|
||||
checkResults.forEach((r) => {
|
||||
if (r.error) {
|
||||
results.set(r.provider as CloudProvider, {
|
||||
success: false,
|
||||
provider: r.provider as CloudProvider,
|
||||
action: 'none',
|
||||
error: r.error,
|
||||
});
|
||||
this.updateProviderStatus(r.provider as CloudProvider, 'error', r.error);
|
||||
this.emit({ type: 'SYNC_ERROR', provider: r.provider as CloudProvider, error: r.error });
|
||||
}
|
||||
});
|
||||
this.state.syncState = 'ERROR';
|
||||
return results;
|
||||
}
|
||||
|
||||
let syncedFile: SyncedFile;
|
||||
try {
|
||||
syncedFile = await EncryptionService.encryptPayload(
|
||||
payload,
|
||||
this.masterPassword,
|
||||
this.state.deviceId,
|
||||
this.state.deviceName,
|
||||
packageJson.version,
|
||||
this.state.localVersion
|
||||
);
|
||||
} catch (error) {
|
||||
const msg = String(error);
|
||||
this.state.syncState = 'ERROR';
|
||||
this.state.lastError = msg;
|
||||
|
||||
// Fail all
|
||||
for (const r of validUploads) {
|
||||
this.updateProviderStatus(r.provider, 'error', msg);
|
||||
this.emit({ type: 'SYNC_ERROR', provider: r.provider, error: msg });
|
||||
results.set(r.provider, {
|
||||
success: false,
|
||||
provider: r.provider,
|
||||
action: 'none',
|
||||
error: msg,
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// 4. Parallel Uploads
|
||||
const uploadTasks = validUploads.map(async ({ provider, adapter }) => {
|
||||
const result = await this.uploadToProvider(provider, adapter, syncedFile);
|
||||
results.set(provider, result);
|
||||
});
|
||||
|
||||
await Promise.all(uploadTasks);
|
||||
|
||||
// 5. Final State Update
|
||||
const hasSuccess = Array.from(results.values()).some((r) => r.success);
|
||||
if (hasSuccess) {
|
||||
this.state.syncState = 'IDLE';
|
||||
} else {
|
||||
this.state.syncState = 'ERROR';
|
||||
// lastError is set by uploadToProvider
|
||||
}
|
||||
|
||||
// Process errors from initial checks (if any)
|
||||
checkResults.forEach((r) => {
|
||||
if (r.error) {
|
||||
results.set(r.provider as CloudProvider, {
|
||||
success: false,
|
||||
provider: r.provider as CloudProvider,
|
||||
action: 'none',
|
||||
error: r.error,
|
||||
});
|
||||
this.updateProviderStatus(r.provider as CloudProvider, 'error', r.error);
|
||||
this.emit({ type: 'SYNC_ERROR', provider: r.provider as CloudProvider, error: r.error });
|
||||
}
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -1071,6 +1310,12 @@ export class CloudSyncManager {
|
||||
// Auto-Sync
|
||||
// ==========================================================================
|
||||
|
||||
setDeviceName(name: string): void {
|
||||
this.state.deviceName = name;
|
||||
this.saveToStorage(SYNC_STORAGE_KEYS.DEVICE_NAME, name);
|
||||
this.notifyStateChange();
|
||||
}
|
||||
|
||||
setAutoSync(enabled: boolean, intervalMinutes?: number): void {
|
||||
this.state.autoSyncEnabled = enabled;
|
||||
if (intervalMinutes) {
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -104,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;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -159,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;
|
||||
|
||||
@@ -167,7 +172,18 @@ export class UploadController {
|
||||
*/
|
||||
async cancel(): Promise<void> {
|
||||
this.cancelled = true;
|
||||
console.log('[UploadController] Cancelling uploads, active IDs:', Array.from(this.activeFileTransferIds));
|
||||
|
||||
// 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);
|
||||
@@ -175,16 +191,13 @@ export class UploadController {
|
||||
try {
|
||||
// Try cancelTransfer first (for stream transfers)
|
||||
if (this.bridge?.cancelTransfer) {
|
||||
console.log('[UploadController] Calling cancelTransfer for:', transferId);
|
||||
await this.bridge.cancelTransfer(transferId);
|
||||
}
|
||||
// Also try cancelSftpUpload (for legacy uploads)
|
||||
if (this.bridge?.cancelSftpUpload) {
|
||||
console.log('[UploadController] Calling cancelSftpUpload for:', transferId);
|
||||
await this.bridge.cancelSftpUpload(transferId);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('[UploadController] Cancel error:', e);
|
||||
} catch {
|
||||
// Ignore cancel errors
|
||||
}
|
||||
}
|
||||
@@ -193,15 +206,12 @@ export class UploadController {
|
||||
if (this.currentTransferId && !activeIds.includes(this.currentTransferId)) {
|
||||
try {
|
||||
if (this.bridge?.cancelTransfer) {
|
||||
console.log('[UploadController] Calling cancelTransfer for current:', this.currentTransferId);
|
||||
await this.bridge.cancelTransfer(this.currentTransferId);
|
||||
}
|
||||
if (this.bridge?.cancelSftpUpload) {
|
||||
console.log('[UploadController] Calling cancelSftpUpload for current:', this.currentTransferId);
|
||||
await this.bridge.cancelSftpUpload(this.currentTransferId);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('[UploadController] Cancel current error:', e);
|
||||
} catch {
|
||||
// Ignore cancel errors
|
||||
}
|
||||
}
|
||||
@@ -222,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];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -231,6 +243,7 @@ export class UploadController {
|
||||
reset(): void {
|
||||
this.cancelled = false;
|
||||
this.activeFileTransferIds.clear();
|
||||
this.activeCompressionIds.clear();
|
||||
this.currentTransferId = "";
|
||||
}
|
||||
|
||||
@@ -265,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);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -284,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) {
|
||||
@@ -307,6 +334,54 @@ 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);
|
||||
}
|
||||
|
||||
@@ -318,39 +393,82 @@ export async function uploadFromFileList(
|
||||
config: UploadConfig,
|
||||
controller?: UploadController
|
||||
): Promise<UploadResult[]> {
|
||||
console.log('[uploadFromFileList] Called with', fileList.length, 'files');
|
||||
const { targetPath, sftpId, isLocal, bridge, joinPath, callbacks } = config;
|
||||
console.log('[uploadFromFileList] Config:', { targetPath, sftpId, isLocal });
|
||||
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)
|
||||
// Use getPathForFile to get the local file path for stream transfer
|
||||
// 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);
|
||||
console.log('[uploadFromFileList] File:', { name: file.name, size: file.size, localPath });
|
||||
// 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: file.name,
|
||||
relativePath,
|
||||
isDirectory: false,
|
||||
};
|
||||
});
|
||||
|
||||
console.log('[uploadFromFileList] Created', entries.length, 'entries');
|
||||
|
||||
if (entries.length === 0) {
|
||||
console.log('[uploadFromFileList] No entries, returning empty');
|
||||
return [];
|
||||
}
|
||||
|
||||
console.log('[uploadFromFileList] Calling uploadEntries');
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -520,18 +638,8 @@ async function uploadEntries(
|
||||
// Check if file has a local path (Electron provides file.path for dropped files)
|
||||
const localFilePath = (entry.file as File & { path?: string }).path;
|
||||
|
||||
console.log('[UploadService] Processing file:', {
|
||||
relativePath: entry.relativePath,
|
||||
localFilePath,
|
||||
hasStreamTransfer: !!bridge.startStreamTransfer,
|
||||
sftpId,
|
||||
isLocal,
|
||||
fileSize: fileTotalBytes,
|
||||
});
|
||||
|
||||
// Use stream transfer if available and we have a local file path (avoids loading file into memory)
|
||||
if (localFilePath && bridge.startStreamTransfer && sftpId && !isLocal) {
|
||||
console.log('[UploadService] Using stream transfer for:', localFilePath);
|
||||
let pendingProgressUpdate: { transferred: number; total: number; speed: number } | null = null;
|
||||
let rafScheduled = false;
|
||||
|
||||
@@ -551,14 +659,19 @@ async function uploadEntries(
|
||||
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: progress.totalBytes > 0 ? (newTransferred / progress.totalBytes) * 100 : 0,
|
||||
percent: displayPercent,
|
||||
});
|
||||
}
|
||||
} else if (standaloneTransferId) {
|
||||
@@ -611,11 +724,6 @@ async function uploadEntries(
|
||||
}
|
||||
} else {
|
||||
// Fallback: load file into memory (for small files or when stream transfer is not available)
|
||||
console.log('[UploadService] FALLBACK: Loading file into memory:', {
|
||||
relativePath: entry.relativePath,
|
||||
fileSize: fileTotalBytes,
|
||||
reason: !localFilePath ? 'no local path' : !bridge.startStreamTransfer ? 'no stream transfer' : 'other',
|
||||
});
|
||||
const arrayBuffer = await entry.file.arrayBuffer();
|
||||
|
||||
if (isLocal) {
|
||||
@@ -647,11 +755,14 @@ async function uploadEntries(
|
||||
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: progress.totalBytes > 0 ? (newTransferred / progress.totalBytes) * 100 : 0,
|
||||
percent: displayPercent,
|
||||
});
|
||||
}
|
||||
} else if (standaloneTransferId) {
|
||||
@@ -679,8 +790,13 @@ async function uploadEntries(
|
||||
arrayBuffer,
|
||||
fileTransferId,
|
||||
onProgress,
|
||||
undefined,
|
||||
undefined
|
||||
() => {
|
||||
// File upload completed successfully
|
||||
},
|
||||
(error) => {
|
||||
// File upload failed - error is handled by the caller
|
||||
void error;
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
controller?.removeActiveTransfer(fileTransferId);
|
||||
@@ -710,6 +826,7 @@ async function uploadEntries(
|
||||
}
|
||||
}
|
||||
|
||||
// File processing completed (both stream transfer and fallback paths)
|
||||
controller?.clearCurrentTransfer();
|
||||
results.push({ fileName: entry.relativePath, success: true });
|
||||
|
||||
@@ -719,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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -797,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;
|
||||
}
|
||||
|
||||
53
package-lock.json
generated
53
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",
|
||||
@@ -3620,6 +3621,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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"types": [
|
||||
"node",
|
||||
"vite/client"
|
||||
|
||||
Reference in New Issue
Block a user