Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab3b2c2055 | ||
|
|
ca8691d53d | ||
|
|
dddaf1a1cd | ||
|
|
d2469f93c8 | ||
|
|
521c9141e9 | ||
|
|
d068cc99c8 | ||
|
|
a89130d732 | ||
|
|
dcea13172d | ||
|
|
0c420d99ed | ||
|
|
a3ffefa067 | ||
|
|
80b6485d02 | ||
|
|
884d643150 | ||
|
|
96fde364df | ||
|
|
920d829299 | ||
|
|
4f7d3bccc8 | ||
|
|
a3eb6ab26c | ||
|
|
c402c45e1d | ||
|
|
d4978f267a | ||
|
|
41ee8e7160 | ||
|
|
3d9f153fe9 | ||
|
|
4332537da0 | ||
|
|
4284b9a9dc | ||
|
|
3afe6ed489 | ||
|
|
1d06e48966 | ||
|
|
3ce2481753 | ||
|
|
ce61c28162 | ||
|
|
55463441f0 | ||
|
|
c17833ae31 | ||
|
|
56bab12b5c | ||
|
|
d1482e47d6 | ||
|
|
4ee3c63768 | ||
|
|
2b067a9aae | ||
|
|
2d4a3a5602 | ||
|
|
6c57ce7b28 | ||
|
|
6a2bd0a6a1 | ||
|
|
0c4900c73d | ||
|
|
3174e9ad27 | ||
|
|
f517c85d07 | ||
|
|
0b9e3c430d | ||
|
|
1c526e6965 | ||
|
|
70ff5299b6 | ||
|
|
3ef53faef5 | ||
|
|
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 | ||
|
|
e74f65729c | ||
|
|
97f53ed87f | ||
|
|
ec4512eb06 | ||
|
|
93c1f1b427 | ||
|
|
58ccd4bfb9 | ||
|
|
2fb82e1cb7 | ||
|
|
159589a09f | ||
|
|
04e1ed569d | ||
|
|
38fb5e8dd4 | ||
|
|
6f2b27206a | ||
|
|
f6eb693fac | ||
|
|
32935e4e87 | ||
|
|
f55c21fc0e | ||
|
|
26d03ace3f | ||
|
|
d85709d42d | ||
|
|
5470e19ae0 | ||
|
|
cd2c18b77c | ||
|
|
7355e29b89 | ||
|
|
64686cc237 | ||
|
|
d65440ace7 | ||
|
|
2dbeddd9aa |
No files matched your search
@@ -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\\)\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -33,3 +33,6 @@ coverage
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Claude Code local settings
|
||||
/.claude/settings.local.json
|
||||
@@ -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';
|
||||
@@ -21,6 +23,7 @@ import { Label } from './components/ui/label';
|
||||
import { ToastProvider, toast } from './components/ui/toast';
|
||||
import { VaultView, VaultSection } from './components/VaultView';
|
||||
import { KeyboardInteractiveModal, KeyboardInteractiveRequest } from './components/KeyboardInteractiveModal';
|
||||
import { PassphraseModal, PassphraseRequest } from './components/PassphraseModal';
|
||||
import { cn } from './lib/utils';
|
||||
import { ConnectionLog, Host, HostProtocol, SerialConfig, TerminalTheme } from './types';
|
||||
import { LogView as LogViewType } from './application/state/useSessionState';
|
||||
@@ -84,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 =
|
||||
@@ -148,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);
|
||||
@@ -155,6 +162,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const [navigateToSection, setNavigateToSection] = useState<VaultSection | null>(null);
|
||||
// Keyboard-interactive authentication queue (2FA/MFA) - queue-based to handle multiple concurrent sessions
|
||||
const [keyboardInteractiveQueue, setKeyboardInteractiveQueue] = useState<KeyboardInteractiveRequest[]>([]);
|
||||
// Passphrase request queue for encrypted SSH keys
|
||||
const [passphraseQueue, setPassphraseQueue] = useState<PassphraseRequest[]>([]);
|
||||
|
||||
const {
|
||||
theme,
|
||||
@@ -184,6 +193,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
knownHosts,
|
||||
shellHistory,
|
||||
connectionLogs,
|
||||
managedSources,
|
||||
updateHosts,
|
||||
updateKeys,
|
||||
updateIdentities,
|
||||
@@ -191,6 +201,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
updateSnippetPackages,
|
||||
updateCustomGroups,
|
||||
updateKnownHosts,
|
||||
updateManagedSources,
|
||||
addShellHistoryEntry,
|
||||
addConnectionLog,
|
||||
updateConnectionLog,
|
||||
@@ -226,6 +237,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
closeSession,
|
||||
closeWorkspace,
|
||||
updateSessionStatus,
|
||||
createWorkspaceWithHosts,
|
||||
createWorkspaceFromSessions,
|
||||
addSessionToWorkspace,
|
||||
updateSplitSizes,
|
||||
@@ -248,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, startTunnel, stopTunnel } = 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,
|
||||
@@ -255,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({
|
||||
@@ -265,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]);
|
||||
@@ -306,6 +342,126 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
keys: portForwardingKeys,
|
||||
});
|
||||
|
||||
// Sync tray menu data + handle tray actions
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.updateTrayMenuData) return;
|
||||
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (cancelled) return;
|
||||
|
||||
const sessionsForTray = sessions.map((s) => {
|
||||
const ws = s.workspaceId ? workspaces.find((w) => w.id === s.workspaceId) : undefined;
|
||||
return {
|
||||
id: s.id,
|
||||
label: s.hostname,
|
||||
hostLabel: s.hostLabel,
|
||||
status: s.status,
|
||||
workspaceId: s.workspaceId,
|
||||
workspaceTitle: ws?.title,
|
||||
};
|
||||
});
|
||||
|
||||
void bridge.updateTrayMenuData({
|
||||
sessions: sessionsForTray,
|
||||
portForwardRules: portForwardingRules,
|
||||
});
|
||||
}, 250);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [sessions, portForwardingRules, workspaces]);
|
||||
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onTrayFocusSession || !bridge?.onTrayTogglePortForward) return;
|
||||
|
||||
const unsubscribeFocus = bridge.onTrayFocusSession((sessionId) => {
|
||||
// Find the session to check if it belongs to a workspace
|
||||
const session = sessions.find((s) => s.id === sessionId);
|
||||
if (session?.workspaceId) {
|
||||
// Session is in a workspace - navigate to workspace and focus the session
|
||||
setActiveTabId(session.workspaceId);
|
||||
setWorkspaceFocusedSession(session.workspaceId, sessionId);
|
||||
} else {
|
||||
// Standalone session or session not found - just set tab
|
||||
setActiveTabId(sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
const unsubscribeToggle = bridge.onTrayTogglePortForward((ruleId, start) => {
|
||||
const rule = portForwardingRules.find((r) => r.id === ruleId);
|
||||
if (!rule) return;
|
||||
const host = rule.hostId ? hosts.find((h) => h.id === rule.hostId) : undefined;
|
||||
if (!host) {
|
||||
toast.error(t("pf.error.hostNotFound"));
|
||||
return;
|
||||
}
|
||||
|
||||
const keysForPf = keys.map((k) => ({ id: k.id, privateKey: k.privateKey }));
|
||||
if (start) {
|
||||
void startTunnel(rule, host, keysForPf, (status, error) => {
|
||||
if (status === "error" && error) toast.error(error);
|
||||
}, rule.autoStart);
|
||||
} else {
|
||||
void stopTunnel(ruleId);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeFocus?.();
|
||||
unsubscribeToggle?.();
|
||||
};
|
||||
}, [hosts, keys, portForwardingRules, sessions, setActiveTabId, setWorkspaceFocusedSession, startTunnel, stopTunnel, t]);
|
||||
|
||||
// Tray panel actions (from main process)
|
||||
useEffect(() => {
|
||||
const handlerJump = (sessionId: string) => {
|
||||
// Find the session to check if it belongs to a workspace
|
||||
const session = sessions.find((s) => s.id === sessionId);
|
||||
if (session?.workspaceId) {
|
||||
// Session is in a workspace - navigate to workspace and focus the session
|
||||
setActiveTabId(session.workspaceId);
|
||||
setWorkspaceFocusedSession(session.workspaceId, sessionId);
|
||||
} else {
|
||||
// Standalone session or session not found - just set tab
|
||||
setActiveTabId(sessionId);
|
||||
}
|
||||
};
|
||||
|
||||
const handlerConnect = (hostId: string) => {
|
||||
const host = hosts.find((h) => h.id === hostId);
|
||||
if (!host) {
|
||||
toast.error(t("pf.error.hostNotFound"));
|
||||
return;
|
||||
}
|
||||
|
||||
addConnectionLog({
|
||||
hostId: host.id,
|
||||
hostLabel: host.label,
|
||||
hostname: host.hostname,
|
||||
username: host.username,
|
||||
localHostname: "",
|
||||
saved: false,
|
||||
});
|
||||
|
||||
connectToHost(host);
|
||||
};
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onTrayPanelJumpToSession || !bridge?.onTrayPanelConnectToHost) return;
|
||||
|
||||
const unsubscribeJump = bridge.onTrayPanelJumpToSession(handlerJump);
|
||||
const unsubscribeConnect = bridge.onTrayPanelConnectToHost(handlerConnect);
|
||||
return () => {
|
||||
unsubscribeJump?.();
|
||||
unsubscribeConnect?.();
|
||||
};
|
||||
}, [addConnectionLog, connectToHost, hosts, sessions, setActiveTabId, setWorkspaceFocusedSession, t]);
|
||||
|
||||
// Keyboard-interactive authentication (2FA/MFA) event listener
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -349,6 +505,76 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
setKeyboardInteractiveQueue(prev => prev.filter(r => r.requestId !== requestId));
|
||||
}, []);
|
||||
|
||||
// Passphrase request event listener for encrypted SSH keys
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onPassphraseRequest) return;
|
||||
|
||||
const unsubscribe = bridge.onPassphraseRequest((request) => {
|
||||
console.log('[App] Passphrase request received:', request);
|
||||
setPassphraseQueue(prev => [...prev, {
|
||||
requestId: request.requestId,
|
||||
keyPath: request.keyPath,
|
||||
keyName: request.keyName,
|
||||
hostname: request.hostname,
|
||||
}]);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle passphrase submit
|
||||
const handlePassphraseSubmit = useCallback((requestId: string, passphrase: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.respondPassphrase) {
|
||||
void bridge.respondPassphrase(requestId, passphrase, false);
|
||||
}
|
||||
setPassphraseQueue(prev => prev.filter(r => r.requestId !== requestId));
|
||||
}, []);
|
||||
|
||||
// Handle passphrase cancel
|
||||
const handlePassphraseCancel = useCallback((requestId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.respondPassphrase) {
|
||||
// Cancel = stop the entire passphrase flow
|
||||
void bridge.respondPassphrase(requestId, '', true);
|
||||
}
|
||||
setPassphraseQueue(prev => prev.filter(r => r.requestId !== requestId));
|
||||
}, []);
|
||||
|
||||
// Handle passphrase skip (skip this key, continue with others)
|
||||
const handlePassphraseSkip = useCallback((requestId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.respondPassphraseSkip) {
|
||||
// Skip = skip this key but continue asking for others
|
||||
void bridge.respondPassphraseSkip(requestId);
|
||||
} else if (bridge?.respondPassphrase) {
|
||||
// Fallback for older API
|
||||
void bridge.respondPassphrase(requestId, '', false);
|
||||
}
|
||||
setPassphraseQueue(prev => prev.filter(r => r.requestId !== requestId));
|
||||
}, []);
|
||||
|
||||
// Handle passphrase timeout (request expired on backend)
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onPassphraseTimeout) return;
|
||||
|
||||
const unsubscribe = bridge.onPassphraseTimeout((event) => {
|
||||
console.log('[App] Passphrase request timed out:', event.requestId);
|
||||
// Remove from queue - the modal will close automatically
|
||||
setPassphraseQueue(prev => prev.filter(r => r.requestId !== event.requestId));
|
||||
// Show a toast notification to inform user
|
||||
toast.error('Passphrase request timed out. Please try connecting again.');
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Debounce ref for moveFocus to prevent double-triggering when focus switches
|
||||
const lastMoveFocusTimeRef = useRef<number>(0);
|
||||
const MOVE_FOCUS_DEBOUNCE_MS = 200;
|
||||
@@ -890,7 +1116,10 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
knownHosts={knownHosts}
|
||||
shellHistory={shellHistory}
|
||||
connectionLogs={connectionLogs}
|
||||
managedSources={managedSources}
|
||||
sessions={sessions}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
onOpenSettings={handleOpenSettings}
|
||||
onOpenQuickSwitcher={handleOpenQuickSwitcher}
|
||||
onCreateLocalTerminal={handleCreateLocalTerminal}
|
||||
@@ -904,6 +1133,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}
|
||||
@@ -994,8 +1227,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
setQuickSearch('');
|
||||
}}
|
||||
onCreateWorkspace={() => {
|
||||
// TODO: Implement workspace creation
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setIsCreateWorkspaceOpen(true);
|
||||
}}
|
||||
onClose={() => {
|
||||
setIsQuickSwitcherOpen(false);
|
||||
@@ -1060,6 +1293,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}>
|
||||
@@ -1083,6 +1327,14 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
{keyboardInteractiveQueue.length - 1} more pending
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Global Passphrase Modal for encrypted SSH keys */}
|
||||
<PassphraseModal
|
||||
request={passphraseQueue[0] || null}
|
||||
onSubmit={handlePassphraseSubmit}
|
||||
onCancel={handlePassphraseCancel}
|
||||
onSkip={handlePassphraseSkip}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,13 +11,13 @@
|
||||
|
||||
<p align="center">
|
||||
Electron、React、xterm.js で構築された機能豊富な SSH ワークスペース。<br/>
|
||||
ホスト管理、分割ターミナル、SFTP、ポートフォワーディング、クラウド同期 — すべてが一つに。
|
||||
分割ターミナル、Vault ビュー、SFTP ワークフロー、カスタムテーマ、キーワードハイライト — すべてが一つに。
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest"><img alt="GitHub Release" src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=Release"></a>
|
||||
|
||||
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows-blue?style=for-the-badge&logo=electron"></a>
|
||||
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows%20%7C%20Linux-blue?style=for-the-badge&logo=electron"></a>
|
||||
|
||||
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/License-GPL--3.0-green?style=for-the-badge"></a>
|
||||
</p>
|
||||
@@ -40,22 +40,20 @@
|
||||
|
||||
---
|
||||
|
||||
[](screenshots/vault_grid_view.png)
|
||||
[](screenshots/main-window-dark.png)
|
||||
|
||||
---
|
||||
|
||||
# 目次 <!-- omit in toc -->
|
||||
|
||||
- [Netcatty とは](#netcatty-とは)
|
||||
- [なぜ Netcatty](#なぜ-netcatty)
|
||||
- [機能](#機能)
|
||||
- [デモ](#デモ)
|
||||
- [スクリーンショット](#スクリーンショット)
|
||||
- [ホスト管理](#ホスト管理)
|
||||
- [ターミナル](#ターミナル)
|
||||
- [SFTP](#sftp)
|
||||
- [キーチェーン](#キーチェーン)
|
||||
- [ポートフォワーディング](#ポートフォワーディング)
|
||||
- [クラウド同期](#クラウド同期)
|
||||
- [テーマとカスタマイズ](#テーマとカスタマイズ)
|
||||
- [メインウィンドウ](#メインウィンドウ)
|
||||
- [Vault ビュー](#vault-ビュー)
|
||||
- [分割ターミナル](#分割ターミナル)
|
||||
- [対応ディストリビューション](#対応ディストリビューション)
|
||||
- [はじめに](#はじめに)
|
||||
- [ビルドとパッケージ](#ビルドとパッケージ)
|
||||
@@ -71,190 +69,119 @@
|
||||
**Netcatty** は、複数のリモートサーバーを効率的に管理する必要がある開発者、システム管理者、DevOps エンジニア向けに設計された、モダンなクロスプラットフォーム SSH クライアントおよびターミナルマネージャーです。
|
||||
|
||||
- **Netcatty は** PuTTY、Termius、SecureCRT、macOS Terminal.app の代替となる SSH 接続ツール
|
||||
- **Netcatty は** デュアルペインファイルブラウザを備えた強力な SFTP クライアント
|
||||
- **Netcatty は** 強力な SFTP クライアント(ドラッグ&ドロップ + 内蔵エディタ)
|
||||
- **Netcatty は** 分割ペイン、タブ、セッション管理を備えたターミナルワークスペース
|
||||
- **Netcatty は** シェルの代替ではありません — SSH/Telnet またはローカルターミナル経由でリモートシェルに接続します
|
||||
- **Netcatty は** シェルの代替ではありません — SSH/Telnet/Mosh やローカル/シリアル経由でシェルに接続します(環境により異なります)
|
||||
|
||||
---
|
||||
|
||||
<a name="なぜ-netcatty"></a>
|
||||
# なぜ Netcatty
|
||||
|
||||
複数サーバーを日常的に扱うなら、Netcatty は「スピード」と「流れ」を重視した作りになっています:
|
||||
|
||||
- **ワークスペース中心** — 分割ペインで複数セッションを並行操作
|
||||
- **Vault の見やすさ** — グリッド/リスト/ツリーで状況に合わせて切り替え
|
||||
- **SFTP の作業感** — ドラッグ&ドロップと内蔵エディタでサクッと編集
|
||||
|
||||
---
|
||||
|
||||
<a name="機能"></a>
|
||||
# 機能
|
||||
|
||||
### 🖥️ ターミナルとセッション
|
||||
- **xterm.js ベースのターミナル**、GPU アクセラレーションレンダリング対応
|
||||
### 🗂️ Vault
|
||||
- **複数ビュー** — グリッド / リスト / ツリー
|
||||
- **高速検索** — ホストやグループを素早く見つける
|
||||
|
||||
### 🖥️ ターミナルワークスペース
|
||||
- **分割ペイン** — 水平・垂直分割でマルチタスク
|
||||
- **タブ管理** — ドラッグ&ドロップで並べ替え可能な複数セッション
|
||||
- **セッション永続化** — 再起動後もセッションを復元
|
||||
- **ブロードキャストモード** — 一度の入力で複数のターミナルに送信
|
||||
- **セッション管理** — 複数の接続を並行して扱う
|
||||
|
||||
### 🔐 SSH クライアント
|
||||
- **SSH2 プロトコル**、完全な認証サポート
|
||||
- **パスワード&キー認証**
|
||||
- **SSH 証明書**サポート
|
||||
- **ジャンプホスト / 踏み台サーバー** — 複数ホストを経由した接続
|
||||
- **プロキシサポート** — HTTP CONNECT および SOCKS5 プロキシ
|
||||
- **エージェント転送** — OpenSSH Agent および Pageant 対応
|
||||
- **環境変数** — ホストごとにカスタム環境変数を設定
|
||||
### 📁 SFTP + 内蔵エディタ
|
||||
- **ファイル作業** — ドラッグ&ドロップでアップロード/ダウンロード
|
||||
- **その場で編集** — 内蔵エディタで小さな修正を素早く
|
||||
|
||||
### 📁 SFTP
|
||||
- **デュアルペインファイルブラウザ** — ローカル ↔ リモート または リモート ↔ リモート
|
||||
- **Sudo 特権昇格** — sudo を使用して root 権限のファイルを閲覧および編集
|
||||
- **ドラッグ&ドロップ** アップロードおよびダウンロード
|
||||
- **ドラッグ&ドロップ**ファイル転送
|
||||
- **キュー管理**でバッチ転送
|
||||
- **進捗追跡**、転送速度表示
|
||||
### 🎨 パーソナライズ
|
||||
- **カスタムテーマ** — UI の見た目を好みに調整
|
||||
- **キーワードハイライト** — ターミナル出力の強調表示ルールをカスタマイズ
|
||||
|
||||
### 🔑 キーチェーン
|
||||
- **SSH キー生成** — RSA、ECDSA、ED25519
|
||||
- **既存キーのインポート** — PEM、OpenSSH 形式
|
||||
- **SSH 証明書**サポート
|
||||
- **アイデンティティ管理** — 再利用可能なユーザー名+認証方式の組み合わせ
|
||||
- **公開鍵をエクスポート**してリモートホストへ
|
||||
---
|
||||
|
||||
### 🔌 ポートフォワーディング
|
||||
- **ローカルフォワーディング** — リモートサービスをローカルに公開
|
||||
- **リモートフォワーディング** — ローカルサービスをリモートに公開
|
||||
- **ダイナミックフォワーディング** — SOCKS5 プロキシ
|
||||
- **ビジュアルトンネル管理**
|
||||
<a name="デモ"></a>
|
||||
# デモ
|
||||
|
||||
### ☁️ クラウド同期
|
||||
- **エンドツーエンド暗号化同期** — デバイスを離れる前にデータを暗号化
|
||||
- **複数のプロバイダー** — GitHub Gist、S3 互換ストレージ、WebDAV、Google Drive、OneDrive
|
||||
- **ホスト、キー、スニペット、設定を同期**
|
||||
GIF で機能をさっと確認できます(素材は `screenshots/gifs/`):
|
||||
|
||||
### 🎨 テーマとカスタマイズ
|
||||
- **ライト&ダークモード**
|
||||
- **カスタムアクセントカラー**
|
||||
- **50以上のターミナル配色**
|
||||
- **フォントカスタマイズ** — JetBrains Mono、Fira Code など
|
||||
- **多言語対応** — English、简体中文 など
|
||||
### Vault ビュー:グリッド / リスト / ツリー
|
||||
状況に合わせて見え方を切り替え。グリッドで全体像、リストで密度、ツリーで階層を扱えます。
|
||||
|
||||

|
||||
|
||||
### 分割ターミナル + セッション管理
|
||||
複数セッションを分割ペインで並べて作業。関連タスクを横並びにしてコンテキストスイッチを減らします。
|
||||
|
||||

|
||||
|
||||
### SFTP:ドラッグ&ドロップ + 内蔵エディタ
|
||||
ドラッグ&ドロップでファイルを移動し、内蔵エディタでそのまま編集できます。
|
||||
|
||||

|
||||
|
||||
### ドラッグでアップロード
|
||||
ファイルをそのままドロップしてアップロードを開始。ダイアログ操作を減らせます。
|
||||
|
||||

|
||||
|
||||
### カスタムテーマ
|
||||
テーマを調整して自分の好みに合わせた見た目に。
|
||||
|
||||

|
||||
|
||||
### キーワードハイライト
|
||||
重要な出力(エラー/警告/マーカーなど)を見つけやすくするために、ハイライトをカスタマイズできます。
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
<a name="スクリーンショット"></a>
|
||||
# スクリーンショット
|
||||
|
||||
<a name="ホスト管理"></a>
|
||||
## ホスト管理
|
||||
<a name="メインウィンドウ"></a>
|
||||
## メインウィンドウ
|
||||
|
||||
Vault ビューはすべての SSH 接続を管理するコマンドセンターです。右クリックメニューで階層的なグループを作成し、グループ間でホストをドラッグ、パンくずナビゲーションでホストツリーを素早く移動できます。各ホストは接続状態、OS アイコン、クイック接続ボタンを表示。グリッドとリストビューを切り替え、強力な検索で名前、ホスト名、タグ、グループでフィルタリングできます。
|
||||
メインウィンドウは、長時間の SSH 作業を前提に設計されています。セッション、ナビゲーション、主要ツールへ素早くアクセスできます。
|
||||
|
||||
**ダークモード**
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
**ネストされたフォルダと整理**
|
||||
<a name="vault-ビュー"></a>
|
||||
## Vault ビュー
|
||||
|
||||

|
||||
作業に合わせて見え方を切り替え:グリッドで全体像、リストでスキャン、ツリーで整理と階層ナビゲーション。
|
||||
|
||||
**リストビュー**
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
<a name="ターミナル"></a>
|
||||
## ターミナル
|
||||

|
||||
|
||||
WebGL アクセラレーション対応の xterm.js ベースのターミナルで、スムーズでレスポンシブな体験を提供。ワークスペースを水平または垂直に分割して、複数のセッションを同時に監視。ブロードキャストモードを有効にすると、すべてのターミナルに一度にコマンドを送信できます — フリート管理に最適。テーマカスタマイズパネルでは、50以上の配色スキームをライブプレビュー、フォントサイズの調整、JetBrains Mono や Fira Code を含む複数のフォントファミリーを選択できます。
|
||||

|
||||
|
||||
**分割ウィンドウ**
|
||||
<a name="分割ターミナル"></a>
|
||||
## 分割ターミナル
|
||||
|
||||
**ブロードキャストモード**
|
||||
分割ペインで複数のサーバー/タスクを同時に扱えます(例:デプロイ + ログ + 監視)。
|
||||
|
||||
一度入力すれば、どこでも実行できます。複数のサーバーを同時にメンテナンスするのに最適です。
|
||||
|
||||

|
||||
|
||||
**パフォーマンス情報とカスタマイズ**
|
||||
|
||||
接続の健全性を監視し、ターミナルのあらゆる側面をカスタマイズします。
|
||||
|
||||

|
||||
|
||||
<a name="sftp"></a>
|
||||
## SFTP
|
||||
|
||||
デュアルペイン SFTP ブラウザは、ローカルからリモート、リモートからリモートへのファイル転送をサポート。シングルクリックでディレクトリを移動、ペイン間でファイルをドラッグ&ドロップ、転送進捗をリアルタイムで監視。インターフェースにはファイル権限、サイズ、変更日時を表示。複数の転送をキューに入れ、詳細な速度と進捗インジケーターで完了を確認。コンテキストメニューから名前変更、削除、ダウンロード、アップロード操作にすばやくアクセス。
|
||||
|
||||

|
||||
|
||||
**転送キュー**
|
||||
|
||||

|
||||
|
||||
<a name="キーチェーン"></a>
|
||||
## キーチェーン
|
||||
|
||||
キーチェーンは SSH 認証情報を保管する安全な保管庫です。新しいキーを生成、既存のキーをインポート、エンタープライズ認証用の SSH 証明書を管理できます。
|
||||
|
||||
| キータイプ | アルゴリズム | 推奨用途 |
|
||||
|----------|------------|---------|
|
||||
| **ED25519** | EdDSA | モダン、高速、最も安全(推奨) |
|
||||
| **ECDSA** | NIST P-256/384/521 | 高いセキュリティ、広くサポート |
|
||||
| **RSA** | RSA 2048/4096 | レガシー互換性、ユニバーサルサポート |
|
||||
| **証明書** | CA 署名 | エンタープライズ環境、短期認証 |
|
||||
|
||||
**機能:**
|
||||
- 🔑 カスタマイズ可能なビット長でキーを生成
|
||||
- 📥 PEM/OpenSSH 形式のキーをインポート
|
||||
- 👤 再利用可能なアイデンティティを作成(ユーザー名+認証方式)
|
||||
- 📤 ワンクリックで公開鍵をリモートホストにエクスポート
|
||||
|
||||

|
||||
|
||||
**キー生成**
|
||||
|
||||

|
||||
|
||||
<a name="ポートフォワーディング"></a>
|
||||
## ポートフォワーディング
|
||||
|
||||
直感的なビジュアルインターフェースで SSH トンネルをセットアップ。各トンネルはリアルタイムステータスを表示し、アクティブ、接続中、エラー状態を明確に示します。トンネル設定を保存してセッション間で素早く再利用。
|
||||
|
||||
| タイプ | 方向 | ユースケース | 例 |
|
||||
|-------|-----|------------|---|
|
||||
| **ローカル** | リモート → ローカル | リモートサービスをローカルマシンでアクセス | リモート MySQL `3306` を `localhost:3306` に転送 |
|
||||
| **リモート** | ローカル → リモート | ローカルサービスをリモートサーバーと共有 | ローカル開発サーバーをリモートマシンに公開 |
|
||||
| **ダイナミック** | SOCKS5 プロキシ | SSH トンネル経由で安全にブラウジング | 暗号化された SSH 接続経由でインターネットをブラウズ |
|
||||
|
||||

|
||||
|
||||
<a name="クラウド同期"></a>
|
||||
## クラウド同期
|
||||
|
||||
エンドツーエンド暗号化で、すべてのデバイス間でホスト、キー、スニペット、設定を同期。マスターパスワードがアップロード前にすべてのデータをローカルで暗号化 — クラウドプロバイダーは平文を見ることはありません。
|
||||
|
||||
| プロバイダー | 最適な用途 | セットアップ複雑度 |
|
||||
|------------|----------|-----------------|
|
||||
| **GitHub Gist** | クイックセットアップ、バージョン履歴 | ⭐ 簡単 |
|
||||
| **Google Drive** | 個人利用、大容量ストレージ | ⭐ 簡単 |
|
||||
| **OneDrive** | Microsoft エコシステムユーザー | ⭐ 簡単 |
|
||||
| **S3 互換** | AWS、MinIO、Cloudflare R2、セルフホスト | ⭐⭐ 中程度 |
|
||||
| **WebDAV** | Nextcloud、ownCloud、セルフホスト | ⭐⭐ 中程度 |
|
||||
|
||||
**同期対象:**
|
||||
- ✅ ホストと接続設定
|
||||
- ✅ SSH キーと証明書
|
||||
- ✅ アイデンティティと認証情報
|
||||
- ✅ スニペットとスクリプト
|
||||
- ✅ カスタムグループとタグ
|
||||
- ✅ ポートフォワーディングルール
|
||||
- ✅ アプリケーション設定
|
||||
|
||||

|
||||
|
||||
<a name="テーマとカスタマイズ"></a>
|
||||
## テーマとカスタマイズ
|
||||
|
||||
Netcatty を自分だけのものに。ライトモードとダークモードを切り替えたり、システム設定に従わせたり。好みに合わせてアクセントカラーを選択。アプリケーションは English や简体中文を含む複数の言語をサポートしており、コミュニティによる翻訳貢献を歓迎しています。クラウド同期を有効にすると、すべての設定がデバイス間で同期され、パーソナライズされた体験がどこでも利用できます。
|
||||
|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
<a name="対応ディストリビューション"></a>
|
||||
# 対応ディストリビューション
|
||||
|
||||
Netcatty は接続したホストの OS アイコンを自動的に検出・表示します:
|
||||
Netcatty は接続したホストの OS を検出し、ホスト一覧でアイコンとして表示します:
|
||||
|
||||
<p align="center">
|
||||
<img src="public/distro/ubuntu.svg" width="48" alt="Ubuntu" title="Ubuntu">
|
||||
@@ -346,7 +273,7 @@ npm run pack
|
||||
# 特定のプラットフォーム用にパッケージ
|
||||
npm run pack:mac # macOS (DMG + ZIP)
|
||||
npm run pack:win # Windows (NSIS インストーラー)
|
||||
npm run pack:linux # Linux (AppImage, deb, rpm)
|
||||
npm run pack:linux # Linux (AppImage + DEB + RPM)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -356,7 +283,7 @@ npm run pack:linux # Linux (AppImage, deb, rpm)
|
||||
|
||||
| カテゴリ | テクノロジー |
|
||||
|--------|------------|
|
||||
| フレームワーク | Electron 39 |
|
||||
| フレームワーク | Electron 40 |
|
||||
| フロントエンド | React 19, TypeScript |
|
||||
| ビルドツール | Vite 7 |
|
||||
| ターミナル | xterm.js 5 |
|
||||
@@ -382,17 +309,6 @@ npm run pack:linux # Linux (AppImage, deb, rpm)
|
||||
|
||||
---
|
||||
|
||||
<a name="コントリビューター"></a>
|
||||
# コントリビューター
|
||||
|
||||
貢献してくれたすべての人々に感謝します!
|
||||
|
||||
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" alt="contributors" />
|
||||
</a>
|
||||
|
||||
---
|
||||
|
||||
<a name="ライセンス"></a>
|
||||
# ライセンス
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
<p align="center">
|
||||
A beautiful, feature-rich SSH workspace built with Electron, React, and xterm.js.<br/>
|
||||
Host management, split terminals, SFTP, port forwarding, and cloud sync — all in one.
|
||||
Split terminals, Vault views, SFTP workflows, custom themes, and keyword highlighting — all in one.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -40,22 +40,20 @@
|
||||
|
||||
---
|
||||
|
||||
[](screenshots/vault_grid_view.png)
|
||||
[](screenshots/main-window-dark.png)
|
||||
|
||||
---
|
||||
|
||||
# Contents <!-- omit in toc -->
|
||||
|
||||
- [What is Netcatty](#what-is-netcatty)
|
||||
- [Why Netcatty](#why-netcatty)
|
||||
- [Features](#features)
|
||||
- [Demos](#demos)
|
||||
- [Screenshots](#screenshots)
|
||||
- [Host Management](#host-management)
|
||||
- [Terminal](#terminal)
|
||||
- [SFTP](#sftp)
|
||||
- [Keychain](#keychain)
|
||||
- [Port Forwarding](#port-forwarding)
|
||||
- [Cloud Sync](#cloud-sync)
|
||||
- [Themes & Customization](#themes--customization)
|
||||
- [Main Window](#main-window)
|
||||
- [Vault Views](#vault-views)
|
||||
- [Split Terminals](#split-terminals)
|
||||
- [Supported Distros](#supported-distros)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Build & Package](#build--package)
|
||||
@@ -73,180 +71,111 @@
|
||||
- **Netcatty is** an alternative to PuTTY, Termius, SecureCRT, and macOS Terminal.app for SSH connections
|
||||
- **Netcatty is** a powerful SFTP client with dual-pane file browser
|
||||
- **Netcatty is** a terminal workspace with split panes, tabs, and session management
|
||||
- **Netcatty is not** a shell replacement — it connects to remote shells via SSH/Telnet or local terminals
|
||||
- **Netcatty supports** SSH, local terminal, Telnet, Mosh, and Serial connections (when available)
|
||||
- **Netcatty is not** a shell replacement — it connects to shells via SSH/Telnet/Mosh or local/serial sessions
|
||||
|
||||
---
|
||||
|
||||
<a name="why-netcatty"></a>
|
||||
# Why Netcatty
|
||||
|
||||
If you regularly work with a fleet of servers, Netcatty is built for speed and flow:
|
||||
|
||||
- **Workspace-first** — split panes + tabs + session restore for “always-on” workflows
|
||||
- **Vault organization** — grid/list/tree views with fast search and drag-friendly workflows
|
||||
- **Serious SFTP** — built-in editor + drag & drop + smooth file operations
|
||||
|
||||
---
|
||||
|
||||
<a name="features"></a>
|
||||
# Features
|
||||
|
||||
### 🖥️ Terminal & Sessions
|
||||
- **xterm.js-based terminal** with GPU-accelerated rendering
|
||||
### 🗂️ Vault
|
||||
- **Multiple views** — grid / list / tree
|
||||
- **Fast search** — locate hosts and groups quickly
|
||||
|
||||
### 🖥️ Terminal Workspaces
|
||||
- **Split panes** — horizontal and vertical splits for multi-tasking
|
||||
- **Tab management** — multiple sessions with drag-to-reorder
|
||||
- **Session persistence** — restore sessions on restart
|
||||
- **Broadcast mode** — type once, send to multiple terminals
|
||||
- **Session management** — run multiple connections side-by-side
|
||||
|
||||
### 🔐 SSH Client
|
||||
- **SSH2 protocol** with full authentication support
|
||||
- **Password & key-based authentication**
|
||||
- **SSH certificates** support
|
||||
- **Jump hosts / Bastion** — chain through multiple hosts
|
||||
- **Proxy support** — HTTP CONNECT and SOCKS5 proxies
|
||||
- **Agent forwarding** — including OpenSSH Agent and Pageant
|
||||
- **Environment variables** — set custom env vars per host
|
||||
### 📁 SFTP + Built-in Editor
|
||||
- **File workflows** — drag & drop uploads/downloads
|
||||
- **Edit in place** — built-in editor for quick changes
|
||||
|
||||
### 📁 SFTP
|
||||
- **Dual-pane file browser** — local ↔ remote or remote ↔ remote
|
||||
- **Sudo Privilege Escalation** — Browse and edit root-owned files with sudo
|
||||
- **Drag & Drop** uploads and downloads
|
||||
- **Queue management** for batch transfers
|
||||
- **Progress tracking** with transfer speed
|
||||
### 🎨 Personalization
|
||||
- **Custom themes** — tune the app appearance to your taste
|
||||
- **Keyword highlighting** — customize highlight rules for terminal output
|
||||
|
||||
### 🔑 Keychain
|
||||
- **Generate SSH keys** — RSA, ECDSA, ED25519
|
||||
- **Import existing keys** — PEM, OpenSSH formats
|
||||
- **SSH certificates** support
|
||||
- **Identity management** — reusable username + auth combinations
|
||||
- **Export public keys** to remote hosts
|
||||
---
|
||||
|
||||
### 🔌 Port Forwarding
|
||||
- **Local forwarding** — expose remote services locally
|
||||
- **Remote forwarding** — expose local services remotely
|
||||
- **Dynamic forwarding** — SOCKS5 proxy
|
||||
- **Visual tunnel management**
|
||||
<a name="demos"></a>
|
||||
# Demos
|
||||
|
||||
### ☁️ Cloud Sync
|
||||
- **End-to-end encrypted sync** — your data is encrypted before leaving your device
|
||||
- **Multiple providers** — GitHub Gist, S3-compatible storage, WebDAV, Google Drive, OneDrive
|
||||
- **Sync hosts, keys, snippets, and settings**
|
||||
GIF previews (stored in `screenshots/gifs/`), rendered inline on GitHub:
|
||||
|
||||
### 🎨 Themes & Customization
|
||||
- **Light & Dark mode**
|
||||
- **Custom accent colors**
|
||||
- **50+ terminal color schemes**
|
||||
- **Font customization** — JetBrains Mono, Fira Code, and more
|
||||
- **i18n support** — English, 简体中文, and more
|
||||
### Vault views: grid / list / tree
|
||||
Switch between different Vault views to match your workflow: overview in grid, dense scanning in list, and hierarchical navigation in tree.
|
||||
|
||||

|
||||
|
||||
### Split terminals + session management
|
||||
Work in multiple sessions at once with split panes. Keep related tasks side-by-side and reduce context switching.
|
||||
|
||||

|
||||
|
||||
### SFTP: drag & drop + built-in editor
|
||||
Move files with drag & drop, then edit quickly using the built-in editor without leaving the app.
|
||||
|
||||

|
||||
|
||||
### Drag file upload
|
||||
Drop files into the app to kick off uploads without hunting through dialogs.
|
||||
|
||||

|
||||
|
||||
### Custom themes
|
||||
Make Netcatty yours: customize themes and UI appearance.
|
||||
|
||||

|
||||
|
||||
### Keyword highlighting
|
||||
Highlight important terminal output so errors, warnings, and key events stand out at a glance.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
<a name="screenshots"></a>
|
||||
# Screenshots
|
||||
|
||||
<a name="host-management"></a>
|
||||
## Host Management
|
||||
<a name="main-window"></a>
|
||||
## Main Window
|
||||
|
||||
The Vault view is your command center for managing all SSH connections. Create hierarchical groups with right-click context menus, drag hosts between groups, and use breadcrumb navigation to quickly traverse your host tree. Each host displays its connection status, OS icon, and quick-connect button. Switch between grid and list views based on your preference, and use the powerful search to filter hosts by name, hostname, tags, or group.
|
||||
The main window is designed for long-running SSH workflows: quick access to sessions, navigation, and core tools in one place.
|
||||
|
||||
**Dark Mode**
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
**Nested Folders & Organization**
|
||||
<a name="vault-views"></a>
|
||||
## Vault Views
|
||||
|
||||

|
||||
Organize and navigate your hosts using the view that best fits the moment: grid for overview, list for scanning, tree for structure.
|
||||
|
||||
**List View**
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
<a name="terminal"></a>
|
||||
## Terminal
|
||||

|
||||
|
||||
Powered by xterm.js with WebGL acceleration, the terminal delivers a smooth, responsive experience. Split your workspace horizontally or vertically to monitor multiple sessions simultaneously. Enable broadcast mode to send commands to all terminals at once — perfect for fleet management. The theme customization panel offers 50+ color schemes with live preview, adjustable font size, and multiple font family options including JetBrains Mono and Fira Code.
|
||||

|
||||
|
||||
**Split Windows**
|
||||
<a name="split-terminals"></a>
|
||||
## Split Terminals
|
||||
|
||||
**Broadcast Mode**
|
||||
Split panes help you monitor multiple servers/services at the same time (deploy + logs + metrics) without juggling windows.
|
||||
|
||||
Type once, execute everywhere. Great for maintaining multiple servers simultaneously.
|
||||
|
||||

|
||||
|
||||
**Performance Info & Customization**
|
||||
|
||||
Monitor your connection health and customize every aspect of your terminal.
|
||||
|
||||

|
||||
|
||||
<a name="sftp"></a>
|
||||
## SFTP
|
||||
|
||||
The dual-pane SFTP browser supports local-to-remote and remote-to-remote file transfers. Navigate directories with single-click, drag files between panes, and monitor transfer progress in real-time. The interface shows file permissions, sizes, and modification dates. Queue multiple transfers and watch them complete with detailed speed and progress indicators. Context menus provide quick access to rename, delete, download, and upload operations.
|
||||
|
||||

|
||||
|
||||
**Transfer Queue**
|
||||
|
||||

|
||||
|
||||
<a name="keychain"></a>
|
||||
## Keychain
|
||||
|
||||
The Keychain is your secure vault for SSH credentials. Generate new keys, import existing ones, or manage SSH certificates for enterprise authentication.
|
||||
|
||||
| Key Type | Algorithm | Recommended Use |
|
||||
|----------|-----------|----------------|
|
||||
| **ED25519** | EdDSA | Modern, fast, most secure (recommended) |
|
||||
| **ECDSA** | NIST P-256/384/521 | Good security, widely supported |
|
||||
| **RSA** | RSA 2048/4096 | Legacy compatibility, universal support |
|
||||
| **Certificate** | CA-signed | Enterprise environments, short-lived auth |
|
||||
|
||||
**Features:**
|
||||
- 🔑 Generate keys with customizable bit lengths
|
||||
- 📥 Import PEM/OpenSSH format keys
|
||||
- 👤 Create reusable identities (username + auth method)
|
||||
- 📤 One-click export public keys to remote hosts
|
||||
|
||||

|
||||
|
||||
**Key Generator**
|
||||
|
||||

|
||||
|
||||
<a name="port-forwarding"></a>
|
||||
## Port Forwarding
|
||||
|
||||
Set up SSH tunnels with an intuitive visual interface. Each tunnel shows real-time status with clear indicators for active, connecting, or error states. Save tunnel configurations for quick reuse across sessions.
|
||||
|
||||
| Type | Direction | Use Case | Example |
|
||||
|------|-----------|----------|--------|
|
||||
| **Local** | Remote → Local | Access remote services on your machine | Forward remote MySQL `3306` to `localhost:3306` |
|
||||
| **Remote** | Local → Remote | Share local services with remote server | Expose local dev server to remote machine |
|
||||
| **Dynamic** | SOCKS5 Proxy | Secure browsing through SSH tunnel | Browse internet via encrypted SSH connection |
|
||||
|
||||

|
||||
|
||||
<a name="cloud-sync"></a>
|
||||
## Cloud Sync
|
||||
|
||||
Keep your hosts, keys, snippets, and settings synchronized across all your devices with end-to-end encryption. Your master password encrypts all data locally before upload — the cloud provider never sees plaintext.
|
||||
|
||||
| Provider | Best For | Setup Complexity |
|
||||
|----------|----------|------------------|
|
||||
| **GitHub Gist** | Quick setup, version history | ⭐ Easy |
|
||||
| **Google Drive** | Personal use, large storage | ⭐ Easy |
|
||||
| **OneDrive** | Microsoft ecosystem users | ⭐ Easy |
|
||||
| **S3-Compatible** | AWS, MinIO, Cloudflare R2, self-hosted | ⭐⭐ Medium |
|
||||
| **WebDAV** | Nextcloud, ownCloud, self-hosted | ⭐⭐ Medium |
|
||||
|
||||
**What syncs:**
|
||||
- ✅ Hosts & connection settings
|
||||
- ✅ SSH keys & certificates
|
||||
- ✅ Identities & credentials
|
||||
- ✅ Snippets & scripts
|
||||
- ✅ Custom groups & tags
|
||||
- ✅ Port forwarding rules
|
||||
- ✅ Application preferences
|
||||
|
||||

|
||||
|
||||
<a name="themes--customization"></a>
|
||||
## Themes & Customization
|
||||
|
||||
Make Netcatty truly yours with extensive customization options. Toggle between light and dark modes, or let the app follow your system preference. Pick any accent color to match your style. The application supports multiple languages including English and 简体中文, with more translations welcome via community contributions. All preferences sync across devices when cloud sync is enabled, so your personalized experience follows you everywhere.
|
||||
|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
@@ -270,8 +199,6 @@ Netcatty automatically detects and displays OS icons for connected hosts:
|
||||
<img src="public/distro/kali.svg" width="48" alt="Kali Linux" title="Kali Linux">
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
<a name="getting-started"></a>
|
||||
# Getting Started
|
||||
|
||||
@@ -355,7 +282,7 @@ npm run pack:linux # Linux (AppImage + DEB + RPM)
|
||||
|
||||
| Category | Technology |
|
||||
|----------|------------|
|
||||
| Framework | Electron 39 |
|
||||
| Framework | Electron 40 |
|
||||
| Frontend | React 19, TypeScript |
|
||||
| Build Tool | Vite 7 |
|
||||
| Terminal | xterm.js 5 |
|
||||
@@ -386,9 +313,7 @@ See [agents.md](agents.md) for architecture overview and coding conventions.
|
||||
|
||||
Thanks to all the people who contribute!
|
||||
|
||||
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" alt="contributors" />
|
||||
</a>
|
||||
See: https://github.com/binaricat/Netcatty/graphs/contributors
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -11,13 +11,13 @@
|
||||
|
||||
<p align="center">
|
||||
一个基于 Electron、React 和 xterm.js 构建的功能丰富的 SSH 工作空间。<br/>
|
||||
主机管理、分屏终端、SFTP、端口转发、云同步 —— 一应俱全。
|
||||
分屏终端、Vault 多视图、SFTP 工作流、自定义主题、关键词高亮 —— 一应俱全。
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/binaricat/Netcatty/releases/latest"><img alt="GitHub Release" src="https://img.shields.io/github/v/release/binaricat/Netcatty?style=for-the-badge&logo=github&label=Release"></a>
|
||||
|
||||
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows-blue?style=for-the-badge&logo=electron"></a>
|
||||
<a href="#"><img alt="Platform" src="https://img.shields.io/badge/Platform-macOS%20%7C%20Windows%20%7C%20Linux-blue?style=for-the-badge&logo=electron"></a>
|
||||
|
||||
<a href="LICENSE"><img alt="License" src="https://img.shields.io/badge/License-GPL--3.0-green?style=for-the-badge"></a>
|
||||
</p>
|
||||
@@ -40,22 +40,20 @@
|
||||
|
||||
---
|
||||
|
||||
[](screenshots/vault_grid_view.png)
|
||||
[](screenshots/main-window-dark.png)
|
||||
|
||||
---
|
||||
|
||||
# 目录 <!-- omit in toc -->
|
||||
|
||||
- [Netcatty 是什么](#netcatty-是什么)
|
||||
- [为什么是 Netcatty](#为什么是-netcatty)
|
||||
- [功能特性](#功能特性)
|
||||
- [演示](#演示)
|
||||
- [界面截图](#界面截图)
|
||||
- [主机管理](#主机管理)
|
||||
- [终端](#终端)
|
||||
- [SFTP](#sftp)
|
||||
- [密钥管理](#密钥管理)
|
||||
- [端口转发](#端口转发)
|
||||
- [云同步](#云同步)
|
||||
- [主题与定制](#主题与定制)
|
||||
- [主界面](#主界面)
|
||||
- [Vault 视图](#vault-视图)
|
||||
- [分屏终端](#分屏终端)
|
||||
- [支持的发行版](#支持的发行版)
|
||||
- [快速开始](#快速开始)
|
||||
- [构建与打包](#构建与打包)
|
||||
@@ -73,188 +71,118 @@
|
||||
- **Netcatty 是** PuTTY、Termius、SecureCRT 和 macOS Terminal.app 的现代替代品
|
||||
- **Netcatty 是** 一个强大的 SFTP 客户端,支持双窗格文件浏览
|
||||
- **Netcatty 是** 一个终端工作空间,支持分屏、标签页和会话管理
|
||||
- **Netcatty 不是** Shell 替代品 —— 它通过 SSH/Telnet 或本地终端连接到远程 Shell
|
||||
- **Netcatty 支持** SSH、本地终端、Telnet、Mosh、串口(Serial)等连接方式(视环境而定)
|
||||
- **Netcatty 不是** Shell 替代品 —— 它通过 SSH/Telnet/Mosh 或本地/串口会话连接到 Shell
|
||||
|
||||
---
|
||||
|
||||
<a name="为什么是-netcatty"></a>
|
||||
# 为什么是 Netcatty
|
||||
|
||||
如果你需要同时维护多台服务器,Netcatty 更像是“工作台”而不是单一终端:
|
||||
|
||||
- **以工作区为核心** —— 分屏 + 多会话并行,适合长期驻留的工作流
|
||||
- **Vault 管理** —— 网格/列表/树形视图,配合搜索与拖拽更顺手
|
||||
- **认真做的 SFTP** —— 内置编辑器 + 拖拽上传,文件操作更丝滑
|
||||
|
||||
---
|
||||
|
||||
<a name="功能特性"></a>
|
||||
# 功能特性
|
||||
|
||||
### 🖥️ 终端与会话
|
||||
- **基于 xterm.js 的终端**,支持 GPU 加速渲染
|
||||
- **分屏功能** —— 水平和垂直分割,多任务并行
|
||||
- **标签页管理** —— 多会话支持,拖拽排序
|
||||
- **会话持久化** —— 重启后恢复会话
|
||||
- **广播模式** —— 一次输入,发送到多个终端
|
||||
### 🗂️ Vault
|
||||
- **多种视图** —— 网格 / 列表 / 树形
|
||||
- **快速搜索** —— 迅速定位主机与分组
|
||||
|
||||
### 🔐 SSH 客户端
|
||||
- **SSH2 协议**,完整的认证支持
|
||||
- **密码和密钥认证**
|
||||
- **SSH 证书**支持
|
||||
- **跳板机 / 堡垒机** —— 多主机链式连接
|
||||
- **代理支持** —— HTTP CONNECT 和 SOCKS5 代理
|
||||
- **Agent 转发** —— 支持 OpenSSH Agent 和 Pageant
|
||||
- **环境变量** —— 为每个主机设置自定义环境变量
|
||||
### 🖥️ 终端工作区
|
||||
- **分屏** —— 水平/垂直分割,多任务并行
|
||||
- **多会话管理** —— 多连接并排处理
|
||||
|
||||
### 📁 SFTP
|
||||
- **双窗格文件浏览器** —— 本地 ↔ 远程 或 远程 ↔ 远程
|
||||
- **Sudo 提权支持** —— 使用 sudo 浏览和编辑 root 权限文件
|
||||
- **拖放操作** —— 支持上传和下载
|
||||
- **拖放传输** 文件
|
||||
- **队列管理** 批量传输
|
||||
- **进度跟踪** 显示传输速度
|
||||
### 📁 SFTP + 内置编辑器
|
||||
- **文件工作流** —— 拖拽上传/下载更直观
|
||||
- **就地编辑** —— 内置编辑器快速修改文件
|
||||
|
||||
### 🔑 密钥管理
|
||||
- **生成 SSH 密钥** —— RSA、ECDSA、ED25519
|
||||
- **导入已有密钥** —— PEM、OpenSSH 格式
|
||||
- **SSH 证书**支持
|
||||
- **身份管理** —— 可复用的用户名 + 认证方式组合
|
||||
- **导出公钥**到远程主机
|
||||
### 🎨 个性化
|
||||
- **自定义主题** —— 按喜好调整应用外观
|
||||
- **关键词高亮** —— 自定义终端输出高亮规则
|
||||
|
||||
### 🔌 端口转发
|
||||
- **本地转发** —— 将远程服务暴露到本地
|
||||
- **远程转发** —— 将本地服务暴露到远程
|
||||
- **动态转发** —— SOCKS5 代理
|
||||
- **可视化隧道管理**
|
||||
---
|
||||
|
||||
### ☁️ 云同步
|
||||
- **端到端加密同步** —— 数据在离开设备前加密
|
||||
- **多种存储后端** —— GitHub Gist、S3 兼容存储、WebDAV、Google Drive、OneDrive
|
||||
- **同步主机、密钥、代码片段和设置**
|
||||
<a name="演示"></a>
|
||||
# 演示
|
||||
|
||||
### 🎨 主题与定制
|
||||
- **浅色 & 深色模式**
|
||||
- **自定义强调色**
|
||||
- **50+ 终端配色方案**
|
||||
- **字体自定义** —— JetBrains Mono、Fira Code 等
|
||||
- **多语言支持** —— English、简体中文 等
|
||||
GIF 预览(素材均在 `screenshots/gifs/`),在 GitHub README 中可直接观看:
|
||||
|
||||
### Vault 视图:网格 / 列表 / 树形
|
||||
根据不同场景自由切换视图:网格适合总览,列表适合密集浏览,树形适合层级导航与整理。
|
||||
|
||||

|
||||
|
||||
### 分屏终端 + 会话管理
|
||||
用分屏把多个会话并排放在同一个工作区里,降低来回切换窗口/标签页的成本。
|
||||
|
||||

|
||||
|
||||
### SFTP:拖拽 + 内置编辑器
|
||||
通过拖拽完成文件传输,并用内置编辑器快速修改文件内容,不用来回切换工具。
|
||||
|
||||

|
||||
|
||||
### 拖拽文件上传
|
||||
把文件直接拖进应用即可触发上传流程,省去多层对话框与路径选择。
|
||||
|
||||

|
||||
|
||||
### 自定义主题
|
||||
按自己的审美与习惯定制主题与界面外观,让日常使用更顺手。
|
||||
|
||||

|
||||
|
||||
### 关键词高亮
|
||||
让关键输出一眼可见:错误、告警或特定标记被高亮后更容易扫到与定位。
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
<a name="界面截图"></a>
|
||||
# 界面截图
|
||||
|
||||
<a name="主机管理"></a>
|
||||
## 主机管理
|
||||
<a name="主界面"></a>
|
||||
## 主界面
|
||||
|
||||
Vault 视图是管理所有 SSH 连接的控制中心。通过右键菜单创建层级分组,在分组间拖拽主机,使用面包屑导航快速遍历主机树。每个主机显示连接状态、操作系统图标和快速连接按钮。根据偏好在网格和列表视图之间切换,使用强大的搜索按名称、主机名、标签或分组过滤主机。
|
||||
主界面围绕长期 SSH 工作流设计:把会话、导航和常用工具集中到同一处,减少切换成本。
|
||||
|
||||
**深色模式**
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
**层级文件夹与分组**
|
||||
<a name="vault-视图"></a>
|
||||
## Vault 视图
|
||||
|
||||

|
||||
用更适合当前任务的方式管理与浏览主机:网格看全局,列表做筛选,树形做整理与层级导航。
|
||||
|
||||
**列表视图**
|
||||

|
||||
|
||||

|
||||

|
||||
|
||||
<a name="终端"></a>
|
||||
## 终端
|
||||

|
||||
|
||||
基于 xterm.js 的 WebGL 加速终端,提供流畅、响应迅速的体验。水平或垂直分割工作区,同时监控多个会话。启用广播模式可一次向所有终端发送命令 —— 非常适合批量管理。主题定制面板提供 50+ 配色方案和实时预览、可调节字号以及多种字体选择,包括 JetBrains Mono 和 Fira Code。
|
||||

|
||||
|
||||
**分屏窗口**
|
||||
<a name="分屏终端"></a>
|
||||
## 分屏终端
|
||||
|
||||
**广播模式**
|
||||
分屏适合同时处理多个任务(例如部署 + 日志 + 排障),不用频繁切换窗口。
|
||||
|
||||
一次输入,多处执行。非常适合同时维护这多台服务器。
|
||||
|
||||

|
||||
|
||||
**性能信息与定制**
|
||||
|
||||
监控连接健康状况,并自定义终端的方方面面。
|
||||
|
||||

|
||||
|
||||
<a name="sftp"></a>
|
||||
## SFTP
|
||||
|
||||
双窗格 SFTP 浏览器支持本地到远程和远程到远程的文件传输。单击导航目录,在窗格之间拖放文件,实时监控传输进度。界面显示文件权限、大小和修改日期。批量传输队列管理,详细的速度和进度指示器。右键菜单快速访问重命名、删除、下载和上传操作。
|
||||
|
||||

|
||||
|
||||
**传输队列**
|
||||
|
||||

|
||||
|
||||
<a name="密钥管理"></a>
|
||||
## 密钥管理
|
||||
|
||||
密钥库是您存储 SSH 凭证的安全保险库。生成新密钥、导入已有密钥或管理企业认证的 SSH 证书。
|
||||
|
||||
| 密钥类型 | 算法 | 推荐用途 |
|
||||
|---------|------|---------|
|
||||
| **ED25519** | EdDSA | 现代、快速、最安全(推荐) |
|
||||
| **ECDSA** | NIST P-256/384/521 | 安全性好、广泛支持 |
|
||||
| **RSA** | RSA 2048/4096 | 旧版兼容、通用支持 |
|
||||
| **证书** | CA 签名 | 企业环境、短期认证 |
|
||||
|
||||
**功能:**
|
||||
- 🔑 生成可自定义位长的密钥
|
||||
- 📥 导入 PEM/OpenSSH 格式密钥
|
||||
- 👤 创建可复用身份(用户名 + 认证方式)
|
||||
- 📤 一键导出公钥到远程主机
|
||||
|
||||

|
||||
|
||||
**密钥生成器**
|
||||
|
||||

|
||||
|
||||
<a name="端口转发"></a>
|
||||
## 端口转发
|
||||
|
||||
通过直观的可视化界面设置 SSH 隧道。每个隧道显示实时状态,清晰指示活动、连接中或错误状态。保存隧道配置以便跨会话快速复用。
|
||||
|
||||
| 类型 | 方向 | 使用场景 | 示例 |
|
||||
|-----|-----|---------|-----|
|
||||
| **本地** | 远程 → 本地 | 在本机访问远程服务 | 将远程 MySQL `3306` 转发到 `localhost:3306` |
|
||||
| **远程** | 本地 → 远程 | 与远程服务器共享本地服务 | 将本地开发服务器暴露给远程机器 |
|
||||
| **动态** | SOCKS5 代理 | 通过 SSH 隧道安全浏览 | 通过加密 SSH 连接浏览互联网 |
|
||||
|
||||

|
||||
|
||||
<a name="云同步"></a>
|
||||
## 云同步
|
||||
|
||||
通过端到端加密在所有设备间同步主机、密钥、代码片段和设置。主密码在上传前本地加密所有数据 —— 云服务商永远看不到明文。
|
||||
|
||||
| 服务商 | 最适合 | 配置复杂度 |
|
||||
|-------|-------|----------|
|
||||
| **GitHub Gist** | 快速设置、版本历史 | ⭐ 简单 |
|
||||
| **Google Drive** | 个人使用、大容量存储 | ⭐ 简单 |
|
||||
| **OneDrive** | 微软生态用户 | ⭐ 简单 |
|
||||
| **S3 兼容存储** | AWS、MinIO、Cloudflare R2、自托管 | ⭐⭐ 中等 |
|
||||
| **WebDAV** | Nextcloud、ownCloud、自托管 | ⭐⭐ 中等 |
|
||||
|
||||
**同步内容:**
|
||||
- ✅ 主机与连接设置
|
||||
- ✅ SSH 密钥与证书
|
||||
- ✅ 身份与凭证
|
||||
- ✅ 代码片段与脚本
|
||||
- ✅ 自定义分组与标签
|
||||
- ✅ 端口转发规则
|
||||
- ✅ 应用程序偏好设置
|
||||
|
||||

|
||||
|
||||
<a name="主题与定制"></a>
|
||||
## 主题与定制
|
||||
|
||||
让 Netcatty 真正属于你。在浅色和深色模式之间切换,或让应用跟随系统偏好。选择任意强调色来匹配你的风格。应用支持多种语言,包括 English 和简体中文,欢迎社区贡献更多翻译。启用云同步后,所有偏好设置都会跨设备同步,个性化体验随处可用。
|
||||
|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
<a name="支持的发行版"></a>
|
||||
# 支持的发行版
|
||||
|
||||
Netcatty 自动检测并显示已连接主机的操作系统图标:
|
||||
Netcatty 会自动识别并在主机列表中展示对应的系统图标:
|
||||
|
||||
<p align="center">
|
||||
<img src="public/distro/ubuntu.svg" width="48" alt="Ubuntu" title="Ubuntu">
|
||||
@@ -271,8 +199,6 @@ Netcatty 自动检测并显示已连接主机的操作系统图标:
|
||||
<img src="public/distro/kali.svg" width="48" alt="Kali Linux" title="Kali Linux">
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
<a name="快速开始"></a>
|
||||
# 快速开始
|
||||
|
||||
@@ -356,7 +282,7 @@ npm run pack:linux # Linux (AppImage, deb, rpm)
|
||||
|
||||
| 分类 | 技术 |
|
||||
|-----|-----|
|
||||
| 框架 | Electron 39 |
|
||||
| 框架 | Electron 40 |
|
||||
| 前端 | React 19, TypeScript |
|
||||
| 构建工具 | Vite 7 |
|
||||
| 终端 | xterm.js 5 |
|
||||
@@ -387,9 +313,7 @@ npm run pack:linux # Linux (AppImage, deb, rpm)
|
||||
|
||||
感谢所有参与贡献的人!
|
||||
|
||||
<a href="https://github.com/binaricat/Netcatty/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=binaricat/Netcatty" alt="contributors" />
|
||||
</a>
|
||||
查看:https://github.com/binaricat/Netcatty/graphs/contributors
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -95,6 +99,34 @@ const en: Messages = {
|
||||
'settings.sessionLogs.formatHtml': 'HTML (.html)',
|
||||
'settings.sessionLogs.hint': 'Session logs capture all terminal output for troubleshooting and auditing purposes.',
|
||||
|
||||
// Settings > Global Hotkey (Quake Mode)
|
||||
'settings.globalHotkey.title': 'Global Hotkey',
|
||||
'settings.globalHotkey.toggleWindow': 'Toggle Window',
|
||||
'settings.globalHotkey.toggleWindowDesc': 'Press a key combination to set a global shortcut for showing/hiding the window.',
|
||||
'settings.globalHotkey.notSet': 'Not set',
|
||||
'settings.globalHotkey.reset': 'Reset to default',
|
||||
'settings.globalHotkey.closeToTray': 'Close to System Tray',
|
||||
'settings.globalHotkey.closeToTrayDesc': 'When enabled, closing the window will minimize to the system tray instead of quitting.',
|
||||
'settings.globalHotkey.hint': 'Global hotkey works system-wide to quickly show or hide the window (Quake-style terminal).',
|
||||
|
||||
// Tray Panel
|
||||
'tray.openMainWindow': 'Open Main Window',
|
||||
'tray.sessions': 'Sessions',
|
||||
'tray.portForwarding': 'Port Forwarding',
|
||||
'tray.status.connected': 'Connected',
|
||||
'tray.status.connecting': 'Connecting',
|
||||
'tray.status.disconnected': 'Disconnected',
|
||||
'tray.status.active': 'Active',
|
||||
'tray.status.inactive': 'Inactive',
|
||||
'tray.status.error': 'Error',
|
||||
'tray.recentHosts': 'Recent Hosts',
|
||||
'tray.empty.title': 'Nothing here yet',
|
||||
'tray.empty.subtitle': 'Go connect to a server, they miss you 🚀',
|
||||
|
||||
// Vault Sidebar
|
||||
'vault.sidebar.collapse': 'Collapse sidebar',
|
||||
'vault.sidebar.expand': 'Expand sidebar',
|
||||
|
||||
// Settings > Application
|
||||
'settings.application.checkUpdates': 'Check for updates',
|
||||
'settings.application.reportProblem': 'Report a problem',
|
||||
@@ -249,6 +281,7 @@ const en: Messages = {
|
||||
'settings.shortcuts.category.terminal': 'Terminal',
|
||||
'settings.shortcuts.category.navigation': 'Navigation',
|
||||
'settings.shortcuts.category.app': 'App',
|
||||
'settings.shortcuts.category.sftp': 'SFTP',
|
||||
|
||||
// Context menus / common actions
|
||||
'action.newHost': 'New Host',
|
||||
@@ -313,6 +346,11 @@ const en: Messages = {
|
||||
'vault.groups.createDialog.desc': 'Create a new group for organizing hosts.',
|
||||
'vault.groups.renameDialogTitle': 'Rename Group',
|
||||
'vault.groups.renameDialog.desc': 'Rename an existing group.',
|
||||
'vault.groups.deleteDialogTitle': 'Delete Group',
|
||||
'vault.groups.deleteDialog.desc': 'This will permanently delete the group and move all hosts to the root level.',
|
||||
'vault.groups.deleteDialog.managedDesc': 'This is a managed SSH config group. Deleting it will also delete all hosts and unlink from the source file.',
|
||||
'vault.groups.deleteDialog.deleteHosts': 'Also delete all hosts in this group',
|
||||
'vault.groups.ungrouped': 'Ungrouped',
|
||||
'vault.groups.field.name': 'Group Name',
|
||||
'vault.groups.placeholder.example': 'e.g. Production',
|
||||
'vault.groups.parentLabel': 'Parent',
|
||||
@@ -320,6 +358,9 @@ const en: Messages = {
|
||||
'vault.groups.errors.required': 'Group name is required.',
|
||||
'vault.groups.errors.invalidChars': "Group name cannot include '/' or '\\\\'.",
|
||||
|
||||
'vault.managedSource.unmanage': 'Unmanage',
|
||||
'vault.managedSource.unmanageSuccess': 'Successfully unmanaged group',
|
||||
|
||||
'vault.hosts.header.entries': '{count} entries',
|
||||
'vault.hosts.header.live': '{count} live',
|
||||
|
||||
@@ -328,6 +369,9 @@ const en: Messages = {
|
||||
'vault.hosts.connect': 'Connect',
|
||||
'vault.view.grid': 'Grid',
|
||||
'vault.view.list': 'List',
|
||||
'vault.view.tree': 'Tree',
|
||||
'vault.tree.expandAll': 'Expand All',
|
||||
'vault.tree.collapseAll': 'Collapse All',
|
||||
'vault.hosts.newHost': 'New Host',
|
||||
'vault.hosts.newGroup': 'New Group',
|
||||
'vault.hosts.import': 'Import',
|
||||
@@ -339,6 +383,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',
|
||||
@@ -355,6 +405,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...',
|
||||
@@ -430,6 +492,13 @@ const en: Messages = {
|
||||
'pf.view.list': 'List',
|
||||
'pf.rule.summary.dynamic': 'SOCKS on {bindAddress}:{localPort}',
|
||||
'pf.rule.summary.default': '{bindAddress}:{localPort} -> {remoteHost}:{remotePort}',
|
||||
'pf.tooltip.relayHost': 'Relay Host',
|
||||
'pf.tooltip.hostLabel': 'Host',
|
||||
'pf.tooltip.hostAddress': 'Address',
|
||||
'pf.tooltip.noHost': 'No relay host configured',
|
||||
'pf.tooltip.localDesc': 'Local port forwarding: Access remote services through SSH tunnel',
|
||||
'pf.tooltip.remoteDesc': 'Remote port forwarding: Expose local services to remote host',
|
||||
'pf.tooltip.dynamicDesc': 'Dynamic SOCKS proxy: Route traffic through SSH tunnel',
|
||||
'pf.deleteActive.title': 'Delete Active Port Forwarding?',
|
||||
'pf.deleteActive.desc': 'This port forwarding rule "{label}" is currently active. Deleting it will stop the tunnel first.',
|
||||
'pf.deleteActive.confirm': 'Stop and Delete',
|
||||
@@ -447,8 +516,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',
|
||||
@@ -534,6 +607,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',
|
||||
@@ -598,10 +677,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',
|
||||
@@ -617,11 +705,14 @@ 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',
|
||||
'qs.createWorkspace': 'Create a workspace',
|
||||
'qs.restore': 'Restore',
|
||||
'qs.jumpTo': 'Jump To',
|
||||
'qs.localTerminal': 'Local Terminal',
|
||||
|
||||
@@ -805,6 +896,15 @@ const en: Messages = {
|
||||
'terminal.toolbar.focus': 'Focus',
|
||||
'terminal.toolbar.focusMode': 'Focus Mode',
|
||||
'terminal.toolbar.closeSession': 'Close session',
|
||||
'terminal.toolbar.hostHighlight.title': 'Host Keyword Highlighting',
|
||||
'terminal.toolbar.hostHighlight.noRules': 'No custom highlight rules defined for this host',
|
||||
'terminal.toolbar.hostHighlight.addRule': 'Add New Rule',
|
||||
'terminal.toolbar.hostHighlight.labelPlaceholder': 'Label (e.g., Error)',
|
||||
'terminal.toolbar.hostHighlight.patternPlaceholder': 'Regex pattern (e.g., \\bfailed\\b)',
|
||||
'terminal.toolbar.hostHighlight.invalidPattern': 'Invalid regex pattern',
|
||||
'terminal.toolbar.hostHighlight.clearAll': 'Clear All',
|
||||
'terminal.toolbar.hostHighlight.changeColor': 'Change highlight color for',
|
||||
'terminal.toolbar.hostHighlight.selectColor': 'Select color for new rule',
|
||||
'terminal.serverStats.cpu': 'CPU Usage',
|
||||
'terminal.serverStats.cpuCores': 'CPU Core Usage',
|
||||
'terminal.serverStats.memory': 'Memory Usage',
|
||||
@@ -819,6 +919,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)',
|
||||
@@ -849,6 +956,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',
|
||||
@@ -1162,6 +1274,23 @@ const en: Messages = {
|
||||
'snippets.packageDialog.placeholder': 'e.g. ops/maintenance',
|
||||
'snippets.packageDialog.hint': 'Use "/" to create nested packages.',
|
||||
|
||||
// Snippets Rename Dialog
|
||||
'snippets.renameDialog.title': 'Rename Package',
|
||||
'snippets.renameDialog.currentPath': 'Current path: {path}',
|
||||
'snippets.renameDialog.placeholder': 'Enter new name',
|
||||
'snippets.renameDialog.error.empty': 'Package name cannot be empty',
|
||||
'snippets.renameDialog.error.duplicate': 'A package with this name already exists',
|
||||
'snippets.renameDialog.error.invalidChars': 'Package name can only contain letters, numbers, hyphens, and underscores',
|
||||
|
||||
// Snippet Shortkey
|
||||
'snippets.field.shortkey': 'Keyboard Shortcut',
|
||||
'snippets.shortkey.placeholder': 'Click to set shortcut',
|
||||
'snippets.shortkey.recording': 'Press a key combination...',
|
||||
'snippets.shortkey.hint': 'Press this shortcut in terminal to quickly send the command.',
|
||||
'snippets.shortkey.clear': 'Clear shortcut',
|
||||
'snippets.shortkey.error.systemConflict': 'This shortcut conflicts with a system shortcut',
|
||||
'snippets.shortkey.error.snippetConflict': 'This shortcut is already used by snippet: {name}',
|
||||
|
||||
// Serial Port
|
||||
'serial.button': 'Serial',
|
||||
'serial.modal.title': 'Connect to Serial Port',
|
||||
@@ -1216,6 +1345,16 @@ const en: Messages = {
|
||||
'keyboard.interactive.fillSaved': 'Fill with saved password',
|
||||
'keyboard.interactive.useSaved': 'Use saved',
|
||||
'keyboard.interactive.useSavedPassword': 'Use saved password',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'SSH Key Passphrase',
|
||||
'passphrase.desc': 'Enter the passphrase for {keyName}',
|
||||
'passphrase.descWithHost': 'Enter the passphrase for {keyName} to connect to {hostname}',
|
||||
'passphrase.label': 'Passphrase',
|
||||
'passphrase.keyPath': 'Key',
|
||||
'passphrase.unlock': 'Unlock',
|
||||
'passphrase.unlocking': 'Unlocking...',
|
||||
'passphrase.skip': 'Skip',
|
||||
};
|
||||
|
||||
export default en;
|
||||
@@ -27,6 +27,7 @@ const zhCN: Messages = {
|
||||
'sort.za': 'Z-a',
|
||||
'sort.newest': '从新到旧',
|
||||
'sort.oldest': '从旧到新',
|
||||
'sort.group': '按分组',
|
||||
'field.label': 'Label',
|
||||
'field.type': '类型',
|
||||
'auth.keyType': '类型 {type}',
|
||||
@@ -83,6 +84,34 @@ const zhCN: Messages = {
|
||||
'settings.sessionLogs.formatHtml': 'HTML (.html)',
|
||||
'settings.sessionLogs.hint': '会话日志用于记录终端输出,便于故障排查和审计。',
|
||||
|
||||
// Settings > Global Hotkey (Quake Mode)
|
||||
'settings.globalHotkey.title': '全局快捷键',
|
||||
'settings.globalHotkey.toggleWindow': '切换窗口',
|
||||
'settings.globalHotkey.toggleWindowDesc': '按下组合键以设置显示/隐藏窗口的全局快捷键。',
|
||||
'settings.globalHotkey.notSet': '未设置',
|
||||
'settings.globalHotkey.reset': '恢复默认',
|
||||
'settings.globalHotkey.closeToTray': '关闭时最小化到托盘',
|
||||
'settings.globalHotkey.closeToTrayDesc': '启用后,关闭窗口将最小化到系统托盘而不是退出程序。',
|
||||
'settings.globalHotkey.hint': '全局快捷键在系统范围内工作,可快速显示或隐藏窗口(下拉式终端风格)。',
|
||||
|
||||
// Tray Panel
|
||||
'tray.openMainWindow': '打开主窗口',
|
||||
'tray.sessions': '会话',
|
||||
'tray.portForwarding': '端口转发',
|
||||
'tray.status.connected': '已连接',
|
||||
'tray.status.connecting': '连接中',
|
||||
'tray.status.disconnected': '已断开',
|
||||
'tray.status.active': '已启用',
|
||||
'tray.status.inactive': '未启用',
|
||||
'tray.status.error': '错误',
|
||||
'tray.recentHosts': '最近连接的主机',
|
||||
'tray.empty.title': '一切都很安静',
|
||||
'tray.empty.subtitle': '去连接个服务器吧,它们想念你了 🚀',
|
||||
|
||||
// Vault Sidebar
|
||||
'vault.sidebar.collapse': '收起侧边栏',
|
||||
'vault.sidebar.expand': '展开侧边栏',
|
||||
|
||||
// Settings > Application
|
||||
'settings.application.checkUpdates': '检查更新',
|
||||
'settings.application.reportProblem': '反馈问题',
|
||||
@@ -184,6 +213,11 @@ const zhCN: Messages = {
|
||||
'vault.groups.createDialog.desc': '创建新的分组用于组织主机。',
|
||||
'vault.groups.renameDialogTitle': '重命名分组',
|
||||
'vault.groups.renameDialog.desc': '重命名已有分组。',
|
||||
'vault.groups.deleteDialogTitle': '删除分组',
|
||||
'vault.groups.deleteDialog.desc': '这将永久删除该分组并将所有主机移动到根级别。',
|
||||
'vault.groups.deleteDialog.managedDesc': '这是一个托管的 SSH config 分组。删除后将同时删除所有主机并断开与源文件的连接。',
|
||||
'vault.groups.deleteDialog.deleteHosts': '同时删除该分组下的所有主机',
|
||||
'vault.groups.ungrouped': '未分组',
|
||||
'vault.groups.field.name': '分组名称',
|
||||
'vault.groups.placeholder.example': '例如:Production',
|
||||
'vault.groups.parentLabel': '父级',
|
||||
@@ -191,6 +225,9 @@ const zhCN: Messages = {
|
||||
'vault.groups.errors.required': '分组名称不能为空。',
|
||||
'vault.groups.errors.invalidChars': "分组名称不能包含 '/' 或 '\\\\'.",
|
||||
|
||||
'vault.managedSource.unmanage': '取消托管',
|
||||
'vault.managedSource.unmanageSuccess': '已取消托管分组',
|
||||
|
||||
'vault.hosts.header.entries': '{count} 条',
|
||||
'vault.hosts.header.live': '{count} 个在线',
|
||||
|
||||
@@ -199,6 +236,9 @@ const zhCN: Messages = {
|
||||
'vault.hosts.connect': '连接',
|
||||
'vault.view.grid': '网格',
|
||||
'vault.view.list': '列表',
|
||||
'vault.view.tree': '树形',
|
||||
'vault.tree.expandAll': '展开全部',
|
||||
'vault.tree.collapseAll': '折叠全部',
|
||||
'vault.hosts.newHost': '新建主机',
|
||||
'vault.hosts.newGroup': '新建分组',
|
||||
'vault.hosts.import': '导入',
|
||||
@@ -210,6 +250,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',
|
||||
@@ -224,6 +270,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': '搜索已知主机...',
|
||||
@@ -300,8 +358,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': '打开',
|
||||
@@ -370,9 +432,6 @@ const zhCN: Messages = {
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': '搜索主机或标签页',
|
||||
'qs.recentConnections': '最近连接',
|
||||
'qs.createWorkspace': '创建工作区',
|
||||
'qs.restore': '恢复',
|
||||
'qs.jumpTo': '跳转到',
|
||||
'qs.localTerminal': '本地终端',
|
||||
|
||||
@@ -523,6 +582,15 @@ const zhCN: Messages = {
|
||||
'terminal.toolbar.focus': '聚焦',
|
||||
'terminal.toolbar.focusMode': '聚焦模式',
|
||||
'terminal.toolbar.closeSession': '关闭会话',
|
||||
'terminal.toolbar.hostHighlight.title': '主机关键字高亮',
|
||||
'terminal.toolbar.hostHighlight.noRules': '此主机未定义自定义高亮规则',
|
||||
'terminal.toolbar.hostHighlight.addRule': '添加新规则',
|
||||
'terminal.toolbar.hostHighlight.labelPlaceholder': '标签(例如:错误)',
|
||||
'terminal.toolbar.hostHighlight.patternPlaceholder': '正则表达式(例如:\\bfailed\\b)',
|
||||
'terminal.toolbar.hostHighlight.invalidPattern': '无效的正则表达式',
|
||||
'terminal.toolbar.hostHighlight.clearAll': '清除全部',
|
||||
'terminal.toolbar.hostHighlight.changeColor': '更改高亮颜色',
|
||||
'terminal.toolbar.hostHighlight.selectColor': '选择新规则的颜色',
|
||||
'terminal.serverStats.cpu': 'CPU 使用率',
|
||||
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
|
||||
'terminal.serverStats.memory': '内存使用',
|
||||
@@ -537,6 +605,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)',
|
||||
@@ -568,6 +643,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': '字体',
|
||||
@@ -761,6 +841,13 @@ const zhCN: Messages = {
|
||||
'pf.view.list': '列表',
|
||||
'pf.rule.summary.dynamic': 'SOCKS 监听于 {bindAddress}:{localPort}',
|
||||
'pf.rule.summary.default': '{bindAddress}:{localPort} -> {remoteHost}:{remotePort}',
|
||||
'pf.tooltip.relayHost': '中转主机',
|
||||
'pf.tooltip.hostLabel': '主机',
|
||||
'pf.tooltip.hostAddress': '地址',
|
||||
'pf.tooltip.noHost': '未配置中转主机',
|
||||
'pf.tooltip.localDesc': '本地端口转发:通过 SSH 隧道访问远程服务',
|
||||
'pf.tooltip.remoteDesc': '远程端口转发:将本地服务暴露给远程主机',
|
||||
'pf.tooltip.dynamicDesc': '动态 SOCKS 代理:通过 SSH 隧道转发流量',
|
||||
'pf.deleteActive.title': '删除正在运行的端口转发?',
|
||||
'pf.deleteActive.desc': '端口转发规则 "{label}" 当前正在运行。删除前将先关闭转发连接。',
|
||||
'pf.deleteActive.confirm': '关闭并删除',
|
||||
@@ -788,6 +875,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': '编辑',
|
||||
@@ -852,10 +945,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': '连接已断开,正在尝试重新连接',
|
||||
@@ -871,6 +973,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': '选择主题',
|
||||
@@ -974,6 +1082,7 @@ const zhCN: Messages = {
|
||||
'settings.shortcuts.category.terminal': '终端',
|
||||
'settings.shortcuts.category.navigation': '导航',
|
||||
'settings.shortcuts.category.app': '应用',
|
||||
'settings.shortcuts.category.sftp': 'SFTP',
|
||||
|
||||
// Host Details (sub-panels)
|
||||
'hostDetails.proxyPanel.title': 'Proxy',
|
||||
@@ -1151,6 +1260,23 @@ const zhCN: Messages = {
|
||||
'snippets.packageDialog.placeholder': '例如:ops/maintenance',
|
||||
'snippets.packageDialog.hint': '使用 "/" 创建嵌套代码包。',
|
||||
|
||||
// Snippets Rename Dialog
|
||||
'snippets.renameDialog.title': '重命名代码包',
|
||||
'snippets.renameDialog.currentPath': '当前路径:{path}',
|
||||
'snippets.renameDialog.placeholder': '输入新名称',
|
||||
'snippets.renameDialog.error.empty': '代码包名称不能为空',
|
||||
'snippets.renameDialog.error.duplicate': '已存在同名的代码包',
|
||||
'snippets.renameDialog.error.invalidChars': '代码包名称只能包含字母、数字、连字符和下划线',
|
||||
|
||||
// Snippet Shortkey
|
||||
'snippets.field.shortkey': '快捷键',
|
||||
'snippets.shortkey.placeholder': '点击设置快捷键',
|
||||
'snippets.shortkey.recording': '请按下快捷键组合...',
|
||||
'snippets.shortkey.hint': '在终端中按下此快捷键可快速发送命令。',
|
||||
'snippets.shortkey.clear': '清除快捷键',
|
||||
'snippets.shortkey.error.systemConflict': '此快捷键与系统快捷键冲突',
|
||||
'snippets.shortkey.error.snippetConflict': '此快捷键已被代码片段使用:{name}',
|
||||
|
||||
// Serial Port
|
||||
'serial.button': '串口',
|
||||
'serial.modal.title': '连接串口',
|
||||
@@ -1205,6 +1331,16 @@ const zhCN: Messages = {
|
||||
'keyboard.interactive.fillSaved': '填入已保存的密码',
|
||||
'keyboard.interactive.useSaved': '使用已保存',
|
||||
'keyboard.interactive.useSavedPassword': '使用已保存的密码',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'SSH 密钥密码',
|
||||
'passphrase.desc': '请输入 {keyName} 的密码',
|
||||
'passphrase.descWithHost': '请输入 {keyName} 的密码以连接到 {hostname}',
|
||||
'passphrase.label': '密码',
|
||||
'passphrase.keyPath': '密钥',
|
||||
'passphrase.unlock': '解锁',
|
||||
'passphrase.unlocking': '解锁中...',
|
||||
'passphrase.skip': '跳过',
|
||||
};
|
||||
|
||||
export default zhCN;
|
||||
@@ -39,6 +39,12 @@ interface UseSftpPaneActionsResult {
|
||||
createDirectory: (side: "left" | "right", name: string) => Promise<void>;
|
||||
createFile: (side: "left" | "right", name: string) => Promise<void>;
|
||||
deleteFiles: (side: "left" | "right", fileNames: string[]) => Promise<void>;
|
||||
deleteFilesAtPath: (
|
||||
side: "left" | "right",
|
||||
connectionId: string,
|
||||
path: string,
|
||||
fileNames: string[],
|
||||
) => Promise<void>;
|
||||
renameFile: (side: "left" | "right", oldName: string, newName: string) => Promise<void>;
|
||||
changePermissions: (side: "left" | "right", filePath: string, mode: string) => Promise<void>;
|
||||
}
|
||||
@@ -452,6 +458,88 @@ export const useSftpPaneActions = ({
|
||||
[getActivePane, refresh, handleSessionError, sftpSessionsRef, isSessionError],
|
||||
);
|
||||
|
||||
const deleteFilesAtPath = useCallback(
|
||||
async (
|
||||
side: "left" | "right",
|
||||
connectionId: string,
|
||||
path: string,
|
||||
fileNames: string[],
|
||||
) => {
|
||||
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
|
||||
const pane = sideTabs.tabs.find((tab) => tab.connection?.id === connectionId);
|
||||
if (!pane?.connection) {
|
||||
throw new Error("Source pane is no longer available");
|
||||
}
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge) {
|
||||
throw new Error("Netcatty bridge not available");
|
||||
}
|
||||
|
||||
try {
|
||||
for (const name of fileNames) {
|
||||
const fullPath = joinPath(path, name);
|
||||
|
||||
if (pane.connection.isLocal) {
|
||||
if (!bridge.deleteLocalFile) {
|
||||
throw new Error("Local delete unavailable");
|
||||
}
|
||||
await bridge.deleteLocalFile(fullPath);
|
||||
} else {
|
||||
const sftpId = sftpSessionsRef.current.get(pane.connection.id);
|
||||
if (!sftpId) {
|
||||
const error = new Error("SFTP session not found");
|
||||
handleSessionError(side, error);
|
||||
throw error;
|
||||
}
|
||||
if (!bridge.deleteSftp) {
|
||||
throw new Error("SFTP delete unavailable");
|
||||
}
|
||||
await bridge.deleteSftp(sftpId, fullPath, pane.filenameEncoding);
|
||||
}
|
||||
}
|
||||
|
||||
clearCacheForConnection(pane.connection.id);
|
||||
|
||||
if (sideTabs.activeTabId === pane.id && pane.connection.currentPath === path) {
|
||||
await refresh(side);
|
||||
} else {
|
||||
updateTab(side, pane.id, (prev) => {
|
||||
if (!prev.connection || prev.connection.id !== connectionId) return prev;
|
||||
if (prev.connection.currentPath !== path) return prev;
|
||||
|
||||
const removeSet = new Set(fileNames);
|
||||
const filteredFiles = prev.files.filter((file) => !removeSet.has(file.name));
|
||||
const nextSelection = new Set(prev.selectedFiles);
|
||||
for (const name of fileNames) {
|
||||
nextSelection.delete(name);
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
files: filteredFiles,
|
||||
selectedFiles: nextSelection,
|
||||
};
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (isSessionError(err)) {
|
||||
handleSessionError(side, err as Error);
|
||||
throw err;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[
|
||||
clearCacheForConnection,
|
||||
handleSessionError,
|
||||
isSessionError,
|
||||
leftTabsRef,
|
||||
refresh,
|
||||
rightTabsRef,
|
||||
sftpSessionsRef,
|
||||
updateTab,
|
||||
],
|
||||
);
|
||||
|
||||
const renameFile = useCallback(
|
||||
async (side: "left" | "right", oldName: string, newName: string) => {
|
||||
const pane = getActivePane(side);
|
||||
@@ -529,6 +617,7 @@ export const useSftpPaneActions = ({
|
||||
createDirectory,
|
||||
createFile,
|
||||
deleteFiles,
|
||||
deleteFilesAtPath,
|
||||
renameFile,
|
||||
changePermissions,
|
||||
};
|
||||
|
||||
@@ -29,7 +29,13 @@ interface UseSftpTransfersResult {
|
||||
sourceFiles: { name: string; isDirectory: boolean }[],
|
||||
sourceSide: "left" | "right",
|
||||
targetSide: "left" | "right",
|
||||
) => Promise<void>;
|
||||
options?: {
|
||||
sourcePane?: SftpPane;
|
||||
sourcePath?: string;
|
||||
sourceConnectionId?: string;
|
||||
onTransferComplete?: (result: TransferResult) => void | Promise<void>;
|
||||
},
|
||||
) => Promise<TransferResult[]>;
|
||||
addExternalUpload: (task: TransferTask) => void;
|
||||
updateExternalUpload: (taskId: string, updates: Partial<TransferTask>) => void;
|
||||
cancelTransfer: (transferId: string) => Promise<void>;
|
||||
@@ -39,6 +45,13 @@ interface UseSftpTransfersResult {
|
||||
resolveConflict: (conflictId: string, action: "replace" | "skip" | "duplicate") => Promise<void>;
|
||||
}
|
||||
|
||||
interface TransferResult {
|
||||
id: string;
|
||||
fileName: string;
|
||||
originalFileName?: string;
|
||||
status: TransferStatus;
|
||||
}
|
||||
|
||||
export const useSftpTransfers = ({
|
||||
getActivePane,
|
||||
refresh,
|
||||
@@ -53,6 +66,7 @@ export const useSftpTransfers = ({
|
||||
const progressIntervalsRef = useRef<Map<string, NodeJS.Timeout>>(new Map());
|
||||
// Track cancelled task IDs for checking during async operations
|
||||
const cancelledTasksRef = useRef<Set<string>>(new Set());
|
||||
const completionHandlersRef = useRef<Map<string, (result: TransferResult) => void | Promise<void>>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
const intervalsRef = progressIntervalsRef.current;
|
||||
@@ -268,6 +282,7 @@ export const useSftpTransfers = ({
|
||||
...task,
|
||||
id: crypto.randomUUID(),
|
||||
fileName: file.name,
|
||||
originalFileName: file.name,
|
||||
sourcePath: joinPath(task.sourcePath, file.name),
|
||||
targetPath: joinPath(task.targetPath, file.name),
|
||||
isDirectory: file.type === "directory",
|
||||
@@ -305,7 +320,7 @@ export const useSftpTransfers = ({
|
||||
sourcePane: SftpPane,
|
||||
targetPane: SftpPane,
|
||||
targetSide: "left" | "right",
|
||||
) => {
|
||||
): Promise<TransferStatus> => {
|
||||
const updateTask = (updates: Partial<TransferTask>) => {
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) => (t.id === task.id ? { ...t, ...updates } : t)),
|
||||
@@ -461,7 +476,7 @@ export const useSftpTransfers = ({
|
||||
status: "pending",
|
||||
totalBytes: sourceStat?.size || estimatedSize,
|
||||
});
|
||||
return;
|
||||
return "pending";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,6 +522,20 @@ export const useSftpTransfers = ({
|
||||
);
|
||||
|
||||
await refresh(targetSide);
|
||||
const completionHandler = completionHandlersRef.current.get(task.id);
|
||||
if (completionHandler) {
|
||||
try {
|
||||
await completionHandler({
|
||||
id: task.id,
|
||||
fileName: task.fileName,
|
||||
originalFileName: task.originalFileName ?? task.fileName,
|
||||
status: "completed",
|
||||
});
|
||||
} finally {
|
||||
completionHandlersRef.current.delete(task.id);
|
||||
}
|
||||
}
|
||||
return "completed";
|
||||
} catch (err) {
|
||||
if (useSimulatedProgress) {
|
||||
stopProgressSimulation(task.id);
|
||||
@@ -518,7 +547,20 @@ export const useSftpTransfers = ({
|
||||
|
||||
if (isCancelled) {
|
||||
// Don't update status - cancelTransfer already set it to cancelled
|
||||
return;
|
||||
const completionHandler = completionHandlersRef.current.get(task.id);
|
||||
if (completionHandler) {
|
||||
try {
|
||||
await completionHandler({
|
||||
id: task.id,
|
||||
fileName: task.fileName,
|
||||
originalFileName: task.originalFileName ?? task.fileName,
|
||||
status: "cancelled",
|
||||
});
|
||||
} finally {
|
||||
completionHandlersRef.current.delete(task.id);
|
||||
}
|
||||
}
|
||||
return "cancelled";
|
||||
}
|
||||
|
||||
updateTask({
|
||||
@@ -527,6 +569,20 @@ export const useSftpTransfers = ({
|
||||
endTime: Date.now(),
|
||||
speed: 0,
|
||||
});
|
||||
const completionHandler = completionHandlersRef.current.get(task.id);
|
||||
if (completionHandler) {
|
||||
try {
|
||||
await completionHandler({
|
||||
id: task.id,
|
||||
fileName: task.fileName,
|
||||
originalFileName: task.originalFileName ?? task.fileName,
|
||||
status: "failed",
|
||||
});
|
||||
} finally {
|
||||
completionHandlersRef.current.delete(task.id);
|
||||
}
|
||||
}
|
||||
return "failed";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -534,23 +590,30 @@ export const useSftpTransfers = ({
|
||||
async (
|
||||
sourceFiles: { name: string; isDirectory: boolean }[],
|
||||
sourceSide: "left" | "right",
|
||||
targetSide: "left" | "right",
|
||||
) => {
|
||||
const sourcePane = getActivePane(sourceSide);
|
||||
targetSide: "left" | "right",
|
||||
options?: {
|
||||
sourcePane?: SftpPane;
|
||||
sourcePath?: string;
|
||||
sourceConnectionId?: string;
|
||||
onTransferComplete?: (result: TransferResult) => void | Promise<void>;
|
||||
},
|
||||
) => {
|
||||
const sourcePane = options?.sourcePane ?? getActivePane(sourceSide);
|
||||
const targetPane = getActivePane(targetSide);
|
||||
|
||||
if (!sourcePane?.connection || !targetPane?.connection) return;
|
||||
if (!sourcePane?.connection || !targetPane?.connection) return [];
|
||||
|
||||
const sourceEncoding: SftpFilenameEncoding = sourcePane.connection.isLocal
|
||||
? "auto"
|
||||
: sourcePane.filenameEncoding || "auto";
|
||||
|
||||
const sourcePath = sourcePane.connection.currentPath;
|
||||
const sourcePath = options?.sourcePath ?? sourcePane.connection.currentPath;
|
||||
const targetPath = targetPane.connection.currentPath;
|
||||
const sourceConnectionId = options?.sourceConnectionId ?? sourcePane.connection.id;
|
||||
|
||||
const sourceSftpId = sourcePane.connection.isLocal
|
||||
? null
|
||||
: sftpSessionsRef.current.get(sourcePane.connection.id);
|
||||
: sftpSessionsRef.current.get(sourceConnectionId);
|
||||
|
||||
const newTasks: TransferTask[] = [];
|
||||
|
||||
@@ -585,9 +648,10 @@ export const useSftpTransfers = ({
|
||||
newTasks.push({
|
||||
id: crypto.randomUUID(),
|
||||
fileName: file.name,
|
||||
originalFileName: file.name,
|
||||
sourcePath: joinPath(sourcePath, file.name),
|
||||
targetPath: joinPath(targetPath, file.name),
|
||||
sourceConnectionId: sourcePane.connection!.id,
|
||||
sourceConnectionId,
|
||||
targetConnectionId: targetPane.connection!.id,
|
||||
direction,
|
||||
status: "pending" as TransferStatus,
|
||||
@@ -601,9 +665,25 @@ export const useSftpTransfers = ({
|
||||
|
||||
setTransfers((prev) => [...prev, ...newTasks]);
|
||||
|
||||
for (const task of newTasks) {
|
||||
await processTransfer(task, sourcePane, targetPane, targetSide);
|
||||
if (options?.onTransferComplete) {
|
||||
for (const task of newTasks) {
|
||||
completionHandlersRef.current.set(task.id, options.onTransferComplete);
|
||||
}
|
||||
}
|
||||
|
||||
const results: TransferResult[] = [];
|
||||
|
||||
for (const task of newTasks) {
|
||||
const status = await processTransfer(task, sourcePane, targetPane, targetSide);
|
||||
results.push({
|
||||
id: task.id,
|
||||
fileName: task.fileName,
|
||||
originalFileName: task.originalFileName ?? task.fileName,
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[getActivePane, sftpSessionsRef],
|
||||
@@ -715,6 +795,19 @@ export const useSftpTransfers = ({
|
||||
: t,
|
||||
),
|
||||
);
|
||||
const completionHandler = completionHandlersRef.current.get(conflictId);
|
||||
if (completionHandler) {
|
||||
try {
|
||||
await completionHandler({
|
||||
id: task.id,
|
||||
fileName: task.fileName,
|
||||
originalFileName: task.originalFileName ?? task.fileName,
|
||||
status: "cancelled",
|
||||
});
|
||||
} finally {
|
||||
completionHandlersRef.current.delete(conflictId);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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[]): PortForwardingRule[] => {
|
||||
return rules.map((rule): PortForwardingRule => {
|
||||
const connection = getActiveConnection(rule.id);
|
||||
if (connection) {
|
||||
return {
|
||||
...rule,
|
||||
status: connection.status,
|
||||
error: connection.error,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...rule,
|
||||
status: "inactive" as const,
|
||||
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,48 +126,55 @@ 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();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
void loadAndSync();
|
||||
void initializeStore();
|
||||
}, []);
|
||||
|
||||
// Persist rules to storage whenever they change
|
||||
const persistRules = useCallback((updatedRules: PortForwardingRule[]) => {
|
||||
localStorageAdapter.write(STORAGE_KEY_PORT_FORWARDING, updatedRules);
|
||||
// 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);
|
||||
};
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}, [rules]);
|
||||
|
||||
// Listen for storage events for cross-window sync (main window <-> tray panel)
|
||||
useEffect(() => {
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
// Only handle changes from our specific key
|
||||
if (e.key !== STORAGE_KEY_PORT_FORWARDING) return;
|
||||
|
||||
// Parse the new value
|
||||
if (e.newValue) {
|
||||
try {
|
||||
const newRules = JSON.parse(e.newValue) as PortForwardingRule[];
|
||||
if (Array.isArray(newRules)) {
|
||||
// Update global state without triggering another localStorage write
|
||||
globalRules = normalizeRulesWithConnections(newRules);
|
||||
notifyListeners();
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("storage", handleStorageChange);
|
||||
return () => window.removeEventListener("storage", handleStorageChange);
|
||||
}, []);
|
||||
|
||||
const addRule = useCallback(
|
||||
@@ -130,47 +187,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 +230,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 +347,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,9 +20,12 @@ 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,
|
||||
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
|
||||
STORAGE_KEY_CLOSE_TO_TRAY,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
||||
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
|
||||
@@ -49,6 +52,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 +200,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>(() => {
|
||||
@@ -211,6 +222,22 @@ export const useSettingsState = () => {
|
||||
return DEFAULT_SESSION_LOGS_FORMAT;
|
||||
});
|
||||
|
||||
// Global Toggle Window Settings (Quake Mode)
|
||||
const [toggleWindowHotkey, setToggleWindowHotkey] = useState<string>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY);
|
||||
if (stored !== null) return stored;
|
||||
// Default: Ctrl+` (Control+backtick) - similar to VS Code terminal toggle
|
||||
const isMac = typeof navigator !== 'undefined' && /Mac/i.test(navigator.platform);
|
||||
return isMac ? '⌃ + `' : 'Ctrl + `';
|
||||
});
|
||||
const [closeToTray, setCloseToTray] = useState<boolean>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_CLOSE_TO_TRAY);
|
||||
// Default to true (enabled)
|
||||
if (stored === null) return true;
|
||||
return stored === 'true';
|
||||
});
|
||||
const [hotkeyRegistrationError, setHotkeyRegistrationError] = useState<string | null>(null);
|
||||
|
||||
// Helper to notify other windows about settings changes via IPC
|
||||
const notifySettingsChanged = useCallback((key: string, value: unknown) => {
|
||||
try {
|
||||
@@ -467,11 +494,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 +574,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');
|
||||
@@ -556,6 +596,49 @@ export const useSettingsState = () => {
|
||||
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_FORMAT, sessionLogsFormat);
|
||||
}, [sessionLogsFormat, notifySettingsChanged]);
|
||||
|
||||
// Persist and sync toggle window hotkey setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
|
||||
notifySettingsChanged(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
|
||||
// Register/unregister the global hotkey in main process
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.registerGlobalHotkey) {
|
||||
if (toggleWindowHotkey) {
|
||||
setHotkeyRegistrationError(null);
|
||||
bridge
|
||||
.registerGlobalHotkey(toggleWindowHotkey)
|
||||
.then((result) => {
|
||||
if (result?.success === false) {
|
||||
console.warn('[GlobalHotkey] Hotkey registration failed:', result.error);
|
||||
setHotkeyRegistrationError(result.error || 'Failed to register hotkey');
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('[GlobalHotkey] Failed to register hotkey:', err);
|
||||
setHotkeyRegistrationError(err?.message || 'Failed to register hotkey');
|
||||
});
|
||||
} else {
|
||||
setHotkeyRegistrationError(null);
|
||||
bridge.unregisterGlobalHotkey?.().catch((err) => {
|
||||
console.warn('[GlobalHotkey] Failed to unregister hotkey:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [toggleWindowHotkey, notifySettingsChanged]);
|
||||
|
||||
// Persist and sync close to tray setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray ? 'true' : 'false');
|
||||
notifySettingsChanged(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray);
|
||||
// Update main process tray behavior
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.setCloseToTray) {
|
||||
bridge.setCloseToTray(closeToTray).catch((err) => {
|
||||
console.warn('[SystemTray] Failed to set close-to-tray:', err);
|
||||
});
|
||||
}
|
||||
}, [closeToTray, notifySettingsChanged]);
|
||||
|
||||
// Get merged key bindings (defaults + custom overrides)
|
||||
const keyBindings = useMemo((): KeyBinding[] => {
|
||||
return DEFAULT_KEY_BINDINGS.map(binding => {
|
||||
@@ -670,6 +753,8 @@ export const useSettingsState = () => {
|
||||
setSftpAutoSync,
|
||||
sftpShowHiddenFiles,
|
||||
setSftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload,
|
||||
setSftpUseCompressedUpload,
|
||||
availableFonts,
|
||||
// Session Logs
|
||||
sessionLogsEnabled,
|
||||
@@ -678,5 +763,11 @@ export const useSettingsState = () => {
|
||||
setSessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
setSessionLogsFormat,
|
||||
// Global Toggle Window (Quake Mode)
|
||||
toggleWindowHotkey,
|
||||
setToggleWindowHotkey,
|
||||
closeToTray,
|
||||
setCloseToTray,
|
||||
hotkeyRegistrationError,
|
||||
};
|
||||
};
|
||||
@@ -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<
|
||||
@@ -155,6 +160,7 @@ export const useSftpState = (
|
||||
createDirectory,
|
||||
createFile,
|
||||
deleteFiles,
|
||||
deleteFilesAtPath,
|
||||
renameFile,
|
||||
changePermissions,
|
||||
} = useSftpPaneActions({
|
||||
@@ -264,6 +270,7 @@ export const useSftpState = (
|
||||
createDirectory,
|
||||
createFile,
|
||||
deleteFiles,
|
||||
deleteFilesAtPath,
|
||||
renameFile,
|
||||
changePermissions,
|
||||
readTextFile,
|
||||
@@ -274,11 +281,14 @@ export const useSftpState = (
|
||||
cancelExternalUpload,
|
||||
selectApplication,
|
||||
startTransfer,
|
||||
addExternalUpload,
|
||||
updateExternalUpload,
|
||||
cancelTransfer,
|
||||
retryTransfer,
|
||||
clearCompletedTransfers,
|
||||
dismissTransfer,
|
||||
resolveConflict,
|
||||
getSftpIdForConnection,
|
||||
});
|
||||
methodsRef.current = {
|
||||
getFilteredFiles,
|
||||
@@ -305,6 +315,7 @@ export const useSftpState = (
|
||||
createDirectory,
|
||||
createFile,
|
||||
deleteFiles,
|
||||
deleteFilesAtPath,
|
||||
renameFile,
|
||||
changePermissions,
|
||||
readTextFile,
|
||||
@@ -315,11 +326,14 @@ export const useSftpState = (
|
||||
cancelExternalUpload,
|
||||
selectApplication,
|
||||
startTransfer,
|
||||
addExternalUpload,
|
||||
updateExternalUpload,
|
||||
cancelTransfer,
|
||||
retryTransfer,
|
||||
clearCompletedTransfers,
|
||||
dismissTransfer,
|
||||
resolveConflict,
|
||||
getSftpIdForConnection,
|
||||
};
|
||||
|
||||
// Create stable method wrappers that call through methodsRef
|
||||
@@ -350,6 +364,8 @@ export const useSftpState = (
|
||||
createDirectory: (...args: Parameters<typeof createDirectory>) => methodsRef.current.createDirectory(...args),
|
||||
createFile: (...args: Parameters<typeof createFile>) => methodsRef.current.createFile(...args),
|
||||
deleteFiles: (...args: Parameters<typeof deleteFiles>) => methodsRef.current.deleteFiles(...args),
|
||||
deleteFilesAtPath: (...args: Parameters<typeof deleteFilesAtPath>) =>
|
||||
methodsRef.current.deleteFilesAtPath(...args),
|
||||
renameFile: (...args: Parameters<typeof renameFile>) => methodsRef.current.renameFile(...args),
|
||||
changePermissions: (...args: Parameters<typeof changePermissions>) => methodsRef.current.changePermissions(...args),
|
||||
readTextFile: (...args: Parameters<typeof readTextFile>) => methodsRef.current.readTextFile(...args),
|
||||
@@ -360,11 +376,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
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
|
||||
/**
|
||||
* Hook for persisting a boolean value to localStorage.
|
||||
* @param storageKey - The key to use for localStorage
|
||||
* @param fallback - The default value if no stored value exists (defaults to false)
|
||||
* @returns A tuple of [value, setValue] similar to useState
|
||||
*/
|
||||
export const useStoredBoolean = (
|
||||
storageKey: string,
|
||||
fallback: boolean = false,
|
||||
) => {
|
||||
const [value, setValue] = useState<boolean>(() => {
|
||||
const stored = localStorageAdapter.readBoolean(storageKey);
|
||||
return stored ?? fallback;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeBoolean(storageKey, value);
|
||||
}, [storageKey, value]);
|
||||
|
||||
return [value, setValue] as const;
|
||||
};
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
|
||||
export type ViewMode = "grid" | "list";
|
||||
export type ViewMode = "grid" | "list" | "tree";
|
||||
|
||||
const isViewMode = (value: string | null): value is ViewMode =>
|
||||
value === "grid" || value === "list";
|
||||
value === "grid" || value === "list" || value === "tree";
|
||||
|
||||
export const useStoredViewMode = (
|
||||
storageKey: string,
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useCallback } from "react";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
|
||||
export const useTrayPanelBackend = () => {
|
||||
const hideTrayPanel = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
await bridge?.hideTrayPanel?.();
|
||||
}, []);
|
||||
|
||||
const openMainWindow = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
await bridge?.openMainWindow?.();
|
||||
}, []);
|
||||
|
||||
const jumpToSession = useCallback(async (sessionId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
await bridge?.jumpToSessionFromTrayPanel?.(sessionId);
|
||||
}, []);
|
||||
|
||||
const connectToHostFromTrayPanel = useCallback(async (hostId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
await bridge?.connectToHostFromTrayPanel?.(hostId);
|
||||
}, []);
|
||||
|
||||
const onTrayPanelCloseRequest = useCallback((callback: () => void) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.onTrayPanelCloseRequest?.(callback);
|
||||
}, []);
|
||||
|
||||
const onTrayPanelRefresh = useCallback((callback: () => void) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.onTrayPanelRefresh?.(callback);
|
||||
}, []);
|
||||
|
||||
const onTrayPanelMenuData = useCallback(
|
||||
(
|
||||
callback: (data: {
|
||||
sessions?: Array<{ id: string; label: string; hostLabel: string; status: "connecting" | "connected" | "disconnected"; workspaceId?: string; workspaceTitle?: string }>;
|
||||
portForwardRules?: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
type: "local" | "remote" | "dynamic";
|
||||
localPort: number;
|
||||
remoteHost?: string;
|
||||
remotePort?: number;
|
||||
status: "inactive" | "connecting" | "active" | "error";
|
||||
hostId?: string;
|
||||
}>;
|
||||
}) => void,
|
||||
) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.onTrayPanelMenuData?.(callback);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
hideTrayPanel,
|
||||
openMainWindow,
|
||||
jumpToSession,
|
||||
connectToHostFromTrayPanel,
|
||||
onTrayPanelCloseRequest,
|
||||
onTrayPanelRefresh,
|
||||
onTrayPanelMenuData,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
|
||||
export const useTreeExpandedState = (storageKey: string) => {
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => {
|
||||
const stored = localStorageAdapter.readString(storageKey);
|
||||
if (stored) {
|
||||
try {
|
||||
const paths = JSON.parse(stored) as string[];
|
||||
return new Set(paths);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
return new Set();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const pathsArray = Array.from(expandedPaths);
|
||||
localStorageAdapter.writeString(storageKey, JSON.stringify(pathsArray));
|
||||
}, [storageKey, expandedPaths]);
|
||||
|
||||
const togglePath = (path: string) => {
|
||||
const newExpanded = new Set(expandedPaths);
|
||||
if (newExpanded.has(path)) {
|
||||
newExpanded.delete(path);
|
||||
} else {
|
||||
newExpanded.add(path);
|
||||
}
|
||||
setExpandedPaths(newExpanded);
|
||||
};
|
||||
|
||||
const expandAll = (allPaths: string[]) => {
|
||||
setExpandedPaths(new Set(allPaths));
|
||||
};
|
||||
|
||||
const collapseAll = () => {
|
||||
setExpandedPaths(new Set());
|
||||
};
|
||||
|
||||
return {
|
||||
expandedPaths,
|
||||
togglePath,
|
||||
expandAll,
|
||||
collapseAll,
|
||||
};
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Identity,
|
||||
KeyCategory,
|
||||
KnownHost,
|
||||
ManagedSource,
|
||||
ShellHistoryEntry,
|
||||
Snippet,
|
||||
SSHKey,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
STORAGE_KEY_KEYS,
|
||||
STORAGE_KEY_KNOWN_HOSTS,
|
||||
STORAGE_KEY_LEGACY_KEYS,
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
STORAGE_KEY_SHELL_HISTORY,
|
||||
STORAGE_KEY_SNIPPET_PACKAGES,
|
||||
STORAGE_KEY_SNIPPETS,
|
||||
@@ -95,6 +97,7 @@ export const useVaultState = () => {
|
||||
const [knownHosts, setKnownHosts] = useState<KnownHost[]>([]);
|
||||
const [shellHistory, setShellHistory] = useState<ShellHistoryEntry[]>([]);
|
||||
const [connectionLogs, setConnectionLogs] = useState<ConnectionLog[]>([]);
|
||||
const [managedSources, setManagedSources] = useState<ManagedSource[]>([]);
|
||||
|
||||
const updateHosts = useCallback((data: Host[]) => {
|
||||
const cleaned = data.map(sanitizeHost);
|
||||
@@ -132,6 +135,11 @@ export const useVaultState = () => {
|
||||
localStorageAdapter.write(STORAGE_KEY_KNOWN_HOSTS, data);
|
||||
}, []);
|
||||
|
||||
const updateManagedSources = useCallback((data: ManagedSource[]) => {
|
||||
setManagedSources(data);
|
||||
localStorageAdapter.write(STORAGE_KEY_MANAGED_SOURCES, data);
|
||||
}, []);
|
||||
|
||||
const clearVaultData = useCallback(() => {
|
||||
updateHosts([]);
|
||||
updateKeys([]);
|
||||
@@ -140,6 +148,7 @@ export const useVaultState = () => {
|
||||
updateSnippetPackages([]);
|
||||
updateCustomGroups([]);
|
||||
updateKnownHosts([]);
|
||||
updateManagedSources([]);
|
||||
localStorageAdapter.remove(STORAGE_KEY_LEGACY_KEYS);
|
||||
}, [
|
||||
updateHosts,
|
||||
@@ -149,6 +158,7 @@ export const useVaultState = () => {
|
||||
updateSnippetPackages,
|
||||
updateCustomGroups,
|
||||
updateKnownHosts,
|
||||
updateManagedSources,
|
||||
]);
|
||||
|
||||
const addShellHistoryEntry = useCallback(
|
||||
@@ -339,6 +349,12 @@ export const useVaultState = () => {
|
||||
STORAGE_KEY_CONNECTION_LOGS,
|
||||
);
|
||||
if (savedConnectionLogs) setConnectionLogs(savedConnectionLogs);
|
||||
|
||||
// Load managed sources
|
||||
const savedManagedSources = localStorageAdapter.read<ManagedSource[]>(
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
);
|
||||
if (savedManagedSources) setManagedSources(savedManagedSources);
|
||||
}, [updateHosts, updateSnippets]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -407,6 +423,12 @@ export const useVaultState = () => {
|
||||
if (key === STORAGE_KEY_CONNECTION_LOGS) {
|
||||
const next = safeParse<ConnectionLog[]>(event.newValue) ?? [];
|
||||
setConnectionLogs(next);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === STORAGE_KEY_MANAGED_SOURCES) {
|
||||
const next = safeParse<ManagedSource[]>(event.newValue) ?? [];
|
||||
setManagedSources(next);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -474,6 +496,7 @@ export const useVaultState = () => {
|
||||
knownHosts,
|
||||
shellHistory,
|
||||
connectionLogs,
|
||||
managedSources,
|
||||
updateHosts,
|
||||
updateKeys,
|
||||
updateIdentities,
|
||||
@@ -481,6 +504,7 @@ export const useVaultState = () => {
|
||||
updateSnippetPackages,
|
||||
updateCustomGroups,
|
||||
updateKnownHosts,
|
||||
updateManagedSources,
|
||||
addShellHistoryEntry,
|
||||
clearShellHistory,
|
||||
addConnectionLog,
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
import { ChevronRight, FileSymlink, Folder, FolderOpen, Monitor, Server, Expand, Minimize2 } from 'lucide-react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useTreeExpandedState } from '../application/state/useTreeExpandedState';
|
||||
import { sanitizeHost } from '../domain/host';
|
||||
import { STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED } from '../infrastructure/config/storageKeys';
|
||||
import { cn } from '../lib/utils';
|
||||
import { GroupNode, Host } from '../types';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from './ui/context-menu';
|
||||
import { DistroAvatar } from './DistroAvatar';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
interface HostTreeViewProps {
|
||||
groupTree: GroupNode[];
|
||||
hosts: Host[];
|
||||
sortMode?: 'az' | 'za' | 'newest' | 'oldest' | 'group';
|
||||
expandedPaths?: Set<string>;
|
||||
onTogglePath?: (path: string) => void;
|
||||
onExpandAll?: (paths: string[]) => void;
|
||||
onCollapseAll?: () => void;
|
||||
onConnect: (host: Host) => void;
|
||||
onEditHost: (host: Host) => void;
|
||||
onDuplicateHost: (host: Host) => void;
|
||||
onDeleteHost: (host: Host) => void;
|
||||
onCopyCredentials: (host: Host) => void;
|
||||
onNewHost: (groupPath?: string) => void;
|
||||
onNewGroup: (parentPath?: string) => void;
|
||||
onEditGroup: (groupPath: string) => void;
|
||||
onDeleteGroup: (groupPath: string) => void;
|
||||
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
|
||||
moveGroup: (sourcePath: string, targetPath: string) => void;
|
||||
managedGroupPaths?: Set<string>;
|
||||
onUnmanageGroup?: (groupPath: string) => void;
|
||||
}
|
||||
|
||||
interface TreeNodeProps {
|
||||
node: GroupNode;
|
||||
depth: number;
|
||||
sortMode: 'az' | 'za' | 'newest' | 'oldest' | 'group';
|
||||
expandedPaths: Set<string>;
|
||||
onToggle: (path: string) => void;
|
||||
onConnect: (host: Host) => void;
|
||||
onEditHost: (host: Host) => void;
|
||||
onDuplicateHost: (host: Host) => void;
|
||||
onDeleteHost: (host: Host) => void;
|
||||
onCopyCredentials: (host: Host) => void;
|
||||
onNewHost: (groupPath?: string) => void;
|
||||
onNewGroup: (parentPath?: string) => void;
|
||||
onEditGroup: (groupPath: string) => void;
|
||||
onDeleteGroup: (groupPath: string) => void;
|
||||
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
|
||||
moveGroup: (sourcePath: string, targetPath: string) => void;
|
||||
managedGroupPaths?: Set<string>;
|
||||
onUnmanageGroup?: (groupPath: string) => void;
|
||||
}
|
||||
|
||||
const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
node,
|
||||
depth,
|
||||
sortMode,
|
||||
expandedPaths,
|
||||
onToggle,
|
||||
onConnect,
|
||||
onEditHost,
|
||||
onDuplicateHost,
|
||||
onDeleteHost,
|
||||
onCopyCredentials,
|
||||
onNewHost,
|
||||
onNewGroup,
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
moveHostToGroup,
|
||||
moveGroup,
|
||||
managedGroupPaths,
|
||||
onUnmanageGroup,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isExpanded = expandedPaths.has(node.path);
|
||||
const hasChildren = node.children && Object.keys(node.children).length > 0;
|
||||
const paddingLeft = `${depth * 20 + 12}px`;
|
||||
const isManaged = managedGroupPaths?.has(node.path) ?? false;
|
||||
|
||||
const childNodes = useMemo(() => {
|
||||
if (!node.children) return [];
|
||||
const nodes = Object.values(node.children) as unknown as GroupNode[];
|
||||
return nodes.sort((a, b) => {
|
||||
switch (sortMode) {
|
||||
case 'za':
|
||||
return b.name.localeCompare(a.name);
|
||||
case 'newest':
|
||||
case 'oldest':
|
||||
// For groups, fall back to name sorting since groups don't have creation dates
|
||||
return a.name.localeCompare(b.name);
|
||||
case 'az':
|
||||
default:
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
});
|
||||
}, [node.children, sortMode]);
|
||||
|
||||
const sortedHosts = useMemo(() => {
|
||||
return [...node.hosts].sort((a, b) => {
|
||||
switch (sortMode) {
|
||||
case 'az':
|
||||
return a.label.localeCompare(b.label);
|
||||
case 'za':
|
||||
return b.label.localeCompare(a.label);
|
||||
case 'newest':
|
||||
return (b.createdAt || 0) - (a.createdAt || 0);
|
||||
case 'oldest':
|
||||
return (a.createdAt || 0) - (b.createdAt || 0);
|
||||
default:
|
||||
return a.label.localeCompare(b.label);
|
||||
}
|
||||
});
|
||||
}, [node.hosts, sortMode]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Group Node */}
|
||||
<Collapsible open={isExpanded} onOpenChange={() => onToggle(node.path)}>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center py-2 pr-3 text-sm font-medium cursor-pointer transition-colors select-none group hover:bg-secondary/60 rounded-lg",
|
||||
)}
|
||||
style={{ paddingLeft }}
|
||||
draggable
|
||||
onDragStart={(e) => e.dataTransfer.setData("group-path", node.path)}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const hostId = e.dataTransfer.getData("host-id");
|
||||
const groupPath = e.dataTransfer.getData("group-path");
|
||||
if (hostId) moveHostToGroup(hostId, node.path);
|
||||
if (groupPath) moveGroup(groupPath, node.path);
|
||||
}}
|
||||
>
|
||||
<div className="mr-2 flex-shrink-0 w-4 h-4 flex items-center justify-center">
|
||||
{(hasChildren || node.hosts.length > 0) && (
|
||||
<div className={cn("transition-transform duration-200", isExpanded ? "rotate-90" : "")}>
|
||||
<ChevronRight size={14} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mr-3 text-primary/80 group-hover:text-primary transition-colors">
|
||||
{isExpanded ? <FolderOpen size={18} /> : <Folder size={18} />}
|
||||
</div>
|
||||
<span className="truncate flex-1 font-semibold">{node.name}</span>
|
||||
{isManaged && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-medium px-1.5 py-0.5 rounded bg-primary/15 text-primary shrink-0 mr-1.5">
|
||||
<FileSymlink size={10} />
|
||||
Managed
|
||||
</span>
|
||||
)}
|
||||
{(node.hosts.length > 0 || hasChildren) && (
|
||||
<span className="text-xs opacity-70 bg-background/50 px-2 py-0.5 rounded-full border border-border">
|
||||
{node.hosts.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => onNewHost(node.path)}>
|
||||
<Server className="mr-2 h-4 w-4" /> {t("vault.hosts.newHost")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onNewGroup(node.path)}>
|
||||
<Folder className="mr-2 h-4 w-4" /> {t("vault.hosts.newGroup")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onEditGroup(node.path)}>
|
||||
<FolderOpen className="mr-2 h-4 w-4" /> {t("vault.groups.rename")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => onDeleteGroup(node.path)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<FolderOpen className="mr-2 h-4 w-4" /> {t("vault.groups.delete")}
|
||||
</ContextMenuItem>
|
||||
{isManaged && onUnmanageGroup && (
|
||||
<ContextMenuItem onClick={() => onUnmanageGroup(node.path)}>
|
||||
<FileSymlink className="mr-2 h-4 w-4" /> {t("vault.managedSource.unmanage")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
<CollapsibleContent>
|
||||
{/* Child Groups */}
|
||||
{childNodes.map((child) => (
|
||||
<TreeNode
|
||||
key={child.path}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
sortMode={sortMode}
|
||||
expandedPaths={expandedPaths}
|
||||
onToggle={onToggle}
|
||||
onConnect={onConnect}
|
||||
onEditHost={onEditHost}
|
||||
onDuplicateHost={onDuplicateHost}
|
||||
onDeleteHost={onDeleteHost}
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
onNewHost={onNewHost}
|
||||
onNewGroup={onNewGroup}
|
||||
onEditGroup={onEditGroup}
|
||||
onDeleteGroup={onDeleteGroup}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
moveGroup={moveGroup}
|
||||
managedGroupPaths={managedGroupPaths}
|
||||
onUnmanageGroup={onUnmanageGroup}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Hosts in this group */}
|
||||
{sortedHosts.map((host) => (
|
||||
<HostTreeItem
|
||||
key={host.id}
|
||||
host={host}
|
||||
depth={depth + 1}
|
||||
onConnect={onConnect}
|
||||
onEditHost={onEditHost}
|
||||
onDuplicateHost={onDuplicateHost}
|
||||
onDeleteHost={onDeleteHost}
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
/>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface HostTreeItemProps {
|
||||
host: Host;
|
||||
depth: number;
|
||||
onConnect: (host: Host) => void;
|
||||
onEditHost: (host: Host) => void;
|
||||
onDuplicateHost: (host: Host) => void;
|
||||
onDeleteHost: (host: Host) => void;
|
||||
onCopyCredentials: (host: Host) => void;
|
||||
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
|
||||
}
|
||||
|
||||
const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
host,
|
||||
depth,
|
||||
onConnect,
|
||||
onEditHost,
|
||||
onDuplicateHost,
|
||||
onDeleteHost,
|
||||
onCopyCredentials,
|
||||
moveHostToGroup: _moveHostToGroup,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const paddingLeft = `${depth * 20 + 12}px`;
|
||||
const safeHost = sanitizeHost(host);
|
||||
const tags = host.tags || [];
|
||||
const isTelnet = host.protocol === 'telnet';
|
||||
const displayUsername = isTelnet
|
||||
? (host.telnetUsername?.trim() || host.username?.trim() || '')
|
||||
: (host.username?.trim() || '');
|
||||
const displayPort = isTelnet
|
||||
? (host.telnetPort ?? host.port ?? 23)
|
||||
: (host.port ?? 22);
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>
|
||||
<div
|
||||
className="flex items-center py-2 pr-3 text-sm cursor-pointer transition-colors select-none group hover:bg-secondary/40 rounded-lg"
|
||||
style={{ paddingLeft }}
|
||||
draggable
|
||||
onDragStart={(e) => e.dataTransfer.setData("host-id", host.id)}
|
||||
onClick={() => onConnect(safeHost)}
|
||||
>
|
||||
<div className="mr-2 flex-shrink-0 w-4 h-4" />
|
||||
<div className="mr-3 flex-shrink-0">
|
||||
<DistroAvatar host={host} fallback={(host.os || "L")[0].toUpperCase()} size="sm" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{host.label}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{displayUsername}@{host.hostname}:{displayPort}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{host.protocol && host.protocol !== 'ssh' && (
|
||||
<span className="text-xs px-1.5 py-0.5 bg-primary/10 text-primary rounded">
|
||||
{host.protocol.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
{tags.length > 0 && (
|
||||
<span className="text-xs opacity-60">
|
||||
{tags.slice(0, 2).join(', ')}
|
||||
{tags.length > 2 && '...'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => onConnect(safeHost)}>
|
||||
<Monitor className="mr-2 h-4 w-4" /> {t("vault.hosts.connect")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onEditHost(host)}>
|
||||
<Server className="mr-2 h-4 w-4" /> {t("action.edit")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onDuplicateHost(host)}>
|
||||
<Server className="mr-2 h-4 w-4" /> {t("action.duplicate")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onCopyCredentials(host)}>
|
||||
<Server className="mr-2 h-4 w-4" /> {t("vault.hosts.copyCredentials")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => onDeleteHost(host)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Server className="mr-2 h-4 w-4" /> {t("action.delete")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export const HostTreeView: React.FC<HostTreeViewProps> = ({
|
||||
groupTree,
|
||||
hosts,
|
||||
sortMode = 'az',
|
||||
expandedPaths: externalExpandedPaths,
|
||||
onTogglePath: externalOnTogglePath,
|
||||
onExpandAll: externalOnExpandAll,
|
||||
onCollapseAll: externalOnCollapseAll,
|
||||
onConnect,
|
||||
onEditHost,
|
||||
onDuplicateHost,
|
||||
onDeleteHost,
|
||||
onCopyCredentials,
|
||||
onNewHost,
|
||||
onNewGroup,
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
moveHostToGroup,
|
||||
moveGroup,
|
||||
managedGroupPaths,
|
||||
onUnmanageGroup,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
// Use external state if provided, otherwise use local persistent state
|
||||
const localTreeState = useTreeExpandedState(STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED);
|
||||
|
||||
const expandedPaths = externalExpandedPaths || localTreeState.expandedPaths;
|
||||
const togglePath = externalOnTogglePath || localTreeState.togglePath;
|
||||
const expandAll = externalOnExpandAll || localTreeState.expandAll;
|
||||
const collapseAll = externalOnCollapseAll || localTreeState.collapseAll;
|
||||
|
||||
// Get all possible group paths for expand/collapse all functionality
|
||||
const getAllGroupPaths = (nodes: GroupNode[]): string[] => {
|
||||
const paths: string[] = [];
|
||||
const traverse = (nodeList: GroupNode[]) => {
|
||||
nodeList.forEach(node => {
|
||||
paths.push(node.path);
|
||||
if (node.children) {
|
||||
traverse(Object.values(node.children) as GroupNode[]);
|
||||
}
|
||||
});
|
||||
};
|
||||
traverse(nodes);
|
||||
return paths;
|
||||
};
|
||||
|
||||
const allGroupPaths = useMemo(() => getAllGroupPaths(groupTree), [groupTree]);
|
||||
|
||||
const handleExpandAll = () => {
|
||||
expandAll(allGroupPaths);
|
||||
};
|
||||
|
||||
const handleCollapseAll = () => {
|
||||
collapseAll();
|
||||
};
|
||||
|
||||
// Get ungrouped hosts (hosts without a group or with empty group) and sort them
|
||||
const ungroupedHosts = useMemo(() => {
|
||||
const hosts_without_group = hosts.filter(host => !host.group || host.group === '');
|
||||
return hosts_without_group.sort((a, b) => {
|
||||
switch (sortMode) {
|
||||
case 'az':
|
||||
return a.label.localeCompare(b.label);
|
||||
case 'za':
|
||||
return b.label.localeCompare(a.label);
|
||||
case 'newest':
|
||||
return (b.createdAt || 0) - (a.createdAt || 0);
|
||||
case 'oldest':
|
||||
return (a.createdAt || 0) - (b.createdAt || 0);
|
||||
default:
|
||||
return a.label.localeCompare(b.label);
|
||||
}
|
||||
});
|
||||
}, [hosts, sortMode]);
|
||||
|
||||
// Sort group tree based on sort mode
|
||||
const sortedGroupTree = useMemo(() => {
|
||||
return [...groupTree].sort((a, b) => {
|
||||
switch (sortMode) {
|
||||
case 'za':
|
||||
return b.name.localeCompare(a.name);
|
||||
case 'newest':
|
||||
case 'oldest':
|
||||
// For groups, fall back to name sorting since groups don't have creation dates
|
||||
return a.name.localeCompare(b.name);
|
||||
case 'az':
|
||||
default:
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
});
|
||||
}, [groupTree, sortMode]);
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{/* Expand/Collapse controls */}
|
||||
{groupTree.length > 0 && (
|
||||
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-border/30">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleExpandAll}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<Expand size={12} className="mr-1" />
|
||||
{t("vault.tree.expandAll")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCollapseAll}
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
<Minimize2 size={12} className="mr-1" />
|
||||
{t("vault.tree.collapseAll")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Group tree */}
|
||||
{sortedGroupTree.map((node) => (
|
||||
<TreeNode
|
||||
key={node.path}
|
||||
node={node}
|
||||
depth={0}
|
||||
sortMode={sortMode}
|
||||
expandedPaths={expandedPaths}
|
||||
onToggle={togglePath}
|
||||
onConnect={onConnect}
|
||||
onEditHost={onEditHost}
|
||||
onDuplicateHost={onDuplicateHost}
|
||||
onDeleteHost={onDeleteHost}
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
onNewHost={onNewHost}
|
||||
onNewGroup={onNewGroup}
|
||||
onEditGroup={onEditGroup}
|
||||
onDeleteGroup={onDeleteGroup}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
moveGroup={moveGroup}
|
||||
managedGroupPaths={managedGroupPaths}
|
||||
onUnmanageGroup={onUnmanageGroup}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Ungrouped hosts at root level */}
|
||||
{ungroupedHosts.map((host) => (
|
||||
<HostTreeItem
|
||||
key={host.id}
|
||||
host={host}
|
||||
depth={0}
|
||||
onConnect={onConnect}
|
||||
onEditHost={onEditHost}
|
||||
onDuplicateHost={onDuplicateHost}
|
||||
onDeleteHost={onDeleteHost}
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Empty state */}
|
||||
{ungroupedHosts.length === 0 && groupTree.length === 0 && (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<Server size={48} className="mx-auto mb-4 opacity-50" />
|
||||
<p className="text-sm">{t("vault.hosts.empty")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -23,6 +23,7 @@ import { STORAGE_KEY_VAULT_KEYS_VIEW_MODE } from "../infrastructure/config/stora
|
||||
import { logger } from "../lib/logger";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Host, Identity, KeyType, SSHKey } from "../types";
|
||||
import { ManagedSource } from "../domain/models";
|
||||
import { useKeychainBackend } from "../application/state/useKeychainBackend";
|
||||
import SelectHostPanel from "./SelectHostPanel";
|
||||
import {
|
||||
@@ -68,6 +69,7 @@ interface KeychainManagerProps {
|
||||
identities?: Identity[];
|
||||
hosts?: Host[];
|
||||
customGroups?: string[];
|
||||
managedSources?: ManagedSource[];
|
||||
onSave: (key: SSHKey) => void;
|
||||
onUpdate: (key: SSHKey) => void;
|
||||
onDelete: (id: string) => void;
|
||||
@@ -83,6 +85,7 @@ const KeychainManager: React.FC<KeychainManagerProps> = ({
|
||||
identities = [],
|
||||
hosts = [],
|
||||
customGroups = [],
|
||||
managedSources = [],
|
||||
onSave,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Passphrase Modal
|
||||
* Modal for requesting passphrase for encrypted SSH keys
|
||||
*/
|
||||
import { Eye, EyeOff, KeyRound, Loader2 } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "./ui/dialog";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
|
||||
export interface PassphraseRequest {
|
||||
requestId: string;
|
||||
keyPath: string;
|
||||
keyName: string;
|
||||
hostname?: string;
|
||||
}
|
||||
|
||||
interface PassphraseModalProps {
|
||||
request: PassphraseRequest | null;
|
||||
onSubmit: (requestId: string, passphrase: string) => void;
|
||||
onCancel: (requestId: string) => void;
|
||||
onSkip?: (requestId: string) => void;
|
||||
}
|
||||
|
||||
export const PassphraseModal: React.FC<PassphraseModalProps> = ({
|
||||
request,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
onSkip,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [passphrase, setPassphrase] = useState("");
|
||||
const [showPassphrase, setShowPassphrase] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Reset state when request changes
|
||||
useEffect(() => {
|
||||
if (request) {
|
||||
setPassphrase("");
|
||||
setShowPassphrase(false);
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [request]);
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!request || isSubmitting || !passphrase) return;
|
||||
setIsSubmitting(true);
|
||||
onSubmit(request.requestId, passphrase);
|
||||
}, [request, passphrase, onSubmit, isSubmitting]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
if (!request) return;
|
||||
onCancel(request.requestId);
|
||||
}, [request, onCancel]);
|
||||
|
||||
const handleSkip = useCallback(() => {
|
||||
if (!request || !onSkip) return;
|
||||
onSkip(request.requestId);
|
||||
}, [request, onSkip]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !isSubmitting && passphrase) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
},
|
||||
[handleSubmit, isSubmitting, passphrase]
|
||||
);
|
||||
|
||||
if (!request) return null;
|
||||
|
||||
const keyDisplayName = request.keyName || request.keyPath.split("/").pop() || "SSH Key";
|
||||
|
||||
return (
|
||||
<Dialog open={!!request} onOpenChange={(open) => !open && handleCancel()}>
|
||||
<DialogContent className="sm:max-w-[425px]" hideCloseButton>
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="h-10 w-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<KeyRound className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle>{t("passphrase.title")}</DialogTitle>
|
||||
<DialogDescription className="mt-1">
|
||||
{request.hostname
|
||||
? t("passphrase.descWithHost", { keyName: keyDisplayName, hostname: request.hostname })
|
||||
: t("passphrase.desc", { keyName: keyDisplayName })}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="passphrase-input">
|
||||
{t("passphrase.label")}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="passphrase-input"
|
||||
type={showPassphrase ? "text" : "password"}
|
||||
value={passphrase}
|
||||
onChange={(e) => setPassphrase(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder=""
|
||||
className="pr-10"
|
||||
autoFocus
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground disabled:opacity-50 p-1"
|
||||
onClick={() => setShowPassphrase(!showPassphrase)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{showPassphrase ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("passphrase.keyPath")}: <code className="text-xs">{request.keyPath}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleCancel}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
{onSkip && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleSkip}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("passphrase.skip")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button onClick={handleSubmit} disabled={isSubmitting || !passphrase}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t("passphrase.unlocking")}
|
||||
</>
|
||||
) : (
|
||||
t("passphrase.unlock")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default PassphraseModal;
|
||||
@@ -15,6 +15,7 @@ import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { usePortForwardingState } from "../application/state/usePortForwardingState";
|
||||
import {
|
||||
Host,
|
||||
ManagedSource,
|
||||
PortForwardingRule,
|
||||
PortForwardingType,
|
||||
SSHKey,
|
||||
@@ -64,6 +65,7 @@ interface PortForwardingProps {
|
||||
keys: SSHKey[];
|
||||
identities?: import('../domain/models').Identity[];
|
||||
customGroups: string[];
|
||||
managedSources?: ManagedSource[];
|
||||
onNewHost?: () => void;
|
||||
onSaveHost?: (host: Host) => void;
|
||||
onCreateGroup?: (groupPath: string) => void;
|
||||
@@ -74,6 +76,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
keys,
|
||||
identities = [],
|
||||
customGroups: _customGroups,
|
||||
managedSources = [],
|
||||
onNewHost: _onNewHost,
|
||||
onSaveHost,
|
||||
onCreateGroup: _onCreateGroup,
|
||||
@@ -698,6 +701,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
<RuleCard
|
||||
key={rule.id}
|
||||
rule={rule}
|
||||
host={hosts.find((h) => h.id === rule.hostId)}
|
||||
viewMode={viewMode}
|
||||
isSelected={selectedRuleId === rule.id}
|
||||
isPending={pendingOperations.has(rule.id)}
|
||||
@@ -844,6 +848,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
onContinue={() => setShowHostSelector(false)}
|
||||
availableKeys={keys}
|
||||
identities={identities}
|
||||
managedSources={managedSources}
|
||||
onSaveHost={onSaveHost}
|
||||
onCreateGroup={_onCreateGroup}
|
||||
/>
|
||||
|
||||
@@ -17,7 +17,6 @@ type QuickSwitcherItem = {
|
||||
data?: Host | TerminalSession | Workspace;
|
||||
};
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
|
||||
@@ -68,7 +67,7 @@ interface QuickSwitcherProps {
|
||||
onSelectTab: (tabId: string) => void;
|
||||
onClose: () => void;
|
||||
onCreateLocalTerminal?: () => void;
|
||||
onCreateWorkspace?: () => void;
|
||||
// onCreateWorkspace removed - feature not currently used
|
||||
keyBindings?: KeyBinding[];
|
||||
}
|
||||
|
||||
@@ -83,7 +82,6 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
onSelectTab,
|
||||
onClose,
|
||||
onCreateLocalTerminal,
|
||||
onCreateWorkspace,
|
||||
keyBindings,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
@@ -94,7 +92,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 +99,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 +130,8 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
[sessions]
|
||||
);
|
||||
|
||||
const showCategorized = isFocused || query.trim().length > 0;
|
||||
// Always show categorized view (Hosts/Tabs/Quick connect)
|
||||
const showCategorized = true;
|
||||
|
||||
// Memoize flat items list and index map
|
||||
const { flatItems, itemIndexMap } = useMemo(() => {
|
||||
@@ -232,7 +229,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"
|
||||
@@ -245,209 +241,163 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 h-full">
|
||||
{!showCategorized ? (
|
||||
/* Default view: Recent connections with header */
|
||||
<div>
|
||||
<div className="px-4 py-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t("qs.recentConnections")}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 px-2 text-[11px]"
|
||||
onClick={() => onCreateWorkspace?.()}
|
||||
>
|
||||
{t("qs.createWorkspace")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 px-2 text-[11px]"
|
||||
disabled
|
||||
>
|
||||
{t("qs.restore")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{results.length > 0 ? (
|
||||
results.map((host) => (
|
||||
<HostItem
|
||||
key={host.id}
|
||||
host={host}
|
||||
isSelected={getItemIndex("host", host.id) === selectedIndex}
|
||||
onSelect={onSelect}
|
||||
onMouseEnter={() => setSelectedIndex(getItemIndex("host", host.id))}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="px-4 py-6 text-sm text-muted-foreground text-center">
|
||||
No recent connections
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Categorized view: Hosts/Tabs/Quick connect */}
|
||||
<div>
|
||||
{/* Jump To hint */}
|
||||
<div className="px-4 py-2 flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">{t("qs.jumpTo")}</span>
|
||||
{quickSwitchKey && (
|
||||
<kbd className="text-[10px] text-muted-foreground bg-muted px-1 py-0.5 rounded">
|
||||
{quickSwitchKey.replace(/ \+ /g, '+')}
|
||||
</kbd>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Focused/searching view: Categorized items */
|
||||
|
||||
{/* Hosts section */}
|
||||
{results.length > 0 && (
|
||||
<div>
|
||||
<div className="px-4 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Hosts
|
||||
</span>
|
||||
</div>
|
||||
{results.map((host) => (
|
||||
<HostItem
|
||||
key={host.id}
|
||||
host={host}
|
||||
isSelected={getItemIndex("host", host.id) === selectedIndex}
|
||||
onSelect={onSelect}
|
||||
onMouseEnter={() => setSelectedIndex(getItemIndex("host", host.id))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs section */}
|
||||
<div>
|
||||
{/* Jump To hint */}
|
||||
<div className="px-4 py-2 flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">{t("qs.jumpTo")}</span>
|
||||
{quickSwitchKey && (
|
||||
<kbd className="text-[10px] text-muted-foreground bg-muted px-1 py-0.5 rounded">
|
||||
{quickSwitchKey.replace(/ \+ /g, '+')}
|
||||
</kbd>
|
||||
)}
|
||||
<div className="px-4 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Tabs
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Hosts section */}
|
||||
{results.length > 0 && (
|
||||
<div>
|
||||
<div className="px-4 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Hosts
|
||||
{/* Built-in tabs */}
|
||||
{["vault", "sftp"].map((tabId) => {
|
||||
const idx = getItemIndex("tab", tabId);
|
||||
const isSelected = idx === selectedIndex;
|
||||
const icon =
|
||||
tabId === "vault" ? (
|
||||
<Shield size={16} />
|
||||
) : (
|
||||
<Folder size={16} />
|
||||
);
|
||||
const label = tabId === "vault" ? "Vaults" : "SFTP";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tabId}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onSelectTab(tabId);
|
||||
onClose();
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(idx)}
|
||||
>
|
||||
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
|
||||
{icon}
|
||||
</div>
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Workspaces */}
|
||||
{workspaces.map((workspace) => {
|
||||
const idx = getItemIndex("workspace", workspace.id);
|
||||
const isSelected = idx === selectedIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={workspace.id}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onSelectTab(workspace.id);
|
||||
onClose();
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(idx)}
|
||||
>
|
||||
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
|
||||
<LayoutGrid size={16} />
|
||||
</div>
|
||||
<span className="text-sm font-medium">
|
||||
{workspace.title}
|
||||
</span>
|
||||
</div>
|
||||
{results.map((host) => (
|
||||
<HostItem
|
||||
key={host.id}
|
||||
host={host}
|
||||
isSelected={getItemIndex("host", host.id) === selectedIndex}
|
||||
onSelect={onSelect}
|
||||
onMouseEnter={() => setSelectedIndex(getItemIndex("host", host.id))}
|
||||
/>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Orphan sessions */}
|
||||
{orphanSessions.map((session) => {
|
||||
const idx = getItemIndex("tab", session.id);
|
||||
const isSelected = idx === selectedIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onSelectTab(session.id);
|
||||
onClose();
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(idx)}
|
||||
>
|
||||
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
|
||||
<TerminalSquare size={16} />
|
||||
</div>
|
||||
<span className="text-sm font-medium">
|
||||
{session.hostLabel}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Quick connect section */}
|
||||
<div>
|
||||
<div className="px-4 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Quick connect
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Local Terminal */}
|
||||
{onCreateLocalTerminal && (
|
||||
<div
|
||||
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${getItemIndex("action", "local-terminal") === selectedIndex
|
||||
? "bg-primary/15"
|
||||
: "hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onCreateLocalTerminal();
|
||||
onClose();
|
||||
}}
|
||||
onMouseEnter={() =>
|
||||
setSelectedIndex(getItemIndex("action", "local-terminal"))
|
||||
}
|
||||
>
|
||||
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
|
||||
<Terminal size={16} />
|
||||
</div>
|
||||
<span className="text-sm font-medium">{t("qs.localTerminal")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs section */}
|
||||
<div>
|
||||
<div className="px-4 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Tabs
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Built-in tabs */}
|
||||
{["vault", "sftp"].map((tabId) => {
|
||||
const idx = getItemIndex("tab", tabId);
|
||||
const isSelected = idx === selectedIndex;
|
||||
const icon =
|
||||
tabId === "vault" ? (
|
||||
<Shield size={16} />
|
||||
) : (
|
||||
<Folder size={16} />
|
||||
);
|
||||
const label = tabId === "vault" ? "Vaults" : "SFTP";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tabId}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onSelectTab(tabId);
|
||||
onClose();
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(idx)}
|
||||
>
|
||||
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
|
||||
{icon}
|
||||
</div>
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Workspaces */}
|
||||
{workspaces.map((workspace) => {
|
||||
const idx = getItemIndex("workspace", workspace.id);
|
||||
const isSelected = idx === selectedIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={workspace.id}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onSelectTab(workspace.id);
|
||||
onClose();
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(idx)}
|
||||
>
|
||||
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
|
||||
<LayoutGrid size={16} />
|
||||
</div>
|
||||
<span className="text-sm font-medium">
|
||||
{workspace.title}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Orphan sessions */}
|
||||
{orphanSessions.map((session) => {
|
||||
const idx = getItemIndex("tab", session.id);
|
||||
const isSelected = idx === selectedIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${isSelected ? "bg-primary/15" : "hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onSelectTab(session.id);
|
||||
onClose();
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(idx)}
|
||||
>
|
||||
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
|
||||
<TerminalSquare size={16} />
|
||||
</div>
|
||||
<span className="text-sm font-medium">
|
||||
{session.hostLabel}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Quick connect section */}
|
||||
<div>
|
||||
<div className="px-4 py-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
Quick connect
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Local Terminal */}
|
||||
{onCreateLocalTerminal && (
|
||||
<div
|
||||
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors ${getItemIndex("action", "local-terminal") === selectedIndex
|
||||
? "bg-primary/15"
|
||||
: "hover:bg-muted/50"
|
||||
}`}
|
||||
onClick={() => {
|
||||
onCreateLocalTerminal();
|
||||
onClose();
|
||||
}}
|
||||
onMouseEnter={() =>
|
||||
setSelectedIndex(getItemIndex("action", "local-terminal"))
|
||||
}
|
||||
>
|
||||
<div className="h-6 w-6 rounded flex items-center justify-center text-muted-foreground">
|
||||
<Terminal size={16} />
|
||||
</div>
|
||||
<span className="text-sm font-medium">{t("qs.localTerminal")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Serial removed (not supported) */}
|
||||
</div>
|
||||
{/* Serial removed (not supported) */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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";
|
||||
@@ -20,6 +21,7 @@ import { useSftpModalPath } from "./sftp-modal/hooks/useSftpModalPath";
|
||||
import { useSftpModalSelection } from "./sftp-modal/hooks/useSftpModalSelection";
|
||||
import { useSftpModalSession } from "./sftp-modal/hooks/useSftpModalSession";
|
||||
import { useSftpModalFileActions } from "./sftp-modal/hooks/useSftpModalFileActions";
|
||||
import { useSftpModalKeyboardShortcuts } from "./sftp-modal/hooks/useSftpModalKeyboardShortcuts";
|
||||
import { joinPath, isRootPath, getParentPath } from "./sftp-modal/pathUtils";
|
||||
import { toast } from "./ui/toast";
|
||||
import { Dialog, DialogContent } from "./ui/dialog";
|
||||
@@ -45,6 +47,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 +57,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
initialPath,
|
||||
initialEntriesToUpload,
|
||||
}) => {
|
||||
const {
|
||||
openSftp,
|
||||
@@ -78,15 +83,17 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
cancelSftpUpload,
|
||||
startStreamTransfer,
|
||||
cancelTransfer,
|
||||
showSaveDialog,
|
||||
} = useSftpBackend();
|
||||
const { t } = useI18n();
|
||||
const { sftpAutoSync, sftpShowHiddenFiles } = useSettingsState();
|
||||
const { sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, hotkeyScheme, keyBindings } = 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 +354,13 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
uploadTasks,
|
||||
dragActive,
|
||||
handleDownload,
|
||||
handleUploadEntries,
|
||||
handleFileSelect,
|
||||
handleFolderSelect,
|
||||
handleDrag,
|
||||
handleDrop,
|
||||
cancelUpload,
|
||||
cancelTask,
|
||||
dismissTask,
|
||||
} = useSftpModalTransfers({
|
||||
currentPath,
|
||||
@@ -369,8 +379,10 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
cancelSftpUpload,
|
||||
startStreamTransfer,
|
||||
cancelTransfer,
|
||||
showSaveDialog,
|
||||
setLoading,
|
||||
t,
|
||||
useCompressedUpload: sftpUseCompressedUpload,
|
||||
});
|
||||
|
||||
const handleClose = async () => {
|
||||
@@ -378,6 +390,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
|
||||
@@ -460,6 +509,56 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
onNavigateUp: handleUp,
|
||||
});
|
||||
|
||||
// Keyboard shortcuts for modal
|
||||
const handleKeyboardRename = useCallback((file: RemoteFile) => {
|
||||
openRenameDialog(file);
|
||||
}, [openRenameDialog]);
|
||||
|
||||
const handleKeyboardDelete = useCallback((fileNames: string[]) => {
|
||||
// Find the files to pass to confirm dialog
|
||||
if (fileNames.length === 0) return;
|
||||
if (!confirm(t("sftp.deleteConfirm.title", { count: fileNames.length }))) return;
|
||||
|
||||
// Delete files
|
||||
(async () => {
|
||||
try {
|
||||
for (const fileName of fileNames) {
|
||||
const fullPath = joinPathForSession(currentPath, fileName);
|
||||
if (isLocalSession) {
|
||||
await deleteLocalFile(fullPath);
|
||||
} else {
|
||||
await deleteSftpWithEncoding(await ensureSftp(), fullPath);
|
||||
}
|
||||
}
|
||||
await loadFiles(currentPath, { force: true });
|
||||
setSelectedFiles(new Set());
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.deleteFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
}
|
||||
})();
|
||||
}, [currentPath, isLocalSession, deleteLocalFile, deleteSftpWithEncoding, ensureSftp, loadFiles, setSelectedFiles, t, joinPathForSession]);
|
||||
|
||||
const handleKeyboardNewFolder = useCallback(() => {
|
||||
handleCreateFolder();
|
||||
}, [handleCreateFolder]);
|
||||
|
||||
useSftpModalKeyboardShortcuts({
|
||||
keyBindings,
|
||||
hotkeyScheme,
|
||||
open,
|
||||
files,
|
||||
visibleFiles: displayFiles,
|
||||
selectedFiles,
|
||||
setSelectedFiles,
|
||||
onRefresh: () => loadFiles(currentPath, { force: true }),
|
||||
onRename: handleKeyboardRename,
|
||||
onDelete: handleKeyboardDelete,
|
||||
onNewFolder: handleKeyboardNewFolder,
|
||||
});
|
||||
|
||||
const handleDeleteSelected = async () => {
|
||||
if (selectedFiles.size === 0) return;
|
||||
const fileNames = Array.from(selectedFiles);
|
||||
@@ -526,12 +625,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 +654,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
visibleRows={visibleRows}
|
||||
fileListRef={fileListRef}
|
||||
inputRef={inputRef}
|
||||
folderInputRef={folderInputRef}
|
||||
handleSort={handleSort}
|
||||
handleResizeStart={handleResizeStart}
|
||||
handleFileListScroll={handleFileListScroll}
|
||||
@@ -576,7 +679,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);
|
||||
|
||||
@@ -211,6 +211,11 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
setSessionLogsDir={settings.setSessionLogsDir}
|
||||
sessionLogsFormat={settings.sessionLogsFormat}
|
||||
setSessionLogsFormat={settings.setSessionLogsFormat}
|
||||
toggleWindowHotkey={settings.toggleWindowHotkey}
|
||||
setToggleWindowHotkey={settings.setToggleWindowHotkey}
|
||||
closeToTray={settings.closeToTray}
|
||||
setCloseToTray={settings.setCloseToTray}
|
||||
hotkeyRegistrationError={settings.hotkeyRegistrationError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -14,10 +14,11 @@
|
||||
* - components/sftp/SftpHostPicker.tsx - Host selection dialog
|
||||
*/
|
||||
|
||||
import React, { memo, useLayoutEffect, useMemo, useRef } from "react";
|
||||
import React, { memo, useCallback, 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";
|
||||
@@ -36,6 +37,8 @@ import { Loader2 } from "lucide-react";
|
||||
import { SftpContextProvider, activeTabStore } from "./sftp";
|
||||
import { useSftpViewPaneCallbacks } from "./sftp/hooks/useSftpViewPaneCallbacks";
|
||||
import { useSftpViewTabs } from "./sftp/hooks/useSftpViewTabs";
|
||||
import { useSftpKeyboardShortcuts } from "./sftp/hooks/useSftpKeyboardShortcuts";
|
||||
import { sftpFocusStore, SftpFocusedSide, useSftpFocusedSide } from "./sftp/hooks/useSftpFocusedPane";
|
||||
|
||||
// Wrapper component that subscribes to activeTabId for CSS visibility
|
||||
// This isolates the activeTabId subscription - only this component re-renders on tab switch
|
||||
@@ -50,7 +53,7 @@ interface SftpViewProps {
|
||||
const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) => {
|
||||
const { t } = useI18n();
|
||||
const isActive = useIsSftpActive();
|
||||
const { sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles } = useSettingsState();
|
||||
const { sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, hotkeyScheme, keyBindings } = useSettingsState();
|
||||
|
||||
// File watch event handlers (stable refs to avoid re-creating the useSftpState options)
|
||||
const fileWatchHandlers = useMemo(() => ({
|
||||
@@ -67,6 +70,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);
|
||||
@@ -80,6 +86,23 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
|
||||
const autoSyncRef = useRef(sftpAutoSync);
|
||||
autoSyncRef.current = sftpAutoSync;
|
||||
|
||||
// SFTP keyboard shortcuts handler
|
||||
useSftpKeyboardShortcuts({
|
||||
keyBindings,
|
||||
hotkeyScheme,
|
||||
sftpRef,
|
||||
isActive,
|
||||
showHiddenFiles: sftpShowHiddenFiles,
|
||||
});
|
||||
|
||||
// Subscribe to focused side for visual indicator
|
||||
const focusedSide = useSftpFocusedSide();
|
||||
|
||||
// Handle pane focus when clicking on a pane container
|
||||
const handlePaneFocus = useCallback((side: SftpFocusedSide) => {
|
||||
sftpFocusStore.setFocusedSide(side);
|
||||
}, []);
|
||||
|
||||
// Sync activeTabId to external store (allows child components to subscribe without parent re-render)
|
||||
// Using useLayoutEffect to sync before paint
|
||||
useLayoutEffect(() => {
|
||||
@@ -130,6 +153,9 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
|
||||
getOpenerForFileRef,
|
||||
setOpenerForExtension,
|
||||
t,
|
||||
showSaveDialog,
|
||||
startStreamTransfer,
|
||||
getSftpIdForConnection: sftp.getSftpIdForConnection,
|
||||
});
|
||||
|
||||
const visibleTransfers = useMemo(
|
||||
@@ -193,7 +219,23 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
|
||||
style={containerStyle}
|
||||
>
|
||||
<div className="flex-1 grid grid-cols-1 lg:grid-cols-2 min-h-0 border-t border-border/70">
|
||||
<div className="relative border-r border-border/70 flex flex-col">
|
||||
<div
|
||||
className="relative border-r border-border/70 flex flex-col"
|
||||
onClick={() => handlePaneFocus("left")}
|
||||
>
|
||||
{/* Focus indicator triangle */}
|
||||
{focusedSide === "left" && (
|
||||
<div
|
||||
className="absolute top-0 left-0 z-50 pointer-events-none"
|
||||
style={{
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderStyle: 'solid',
|
||||
borderWidth: '12px 12px 0 0',
|
||||
borderColor: 'hsl(var(--primary)) transparent transparent transparent',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Left side tab bar - only show when there are tabs */}
|
||||
{leftTabsInfo.length > 0 && (
|
||||
<SftpTabBar
|
||||
@@ -233,7 +275,23 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex flex-col">
|
||||
<div
|
||||
className="relative flex flex-col"
|
||||
onClick={() => handlePaneFocus("right")}
|
||||
>
|
||||
{/* Focus indicator triangle */}
|
||||
{focusedSide === "right" && (
|
||||
<div
|
||||
className="absolute top-0 left-0 z-50 pointer-events-none"
|
||||
style={{
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderStyle: 'solid',
|
||||
borderWidth: '12px 12px 0 0',
|
||||
borderColor: 'hsl(var(--primary)) transparent transparent transparent',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Right side tab bar - only show when there are tabs */}
|
||||
{rightTabsInfo.length > 0 && (
|
||||
<SftpTabBar
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Check, ChevronDown, Clock, Copy, Edit2, FileCode, FolderPlus, LayoutGrid, List as ListIcon, Loader2, Package, Play, Plus, Search, Trash2 } from 'lucide-react';
|
||||
import { Check, ChevronDown, Clock, Copy, Edit2, FileCode, FolderPlus, Keyboard, LayoutGrid, List as ListIcon, Loader2, Package, Play, Plus, RotateCcw, Search, Trash2 } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useStoredViewMode } from '../application/state/useStoredViewMode';
|
||||
import { STORAGE_KEY_VAULT_SNIPPETS_VIEW_MODE } from '../infrastructure/config/storageKeys';
|
||||
import { cn } from '../lib/utils';
|
||||
import { cn, isMacPlatform } from '../lib/utils';
|
||||
import { Host, ShellHistoryEntry, Snippet, SSHKey } from '../types';
|
||||
import { HotkeyScheme, KeyBinding, keyEventToString, ManagedSource, matchesKeyBinding, parseKeyCombo } from '../domain/models';
|
||||
import { DistroAvatar } from './DistroAvatar';
|
||||
import SelectHostPanel from './SelectHostPanel';
|
||||
import { AsidePanel, AsidePanelContent } from './ui/aside-panel';
|
||||
@@ -24,12 +25,15 @@ interface SnippetsManagerProps {
|
||||
hosts: Host[];
|
||||
customGroups?: string[];
|
||||
shellHistory: ShellHistoryEntry[];
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
keyBindings: KeyBinding[];
|
||||
onSave: (snippet: Snippet) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onPackagesChange: (packages: string[]) => void;
|
||||
onRunSnippet?: (snippet: Snippet, targetHosts: Host[]) => void;
|
||||
// Props for inline host creation
|
||||
availableKeys?: SSHKey[];
|
||||
managedSources?: ManagedSource[];
|
||||
onSaveHost?: (host: Host) => void;
|
||||
onCreateGroup?: (groupPath: string) => void;
|
||||
}
|
||||
@@ -44,11 +48,14 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
hosts,
|
||||
customGroups = [],
|
||||
shellHistory,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
onSave,
|
||||
onDelete,
|
||||
onPackagesChange,
|
||||
onRunSnippet,
|
||||
availableKeys = [],
|
||||
managedSources = [],
|
||||
onSaveHost,
|
||||
onCreateGroup,
|
||||
}) => {
|
||||
@@ -67,6 +74,12 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
const [newPackageName, setNewPackageName] = useState('');
|
||||
const [isPackageDialogOpen, setIsPackageDialogOpen] = useState(false);
|
||||
|
||||
// Rename package state
|
||||
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
|
||||
const [renamingPackagePath, setRenamingPackagePath] = useState<string | null>(null);
|
||||
const [renamePackageName, setRenamePackageName] = useState('');
|
||||
const [renameError, setRenameError] = useState('');
|
||||
|
||||
// Search, sort, and view mode state
|
||||
const [search, setSearch] = useState('');
|
||||
const [viewMode, setViewMode] = useStoredViewMode(
|
||||
@@ -80,6 +93,187 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
const historyScrollRef = useRef<HTMLDivElement>(null);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
|
||||
// Shortkey recording state
|
||||
const [isRecordingShortkey, setIsRecordingShortkey] = useState(false);
|
||||
const [shortkeyError, setShortkeyError] = useState<string | null>(null);
|
||||
|
||||
const existingShortkeys = useMemo(() => (
|
||||
snippets.filter(s => Boolean(s.shortkey) && s.id !== editingSnippet.id)
|
||||
), [snippets, editingSnippet.id]);
|
||||
|
||||
const isMac = useMemo(() => (
|
||||
hotkeyScheme === 'mac' || (hotkeyScheme === 'disabled' && isMacPlatform())
|
||||
), [hotkeyScheme]);
|
||||
|
||||
const activeSystemBindings = useMemo(() => {
|
||||
return keyBindings.flatMap((binding) => {
|
||||
const entries: { binding: string; isMac: boolean }[] = [];
|
||||
const macBinding = binding.mac;
|
||||
const pcBinding = binding.pc;
|
||||
|
||||
if (hotkeyScheme === 'mac') {
|
||||
if (macBinding && macBinding !== 'Disabled') {
|
||||
entries.push({ binding: macBinding, isMac: true });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
if (hotkeyScheme === 'pc') {
|
||||
if (pcBinding && pcBinding !== 'Disabled') {
|
||||
entries.push({ binding: pcBinding, isMac: false });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
if (macBinding && macBinding !== 'Disabled') {
|
||||
entries.push({ binding: macBinding, isMac: true });
|
||||
}
|
||||
if (pcBinding && pcBinding !== 'Disabled') {
|
||||
entries.push({ binding: pcBinding, isMac: false });
|
||||
}
|
||||
return entries;
|
||||
});
|
||||
}, [hotkeyScheme, keyBindings]);
|
||||
|
||||
const buildKeyEventFromString = useCallback((keyString: string) => {
|
||||
const parsed = parseKeyCombo(keyString);
|
||||
if (!parsed) return null;
|
||||
|
||||
const modifiers = new Set(parsed.modifiers);
|
||||
const key = parsed.key;
|
||||
const normalizedKey = (() => {
|
||||
switch (key) {
|
||||
case 'Space':
|
||||
return ' ';
|
||||
case '↑':
|
||||
return 'ArrowUp';
|
||||
case '↓':
|
||||
return 'ArrowDown';
|
||||
case '←':
|
||||
return 'ArrowLeft';
|
||||
case '→':
|
||||
return 'ArrowRight';
|
||||
case 'Esc':
|
||||
return 'Escape';
|
||||
case '⌫':
|
||||
return 'Backspace';
|
||||
case 'Del':
|
||||
return 'Delete';
|
||||
case '↵':
|
||||
return 'Enter';
|
||||
case '⇥':
|
||||
return 'Tab';
|
||||
default:
|
||||
return key.length === 1 ? key.toLowerCase() : key;
|
||||
}
|
||||
})();
|
||||
|
||||
return new KeyboardEvent('keydown', {
|
||||
key: normalizedKey,
|
||||
metaKey: modifiers.has('⌘') || modifiers.has('Win'),
|
||||
ctrlKey: modifiers.has('⌃') || modifiers.has('Ctrl'),
|
||||
altKey: modifiers.has('⌥') || modifiers.has('Alt'),
|
||||
shiftKey: modifiers.has('Shift'),
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Validate shortkey for conflicts (case-insensitive comparison)
|
||||
const normalizeKeyString = useCallback((value: string) => (
|
||||
value.toLowerCase().replace(/\s+/g, '')
|
||||
), []);
|
||||
|
||||
const validateShortkey = useCallback((key: string): string | null => {
|
||||
if (!key) return null;
|
||||
|
||||
const syntheticEvent = buildKeyEventFromString(key);
|
||||
if (syntheticEvent) {
|
||||
const conflictsSystem = activeSystemBindings.some(({ binding, isMac: bindingIsMac }) => (
|
||||
matchesKeyBinding(syntheticEvent, binding, bindingIsMac)
|
||||
));
|
||||
if (conflictsSystem) {
|
||||
return t('snippets.shortkey.error.systemConflict');
|
||||
}
|
||||
}
|
||||
|
||||
// Check other snippet shortcuts
|
||||
if (syntheticEvent) {
|
||||
for (const snippet of existingShortkeys) {
|
||||
if (snippet.shortkey && matchesKeyBinding(syntheticEvent, snippet.shortkey, isMac)) {
|
||||
return t('snippets.shortkey.error.snippetConflict', { name: snippet.label });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const normalizedKey = normalizeKeyString(key);
|
||||
const conflictingSnippet = existingShortkeys.find(snippet => (
|
||||
snippet.shortkey && normalizeKeyString(snippet.shortkey) === normalizedKey
|
||||
));
|
||||
if (conflictingSnippet) {
|
||||
return t('snippets.shortkey.error.snippetConflict', { name: conflictingSnippet.label });
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [
|
||||
activeSystemBindings,
|
||||
buildKeyEventFromString,
|
||||
existingShortkeys,
|
||||
isMac,
|
||||
normalizeKeyString,
|
||||
t,
|
||||
]);
|
||||
|
||||
// Handle shortkey recording
|
||||
useEffect(() => {
|
||||
if (!isRecordingShortkey) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Escape cancels recording
|
||||
if (e.key === 'Escape') {
|
||||
setIsRecordingShortkey(false);
|
||||
setShortkeyError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip pure modifier keys
|
||||
if (['Meta', 'Control', 'Alt', 'Shift'].includes(e.key)) return;
|
||||
|
||||
const keyString = keyEventToString(e, isMac);
|
||||
|
||||
// Validate the new shortkey
|
||||
const error = validateShortkey(keyString);
|
||||
if (error) {
|
||||
setShortkeyError(error);
|
||||
// Don't stop recording, let user try again
|
||||
return;
|
||||
}
|
||||
|
||||
setShortkeyError(null);
|
||||
setEditingSnippet(prev => ({ ...prev, shortkey: keyString }));
|
||||
setIsRecordingShortkey(false);
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
setIsRecordingShortkey(false);
|
||||
setShortkeyError(null);
|
||||
};
|
||||
|
||||
// Delay adding click handler by 100ms to prevent the button click that
|
||||
// initiated recording from immediately triggering the click handler
|
||||
const timer = setTimeout(() => {
|
||||
window.addEventListener('click', handleClick, true);
|
||||
}, 100);
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
window.removeEventListener('keydown', handleKeyDown, true);
|
||||
window.removeEventListener('click', handleClick, true);
|
||||
};
|
||||
}, [isRecordingShortkey, isMac, validateShortkey]);
|
||||
|
||||
const handleEdit = (snippet?: Snippet) => {
|
||||
if (snippet) {
|
||||
setEditingSnippet(snippet);
|
||||
@@ -105,6 +299,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
tags: editingSnippet.tags || [],
|
||||
package: editingSnippet.package || '',
|
||||
targets: targetSelection,
|
||||
shortkey: editingSnippet.shortkey,
|
||||
});
|
||||
setRightPanelMode('none');
|
||||
}
|
||||
@@ -144,23 +339,60 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
|
||||
const displayedPackages = useMemo(() => {
|
||||
if (!selectedPackage) {
|
||||
const roots = packages
|
||||
// Separate absolute paths (starting with /) from relative paths
|
||||
const absolutePaths = packages.filter(p => p.startsWith('/'));
|
||||
const relativePaths = packages.filter(p => !p.startsWith('/'));
|
||||
|
||||
const results: { name: string; path: string; count: number }[] = [];
|
||||
|
||||
// Process relative paths (traditional behavior)
|
||||
const relativeRoots = relativePaths
|
||||
.map((p) => p.split('/')[0])
|
||||
.filter(Boolean);
|
||||
return Array.from(new Set(roots)).map((name) => {
|
||||
const path = name;
|
||||
const count = snippets.filter((s) => (s.package || '') === path).length;
|
||||
return { name, path, count };
|
||||
.filter((name): name is string => Boolean(name) && name.length > 0);
|
||||
|
||||
Array.from(new Set(relativeRoots)).forEach((name: string) => {
|
||||
const path: string = name;
|
||||
const count = snippets.filter((s) => {
|
||||
const pkg = s.package || '';
|
||||
return pkg === path || pkg.startsWith(path + '/');
|
||||
}).length;
|
||||
results.push({ name, path, count });
|
||||
});
|
||||
|
||||
// Process absolute paths - show them as separate roots with "/" prefix
|
||||
const absoluteRoots = absolutePaths
|
||||
.map((p) => {
|
||||
const cleanPath = p.substring(1); // Remove leading slash
|
||||
const firstSegment = cleanPath.split('/')[0];
|
||||
return firstSegment;
|
||||
})
|
||||
.filter((name): name is string => Boolean(name) && name.length > 0);
|
||||
|
||||
Array.from(new Set(absoluteRoots)).forEach((name: string) => {
|
||||
const path: string = `/${name}`;
|
||||
const displayName: string = `/${name}`; // Show with leading slash to distinguish
|
||||
const count = snippets.filter((s) => {
|
||||
const pkg = s.package || '';
|
||||
return pkg === path || pkg.startsWith(path + '/');
|
||||
}).length;
|
||||
results.push({ name: displayName, path, count });
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
const prefix = selectedPackage + '/';
|
||||
const children = packages
|
||||
.filter((p) => p.startsWith(prefix))
|
||||
.map((p) => p.replace(prefix, '').split('/')[0])
|
||||
.filter(Boolean);
|
||||
.filter((name): name is string => Boolean(name) && name.length > 0);
|
||||
return Array.from(new Set(children)).map((name) => {
|
||||
const path = `${selectedPackage}/${name}`;
|
||||
const count = snippets.filter((s) => (s.package || '') === path).length;
|
||||
// Count snippets in this package AND all nested packages
|
||||
const count = snippets.filter((s) => {
|
||||
const pkg = s.package || '';
|
||||
return pkg === path || pkg.startsWith(path + '/');
|
||||
}).length;
|
||||
return { name, path, count };
|
||||
});
|
||||
}, [packages, selectedPackage, snippets]);
|
||||
@@ -191,28 +423,76 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
|
||||
const breadcrumb = useMemo(() => {
|
||||
if (!selectedPackage) return [];
|
||||
const isAbsolute = selectedPackage.startsWith('/');
|
||||
const parts = selectedPackage.split('/').filter(Boolean);
|
||||
return parts.map((name, idx) => ({ name, path: parts.slice(0, idx + 1).join('/') }));
|
||||
return parts.map((name, idx) => {
|
||||
const pathSegments = parts.slice(0, idx + 1);
|
||||
const path = isAbsolute ? `/${pathSegments.join('/')}` : pathSegments.join('/');
|
||||
return { name, path };
|
||||
});
|
||||
}, [selectedPackage]);
|
||||
|
||||
const createPackage = () => {
|
||||
const name = newPackageName.trim();
|
||||
if (!name) return;
|
||||
const full = selectedPackage ? `${selectedPackage}/${name}` : name;
|
||||
if (!packages.includes(full)) onPackagesChange([...packages, full]);
|
||||
|
||||
// Allow leading slash and validate the rest - allow hyphens anywhere in package names
|
||||
if (!/^\/?([\w-]+(\/[\w-]+)*)\/?$/.test(name)) {
|
||||
// Could add toast notification here for invalid characters
|
||||
return;
|
||||
}
|
||||
|
||||
// Normalize path construction to avoid double slashes
|
||||
let full: string;
|
||||
if (selectedPackage) {
|
||||
// Strip leading slash from name when we're inside a package to avoid double slashes
|
||||
const normalizedName = name.startsWith('/') ? name.substring(1) : name;
|
||||
full = `${selectedPackage}/${normalizedName}`;
|
||||
} else {
|
||||
// At root level, preserve the leading slash if user intended it
|
||||
full = name;
|
||||
}
|
||||
|
||||
// Strip trailing slash to ensure consistent path handling
|
||||
if (full.endsWith('/')) {
|
||||
full = full.slice(0, -1);
|
||||
}
|
||||
|
||||
// Check for duplicate package names (case-insensitive)
|
||||
const existingPackage = packages.find(p => p.toLowerCase() === full.toLowerCase());
|
||||
if (existingPackage) {
|
||||
// Could add toast notification here for duplicate package
|
||||
return;
|
||||
}
|
||||
|
||||
onPackagesChange([...packages, full]);
|
||||
setNewPackageName('');
|
||||
setIsPackageDialogOpen(false);
|
||||
};
|
||||
|
||||
const deletePackage = (path: string) => {
|
||||
// Remove the package and all its children
|
||||
const keep = packages.filter((p) => !(p === path || p.startsWith(path + '/')));
|
||||
|
||||
// Move all snippets from deleted packages to root
|
||||
const updatedSnippets = snippets.map((s) => {
|
||||
if (!s.package) return s;
|
||||
if (s.package === path || s.package.startsWith(path + '/')) return { ...s, package: '' };
|
||||
if (s.package === path || s.package.startsWith(path + '/')) {
|
||||
return { ...s, package: '' };
|
||||
}
|
||||
return s;
|
||||
});
|
||||
|
||||
// Update packages first, then save snippets
|
||||
onPackagesChange(keep);
|
||||
updatedSnippets.forEach(onSave);
|
||||
|
||||
// Only save snippets that were actually modified
|
||||
const modifiedSnippets = updatedSnippets.filter((s, index) =>
|
||||
s.package !== snippets[index].package
|
||||
);
|
||||
modifiedSnippets.forEach(onSave);
|
||||
|
||||
// Reset selected package if it was deleted
|
||||
if (selectedPackage && (selectedPackage === path || selectedPackage.startsWith(path + '/'))) {
|
||||
setSelectedPackage(null);
|
||||
}
|
||||
@@ -220,24 +500,125 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
|
||||
const movePackage = (source: string, target: string | null) => {
|
||||
const name = source.split('/').pop() || '';
|
||||
const newPath = target ? `${target}/${name}` : name;
|
||||
const isAbsolute = source.startsWith('/');
|
||||
const newPath = target ? `${target}/${name}` : (isAbsolute ? `/${name}` : name);
|
||||
if (newPath === source || newPath.startsWith(source + '/')) return;
|
||||
|
||||
// Check if target path already exists
|
||||
if (packages.includes(newPath)) return;
|
||||
|
||||
const updatedPackages = packages.map((p) => {
|
||||
if (p === source) return newPath;
|
||||
if (p.startsWith(source + '/')) return p.replace(source, newPath);
|
||||
// Use more precise replacement to avoid substring issues
|
||||
if (p.startsWith(source + '/')) {
|
||||
return newPath + p.substring(source.length);
|
||||
}
|
||||
return p;
|
||||
});
|
||||
|
||||
const updatedSnippets = snippets.map((s) => {
|
||||
if (!s.package) return s;
|
||||
if (s.package === source) return { ...s, package: newPath };
|
||||
if (s.package.startsWith(source + '/')) return { ...s, package: s.package.replace(source, newPath) };
|
||||
// Use more precise replacement to avoid substring issues
|
||||
if (s.package.startsWith(source + '/')) {
|
||||
return { ...s, package: newPath + s.package.substring(source.length) };
|
||||
}
|
||||
return s;
|
||||
});
|
||||
|
||||
onPackagesChange(Array.from(new Set(updatedPackages)));
|
||||
updatedSnippets.forEach(onSave);
|
||||
if (selectedPackage === source) setSelectedPackage(newPath);
|
||||
};
|
||||
|
||||
const openRenameDialog = (path: string) => {
|
||||
const name = path.split('/').pop() || '';
|
||||
setRenamingPackagePath(path);
|
||||
setRenamePackageName(name);
|
||||
setRenameError('');
|
||||
setIsRenameDialogOpen(true);
|
||||
};
|
||||
|
||||
const renamePackage = () => {
|
||||
if (!renamingPackagePath) return;
|
||||
|
||||
const newName = renamePackageName.trim();
|
||||
|
||||
// Validate: empty name
|
||||
if (!newName) {
|
||||
setRenameError(t('snippets.renameDialog.error.empty'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate: same rules as createPackage - only allow letters, numbers, hyphens, underscores
|
||||
// Since we're renaming a single segment (no slashes allowed), use the segment-level pattern
|
||||
if (!/^[\w-]+$/.test(newName)) {
|
||||
setRenameError(t('snippets.renameDialog.error.invalidChars'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Build new path
|
||||
const parts = renamingPackagePath.split('/');
|
||||
parts[parts.length - 1] = newName;
|
||||
const newPath = parts.join('/');
|
||||
|
||||
// Validate: same name
|
||||
if (newPath === renamingPackagePath) {
|
||||
setIsRenameDialogOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate: duplicate (case-insensitive)
|
||||
const existingPackage = packages.find(p => p.toLowerCase() === newPath.toLowerCase());
|
||||
if (existingPackage) {
|
||||
setRenameError(t('snippets.renameDialog.error.duplicate'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Update all packages with this path or nested under it
|
||||
const updatedPackages = packages.map((p) => {
|
||||
if (p === renamingPackagePath) return newPath;
|
||||
if (p.startsWith(renamingPackagePath + '/')) {
|
||||
return newPath + p.substring(renamingPackagePath.length);
|
||||
}
|
||||
return p;
|
||||
});
|
||||
|
||||
// Update all snippets with this package or nested under it
|
||||
const updatedSnippets = snippets.map((s) => {
|
||||
if (!s.package) return s;
|
||||
if (s.package === renamingPackagePath) return { ...s, package: newPath };
|
||||
if (s.package.startsWith(renamingPackagePath + '/')) {
|
||||
return { ...s, package: newPath + s.package.substring(renamingPackagePath.length) };
|
||||
}
|
||||
return s;
|
||||
});
|
||||
|
||||
onPackagesChange(Array.from(new Set(updatedPackages)));
|
||||
updatedSnippets.forEach(onSave);
|
||||
|
||||
// Update selected package if it was renamed
|
||||
if (selectedPackage === renamingPackagePath) {
|
||||
setSelectedPackage(newPath);
|
||||
} else if (selectedPackage?.startsWith(renamingPackagePath + '/')) {
|
||||
setSelectedPackage(newPath + selectedPackage.substring(renamingPackagePath.length));
|
||||
}
|
||||
|
||||
// Update editingSnippet.package if it's in the renamed package (fixes stale state when editing)
|
||||
if (editingSnippet.package) {
|
||||
if (editingSnippet.package === renamingPackagePath) {
|
||||
setEditingSnippet(prev => ({ ...prev, package: newPath }));
|
||||
} else if (editingSnippet.package.startsWith(renamingPackagePath + '/')) {
|
||||
setEditingSnippet(prev => ({
|
||||
...prev,
|
||||
package: newPath + prev.package!.substring(renamingPackagePath.length)
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
setIsRenameDialogOpen(false);
|
||||
};
|
||||
|
||||
const moveSnippet = (id: string, pkg: string | null) => {
|
||||
const sn = snippets.find((s) => s.id === id);
|
||||
if (!sn) return;
|
||||
@@ -246,11 +627,36 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
|
||||
// Package options for Combobox
|
||||
const packageOptions: ComboboxOption[] = useMemo(() => {
|
||||
return packages.map(p => ({
|
||||
value: p,
|
||||
label: p.includes('/') ? p.split('/').pop()! : p,
|
||||
sublabel: p.includes('/') ? p : undefined,
|
||||
}));
|
||||
// Generate all possible parent paths for each package
|
||||
const allPaths = new Set<string>();
|
||||
|
||||
packages.forEach(pkg => {
|
||||
// Add the full package path
|
||||
allPaths.add(pkg);
|
||||
|
||||
// Add all parent paths
|
||||
const parts = pkg.split('/').filter(Boolean);
|
||||
const isAbsolute = pkg.startsWith('/');
|
||||
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
const parentPath = (isAbsolute ? '/' : '') + parts.slice(0, i).join('/');
|
||||
allPaths.add(parentPath);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(allPaths)
|
||||
.sort((a, b) => {
|
||||
// Sort by depth first (shorter paths first), then alphabetically
|
||||
const depthA = (a.match(/\//g) || []).length;
|
||||
const depthB = (b.match(/\//g) || []).length;
|
||||
if (depthA !== depthB) return depthA - depthB;
|
||||
return a.localeCompare(b);
|
||||
})
|
||||
.map(p => ({
|
||||
value: p,
|
||||
label: p.includes('/') ? p.split('/').pop()! : p,
|
||||
sublabel: p.includes('/') ? p : undefined,
|
||||
}));
|
||||
}, [packages]);
|
||||
|
||||
// Shell history lazy loading
|
||||
@@ -310,6 +716,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
onBack={handleTargetPickerBack}
|
||||
onContinue={handleTargetPickerBack}
|
||||
availableKeys={availableKeys}
|
||||
managedSources={managedSources}
|
||||
onSaveHost={onSaveHost}
|
||||
onCreateGroup={onCreateGroup}
|
||||
title={t('snippets.targets.add')}
|
||||
@@ -354,7 +761,13 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
<Combobox
|
||||
options={packageOptions}
|
||||
value={editingSnippet.package || selectedPackage || ''}
|
||||
onValueChange={(val) => setEditingSnippet({ ...editingSnippet, package: val })}
|
||||
onValueChange={(val) => {
|
||||
setEditingSnippet({ ...editingSnippet, package: val });
|
||||
// If selecting an implicit parent path, persist it to packages
|
||||
if (val && !packages.includes(val)) {
|
||||
onPackagesChange([...packages, val]);
|
||||
}
|
||||
}}
|
||||
placeholder={t('snippets.field.packagePlaceholder')}
|
||||
allowCreate={true}
|
||||
onCreateNew={(val) => {
|
||||
@@ -379,6 +792,50 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Shortkey */}
|
||||
<Card className="p-3 space-y-2 bg-card border-border/80">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold text-muted-foreground">{t('snippets.field.shortkey')}</p>
|
||||
{editingSnippet.shortkey && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => {
|
||||
setEditingSnippet(prev => ({ ...prev, shortkey: undefined }));
|
||||
setShortkeyError(null);
|
||||
}}
|
||||
title={t('snippets.shortkey.clear')}
|
||||
>
|
||||
<RotateCcw size={12} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsRecordingShortkey(true);
|
||||
setShortkeyError(null);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full h-10 px-3 text-sm font-mono rounded-lg border transition-colors flex items-center justify-center gap-2",
|
||||
isRecordingShortkey
|
||||
? "border-primary bg-primary/10 animate-pulse"
|
||||
: "border-border hover:border-primary/50 bg-background"
|
||||
)}
|
||||
>
|
||||
<Keyboard size={14} className="text-muted-foreground" />
|
||||
{isRecordingShortkey
|
||||
? t('snippets.shortkey.recording')
|
||||
: editingSnippet.shortkey || t('snippets.shortkey.placeholder')}
|
||||
</button>
|
||||
{shortkeyError && (
|
||||
<p className="text-xs text-destructive">{shortkeyError}</p>
|
||||
)}
|
||||
<p className="text-[11px] text-muted-foreground">{t('snippets.shortkey.hint')}</p>
|
||||
</Card>
|
||||
|
||||
{/* Targets */}
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -624,6 +1081,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => setSelectedPackage(pkg.path)}>{t('action.open')}</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => openRenameDialog(pkg.path)}>{t('common.rename')}</ContextMenuItem>
|
||||
<ContextMenuItem className="text-destructive" onClick={() => deletePackage(pkg.path)}>{t('action.delete')}</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
@@ -667,6 +1125,11 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
{snippet.command.replace(/\s+/g, ' ') || t('snippets.commandFallback')}
|
||||
</div>
|
||||
</div>
|
||||
{snippet.shortkey && (
|
||||
<div className="shrink-0 px-2 py-1 text-[10px] font-mono rounded border border-border bg-muted/50 text-muted-foreground">
|
||||
{snippet.shortkey}
|
||||
</div>
|
||||
)}
|
||||
{viewMode === 'list' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -729,6 +1192,8 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
value={newPackageName}
|
||||
onChange={(e) => setNewPackageName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && createPackage()}
|
||||
pattern="^/?([\w-]+(/[\w-]+)*)?/?$"
|
||||
title="Package names can contain letters, numbers, hyphens, underscores, and forward slashes. Can optionally start with /"
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">{t('snippets.packageDialog.hint')}</p>
|
||||
</div>
|
||||
@@ -742,6 +1207,40 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rename Package Dialog */}
|
||||
{isRenameDialogOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<Card className="w-full max-w-sm p-4 space-y-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{t('snippets.renameDialog.title')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('snippets.renameDialog.currentPath', { path: renamingPackagePath })}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('field.name')}</Label>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder={t('snippets.renameDialog.placeholder')}
|
||||
value={renamePackageName}
|
||||
onChange={(e) => {
|
||||
setRenamePackageName(e.target.value);
|
||||
setRenameError('');
|
||||
}}
|
||||
onKeyDown={(e) => e.key === 'Enter' && renamePackage()}
|
||||
/>
|
||||
{renameError && (
|
||||
<p className="text-[11px] text-destructive">{renameError}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => setIsRenameDialogOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={renamePackage}>{t('common.rename')}</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right Panel */}
|
||||
{renderRightPanel()}
|
||||
</div>
|
||||
|
||||
@@ -42,6 +42,57 @@ import { useTerminalSearch } from "./terminal/hooks/useTerminalSearch";
|
||||
import { useTerminalContextActions } from "./terminal/hooks/useTerminalContextActions";
|
||||
import { useTerminalAuthState } from "./terminal/hooks/useTerminalAuthState";
|
||||
import { useServerStats } from "./terminal/hooks/useServerStats";
|
||||
import { extractDropEntries, getPathForFile, DropEntry } from "../lib/sftpFileUtils";
|
||||
|
||||
/**
|
||||
* Extract unique root paths from drop entries for local terminal path insertion.
|
||||
* For nested files, extracts the root folder path; for single files, uses the full path.
|
||||
* Paths with spaces are quoted.
|
||||
*/
|
||||
function extractRootPathsFromDropEntries(dropEntries: DropEntry[]): string[] {
|
||||
const paths: string[] = [];
|
||||
const seenPaths = new Set<string>();
|
||||
|
||||
for (const entry of dropEntries) {
|
||||
if (!entry.file) continue;
|
||||
|
||||
const fullPath = getPathForFile(entry.file);
|
||||
if (!fullPath) continue;
|
||||
|
||||
const pathParts = entry.relativePath.split('/');
|
||||
|
||||
if (pathParts.length > 1) {
|
||||
// Nested file in a folder - extract the root folder path
|
||||
const rootFolderName = pathParts[0];
|
||||
const separator = fullPath.includes('\\') ? '\\' : '/';
|
||||
|
||||
// Find the position of the root folder name in the full path
|
||||
const rootFolderIndex = fullPath.lastIndexOf(separator + rootFolderName + separator);
|
||||
const altRootFolderIndex = fullPath.lastIndexOf(separator + rootFolderName);
|
||||
const folderStartIndex = rootFolderIndex !== -1
|
||||
? rootFolderIndex + 1
|
||||
: (altRootFolderIndex !== -1 ? altRootFolderIndex + 1 : -1);
|
||||
|
||||
if (folderStartIndex !== -1) {
|
||||
const folderEndIndex = folderStartIndex + rootFolderName.length;
|
||||
const folderPath = fullPath.substring(0, folderEndIndex);
|
||||
|
||||
if (!seenPaths.has(folderPath)) {
|
||||
paths.push(folderPath.includes(' ') ? `"${folderPath}"` : folderPath);
|
||||
seenPaths.add(folderPath);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Single file (not in a folder)
|
||||
if (!seenPaths.has(fullPath)) {
|
||||
paths.push(fullPath.includes(' ') ? `"${fullPath}"` : fullPath);
|
||||
seenPaths.add(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return paths;
|
||||
}
|
||||
|
||||
interface TerminalProps {
|
||||
host: Host;
|
||||
@@ -165,12 +216,32 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (xtermRuntimeRef.current) {
|
||||
xtermRuntimeRef.current.keywordHighlighter.setRules(
|
||||
terminalSettings?.keywordHighlightRules ?? [],
|
||||
terminalSettings?.keywordHighlightEnabled ?? false
|
||||
);
|
||||
// Merge global rules with host-level rules
|
||||
// Host-level rules are appended to global rules, allowing hosts to add custom highlighting
|
||||
const globalRules = terminalSettings?.keywordHighlightRules ?? [];
|
||||
const hostRules = host?.keywordHighlightRules ?? [];
|
||||
|
||||
// Check if highlighting is enabled at either global or host level
|
||||
const globalEnabled = terminalSettings?.keywordHighlightEnabled ?? false;
|
||||
const hostEnabled = host?.keywordHighlightEnabled ?? false;
|
||||
|
||||
// Merge rules: include only rules from enabled sources
|
||||
const mergedRules = [
|
||||
...(globalEnabled ? globalRules : []),
|
||||
...(hostEnabled ? hostRules : [])
|
||||
];
|
||||
|
||||
// Enable highlighting if either global or host-level is enabled
|
||||
const isEnabled = globalEnabled || hostEnabled;
|
||||
|
||||
xtermRuntimeRef.current.keywordHighlighter.setRules(mergedRules, isEnabled);
|
||||
}
|
||||
}, [terminalSettings?.keywordHighlightEnabled, terminalSettings?.keywordHighlightRules]);
|
||||
}, [
|
||||
terminalSettings?.keywordHighlightEnabled,
|
||||
terminalSettings?.keywordHighlightRules,
|
||||
host?.keywordHighlightEnabled,
|
||||
host?.keywordHighlightRules
|
||||
]);
|
||||
|
||||
const hotkeySchemeRef = useRef(hotkeyScheme);
|
||||
const keyBindingsRef = useRef(keyBindings);
|
||||
@@ -184,6 +255,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
isBroadcastEnabledRef.current = isBroadcastEnabled;
|
||||
onBroadcastInputRef.current = onBroadcastInput;
|
||||
|
||||
// Snippets ref for shortkey support in terminal
|
||||
const snippetsRef = useRef(snippets);
|
||||
snippetsRef.current = snippets;
|
||||
|
||||
const terminalBackend = useTerminalBackend();
|
||||
const { resizeSession } = terminalBackend;
|
||||
|
||||
@@ -211,6 +286,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 +303,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,
|
||||
@@ -365,6 +449,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onHotkeyActionRef,
|
||||
isBroadcastEnabledRef,
|
||||
onBroadcastInputRef,
|
||||
snippetsRef,
|
||||
sessionId,
|
||||
statusRef,
|
||||
onCommandExecuted,
|
||||
@@ -382,6 +467,20 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
serializeAddonRef.current = runtime.serializeAddon;
|
||||
searchAddonRef.current = runtime.searchAddon;
|
||||
|
||||
// Apply merged keyword highlight rules immediately after runtime creation
|
||||
// This fixes a timing issue where the useEffect for keyword highlighting
|
||||
// runs before the runtime is created, causing host-level rules to be missed
|
||||
const globalRules = terminalSettingsRef.current?.keywordHighlightRules ?? [];
|
||||
const hostRules = host?.keywordHighlightRules ?? [];
|
||||
const globalEnabled = terminalSettingsRef.current?.keywordHighlightEnabled ?? false;
|
||||
const hostEnabled = host?.keywordHighlightEnabled ?? false;
|
||||
const mergedRules = [
|
||||
...(globalEnabled ? globalRules : []),
|
||||
...(hostEnabled ? hostRules : [])
|
||||
];
|
||||
const isEnabled = globalEnabled || hostEnabled;
|
||||
runtime.keywordHighlighter.setRules(mergedRules, isEnabled);
|
||||
|
||||
const term = runtime.term;
|
||||
|
||||
if (host.protocol === "serial") {
|
||||
@@ -450,8 +549,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 +985,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 +1121,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,47 +1513,50 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status !== "connected" && !needsHostKeyVerification && (
|
||||
<TerminalConnectionDialog
|
||||
host={host}
|
||||
status={status}
|
||||
error={error}
|
||||
progressValue={progressValue}
|
||||
chainProgress={chainProgress}
|
||||
needsAuth={auth.needsAuth}
|
||||
showLogs={showLogs}
|
||||
_setShowLogs={setShowLogs}
|
||||
keys={keys}
|
||||
authProps={{
|
||||
authMethod: auth.authMethod,
|
||||
setAuthMethod: auth.setAuthMethod,
|
||||
authUsername: auth.authUsername,
|
||||
setAuthUsername: auth.setAuthUsername,
|
||||
authPassword: auth.authPassword,
|
||||
setAuthPassword: auth.setAuthPassword,
|
||||
authKeyId: auth.authKeyId,
|
||||
setAuthKeyId: auth.setAuthKeyId,
|
||||
authPassphrase: auth.authPassphrase,
|
||||
setAuthPassphrase: auth.setAuthPassphrase,
|
||||
showAuthPassphrase: auth.showAuthPassphrase,
|
||||
setShowAuthPassphrase: auth.setShowAuthPassphrase,
|
||||
showAuthPassword: auth.showAuthPassword,
|
||||
setShowAuthPassword: auth.setShowAuthPassword,
|
||||
authRetryMessage: auth.authRetryMessage,
|
||||
onSubmit: () => auth.submit(),
|
||||
onSubmitWithoutSave: () => auth.submit({ saveToHost: false }),
|
||||
onCancel: handleCancelConnect,
|
||||
isValid: auth.isValid,
|
||||
}}
|
||||
progressProps={{
|
||||
timeLeft,
|
||||
isCancelling,
|
||||
progressLogs,
|
||||
onCancel: handleCancelConnect,
|
||||
onRetry: handleRetry,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* 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}
|
||||
error={error}
|
||||
progressValue={progressValue}
|
||||
chainProgress={chainProgress}
|
||||
needsAuth={auth.needsAuth}
|
||||
showLogs={showLogs}
|
||||
_setShowLogs={setShowLogs}
|
||||
keys={keys}
|
||||
authProps={{
|
||||
authMethod: auth.authMethod,
|
||||
setAuthMethod: auth.setAuthMethod,
|
||||
authUsername: auth.authUsername,
|
||||
setAuthUsername: auth.setAuthUsername,
|
||||
authPassword: auth.authPassword,
|
||||
setAuthPassword: auth.setAuthPassword,
|
||||
authKeyId: auth.authKeyId,
|
||||
setAuthKeyId: auth.setAuthKeyId,
|
||||
authPassphrase: auth.authPassphrase,
|
||||
setAuthPassphrase: auth.setAuthPassphrase,
|
||||
showAuthPassphrase: auth.showAuthPassphrase,
|
||||
setShowAuthPassphrase: auth.setShowAuthPassphrase,
|
||||
showAuthPassword: auth.showAuthPassword,
|
||||
setShowAuthPassword: auth.setShowAuthPassword,
|
||||
authRetryMessage: auth.authRetryMessage,
|
||||
onSubmit: () => auth.submit(),
|
||||
onSubmitWithoutSave: () => auth.submit({ saveToHost: false }),
|
||||
onCancel: handleCancelConnect,
|
||||
isValid: auth.isValid,
|
||||
}}
|
||||
progressProps={{
|
||||
timeLeft,
|
||||
isCancelling,
|
||||
progressLogs,
|
||||
onCancel: handleCancelConnect,
|
||||
onRetry: handleRetry,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SFTPModal
|
||||
@@ -1400,8 +1621,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
};
|
||||
})()}
|
||||
open={showSFTP && status === "connected"}
|
||||
onClose={() => setShowSFTP(false)}
|
||||
onClose={() => {
|
||||
setShowSFTP(false);
|
||||
setPendingUploadEntries([]);
|
||||
}}
|
||||
initialPath={sftpInitialPath}
|
||||
initialEntriesToUpload={pendingUploadEntries}
|
||||
/>
|
||||
</div>
|
||||
</TerminalContextMenu>
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import { useSessionState } from "../application/state/useSessionState";
|
||||
import { usePortForwardingState } from "../application/state/usePortForwardingState";
|
||||
import { useVaultState } from "../application/state/useVaultState";
|
||||
import { toast } from "./ui/toast";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider";
|
||||
import { useSettingsState } from "../application/state/useSettingsState";
|
||||
import { useTrayPanelBackend } from "../application/state/useTrayPanelBackend";
|
||||
import { useActiveTabId } from "../application/state/activeTabStore";
|
||||
import { X, Maximize2, ChevronRight, ChevronDown } from "lucide-react";
|
||||
import { AppLogo } from "./AppLogo";
|
||||
|
||||
const StatusDot: React.FC<{ status: "success" | "warning" | "error" | "neutral"; spinning?: boolean }> = ({
|
||||
status,
|
||||
spinning,
|
||||
}) => {
|
||||
const color =
|
||||
status === "success"
|
||||
? "bg-emerald-500"
|
||||
: status === "warning"
|
||||
? "bg-amber-500"
|
||||
: status === "error"
|
||||
? "bg-rose-500"
|
||||
: "bg-zinc-500";
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-2 w-2 rounded-full",
|
||||
color,
|
||||
spinning ? "animate-spin" : "",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Session type for workspace grouping
|
||||
type TraySession = {
|
||||
id: string;
|
||||
label: string;
|
||||
hostLabel: string;
|
||||
status: "connecting" | "connected" | "disconnected";
|
||||
workspaceId?: string;
|
||||
workspaceTitle?: string;
|
||||
};
|
||||
|
||||
// Collapsible workspace group component
|
||||
const WorkspaceGroup: React.FC<{
|
||||
workspaceId: string;
|
||||
title: string;
|
||||
sessions: TraySession[];
|
||||
activeTabId: string | null;
|
||||
jumpToSession: (sessionId: string) => Promise<void>;
|
||||
t: (key: string) => string;
|
||||
}> = ({ workspaceId, title, sessions, activeTabId, jumpToSession, t }) => {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const isAnyActive = sessions.some((s) => s.id === activeTabId) || activeTabId === workspaceId;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className={cn(
|
||||
"w-full text-left px-2 py-1.5 rounded hover:bg-muted flex items-center gap-1",
|
||||
isAnyActive ? "bg-muted" : "",
|
||||
)}
|
||||
>
|
||||
{expanded ? <ChevronDown size={14} className="text-muted-foreground" /> : <ChevronRight size={14} className="text-muted-foreground" />}
|
||||
<span className="font-medium truncate">{title}</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">{sessions.length}</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="ml-4 mt-0.5 space-y-0.5">
|
||||
{sessions.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
title={s.hostLabel || s.label}
|
||||
onClick={() => {
|
||||
// Jump to session (using session id)
|
||||
void jumpToSession(s.id);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full text-left px-2 py-1 rounded hover:bg-muted flex items-center justify-between text-sm",
|
||||
s.status === "connected" ? "" : "text-muted-foreground",
|
||||
activeTabId === s.id ? "bg-muted/60" : "",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<StatusDot
|
||||
status={s.status === "connected" ? "success" : s.status === "connecting" ? "warning" : "error"}
|
||||
spinning={s.status === "connecting"}
|
||||
/>
|
||||
<span className="truncate">{s.hostLabel || s.label}</span>
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{t(`tray.status.${s.status}`)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TrayPanelContent: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const {
|
||||
hideTrayPanel,
|
||||
openMainWindow,
|
||||
jumpToSession,
|
||||
onTrayPanelCloseRequest,
|
||||
onTrayPanelRefresh,
|
||||
onTrayPanelMenuData,
|
||||
} = useTrayPanelBackend();
|
||||
|
||||
const { hosts, keys } = useVaultState();
|
||||
useSessionState();
|
||||
const { rules: portForwardingRules, startTunnel, stopTunnel } = usePortForwardingState();
|
||||
const activeTabId = useActiveTabId();
|
||||
|
||||
const [traySessions, setTraySessions] = useState<TraySession[]>([]);
|
||||
|
||||
const jumpableSessions = useMemo(
|
||||
() => traySessions.filter((s) => s.status === "connected" || s.status === "connecting"),
|
||||
[traySessions],
|
||||
);
|
||||
|
||||
const activeSession = useMemo(() => {
|
||||
if (!activeTabId) return null;
|
||||
return traySessions.find((s) => s.id === activeTabId) || null;
|
||||
}, [activeTabId, traySessions]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onTrayPanelMenuData?.((data) => {
|
||||
setTraySessions(data.sessions || []);
|
||||
});
|
||||
return () => unsubscribe?.();
|
||||
}, [onTrayPanelMenuData]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onTrayPanelRefresh?.(() => {
|
||||
try {
|
||||
window.dispatchEvent(new Event("storage"));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
return () => unsubscribe?.();
|
||||
}, [onTrayPanelRefresh]);
|
||||
|
||||
const keysForPf = useMemo(
|
||||
() => keys.map((k) => ({ id: k.id, privateKey: k.privateKey })),
|
||||
[keys],
|
||||
);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
void hideTrayPanel();
|
||||
}, [hideTrayPanel]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [handleClose]);
|
||||
|
||||
useEffect(() => {
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
const target = e.target;
|
||||
if (!(target instanceof Node)) return;
|
||||
if (document.body && !document.body.contains(target)) return;
|
||||
// Ignore clicks on interactive elements inside the panel.
|
||||
if (target instanceof HTMLElement && target.closest("button,a,input,select,textarea,[role='button']")) {
|
||||
return;
|
||||
}
|
||||
// Clicking on background should close panel
|
||||
const root = document.getElementById("tray-panel-root");
|
||||
if (root && !root.contains(target)) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("pointerdown", onPointerDown, true);
|
||||
return () => window.removeEventListener("pointerdown", onPointerDown, true);
|
||||
}, [handleClose]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = onTrayPanelCloseRequest(() => {
|
||||
handleClose();
|
||||
});
|
||||
return () => unsubscribe?.();
|
||||
}, [handleClose, onTrayPanelCloseRequest]);
|
||||
|
||||
const handleOpenMain = useCallback(() => {
|
||||
void openMainWindow();
|
||||
}, [openMainWindow]);
|
||||
|
||||
return (
|
||||
<div id="tray-panel-root" className="w-full h-full bg-background/95 backdrop-blur border border-border/60 rounded-lg shadow-lg overflow-hidden">
|
||||
<div className="px-3 py-2 border-b border-border/60 flex items-center justify-between app-no-drag">
|
||||
<div className="flex items-center gap-2">
|
||||
<AppLogo className="w-5 h-5" />
|
||||
<span className="text-sm font-medium">Netcatty</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||
onClick={handleOpenMain}
|
||||
title={t("tray.openMainWindow")}
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</button>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||
onClick={handleClose}
|
||||
title="Close"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-2 space-y-3 text-sm">
|
||||
|
||||
{jumpableSessions.length > 0 && (() => {
|
||||
// Group sessions by workspace
|
||||
const workspaceGroups = new Map<string, { title: string; sessions: typeof jumpableSessions }>();
|
||||
const soloSessions: typeof jumpableSessions = [];
|
||||
|
||||
jumpableSessions.forEach((s) => {
|
||||
if (s.workspaceId) {
|
||||
const existing = workspaceGroups.get(s.workspaceId);
|
||||
if (existing) {
|
||||
existing.sessions.push(s);
|
||||
} else {
|
||||
workspaceGroups.set(s.workspaceId, {
|
||||
title: s.workspaceTitle || "Workspace",
|
||||
sessions: [s],
|
||||
});
|
||||
}
|
||||
} else {
|
||||
soloSessions.push(s);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="px-2 py-1 text-xs text-muted-foreground">{t("tray.sessions")}</div>
|
||||
<div className="space-y-1">
|
||||
{/* Workspace groups */}
|
||||
{Array.from(workspaceGroups.entries()).map(([wsId, group]) => (
|
||||
<WorkspaceGroup
|
||||
key={wsId}
|
||||
workspaceId={wsId}
|
||||
title={group.title}
|
||||
sessions={group.sessions}
|
||||
activeTabId={activeTabId}
|
||||
jumpToSession={jumpToSession}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
{/* Solo sessions */}
|
||||
{soloSessions.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
title={s.hostLabel || s.label}
|
||||
onClick={() => {
|
||||
void jumpToSession(s.id);
|
||||
}}
|
||||
className={cn(
|
||||
"w-full text-left px-2 py-1.5 rounded hover:bg-muted flex items-center justify-between",
|
||||
s.status === "connected" ? "" : "text-muted-foreground",
|
||||
activeTabId === s.id ? "bg-muted" : "",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<StatusDot
|
||||
status={s.status === "connected" ? "success" : s.status === "connecting" ? "warning" : "error"}
|
||||
spinning={s.status === "connecting"}
|
||||
/>
|
||||
<span className="truncate">{s.hostLabel || s.label}</span>
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{t(`tray.status.${s.status}`)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{activeSession && (
|
||||
<div>
|
||||
<div className="px-2 py-1 text-xs text-muted-foreground">Current</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start px-2 h-8"
|
||||
title={activeSession.hostLabel || activeSession.label}
|
||||
onClick={() => {
|
||||
void jumpToSession(activeSession.id);
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{activeSession.hostLabel || activeSession.label}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{portForwardingRules.length > 0 && (
|
||||
<div>
|
||||
<div className="px-2 py-1 text-xs text-muted-foreground">{t("tray.portForwarding")}</div>
|
||||
<div className="space-y-1">
|
||||
{portForwardingRules.map((rule) => {
|
||||
const isConnecting = rule.status === "connecting";
|
||||
const isActive = rule.status === "active";
|
||||
const label = rule.label || (rule.type === "dynamic"
|
||||
? `SOCKS:${rule.localPort}`
|
||||
: `${rule.localPort} → ${rule.remoteHost}:${rule.remotePort}`);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={rule.id}
|
||||
disabled={isConnecting}
|
||||
title={label}
|
||||
onClick={() => {
|
||||
const host = rule.hostId ? hosts.find((h) => h.id === rule.hostId) : undefined;
|
||||
if (!host) {
|
||||
toast.error(t("pf.error.hostNotFound"));
|
||||
return;
|
||||
}
|
||||
if (isActive) {
|
||||
void stopTunnel(rule.id);
|
||||
} else {
|
||||
void startTunnel(rule, host, keysForPf, (status, error) => {
|
||||
if (status === "error" && error) toast.error(error);
|
||||
}, rule.autoStart);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"w-full text-left px-2 py-1.5 rounded hover:bg-muted flex items-center justify-between",
|
||||
isConnecting ? "opacity-60" : "",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<StatusDot
|
||||
status={
|
||||
rule.status === "active"
|
||||
? "success"
|
||||
: rule.status === "connecting"
|
||||
? "warning"
|
||||
: rule.status === "error"
|
||||
? "error"
|
||||
: "neutral"
|
||||
}
|
||||
spinning={rule.status === "connecting"}
|
||||
/>
|
||||
<span className="truncate">{label}</span>
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{t(`tray.status.${rule.status}`)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state - show when nothing is active */}
|
||||
{jumpableSessions.length === 0 && portForwardingRules.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<span className="text-2xl mb-2">😴</span>
|
||||
<span className="text-sm text-muted-foreground">{t("tray.empty.title")}</span>
|
||||
<span className="text-xs text-muted-foreground/60 mt-1">{t("tray.empty.subtitle")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TrayPanel: React.FC = () => {
|
||||
const settings = useSettingsState();
|
||||
return (
|
||||
<I18nProvider locale={settings.uiLanguage}>
|
||||
<TrayPanelContent />
|
||||
</I18nProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default TrayPanel;
|
||||
@@ -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}
|
||||
|
||||
@@ -5,16 +5,18 @@
|
||||
import { Copy,Loader2,Pencil,Play,Square,Trash2 } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { PortForwardingRule } from '../../domain/models';
|
||||
import { Host, PortForwardingRule } from '../../domain/models';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Button } from '../ui/button';
|
||||
import { ContextMenu,ContextMenuContent,ContextMenuItem,ContextMenuSeparator,ContextMenuTrigger } from '../ui/context-menu';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
|
||||
import { getStatusColor,getTypeColor } from './utils';
|
||||
|
||||
export type ViewMode = 'grid' | 'list';
|
||||
|
||||
export interface RuleCardProps {
|
||||
rule: PortForwardingRule;
|
||||
host?: Host; // The relay host for this rule (for tooltip display)
|
||||
viewMode: ViewMode;
|
||||
isSelected: boolean;
|
||||
isPending: boolean;
|
||||
@@ -28,6 +30,7 @@ export interface RuleCardProps {
|
||||
|
||||
export const RuleCard: React.FC<RuleCardProps> = ({
|
||||
rule,
|
||||
host,
|
||||
viewMode,
|
||||
isSelected,
|
||||
isPending,
|
||||
@@ -74,12 +77,39 @@ export const RuleCard: React.FC<RuleCardProps> = ({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<span className="truncate">
|
||||
{rule.type === 'dynamic'
|
||||
? t('pf.rule.summary.dynamic', { bindAddress: rule.bindAddress, localPort: rule.localPort })
|
||||
: t('pf.rule.summary.default', { bindAddress: rule.bindAddress, localPort: rule.localPort, remoteHost: rule.remoteHost, remotePort: rule.remotePort })
|
||||
}
|
||||
</span>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="truncate cursor-default">
|
||||
{rule.type === 'dynamic'
|
||||
? t('pf.rule.summary.dynamic', { bindAddress: rule.bindAddress, localPort: rule.localPort })
|
||||
: t('pf.rule.summary.default', { bindAddress: rule.bindAddress, localPort: rule.localPort, remoteHost: rule.remoteHost, remotePort: rule.remotePort })
|
||||
}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start" className="max-w-xs">
|
||||
<div className="space-y-1 text-xs">
|
||||
{host ? (
|
||||
<>
|
||||
<div className="font-medium">{t('pf.tooltip.relayHost')}</div>
|
||||
<div>{t('pf.tooltip.hostLabel')}: {host.label}</div>
|
||||
<div>{t('pf.tooltip.hostAddress')}: {host.username}@{host.hostname}:{host.port}</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-muted-foreground">{t('pf.tooltip.noHost')}</div>
|
||||
)}
|
||||
<div className="border-t border-border/40 pt-1 mt-1">
|
||||
{rule.type === 'dynamic'
|
||||
? t('pf.tooltip.dynamicDesc')
|
||||
: rule.type === 'local'
|
||||
? t('pf.tooltip.localDesc')
|
||||
: t('pf.tooltip.remoteDesc')
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
|
||||
@@ -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')} />
|
||||
|
||||
@@ -115,7 +115,7 @@ export default function SettingsShortcutsTab(props: {
|
||||
};
|
||||
}, [recordingBindingId, recordingScheme, setIsHotkeyRecording]);
|
||||
|
||||
const categories = useMemo(() => ["tabs", "terminal", "navigation", "app"] as const, []);
|
||||
const categories = useMemo(() => ["tabs", "terminal", "navigation", "app", "sftp"] as const, []);
|
||||
|
||||
return (
|
||||
<SettingsTabContent value="shortcuts">
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
/**
|
||||
* Settings System Tab - System information, temp file management, and session logs
|
||||
* Settings System Tab - System information, temp file management, session logs, and global hotkey
|
||||
*/
|
||||
import { FileText, FolderOpen, HardDrive, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { FileText, FolderOpen, HardDrive, Keyboard, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { SessionLogFormat } from "../../../domain/models";
|
||||
import { SessionLogFormat, keyEventToString } from "../../../domain/models";
|
||||
import { TabsContent } from "../../ui/tabs";
|
||||
import { Button } from "../../ui/button";
|
||||
import { Toggle, Select, SettingRow } from "../settings-ui";
|
||||
import { cn } from "../../../lib/utils";
|
||||
|
||||
interface TempDirInfo {
|
||||
path: string;
|
||||
@@ -31,6 +32,11 @@ interface SettingsSystemTabProps {
|
||||
setSessionLogsDir: (dir: string) => void;
|
||||
sessionLogsFormat: SessionLogFormat;
|
||||
setSessionLogsFormat: (format: SessionLogFormat) => void;
|
||||
toggleWindowHotkey: string;
|
||||
setToggleWindowHotkey: (hotkey: string) => void;
|
||||
closeToTray: boolean;
|
||||
setCloseToTray: (enabled: boolean) => void;
|
||||
hotkeyRegistrationError: string | null;
|
||||
}
|
||||
|
||||
const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
@@ -40,13 +46,21 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
setSessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
setSessionLogsFormat,
|
||||
toggleWindowHotkey,
|
||||
setToggleWindowHotkey,
|
||||
closeToTray,
|
||||
setCloseToTray,
|
||||
hotkeyRegistrationError,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isMac = typeof navigator !== "undefined" && /Mac/i.test(navigator.platform);
|
||||
|
||||
const [tempDirInfo, setTempDirInfo] = useState<TempDirInfo | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
const [clearResult, setClearResult] = useState<{ deletedCount: number; failedCount: number } | null>(null);
|
||||
const [isRecordingHotkey, setIsRecordingHotkey] = useState(false);
|
||||
const [hotkeyError, setHotkeyError] = useState<string | null>(null);
|
||||
|
||||
const loadTempDirInfo = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -116,6 +130,56 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
}
|
||||
}, [sessionLogsDir]);
|
||||
|
||||
// Handle global toggle hotkey recording
|
||||
const cancelHotkeyRecording = useCallback(() => {
|
||||
setIsRecordingHotkey(false);
|
||||
}, []);
|
||||
|
||||
const handleResetHotkey = useCallback(() => {
|
||||
// Reset to default hotkey (Ctrl+` or ⌃+` on Mac)
|
||||
const defaultHotkey = isMac ? '⌃ + `' : 'Ctrl + `';
|
||||
setToggleWindowHotkey(defaultHotkey);
|
||||
setHotkeyError(null);
|
||||
}, [isMac, setToggleWindowHotkey]);
|
||||
|
||||
// Hotkey recording effect
|
||||
useEffect(() => {
|
||||
if (!isRecordingHotkey) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (e.key === "Escape") {
|
||||
cancelHotkeyRecording();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore modifier-only keys
|
||||
if (["Meta", "Control", "Alt", "Shift"].includes(e.key)) return;
|
||||
|
||||
const keyString = keyEventToString(e, isMac);
|
||||
setToggleWindowHotkey(keyString);
|
||||
setHotkeyError(null);
|
||||
cancelHotkeyRecording();
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
cancelHotkeyRecording();
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
window.addEventListener("click", handleClick, true);
|
||||
}, 100);
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown, true);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
window.removeEventListener("keydown", handleKeyDown, true);
|
||||
window.removeEventListener("click", handleClick, true);
|
||||
};
|
||||
}, [isRecordingHotkey, isMac, setToggleWindowHotkey, cancelHotkeyRecording]);
|
||||
|
||||
const formatOptions = [
|
||||
{ value: "txt", label: t("settings.sessionLogs.formatTxt") },
|
||||
{ value: "raw", label: t("settings.sessionLogs.formatRaw") },
|
||||
@@ -295,6 +359,68 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
{t("settings.sessionLogs.hint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Global Toggle Window Section (Quake Mode) */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Keyboard size={18} className="text-muted-foreground" />
|
||||
<h3 className="text-base font-medium">{t("settings.globalHotkey.title")}</h3>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/30 rounded-lg p-4 space-y-4">
|
||||
{/* Toggle Window Hotkey */}
|
||||
<SettingRow
|
||||
label={t("settings.globalHotkey.toggleWindow")}
|
||||
description={t("settings.globalHotkey.toggleWindowDesc")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsRecordingHotkey(true);
|
||||
}}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-sm font-mono rounded border transition-colors min-w-[100px] text-center",
|
||||
isRecordingHotkey
|
||||
? "border-primary bg-primary/10 animate-pulse"
|
||||
: "border-border hover:border-primary/50",
|
||||
)}
|
||||
>
|
||||
{isRecordingHotkey
|
||||
? t("settings.shortcuts.recording")
|
||||
: toggleWindowHotkey || t("settings.globalHotkey.notSet")}
|
||||
</button>
|
||||
{toggleWindowHotkey && (
|
||||
<button
|
||||
onClick={handleResetHotkey}
|
||||
className="p-1 hover:bg-muted rounded"
|
||||
title={t("settings.globalHotkey.reset")}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</SettingRow>
|
||||
{(hotkeyError || hotkeyRegistrationError) && (
|
||||
<p className="text-sm text-destructive">{hotkeyError || hotkeyRegistrationError}</p>
|
||||
)}
|
||||
|
||||
{/* Close to Tray */}
|
||||
<SettingRow
|
||||
label={t("settings.globalHotkey.closeToTray")}
|
||||
description={t("settings.globalHotkey.closeToTrayDesc")}
|
||||
>
|
||||
<Toggle
|
||||
checked={closeToTray}
|
||||
onChange={setCloseToTray}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.globalHotkey.hint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -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 React, { useEffect, useState } from "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,179 +79,277 @@ export const SftpModalHeader: React.FC<SftpModalHeaderProps> = ({
|
||||
onBreadcrumbSelect,
|
||||
onRootSelect,
|
||||
inputRef,
|
||||
folderInputRef,
|
||||
pathInputRef,
|
||||
uploading,
|
||||
onTriggerUpload,
|
||||
onTriggerFolderUpload,
|
||||
onCreateFolder,
|
||||
onCreateFile,
|
||||
onFileSelect,
|
||||
}) => (
|
||||
<>
|
||||
<DialogHeader className="px-4 py-3 border-b border-border/60 flex-shrink-0">
|
||||
<div className="flex items-center gap-3 pr-8">
|
||||
<DistroAvatar
|
||||
host={host}
|
||||
fallback={host.label.slice(0, 2).toUpperCase()}
|
||||
className="h-8 w-8"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<DialogTitle className="text-sm font-semibold">
|
||||
{host.label}
|
||||
</DialogTitle>
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
{credentials.username || "root"}@{credentials.hostname}:
|
||||
{credentials.port || 22}
|
||||
onFolderSelect,
|
||||
}) => {
|
||||
// Delay tooltip activation to prevent flickering when modal opens
|
||||
const [tooltipsReady, setTooltipsReady] = useState(false);
|
||||
const [openTooltip, setOpenTooltip] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setTooltipsReady(true), 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const handleTooltipOpenChange = (id: string) => (open: boolean) => {
|
||||
if (!tooltipsReady) return;
|
||||
setOpenTooltip(open ? id : null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader className="px-4 py-3 border-b border-border/60 flex-shrink-0">
|
||||
<div className="flex items-center gap-3 pr-8">
|
||||
<DistroAvatar
|
||||
host={host}
|
||||
fallback={host.label.slice(0, 2).toUpperCase()}
|
||||
className="h-8 w-8"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<DialogTitle className="text-sm font-semibold">
|
||||
{host.label}
|
||||
</DialogTitle>
|
||||
<div className="text-xs text-muted-foreground font-mono">
|
||||
{credentials.username || "root"}@{credentials.hostname}:
|
||||
{credentials.port || 22}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="px-4 py-2 border-b border-border/60 flex items-center gap-2 flex-shrink-0 bg-muted/30">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onUp}
|
||||
disabled={isAtRoot}
|
||||
>
|
||||
<ArrowUp size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onHome}
|
||||
>
|
||||
<Home size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onRefresh}
|
||||
>
|
||||
<RefreshCw
|
||||
size={14}
|
||||
className={cn(isRefreshing && "animate-spin")}
|
||||
/>
|
||||
</Button>
|
||||
{showEncoding && (
|
||||
<Select
|
||||
value={filenameEncoding}
|
||||
onValueChange={(value) => onFilenameEncodingChange(value as SftpFilenameEncoding)}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-[130px] text-xs" title={t("sftp.encoding.label")}>
|
||||
<SelectValue placeholder={t("sftp.encoding.label")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{t("sftp.encoding.auto")}</SelectItem>
|
||||
<SelectItem value="utf-8">{t("sftp.encoding.utf8")}</SelectItem>
|
||||
<SelectItem value="gb18030">{t("sftp.encoding.gb18030")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<TooltipProvider delayDuration={500} skipDelayDuration={800} disableHoverableContent>
|
||||
<div className="px-4 py-2 border-b border-border/60 flex items-center gap-2 flex-shrink-0 bg-muted/30">
|
||||
<Tooltip open={openTooltip === 'up'} onOpenChange={handleTooltipOpenChange('up')}>
|
||||
<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 open={openTooltip === 'home'} onOpenChange={handleTooltipOpenChange('home')}>
|
||||
<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 open={openTooltip === 'refresh'} onOpenChange={handleTooltipOpenChange('refresh')}>
|
||||
<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 && (
|
||||
<Popover>
|
||||
<Tooltip open={openTooltip === 'encoding'} onOpenChange={handleTooltipOpenChange('encoding')}>
|
||||
<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">
|
||||
{isEditingPath ? (
|
||||
<Input
|
||||
ref={pathInputRef}
|
||||
value={editingPathValue}
|
||||
onChange={(e) => setEditingPathValue(e.target.value)}
|
||||
onBlur={handlePathSubmit}
|
||||
onKeyDown={handlePathKeyDown}
|
||||
className="h-7 text-sm bg-background"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="flex items-center gap-1 flex-1 min-w-0 cursor-text hover:bg-secondary/50 rounded px-1 py-0.5 transition-colors"
|
||||
onDoubleClick={handlePathDoubleClick}
|
||||
title={currentPath}
|
||||
>
|
||||
<button
|
||||
className="text-muted-foreground hover:text-foreground px-1 shrink-0"
|
||||
onClick={onRootSelect}
|
||||
>
|
||||
{rootLabel}
|
||||
</button>
|
||||
{visibleBreadcrumbs.map(({ part, originalIndex }, displayIdx) => {
|
||||
const isLast = originalIndex === breadcrumbs.length - 1;
|
||||
const showEllipsisBefore =
|
||||
needsBreadcrumbTruncation && displayIdx === 1;
|
||||
<div className="flex items-center gap-1 text-sm flex-1 min-w-0 overflow-hidden">
|
||||
{isEditingPath ? (
|
||||
<Input
|
||||
ref={pathInputRef}
|
||||
value={editingPathValue}
|
||||
onChange={(e) => setEditingPathValue(e.target.value)}
|
||||
onBlur={handlePathSubmit}
|
||||
onKeyDown={handlePathKeyDown}
|
||||
className="h-7 text-sm bg-background"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="flex items-center gap-1 flex-1 min-w-0 cursor-text hover:bg-secondary/50 rounded px-1 py-0.5 transition-colors"
|
||||
onDoubleClick={handlePathDoubleClick}
|
||||
title={currentPath}
|
||||
>
|
||||
<button
|
||||
className="text-muted-foreground hover:text-foreground px-1 shrink-0"
|
||||
onClick={onRootSelect}
|
||||
>
|
||||
{rootLabel}
|
||||
</button>
|
||||
{visibleBreadcrumbs.map(({ part, originalIndex }, displayIdx) => {
|
||||
const isLast = originalIndex === breadcrumbs.length - 1;
|
||||
const showEllipsisBefore =
|
||||
needsBreadcrumbTruncation && displayIdx === 1;
|
||||
|
||||
return (
|
||||
<React.Fragment key={originalIndex}>
|
||||
{showEllipsisBefore && (
|
||||
<>
|
||||
return (
|
||||
<React.Fragment key={originalIndex}>
|
||||
{showEllipsisBefore && (
|
||||
<>
|
||||
<ChevronRight
|
||||
size={12}
|
||||
className="text-muted-foreground flex-shrink-0"
|
||||
/>
|
||||
<span
|
||||
className="text-muted-foreground px-1 shrink-0 flex items-center cursor-default"
|
||||
title={`${t("sftp.showHiddenPaths")}: ${hiddenBreadcrumbs
|
||||
.map((h) => h.part)
|
||||
.join(" > ")}`}
|
||||
>
|
||||
<MoreHorizontal size={14} />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<ChevronRight
|
||||
size={12}
|
||||
className="text-muted-foreground flex-shrink-0"
|
||||
/>
|
||||
<span
|
||||
className="text-muted-foreground px-1 shrink-0 flex items-center cursor-default"
|
||||
title={`${t("sftp.showHiddenPaths")}: ${hiddenBreadcrumbs
|
||||
.map((h) => h.part)
|
||||
.join(" > ")}`}
|
||||
<button
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground truncate px-1 max-w-[100px]",
|
||||
isLast && "text-foreground font-medium",
|
||||
)}
|
||||
onClick={() => onBreadcrumbSelect(originalIndex)}
|
||||
title={part}
|
||||
>
|
||||
<MoreHorizontal size={14} />
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<ChevronRight
|
||||
size={12}
|
||||
className="text-muted-foreground flex-shrink-0"
|
||||
/>
|
||||
<button
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground truncate px-1 max-w-[100px]",
|
||||
isLast && "text-foreground font-medium",
|
||||
)}
|
||||
onClick={() => onBreadcrumbSelect(originalIndex)}
|
||||
title={part}
|
||||
>
|
||||
{part}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{part}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={onTriggerUpload}
|
||||
disabled={uploading}
|
||||
>
|
||||
<Upload size={14} className="mr-1.5" /> {t("sftp.upload")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={onCreateFolder}
|
||||
>
|
||||
<Plus size={14} className="mr-1.5" /> {t("sftp.newFolder")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7"
|
||||
onClick={onCreateFile}
|
||||
>
|
||||
<Plus size={14} className="mr-1.5" /> {t("sftp.newFile")}
|
||||
</Button>
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
ref={inputRef}
|
||||
onChange={onFileSelect}
|
||||
multiple
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
<div className="flex items-center gap-1 ml-auto">
|
||||
<Tooltip open={openTooltip === 'upload'} onOpenChange={handleTooltipOpenChange('upload')}>
|
||||
<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 open={openTooltip === 'uploadFolder'} onOpenChange={handleTooltipOpenChange('uploadFolder')}>
|
||||
<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 open={openTooltip === 'newFolder'} onOpenChange={handleTooltipOpenChange('newFolder')}>
|
||||
<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 open={openTooltip === 'newFile'} onOpenChange={handleTooltipOpenChange('newFile')}>
|
||||
<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"
|
||||
ref={inputRef}
|
||||
onChange={onFileSelect}
|
||||
multiple
|
||||
/>
|
||||
<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} />
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* useSftpModalKeyboardShortcuts
|
||||
*
|
||||
* Hook that handles keyboard shortcuts for SFTPModal operations.
|
||||
* Supports select all, rename, delete, refresh, and new folder.
|
||||
* Note: Copy/Cut/Paste are not supported in the modal as it's a single-pane view.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { KeyBinding, matchesKeyBinding } from "../../../domain/models";
|
||||
import type { RemoteFile } from "../../../types";
|
||||
|
||||
// SFTP Modal action names that we handle (subset of main SFTP actions)
|
||||
const SFTP_MODAL_ACTIONS = new Set([
|
||||
"sftpSelectAll",
|
||||
"sftpRename",
|
||||
"sftpDelete",
|
||||
"sftpRefresh",
|
||||
"sftpNewFolder",
|
||||
]);
|
||||
|
||||
interface UseSftpModalKeyboardShortcutsParams {
|
||||
keyBindings: KeyBinding[];
|
||||
hotkeyScheme: "disabled" | "mac" | "pc";
|
||||
open: boolean;
|
||||
files: RemoteFile[];
|
||||
visibleFiles: RemoteFile[];
|
||||
selectedFiles: Set<string>;
|
||||
setSelectedFiles: (files: Set<string>) => void;
|
||||
onRefresh: () => void;
|
||||
onRename?: (file: RemoteFile) => void;
|
||||
onDelete?: (fileNames: string[]) => void;
|
||||
onNewFolder?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a keyboard event matches any SFTP action
|
||||
*/
|
||||
const matchSftpAction = (
|
||||
e: KeyboardEvent,
|
||||
keyBindings: KeyBinding[],
|
||||
isMac: boolean
|
||||
): { action: string; binding: KeyBinding } | null => {
|
||||
for (const binding of keyBindings) {
|
||||
if (binding.category !== "sftp") continue;
|
||||
const keyStr = isMac ? binding.mac : binding.pc;
|
||||
if (matchesKeyBinding(e, keyStr, isMac)) {
|
||||
return { action: binding.action, binding };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const useSftpModalKeyboardShortcuts = ({
|
||||
keyBindings,
|
||||
hotkeyScheme,
|
||||
open,
|
||||
files,
|
||||
visibleFiles,
|
||||
selectedFiles,
|
||||
setSelectedFiles,
|
||||
onRefresh,
|
||||
onRename,
|
||||
onDelete,
|
||||
onNewFolder,
|
||||
}: UseSftpModalKeyboardShortcutsParams) => {
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
// Skip if shortcuts are disabled or modal is not open
|
||||
if (hotkeyScheme === "disabled" || !open) return;
|
||||
|
||||
// Skip if focus is on an input element
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.isContentEditable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isMac = hotkeyScheme === "mac";
|
||||
const matched = matchSftpAction(e, keyBindings, isMac);
|
||||
if (!matched) return;
|
||||
|
||||
const { action } = matched;
|
||||
if (!SFTP_MODAL_ACTIONS.has(action)) return;
|
||||
|
||||
// Prevent default behavior
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
switch (action) {
|
||||
case "sftpSelectAll": {
|
||||
// Select all files
|
||||
const allFileNames = new Set(
|
||||
visibleFiles.filter((f) => f.name !== "..").map((f) => f.name)
|
||||
);
|
||||
setSelectedFiles(allFileNames);
|
||||
break;
|
||||
}
|
||||
|
||||
case "sftpRename": {
|
||||
// Trigger rename for the first selected file
|
||||
const selectedArray = Array.from(selectedFiles);
|
||||
if (selectedArray.length !== 1) return;
|
||||
const file = files.find((f) => f.name === selectedArray[0]);
|
||||
if (file && onRename) {
|
||||
onRename(file);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "sftpDelete": {
|
||||
// Delete selected files
|
||||
const selectedArray = Array.from(selectedFiles);
|
||||
if (selectedArray.length === 0) return;
|
||||
onDelete?.(selectedArray);
|
||||
break;
|
||||
}
|
||||
|
||||
case "sftpRefresh": {
|
||||
// Refresh file list
|
||||
onRefresh();
|
||||
break;
|
||||
}
|
||||
|
||||
case "sftpNewFolder": {
|
||||
// Create new folder
|
||||
onNewFolder?.();
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
hotkeyScheme,
|
||||
open,
|
||||
files,
|
||||
visibleFiles,
|
||||
selectedFiles,
|
||||
setSelectedFiles,
|
||||
onRefresh,
|
||||
onRename,
|
||||
onDelete,
|
||||
onNewFolder,
|
||||
keyBindings,
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Use capture phase to intercept before other handlers
|
||||
window.addEventListener("keydown", handleKeyDown, true);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown, true);
|
||||
}, [handleKeyDown]);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { memo, useEffect, useRef, useState, useTransition } from "react";
|
||||
import React, { memo, useEffect, useMemo, useRef, useState, useTransition } from "react";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import { logger } from "../../lib/logger";
|
||||
import { useRenderTracker } from "../../lib/useRenderTracker";
|
||||
@@ -21,6 +21,7 @@ import { useSftpPaneFiles } from "./hooks/useSftpPaneFiles";
|
||||
import { useSftpPanePath } from "./hooks/useSftpPanePath";
|
||||
import { useSftpPaneSorting } from "./hooks/useSftpPaneSorting";
|
||||
import { useSftpPaneVirtualList } from "./hooks/useSftpPaneVirtualList";
|
||||
import { useSftpDialogActionHandler } from "./hooks/useSftpDialogAction";
|
||||
|
||||
interface SftpPaneWrapperProps {
|
||||
side: "left" | "right";
|
||||
@@ -195,6 +196,33 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
sortedDisplayFiles,
|
||||
});
|
||||
|
||||
// Handle keyboard shortcut dialog actions
|
||||
const dialogActionHandlers = useMemo(
|
||||
() => ({
|
||||
onRename: (fileName: string) => openRenameDialog(fileName),
|
||||
onDelete: (fileNames: string[]) => openDeleteConfirm(fileNames),
|
||||
onNewFolder: () => setShowNewFolderDialog(true),
|
||||
onNewFile: () => {
|
||||
const defaultName = getNextUntitledName(pane.files.map(f => f.name));
|
||||
setNewFileName(defaultName);
|
||||
setFileNameError(null);
|
||||
setShowNewFileDialog(true);
|
||||
},
|
||||
}),
|
||||
[
|
||||
getNextUntitledName,
|
||||
openDeleteConfirm,
|
||||
openRenameDialog,
|
||||
pane.files,
|
||||
setFileNameError,
|
||||
setNewFileName,
|
||||
setShowNewFileDialog,
|
||||
setShowNewFolderDialog,
|
||||
],
|
||||
);
|
||||
|
||||
useSftpDialogActionHandler(side, dialogActionHandlers);
|
||||
|
||||
const handleSortWithTransition = (field: typeof sortField) => {
|
||||
startTransition(() => handleSort(field));
|
||||
};
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* SFTP Clipboard Store
|
||||
*
|
||||
* Manages clipboard state for SFTP file operations (copy/cut/paste)
|
||||
* This is a simple store that holds the clipboard state and operation type.
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
export type SftpClipboardOperation = "copy" | "cut";
|
||||
|
||||
export interface SftpClipboardFile {
|
||||
name: string;
|
||||
isDirectory: boolean;
|
||||
}
|
||||
|
||||
export interface SftpClipboardState {
|
||||
files: SftpClipboardFile[];
|
||||
sourcePath: string;
|
||||
sourceConnectionId: string;
|
||||
sourceSide: "left" | "right";
|
||||
operation: SftpClipboardOperation;
|
||||
}
|
||||
|
||||
type ClipboardListener = () => void;
|
||||
|
||||
let clipboardState: SftpClipboardState | null = null;
|
||||
const clipboardListeners = new Set<ClipboardListener>();
|
||||
|
||||
const notifyListeners = () => {
|
||||
clipboardListeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
export const sftpClipboardStore = {
|
||||
getSnapshot: (): SftpClipboardState | null => clipboardState,
|
||||
|
||||
subscribe: (listener: ClipboardListener) => {
|
||||
clipboardListeners.add(listener);
|
||||
return () => clipboardListeners.delete(listener);
|
||||
},
|
||||
|
||||
/**
|
||||
* Copy files to clipboard
|
||||
*/
|
||||
copy: (
|
||||
files: SftpClipboardFile[],
|
||||
sourcePath: string,
|
||||
sourceConnectionId: string,
|
||||
sourceSide: "left" | "right"
|
||||
) => {
|
||||
clipboardState = {
|
||||
files,
|
||||
sourcePath,
|
||||
sourceConnectionId,
|
||||
sourceSide,
|
||||
operation: "copy",
|
||||
};
|
||||
notifyListeners();
|
||||
},
|
||||
|
||||
/**
|
||||
* Cut files to clipboard
|
||||
*/
|
||||
cut: (
|
||||
files: SftpClipboardFile[],
|
||||
sourcePath: string,
|
||||
sourceConnectionId: string,
|
||||
sourceSide: "left" | "right"
|
||||
) => {
|
||||
clipboardState = {
|
||||
files,
|
||||
sourcePath,
|
||||
sourceConnectionId,
|
||||
sourceSide,
|
||||
operation: "cut",
|
||||
};
|
||||
notifyListeners();
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear clipboard (called after paste for cut operation)
|
||||
*/
|
||||
clear: () => {
|
||||
clipboardState = null;
|
||||
notifyListeners();
|
||||
},
|
||||
|
||||
/**
|
||||
* Update clipboard file list (used for partial cut transfers)
|
||||
*/
|
||||
updateFiles: (files: SftpClipboardFile[]) => {
|
||||
if (!clipboardState) return;
|
||||
if (files.length === 0) {
|
||||
clipboardState = null;
|
||||
} else {
|
||||
clipboardState = {
|
||||
...clipboardState,
|
||||
files,
|
||||
};
|
||||
}
|
||||
notifyListeners();
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if there are files in the clipboard
|
||||
*/
|
||||
hasFiles: (): boolean => clipboardState !== null && clipboardState.files.length > 0,
|
||||
|
||||
/**
|
||||
* Get the clipboard state
|
||||
*/
|
||||
get: (): SftpClipboardState | null => clipboardState,
|
||||
};
|
||||
|
||||
/**
|
||||
* React hook to subscribe to clipboard state changes
|
||||
*/
|
||||
export const useSftpClipboard = (): SftpClipboardState | null => {
|
||||
return useSyncExternalStore(
|
||||
sftpClipboardStore.subscribe,
|
||||
sftpClipboardStore.getSnapshot,
|
||||
sftpClipboardStore.getSnapshot
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* SFTP Dialog Action Store
|
||||
*
|
||||
* Manages dialog action triggers for SFTP operations.
|
||||
* This store allows keyboard shortcuts to trigger dialogs in the appropriate pane.
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore, useEffect } from "react";
|
||||
import { sftpFocusStore, SftpFocusedSide } from "./useSftpFocusedPane";
|
||||
|
||||
export type SftpDialogActionType = "rename" | "delete" | "newFolder" | "newFile" | null;
|
||||
|
||||
export interface SftpDialogAction {
|
||||
type: SftpDialogActionType;
|
||||
targetSide: SftpFocusedSide;
|
||||
targetFiles?: string[]; // For rename (single file) or delete (multiple files)
|
||||
timestamp: number; // To distinguish different triggers of the same action
|
||||
}
|
||||
|
||||
type ActionListener = () => void;
|
||||
|
||||
let dialogAction: SftpDialogAction | null = null;
|
||||
const actionListeners = new Set<ActionListener>();
|
||||
|
||||
const notifyListeners = () => {
|
||||
actionListeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
export const sftpDialogActionStore = {
|
||||
getSnapshot: (): SftpDialogAction | null => dialogAction,
|
||||
|
||||
subscribe: (listener: ActionListener) => {
|
||||
actionListeners.add(listener);
|
||||
return () => actionListeners.delete(listener);
|
||||
},
|
||||
|
||||
/**
|
||||
* Trigger a dialog action
|
||||
*/
|
||||
trigger: (type: SftpDialogActionType, targetFiles?: string[]) => {
|
||||
if (!type) {
|
||||
dialogAction = null;
|
||||
} else {
|
||||
dialogAction = {
|
||||
type,
|
||||
targetSide: sftpFocusStore.getFocusedSide(),
|
||||
targetFiles,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
notifyListeners();
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear the current action (called after a pane handles it)
|
||||
*/
|
||||
clear: () => {
|
||||
dialogAction = null;
|
||||
notifyListeners();
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the current action
|
||||
*/
|
||||
get: (): SftpDialogAction | null => dialogAction,
|
||||
};
|
||||
|
||||
/**
|
||||
* React hook to subscribe to dialog action changes
|
||||
*/
|
||||
export const useSftpDialogAction = (): SftpDialogAction | null => {
|
||||
return useSyncExternalStore(
|
||||
sftpDialogActionStore.subscribe,
|
||||
sftpDialogActionStore.getSnapshot,
|
||||
sftpDialogActionStore.getSnapshot
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* React hook for a pane to respond to dialog actions
|
||||
* Only the pane matching the targetSide will execute the callback
|
||||
*/
|
||||
export const useSftpDialogActionHandler = (
|
||||
side: SftpFocusedSide,
|
||||
handlers: {
|
||||
onRename?: (fileName: string) => void;
|
||||
onDelete?: (fileNames: string[]) => void;
|
||||
onNewFolder?: () => void;
|
||||
onNewFile?: () => void;
|
||||
}
|
||||
) => {
|
||||
const action = useSftpDialogAction();
|
||||
|
||||
useEffect(() => {
|
||||
if (!action || action.targetSide !== side) return;
|
||||
|
||||
// Handle the action and clear it
|
||||
switch (action.type) {
|
||||
case "rename":
|
||||
if (handlers.onRename && action.targetFiles?.[0]) {
|
||||
handlers.onRename(action.targetFiles[0]);
|
||||
}
|
||||
break;
|
||||
case "delete":
|
||||
if (handlers.onDelete && action.targetFiles) {
|
||||
handlers.onDelete(action.targetFiles);
|
||||
}
|
||||
break;
|
||||
case "newFolder":
|
||||
handlers.onNewFolder?.();
|
||||
break;
|
||||
case "newFile":
|
||||
handlers.onNewFile?.();
|
||||
break;
|
||||
}
|
||||
|
||||
// Clear the action after handling
|
||||
sftpDialogActionStore.clear();
|
||||
}, [action, side, handlers]);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* SFTP Focused Pane Store
|
||||
*
|
||||
* Tracks which SFTP pane (left or right) is currently focused.
|
||||
* This is used to determine which pane should receive keyboard shortcut actions.
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
export type SftpFocusedSide = "left" | "right";
|
||||
|
||||
type FocusListener = () => void;
|
||||
|
||||
let focusedSide: SftpFocusedSide = "left";
|
||||
const focusListeners = new Set<FocusListener>();
|
||||
|
||||
const notifyListeners = () => {
|
||||
focusListeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
export const sftpFocusStore = {
|
||||
getSnapshot: (): SftpFocusedSide => focusedSide,
|
||||
|
||||
subscribe: (listener: FocusListener) => {
|
||||
focusListeners.add(listener);
|
||||
return () => focusListeners.delete(listener);
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the focused side
|
||||
*/
|
||||
setFocusedSide: (side: SftpFocusedSide) => {
|
||||
if (focusedSide !== side) {
|
||||
focusedSide = side;
|
||||
notifyListeners();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the current focused side
|
||||
*/
|
||||
getFocusedSide: (): SftpFocusedSide => focusedSide,
|
||||
};
|
||||
|
||||
/**
|
||||
* React hook to subscribe to focused side changes
|
||||
*/
|
||||
export const useSftpFocusedSide = (): SftpFocusedSide => {
|
||||
return useSyncExternalStore(
|
||||
sftpFocusStore.subscribe,
|
||||
sftpFocusStore.getSnapshot,
|
||||
sftpFocusStore.getSnapshot
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* useSftpKeyboardShortcuts
|
||||
*
|
||||
* Hook that handles keyboard shortcuts for SFTP operations.
|
||||
* Supports copy, cut, paste, select all, rename, delete, refresh, and new folder.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect } from "react";
|
||||
import type { MutableRefObject } from "react";
|
||||
import { KeyBinding, matchesKeyBinding } from "../../../domain/models";
|
||||
import { sftpClipboardStore, SftpClipboardFile } from "./useSftpClipboard";
|
||||
import { sftpFocusStore } from "./useSftpFocusedPane";
|
||||
import { sftpDialogActionStore } from "./useSftpDialogAction";
|
||||
import type { SftpStateApi } from "../../../application/state/useSftpState";
|
||||
import { filterHiddenFiles, isNavigableDirectory } from "../index";
|
||||
import { toast } from "../../ui/toast";
|
||||
|
||||
// SFTP action names that we handle
|
||||
const SFTP_ACTIONS = new Set([
|
||||
"sftpCopy",
|
||||
"sftpCut",
|
||||
"sftpPaste",
|
||||
"sftpSelectAll",
|
||||
"sftpRename",
|
||||
"sftpDelete",
|
||||
"sftpRefresh",
|
||||
"sftpNewFolder",
|
||||
]);
|
||||
|
||||
interface UseSftpKeyboardShortcutsParams {
|
||||
keyBindings: KeyBinding[];
|
||||
hotkeyScheme: "disabled" | "mac" | "pc";
|
||||
sftpRef: MutableRefObject<SftpStateApi>;
|
||||
isActive: boolean;
|
||||
showHiddenFiles: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a keyboard event matches any SFTP action
|
||||
*/
|
||||
const matchSftpAction = (
|
||||
e: KeyboardEvent,
|
||||
keyBindings: KeyBinding[],
|
||||
isMac: boolean
|
||||
): { action: string; binding: KeyBinding } | null => {
|
||||
for (const binding of keyBindings) {
|
||||
if (binding.category !== "sftp") continue;
|
||||
const keyStr = isMac ? binding.mac : binding.pc;
|
||||
if (matchesKeyBinding(e, keyStr, isMac)) {
|
||||
return { action: binding.action, binding };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const useSftpKeyboardShortcuts = ({
|
||||
keyBindings,
|
||||
hotkeyScheme,
|
||||
sftpRef,
|
||||
isActive,
|
||||
showHiddenFiles,
|
||||
}: UseSftpKeyboardShortcutsParams) => {
|
||||
const handleKeyDown = useCallback(
|
||||
async (e: KeyboardEvent) => {
|
||||
// Skip if shortcuts are disabled or SFTP is not active
|
||||
if (hotkeyScheme === "disabled" || !isActive) return;
|
||||
|
||||
// Skip if focus is on an input element
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.isContentEditable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isMac = hotkeyScheme === "mac";
|
||||
const matched = matchSftpAction(e, keyBindings, isMac);
|
||||
if (!matched) return;
|
||||
|
||||
const { action } = matched;
|
||||
if (!SFTP_ACTIONS.has(action)) return;
|
||||
|
||||
// Prevent default behavior
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const sftp = sftpRef.current;
|
||||
const focusedSide = sftpFocusStore.getFocusedSide();
|
||||
|
||||
// Get the active pane for the focused side
|
||||
const pane = focusedSide === "left"
|
||||
? sftp.leftTabs.tabs.find(p => p.id === sftp.leftTabs.activeTabId)
|
||||
: sftp.rightTabs.tabs.find(p => p.id === sftp.rightTabs.activeTabId);
|
||||
|
||||
if (!pane || !pane.connection) return;
|
||||
|
||||
switch (action) {
|
||||
case "sftpCopy": {
|
||||
// Copy selected files to clipboard
|
||||
const selectedFiles = Array.from(pane.selectedFiles) as string[];
|
||||
if (selectedFiles.length === 0) return;
|
||||
|
||||
const clipboardFiles: SftpClipboardFile[] = selectedFiles.map((name: string) => {
|
||||
const file = pane.files.find((f) => f.name === name);
|
||||
return {
|
||||
name,
|
||||
isDirectory: file ? isNavigableDirectory(file) : false,
|
||||
};
|
||||
});
|
||||
|
||||
sftpClipboardStore.copy(
|
||||
clipboardFiles,
|
||||
pane.connection.currentPath,
|
||||
pane.connection.id,
|
||||
focusedSide
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "sftpCut": {
|
||||
// Cut selected files to clipboard
|
||||
const selectedFiles = Array.from(pane.selectedFiles) as string[];
|
||||
if (selectedFiles.length === 0) return;
|
||||
|
||||
const clipboardFiles: SftpClipboardFile[] = selectedFiles.map((name: string) => {
|
||||
const file = pane.files.find((f) => f.name === name);
|
||||
return {
|
||||
name,
|
||||
isDirectory: file ? isNavigableDirectory(file) : false,
|
||||
};
|
||||
});
|
||||
|
||||
sftpClipboardStore.cut(
|
||||
clipboardFiles,
|
||||
pane.connection.currentPath,
|
||||
pane.connection.id,
|
||||
focusedSide
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "sftpPaste": {
|
||||
// Paste files from clipboard
|
||||
const clipboard = sftpClipboardStore.get();
|
||||
if (!clipboard || clipboard.files.length === 0) return;
|
||||
|
||||
// Use startTransfer to paste files from source to current pane
|
||||
// The transfer direction is determined by clipboard sourceSide and current focusedSide
|
||||
if (clipboard.sourceSide !== focusedSide) {
|
||||
const sourceTabs = clipboard.sourceSide === "left" ? sftp.leftTabs.tabs : sftp.rightTabs.tabs;
|
||||
const sourcePane = sourceTabs.find((tab) => tab.connection?.id === clipboard.sourceConnectionId);
|
||||
|
||||
if (!sourcePane?.connection) {
|
||||
toast.info("Paste source is no longer available.", "SFTP");
|
||||
return;
|
||||
}
|
||||
|
||||
// Cross-pane paste - use startTransfer
|
||||
try {
|
||||
const isCut = clipboard.operation === "cut";
|
||||
const pendingNames = new Set(clipboard.files.map((file) => file.name));
|
||||
const completedNames = new Set<string>();
|
||||
const failedNames = new Set<string>();
|
||||
|
||||
const updateClipboardAfterCompletion = (showToast: boolean) => {
|
||||
if (!isCut) return;
|
||||
const current = sftpClipboardStore.get();
|
||||
if (
|
||||
!current ||
|
||||
current.operation !== "cut" ||
|
||||
current.sourceConnectionId !== clipboard.sourceConnectionId ||
|
||||
current.sourcePath !== clipboard.sourcePath ||
|
||||
current.sourceSide !== clipboard.sourceSide
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const remainingFiles = current.files.filter((file) => !completedNames.has(file.name));
|
||||
if (remainingFiles.length === 0) {
|
||||
sftpClipboardStore.clear();
|
||||
} else {
|
||||
sftpClipboardStore.updateFiles(remainingFiles);
|
||||
}
|
||||
|
||||
if (showToast && failedNames.size > 0) {
|
||||
toast.info("Some items could not be transferred and were kept in the clipboard.", "SFTP");
|
||||
}
|
||||
};
|
||||
|
||||
const handleTransferComplete = async (result: {
|
||||
fileName: string;
|
||||
originalFileName?: string;
|
||||
status: string;
|
||||
}) => {
|
||||
if (!isCut) return;
|
||||
const sourceFileName = result.originalFileName ?? result.fileName;
|
||||
if (!pendingNames.has(sourceFileName)) return;
|
||||
pendingNames.delete(sourceFileName);
|
||||
|
||||
if (result.status === "completed") {
|
||||
try {
|
||||
await sftp.deleteFilesAtPath(
|
||||
clipboard.sourceSide,
|
||||
clipboard.sourceConnectionId,
|
||||
clipboard.sourcePath,
|
||||
[sourceFileName],
|
||||
);
|
||||
completedNames.add(sourceFileName);
|
||||
} catch {
|
||||
failedNames.add(sourceFileName);
|
||||
}
|
||||
} else {
|
||||
failedNames.add(sourceFileName);
|
||||
}
|
||||
|
||||
updateClipboardAfterCompletion(pendingNames.size === 0);
|
||||
};
|
||||
|
||||
await sftp.startTransfer(clipboard.files, clipboard.sourceSide, focusedSide, {
|
||||
sourcePane,
|
||||
sourcePath: clipboard.sourcePath,
|
||||
sourceConnectionId: clipboard.sourceConnectionId,
|
||||
onTransferComplete: handleTransferComplete,
|
||||
});
|
||||
} catch {
|
||||
toast.error("Paste failed. Please try again.", "SFTP");
|
||||
}
|
||||
} else {
|
||||
// Same-pane paste is not supported - show info toast
|
||||
toast.info("Paste within the same pane is not supported. Use copy to other pane instead.", "SFTP");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "sftpSelectAll": {
|
||||
// Select all files in the current pane
|
||||
const term = pane.filter.trim().toLowerCase();
|
||||
let visibleFiles = filterHiddenFiles(pane.files, showHiddenFiles);
|
||||
if (term) {
|
||||
visibleFiles = visibleFiles.filter(
|
||||
(f) => f.name === ".." || f.name.toLowerCase().includes(term),
|
||||
);
|
||||
}
|
||||
const allFileNames = visibleFiles
|
||||
.filter((f) => f.name !== "..")
|
||||
.map((f) => f.name);
|
||||
sftp.rangeSelect(focusedSide, allFileNames);
|
||||
break;
|
||||
}
|
||||
|
||||
case "sftpRename": {
|
||||
// Trigger rename for the first selected file
|
||||
const selectedFiles = Array.from(pane.selectedFiles) as string[];
|
||||
if (selectedFiles.length !== 1) return;
|
||||
sftpDialogActionStore.trigger("rename", selectedFiles);
|
||||
break;
|
||||
}
|
||||
|
||||
case "sftpDelete": {
|
||||
// Delete selected files
|
||||
const selectedFiles = Array.from(pane.selectedFiles) as string[];
|
||||
if (selectedFiles.length === 0) return;
|
||||
sftpDialogActionStore.trigger("delete", selectedFiles);
|
||||
break;
|
||||
}
|
||||
|
||||
case "sftpRefresh": {
|
||||
// Refresh the current pane
|
||||
sftp.refresh(focusedSide);
|
||||
break;
|
||||
}
|
||||
|
||||
case "sftpNewFolder": {
|
||||
// Create new folder
|
||||
sftpDialogActionStore.trigger("newFolder");
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
[hotkeyScheme, isActive, keyBindings, sftpRef, showHiddenFiles]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Use capture phase to intercept before other handlers
|
||||
window.addEventListener("keydown", handleKeyDown, true);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown, true);
|
||||
}, [handleKeyDown]);
|
||||
};
|
||||
@@ -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 */
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* Host Keyword Highlight Popover
|
||||
* Allows users to manage host-specific keyword highlighting rules in the terminal statusbar
|
||||
*/
|
||||
import { Highlighter, Plus, Trash2, RotateCcw } from 'lucide-react';
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { Host, KeywordHighlightRule } from '../../types';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
|
||||
export interface HostKeywordHighlightPopoverProps {
|
||||
host?: Host;
|
||||
onUpdateHost?: (host: Host) => void;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
buttonClassName?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_NEW_RULE_COLOR = '#F87171';
|
||||
|
||||
export const HostKeywordHighlightPopover: React.FC<HostKeywordHighlightPopoverProps> = ({
|
||||
host,
|
||||
onUpdateHost,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
buttonClassName = '',
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [newRuleLabel, setNewRuleLabel] = useState('');
|
||||
const [newRulePattern, setNewRulePattern] = useState('');
|
||||
const [newRuleColor, setNewRuleColor] = useState(DEFAULT_NEW_RULE_COLOR);
|
||||
const [patternError, setPatternError] = useState<string | null>(null);
|
||||
|
||||
const rules = useMemo(() => host?.keywordHighlightRules ?? [], [host?.keywordHighlightRules]);
|
||||
const enabled = host?.keywordHighlightEnabled ?? false;
|
||||
|
||||
const updateRules = useCallback((newRules: KeywordHighlightRule[]) => {
|
||||
if (!host || !onUpdateHost) return;
|
||||
onUpdateHost({ ...host, keywordHighlightRules: newRules });
|
||||
}, [host, onUpdateHost]);
|
||||
|
||||
const toggleEnabled = useCallback(() => {
|
||||
if (!host || !onUpdateHost) return;
|
||||
onUpdateHost({ ...host, keywordHighlightEnabled: !enabled });
|
||||
}, [host, onUpdateHost, enabled]);
|
||||
|
||||
const validatePattern = (pattern: string): boolean => {
|
||||
try {
|
||||
new RegExp(pattern, 'gi');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddRule = useCallback(() => {
|
||||
if (!newRuleLabel.trim() || !newRulePattern.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validatePattern(newRulePattern)) {
|
||||
setPatternError(t('terminal.toolbar.hostHighlight.invalidPattern'));
|
||||
return;
|
||||
}
|
||||
|
||||
const newRule: KeywordHighlightRule = {
|
||||
id: uuidv4(),
|
||||
label: newRuleLabel.trim(),
|
||||
patterns: [newRulePattern.trim()],
|
||||
color: newRuleColor,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
updateRules([...rules, newRule]);
|
||||
|
||||
// Reset form
|
||||
setNewRuleLabel('');
|
||||
setNewRulePattern('');
|
||||
setNewRuleColor(DEFAULT_NEW_RULE_COLOR);
|
||||
setPatternError(null);
|
||||
|
||||
// Auto-enable if adding the first rule and not enabled
|
||||
if (rules.length === 0 && !enabled && host && onUpdateHost) {
|
||||
onUpdateHost({ ...host, keywordHighlightRules: [newRule], keywordHighlightEnabled: true });
|
||||
}
|
||||
}, [newRuleLabel, newRulePattern, newRuleColor, rules, updateRules, enabled, host, onUpdateHost, t]);
|
||||
|
||||
const handleDeleteRule = useCallback((ruleId: string) => {
|
||||
updateRules(rules.filter((r) => r.id !== ruleId));
|
||||
}, [rules, updateRules]);
|
||||
|
||||
const handleColorChange = useCallback((ruleId: string, color: string) => {
|
||||
updateRules(rules.map((r) => (r.id === ruleId ? { ...r, color } : r)));
|
||||
}, [rules, updateRules]);
|
||||
|
||||
const handleToggleRule = useCallback((ruleId: string) => {
|
||||
updateRules(rules.map((r) => (r.id === ruleId ? { ...r, enabled: !r.enabled } : r)));
|
||||
}, [rules, updateRules]);
|
||||
|
||||
const handleClearAll = useCallback(() => {
|
||||
if (!host || !onUpdateHost) return;
|
||||
onUpdateHost({ ...host, keywordHighlightRules: [], keywordHighlightEnabled: false });
|
||||
}, [host, onUpdateHost]);
|
||||
|
||||
const handlePatternChange = (value: string) => {
|
||||
setNewRulePattern(value);
|
||||
if (patternError && validatePattern(value)) {
|
||||
setPatternError(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Disable if no host (local/serial terminal sessions)
|
||||
const isLocalTerminal = host?.protocol === 'local' || host?.id?.startsWith('local-');
|
||||
const isSerialTerminal = host?.protocol === 'serial' || host?.id?.startsWith('serial-');
|
||||
const isDisabled = !host || !onUpdateHost || isLocalTerminal || isSerialTerminal;
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className={buttonClassName}
|
||||
title={t('terminal.toolbar.hostHighlight.title')}
|
||||
aria-label={t('terminal.toolbar.hostHighlight.title')}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<Highlighter size={12} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 p-0" align="start" side="top">
|
||||
<div className="px-3 py-2 border-b bg-muted/30 flex items-center justify-between">
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground">
|
||||
{t('terminal.toolbar.hostHighlight.title')}
|
||||
</span>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{enabled ? t('common.enabled') : t('common.disabled')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
onClick={toggleEnabled}
|
||||
className={`
|
||||
relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent
|
||||
transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2
|
||||
${enabled ? 'bg-primary' : 'bg-muted-foreground/30'}
|
||||
`}
|
||||
>
|
||||
<span
|
||||
className={`
|
||||
pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0
|
||||
transition duration-200 ease-in-out
|
||||
${enabled ? 'translate-x-4' : 'translate-x-0'}
|
||||
`}
|
||||
/>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="max-h-64">
|
||||
<div className="p-2 space-y-1.5">
|
||||
{rules.length === 0 ? (
|
||||
<div className="px-2 py-4 text-xs text-muted-foreground text-center italic">
|
||||
{t('terminal.toolbar.hostHighlight.noRules')}
|
||||
</div>
|
||||
) : (
|
||||
rules.map((rule) => (
|
||||
<div
|
||||
key={rule.id}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-accent/50 group"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleRule(rule.id)}
|
||||
className={`
|
||||
flex-shrink-0 w-3 h-3 rounded-sm border transition-colors
|
||||
${rule.enabled
|
||||
? 'bg-primary border-primary'
|
||||
: 'bg-transparent border-muted-foreground/50'
|
||||
}
|
||||
`}
|
||||
title={rule.enabled ? t('common.enabled') : t('common.disabled')}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className="text-xs font-medium truncate"
|
||||
style={{ color: rule.enabled ? rule.color : 'inherit' }}
|
||||
>
|
||||
{rule.label}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground font-mono truncate">
|
||||
{rule.patterns.join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative flex-shrink-0">
|
||||
<input
|
||||
type="color"
|
||||
value={rule.color}
|
||||
onChange={(e) => handleColorChange(rule.id, e.target.value)}
|
||||
className="sr-only"
|
||||
aria-label={`${t('terminal.toolbar.hostHighlight.changeColor')} ${rule.label}`}
|
||||
/>
|
||||
<span
|
||||
className="block w-6 h-4 rounded cursor-pointer border border-border/50 hover:border-border"
|
||||
style={{ backgroundColor: rule.color }}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 opacity-0 group-hover:opacity-100 transition-opacity text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => handleDeleteRule(rule.id)}
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Add new rule form */}
|
||||
<div className="p-2 border-t bg-muted/20 space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">
|
||||
{t('terminal.toolbar.hostHighlight.addRule')}
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Input
|
||||
placeholder={t('terminal.toolbar.hostHighlight.labelPlaceholder')}
|
||||
value={newRuleLabel}
|
||||
onChange={(e) => setNewRuleLabel(e.target.value)}
|
||||
className="h-7 text-xs flex-1"
|
||||
/>
|
||||
<label className="relative flex-shrink-0">
|
||||
<input
|
||||
type="color"
|
||||
value={newRuleColor}
|
||||
onChange={(e) => setNewRuleColor(e.target.value)}
|
||||
className="sr-only"
|
||||
aria-label={t('terminal.toolbar.hostHighlight.selectColor')}
|
||||
/>
|
||||
<span
|
||||
className="block w-7 h-7 rounded cursor-pointer border border-border/50 hover:border-border"
|
||||
style={{ backgroundColor: newRuleColor }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Input
|
||||
placeholder={t('terminal.toolbar.hostHighlight.patternPlaceholder')}
|
||||
value={newRulePattern}
|
||||
onChange={(e) => handlePatternChange(e.target.value)}
|
||||
className={`h-7 text-xs font-mono flex-1 ${patternError ? 'border-destructive' : ''}`}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-7 w-7 flex-shrink-0"
|
||||
onClick={handleAddRule}
|
||||
disabled={!newRuleLabel.trim() || !newRulePattern.trim()}
|
||||
>
|
||||
<Plus size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
{patternError && (
|
||||
<div className="text-[10px] text-destructive">{patternError}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
{rules.length > 0 && (
|
||||
<div className="p-2 border-t flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 text-xs text-muted-foreground hover:text-destructive"
|
||||
onClick={handleClearAll}
|
||||
>
|
||||
<RotateCcw size={10} className="mr-1" />
|
||||
{t('terminal.toolbar.hostHighlight.clearAll')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default HostKeywordHighlightPopover;
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Terminal Toolbar
|
||||
* Displays SFTP, Scripts, Theme, Search buttons and close button in terminal status bar
|
||||
* Displays SFTP, Scripts, Theme, Highlight, Search buttons and close button in terminal status bar
|
||||
*/
|
||||
import { FolderInput, X, Zap, Palette, Search } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
@@ -10,6 +10,7 @@ import { Button } from '../ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import ThemeCustomizeModal from './ThemeCustomizeModal';
|
||||
import HostKeywordHighlightPopover from './HostKeywordHighlightPopover';
|
||||
|
||||
export interface TerminalToolbarProps {
|
||||
status: 'connecting' | 'connected' | 'disconnected';
|
||||
@@ -55,6 +56,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [themeModalOpen, setThemeModalOpen] = useState(false);
|
||||
const [highlightPopoverOpen, setHighlightPopoverOpen] = useState(false);
|
||||
const buttonBase = "h-6 w-6 p-0 shadow-none border-none text-[color:var(--terminal-toolbar-fg)] bg-transparent hover:bg-transparent";
|
||||
|
||||
const isLocalTerminal = host?.protocol === 'local' || host?.id?.startsWith('local-');
|
||||
@@ -163,6 +165,14 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
<Palette size={12} />
|
||||
</Button>
|
||||
|
||||
<HostKeywordHighlightPopover
|
||||
host={host}
|
||||
onUpdateHost={onUpdateHost}
|
||||
isOpen={highlightPopoverOpen}
|
||||
setIsOpen={setHighlightPopoverOpen}
|
||||
buttonClassName={buttonBase}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
|
||||
@@ -12,6 +12,9 @@ export type { TerminalConnectionProgressProps } from './TerminalConnectionProgre
|
||||
export { TerminalToolbar } from './TerminalToolbar';
|
||||
export type { TerminalToolbarProps } from './TerminalToolbar';
|
||||
|
||||
export { HostKeywordHighlightPopover } from './HostKeywordHighlightPopover';
|
||||
export type { HostKeywordHighlightPopoverProps } from './HostKeywordHighlightPopover';
|
||||
|
||||
export { TerminalConnectionDialog } from './TerminalConnectionDialog';
|
||||
export type { ChainProgress,TerminalConnectionDialogProps } from './TerminalConnectionDialog';
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
resolveXTermPerformanceConfig,
|
||||
} from "../../../infrastructure/config/xtermPerformance";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { normalizeLineEndings } from "../../../lib/utils";
|
||||
import { isMacPlatform, normalizeLineEndings } from "../../../lib/utils";
|
||||
import type {
|
||||
Host,
|
||||
KeyBinding,
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
TerminalSettings,
|
||||
TerminalTheme,
|
||||
} from "../../../types";
|
||||
import { matchesKeyBinding } from "../../../domain/models";
|
||||
|
||||
type TerminalBackendApi = {
|
||||
openExternalAvailable: () => boolean;
|
||||
@@ -66,6 +67,9 @@ export type CreateXTermRuntimeContext = {
|
||||
((data: string, sourceSessionId: string) => void) | undefined
|
||||
>;
|
||||
|
||||
// Snippets for shortkey support
|
||||
snippetsRef?: RefObject<{ id: string; command: string; shortkey?: string }[]>;
|
||||
|
||||
sessionId: string;
|
||||
statusRef: RefObject<TerminalSession["status"]>;
|
||||
onCommandExecuted?: (
|
||||
@@ -333,12 +337,41 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
}
|
||||
|
||||
const currentScheme = ctx.hotkeySchemeRef.current;
|
||||
// Use shared utility for platform detection when hotkey scheme is disabled
|
||||
const isMac = currentScheme === "mac" || (currentScheme === "disabled" && isMacPlatform());
|
||||
|
||||
// Check snippet shortcuts first (even if hotkeys are disabled)
|
||||
const snippets = ctx.snippetsRef?.current;
|
||||
if (snippets && snippets.length > 0) {
|
||||
for (const snippet of snippets) {
|
||||
if (snippet.shortkey && matchesKeyBinding(e, snippet.shortkey, isMac)) {
|
||||
const id = ctx.sessionRef.current;
|
||||
if (id && ctx.statusRef.current === "connected") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Send the snippet command to the terminal
|
||||
const payload = `${normalizeLineEndings(snippet.command)}\r`;
|
||||
ctx.terminalBackend.writeToSession(id, payload);
|
||||
if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
|
||||
ctx.onBroadcastInputRef.current(payload, ctx.sessionId);
|
||||
}
|
||||
if (ctx.onCommandExecuted) {
|
||||
const cmd = snippet.command.trim();
|
||||
if (cmd) ctx.onCommandExecuted(cmd, ctx.host.id, ctx.host.label, ctx.sessionId);
|
||||
ctx.commandBufferRef.current = "";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const currentBindings = ctx.keyBindingsRef.current;
|
||||
if (currentScheme === "disabled" || currentBindings.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const isMac = currentScheme === "mac";
|
||||
const matched = checkAppShortcut(e, currentBindings, isMac);
|
||||
if (!matched) return true;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,11 @@ 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
|
||||
// Host-level keyword highlighting (overrides/extends global settings)
|
||||
keywordHighlightRules?: KeywordHighlightRule[];
|
||||
keywordHighlightEnabled?: boolean;
|
||||
}
|
||||
|
||||
export type KeyType = 'RSA' | 'ECDSA' | 'ED25519';
|
||||
@@ -134,6 +139,7 @@ export interface Snippet {
|
||||
tags?: string[];
|
||||
package?: string; // package path
|
||||
targets?: string[]; // host ids
|
||||
shortkey?: string; // Keyboard shortcut to send this snippet in terminal (e.g., "F1", "Ctrl + F1")
|
||||
}
|
||||
|
||||
export interface TerminalLine {
|
||||
@@ -171,7 +177,7 @@ export interface KeyBinding {
|
||||
label: string;
|
||||
mac: string; // e.g., '⌘+1', '⌘+⌥+arrows'
|
||||
pc: string; // e.g., 'Ctrl+1', 'Ctrl+Alt+arrows'
|
||||
category: 'tabs' | 'terminal' | 'navigation' | 'app';
|
||||
category: 'tabs' | 'terminal' | 'navigation' | 'app' | 'sftp';
|
||||
}
|
||||
|
||||
// User's custom key bindings - only stores overrides from defaults
|
||||
@@ -259,6 +265,12 @@ export const matchesKeyBinding = (e: KeyboardEvent, keyStr: string, isMac: boole
|
||||
if (!parsed) return false;
|
||||
|
||||
const { modifiers, key } = parsed;
|
||||
|
||||
const hasMacModifiers = modifiers.some((modifier) => ['⌘', '⌃', '⌥'].includes(modifier));
|
||||
const hasPcModifiers = modifiers.some((modifier) => ['Ctrl', 'Alt', 'Win'].includes(modifier));
|
||||
if ((!isMac && hasMacModifiers) || (isMac && hasPcModifiers)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check modifiers
|
||||
if (isMac) {
|
||||
@@ -283,18 +295,26 @@ export const matchesKeyBinding = (e: KeyboardEvent, keyStr: string, isMac: boole
|
||||
if (e.metaKey !== needMeta) return false;
|
||||
}
|
||||
|
||||
// Check key
|
||||
let eventKey = e.key;
|
||||
if (eventKey === ' ') eventKey = 'Space';
|
||||
else if (eventKey === 'ArrowUp') eventKey = '↑';
|
||||
else if (eventKey === 'ArrowDown') eventKey = '↓';
|
||||
else if (eventKey === 'ArrowLeft') eventKey = '←';
|
||||
else if (eventKey === 'ArrowRight') eventKey = '→';
|
||||
else if (eventKey === 'Escape') eventKey = 'Esc';
|
||||
else if (eventKey === '[') eventKey = '[';
|
||||
else if (eventKey === ']') eventKey = ']';
|
||||
|
||||
return eventKey.toLowerCase() === key.toLowerCase();
|
||||
const normalizeKey = (rawKey: string): string => {
|
||||
let normalizedKey = rawKey;
|
||||
if (normalizedKey === ' ') normalizedKey = 'Space';
|
||||
else if (normalizedKey === 'ArrowUp') normalizedKey = '↑';
|
||||
else if (normalizedKey === 'ArrowDown') normalizedKey = '↓';
|
||||
else if (normalizedKey === 'ArrowLeft') normalizedKey = '←';
|
||||
else if (normalizedKey === 'ArrowRight') normalizedKey = '→';
|
||||
else if (normalizedKey === 'Escape') normalizedKey = 'Esc';
|
||||
else if (normalizedKey === 'Backspace') normalizedKey = '⌫';
|
||||
else if (normalizedKey === 'Delete') normalizedKey = 'Del';
|
||||
else if (normalizedKey === '[') normalizedKey = '[';
|
||||
else if (normalizedKey === ']') normalizedKey = ']';
|
||||
else if (normalizedKey === 'Del') normalizedKey = 'Del';
|
||||
return normalizedKey;
|
||||
};
|
||||
|
||||
const eventKey = normalizeKey(e.key);
|
||||
const parsedKey = normalizeKey(key);
|
||||
|
||||
return eventKey.toLowerCase() === parsedKey.toLowerCase();
|
||||
};
|
||||
|
||||
export const DEFAULT_KEY_BINDINGS: KeyBinding[] = [
|
||||
@@ -326,6 +346,16 @@ export const DEFAULT_KEY_BINDINGS: KeyBinding[] = [
|
||||
{ id: 'quick-switch', action: 'quickSwitch', label: 'Quick Switch', mac: '⌘ + J', pc: 'Ctrl + J', category: 'app' },
|
||||
{ id: 'snippets', action: 'snippets', label: 'Open Snippets', mac: '⌘ + Shift + S', pc: 'Ctrl + Shift + S', category: 'app' },
|
||||
{ id: 'broadcast', action: 'broadcast', label: 'Switch the Broadcast Mode', mac: '⌘ + B', pc: 'Ctrl + B', category: 'app' },
|
||||
|
||||
// SFTP Operations
|
||||
{ id: 'sftp-copy', action: 'sftpCopy', label: 'Copy Files', mac: '⌘ + C', pc: 'Ctrl + C', category: 'sftp' },
|
||||
{ id: 'sftp-cut', action: 'sftpCut', label: 'Cut Files', mac: '⌘ + X', pc: 'Ctrl + X', category: 'sftp' },
|
||||
{ id: 'sftp-paste', action: 'sftpPaste', label: 'Paste Files', mac: '⌘ + V', pc: 'Ctrl + V', category: 'sftp' },
|
||||
{ id: 'sftp-select-all', action: 'sftpSelectAll', label: 'Select All Files', mac: '⌘ + A', pc: 'Ctrl + A', category: 'sftp' },
|
||||
{ id: 'sftp-rename', action: 'sftpRename', label: 'Rename File', mac: 'F2', pc: 'F2', category: 'sftp' },
|
||||
{ id: 'sftp-delete', action: 'sftpDelete', label: 'Delete Files', mac: '⌘ + ⌫', pc: 'Delete', category: 'sftp' },
|
||||
{ id: 'sftp-refresh', action: 'sftpRefresh', label: 'Refresh', mac: '⌘ + R', pc: 'F5', category: 'sftp' },
|
||||
{ id: 'sftp-new-folder', action: 'sftpNewFolder', label: 'New Folder', mac: '⌘ + Shift + N', pc: 'Ctrl + Shift + N', category: 'sftp' },
|
||||
];
|
||||
|
||||
// Terminal appearance settings
|
||||
@@ -551,6 +581,7 @@ export type TransferDirection = 'upload' | 'download' | 'remote-to-remote' | 'lo
|
||||
export interface TransferTask {
|
||||
id: string;
|
||||
fileName: string;
|
||||
originalFileName?: string;
|
||||
sourcePath: string;
|
||||
targetPath: string;
|
||||
sourceConnectionId: string;
|
||||
@@ -654,3 +685,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;
|
||||
}
|
||||
@@ -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");
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,718 @@
|
||||
/**
|
||||
* Global Shortcut Bridge - Handles global keyboard shortcuts and system tray
|
||||
* Implements the "Quake mode" / drop-down terminal feature
|
||||
*/
|
||||
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
|
||||
let electronModule = null;
|
||||
let tray = null;
|
||||
let closeToTray = false;
|
||||
let currentHotkey = null;
|
||||
let hotkeyEnabled = false;
|
||||
|
||||
const STATUS_TEXT = {
|
||||
session: {
|
||||
connected: "Connected",
|
||||
connecting: "Connecting",
|
||||
disconnected: "Disconnected",
|
||||
},
|
||||
portForward: {
|
||||
active: "Active",
|
||||
connecting: "Connecting",
|
||||
inactive: "Inactive",
|
||||
error: "Error",
|
||||
},
|
||||
};
|
||||
// Dynamic tray menu data (synced from renderer)
|
||||
let trayMenuData = {
|
||||
sessions: [], // { id, label, hostLabel, status }
|
||||
portForwardRules: [], // { id, label, type, localPort, remoteHost, remotePort, status, hostId }
|
||||
};
|
||||
|
||||
let trayPanelWindow = null;
|
||||
|
||||
let trayPanelRefreshTimer = null;
|
||||
|
||||
function openMainWindow() {
|
||||
const { app } = electronModule;
|
||||
const win = getMainWindow();
|
||||
if (!win) return;
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.show();
|
||||
win.focus();
|
||||
try {
|
||||
app.focus({ steal: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function getTrayPanelUrl() {
|
||||
const devServerUrl = process.env.VITE_DEV_SERVER_URL;
|
||||
if (devServerUrl) {
|
||||
return `${devServerUrl.replace(/\/$/, "")}/#/tray`;
|
||||
}
|
||||
return "app://netcatty/index.html#/tray";
|
||||
}
|
||||
|
||||
function ensureTrayPanelWindow() {
|
||||
const { BrowserWindow } = electronModule;
|
||||
if (trayPanelWindow && !trayPanelWindow.isDestroyed()) return trayPanelWindow;
|
||||
|
||||
trayPanelWindow = new BrowserWindow({
|
||||
width: 360,
|
||||
height: 520,
|
||||
show: false,
|
||||
frame: false,
|
||||
resizable: false,
|
||||
movable: false,
|
||||
fullscreenable: false,
|
||||
minimizable: false,
|
||||
maximizable: false,
|
||||
skipTaskbar: true,
|
||||
alwaysOnTop: true,
|
||||
transparent: true,
|
||||
hasShadow: true,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "../preload.cjs"),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: false,
|
||||
},
|
||||
});
|
||||
|
||||
trayPanelWindow.webContents.on("console-message", (_event, level, message) => {
|
||||
// Forward renderer logs to main process output for easy debugging.
|
||||
console.log(`[TrayPanel:renderer:${level}] ${message}`);
|
||||
});
|
||||
|
||||
trayPanelWindow.on("blur", () => {
|
||||
try {
|
||||
trayPanelWindow?.hide();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
const url = getTrayPanelUrl();
|
||||
console.log("[TrayPanel] loadURL", url);
|
||||
void trayPanelWindow.loadURL(url);
|
||||
|
||||
trayPanelWindow.webContents.on("did-finish-load", () => {
|
||||
try {
|
||||
trayPanelWindow?.webContents?.send("netcatty:trayPanel:setMenuData", trayMenuData);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
return trayPanelWindow;
|
||||
}
|
||||
|
||||
function showTrayPanel() {
|
||||
if (!tray) return;
|
||||
const { screen } = electronModule;
|
||||
const win = ensureTrayPanelWindow();
|
||||
|
||||
const trayBounds = tray.getBounds();
|
||||
const display = screen.getDisplayNearestPoint({ x: trayBounds.x, y: trayBounds.y });
|
||||
const workArea = display.workArea;
|
||||
|
||||
const panelBounds = win.getBounds();
|
||||
const x = Math.min(
|
||||
Math.max(trayBounds.x + Math.round(trayBounds.width / 2) - Math.round(panelBounds.width / 2), workArea.x),
|
||||
workArea.x + workArea.width - panelBounds.width,
|
||||
);
|
||||
const y = Math.min(trayBounds.y + trayBounds.height + 6, workArea.y + workArea.height - panelBounds.height);
|
||||
|
||||
win.setBounds({ x, y, width: panelBounds.width, height: panelBounds.height }, false);
|
||||
win.show();
|
||||
win.focus();
|
||||
|
||||
try {
|
||||
win.webContents?.send("netcatty:trayPanel:setMenuData", trayMenuData);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (trayPanelRefreshTimer) clearInterval(trayPanelRefreshTimer);
|
||||
trayPanelRefreshTimer = setInterval(() => {
|
||||
try {
|
||||
if (!trayPanelWindow || trayPanelWindow.isDestroyed() || !trayPanelWindow.isVisible()) return;
|
||||
trayPanelWindow.webContents?.send("netcatty:trayPanel:refresh");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function hideTrayPanel() {
|
||||
if (trayPanelWindow && !trayPanelWindow.isDestroyed()) {
|
||||
trayPanelWindow.hide();
|
||||
}
|
||||
|
||||
if (trayPanelRefreshTimer) {
|
||||
clearInterval(trayPanelRefreshTimer);
|
||||
trayPanelRefreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTrayPanel() {
|
||||
if (trayPanelWindow && !trayPanelWindow.isDestroyed() && trayPanelWindow.isVisible()) {
|
||||
hideTrayPanel();
|
||||
} else {
|
||||
showTrayPanel();
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTrayIconPath() {
|
||||
const { app } = electronModule;
|
||||
|
||||
// Use different icons for different platforms
|
||||
// macOS: template image (black + transparent, system handles color)
|
||||
// Windows/Linux: colored icon
|
||||
const isMac = process.platform === "darwin";
|
||||
const iconName = isMac ? "tray-iconTemplate.png" : "tray-icon.png";
|
||||
|
||||
// Security: Only use known packaged icon locations, ignore renderer-provided paths
|
||||
const candidates = [
|
||||
path.join(app.getAppPath(), "dist", iconName),
|
||||
path.join(app.getAppPath(), "public", iconName),
|
||||
path.join(__dirname, "../../public", iconName),
|
||||
path.join(__dirname, "../../dist", iconName),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the bridge with dependencies
|
||||
*/
|
||||
function init(deps) {
|
||||
electronModule = deps.electronModule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the main window reference
|
||||
* Uses windowManager's tracked mainWindow for reliability
|
||||
*/
|
||||
function getMainWindow() {
|
||||
// Prefer the explicitly tracked main window from windowManager
|
||||
const windowManager = require("./windowManager.cjs");
|
||||
const tracked = windowManager.getMainWindow?.();
|
||||
if (tracked && !tracked.isDestroyed?.()) {
|
||||
return tracked;
|
||||
}
|
||||
// Fallback: filter out tray panel window from all windows
|
||||
const { BrowserWindow } = electronModule;
|
||||
const wins = BrowserWindow.getAllWindows();
|
||||
const mainWins = wins.filter((w) => w !== trayPanelWindow && !w.isDestroyed?.());
|
||||
return mainWins && mainWins.length ? mainWins[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a hotkey string from frontend format to Electron accelerator format
|
||||
* e.g., "⌘ + Space" -> "CommandOrControl+Space"
|
||||
* "Ctrl + `" -> "CommandOrControl+`"
|
||||
* "Alt + Space" -> "Alt+Space"
|
||||
*/
|
||||
function toElectronAccelerator(hotkeyStr) {
|
||||
if (!hotkeyStr || hotkeyStr === "Disabled" || hotkeyStr === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse the hotkey string
|
||||
const parts = hotkeyStr.split("+").map((p) => p.trim());
|
||||
|
||||
// Convert each part to Electron accelerator format
|
||||
const acceleratorParts = parts.map((part) => {
|
||||
// Mac symbols to Electron format
|
||||
if (part === "⌘" || part === "Cmd" || part === "Command") {
|
||||
return "CommandOrControl";
|
||||
}
|
||||
if (part === "⌃" || part === "Ctrl" || part === "Control") {
|
||||
return "Control";
|
||||
}
|
||||
if (part === "⌥" || part === "Alt" || part === "Option") {
|
||||
return "Alt";
|
||||
}
|
||||
if (part === "Shift") {
|
||||
return "Shift";
|
||||
}
|
||||
if (part === "Win" || part === "Super" || part === "Meta") {
|
||||
return "Super";
|
||||
}
|
||||
// Arrow symbols
|
||||
if (part === "↑") return "Up";
|
||||
if (part === "↓") return "Down";
|
||||
if (part === "←") return "Left";
|
||||
if (part === "→") return "Right";
|
||||
// Special keys
|
||||
if (part === "↵" || part === "Enter" || part === "Return") return "Return";
|
||||
if (part === "⇥" || part === "Tab") return "Tab";
|
||||
if (part === "⌫" || part === "Backspace") return "Backspace";
|
||||
if (part === "Del" || part === "Delete") return "Delete";
|
||||
if (part === "Esc" || part === "Escape") return "Escape";
|
||||
if (part === "Space") return "Space";
|
||||
// Backtick/grave accent
|
||||
if (part === "`" || part === "~") return "`";
|
||||
// Function keys
|
||||
if (/^F\d+$/i.test(part)) return part.toUpperCase();
|
||||
// Single character - keep as-is
|
||||
return part;
|
||||
});
|
||||
|
||||
return acceleratorParts.join("+");
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the main window visibility
|
||||
*/
|
||||
function toggleWindowVisibility() {
|
||||
const win = getMainWindow();
|
||||
if (!win) return;
|
||||
|
||||
try {
|
||||
// Check if window is minimized first - minimized windows may still report isVisible() = true
|
||||
if (win.isMinimized()) {
|
||||
win.restore();
|
||||
win.show();
|
||||
win.focus();
|
||||
const { app } = electronModule;
|
||||
try {
|
||||
app.focus({ steal: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
} else if (win.isVisible()) {
|
||||
if (win.isFocused()) {
|
||||
// Window is visible and focused - hide it
|
||||
win.hide();
|
||||
} else {
|
||||
// Window is visible but not focused - focus it
|
||||
win.focus();
|
||||
const { app } = electronModule;
|
||||
try {
|
||||
app.focus({ steal: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Window is hidden - show and focus it
|
||||
win.show();
|
||||
win.focus();
|
||||
const { app } = electronModule;
|
||||
try {
|
||||
app.focus({ steal: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[GlobalShortcut] Error toggling window visibility:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the global toggle hotkey
|
||||
*/
|
||||
function registerGlobalHotkey(hotkeyStr) {
|
||||
const { globalShortcut } = electronModule;
|
||||
|
||||
// Unregister existing hotkey first
|
||||
unregisterGlobalHotkey();
|
||||
|
||||
if (!hotkeyStr || hotkeyStr === "Disabled" || hotkeyStr === "") {
|
||||
hotkeyEnabled = false;
|
||||
currentHotkey = null;
|
||||
return { success: true, enabled: false };
|
||||
}
|
||||
|
||||
const accelerator = toElectronAccelerator(hotkeyStr);
|
||||
if (!accelerator) {
|
||||
hotkeyEnabled = false;
|
||||
currentHotkey = null;
|
||||
return { success: false, error: "Invalid hotkey format" };
|
||||
}
|
||||
|
||||
try {
|
||||
const registered = globalShortcut.register(accelerator, toggleWindowVisibility);
|
||||
if (registered) {
|
||||
hotkeyEnabled = true;
|
||||
currentHotkey = hotkeyStr;
|
||||
console.log(`[GlobalShortcut] Registered hotkey: ${accelerator}`);
|
||||
return { success: true, enabled: true, accelerator };
|
||||
} else {
|
||||
console.warn(`[GlobalShortcut] Failed to register hotkey: ${accelerator}`);
|
||||
return { success: false, error: "Hotkey may be in use by another application" };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[GlobalShortcut] Error registering hotkey:", err);
|
||||
return { success: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister the global toggle hotkey
|
||||
*/
|
||||
function unregisterGlobalHotkey() {
|
||||
if (!hotkeyEnabled || !currentHotkey) return;
|
||||
|
||||
const { globalShortcut } = electronModule;
|
||||
const accelerator = toElectronAccelerator(currentHotkey);
|
||||
|
||||
if (accelerator) {
|
||||
try {
|
||||
globalShortcut.unregister(accelerator);
|
||||
console.log(`[GlobalShortcut] Unregistered hotkey: ${accelerator}`);
|
||||
} catch (err) {
|
||||
console.warn("[GlobalShortcut] Error unregistering hotkey:", err);
|
||||
}
|
||||
}
|
||||
|
||||
hotkeyEnabled = false;
|
||||
currentHotkey = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the system tray icon
|
||||
*/
|
||||
function createTray() {
|
||||
const { Tray, Menu, app, nativeImage } = electronModule;
|
||||
|
||||
if (tray) {
|
||||
// Tray already exists
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Load the tray icon
|
||||
let trayIcon;
|
||||
const resolvedIconPath = resolveTrayIconPath();
|
||||
if (resolvedIconPath) {
|
||||
trayIcon = nativeImage.createFromPath(resolvedIconPath);
|
||||
// Resize for tray (16x16 on most platforms, 22x22 on some Linux)
|
||||
if (process.platform === "darwin") {
|
||||
trayIcon = trayIcon.resize({ width: 16, height: 16 });
|
||||
trayIcon.setTemplateImage(true);
|
||||
} else {
|
||||
trayIcon = trayIcon.resize({ width: 16, height: 16 });
|
||||
}
|
||||
}
|
||||
|
||||
tray = new Tray(trayIcon || nativeImage.createEmpty());
|
||||
tray.setToolTip("Netcatty");
|
||||
|
||||
// Build and set initial context menu
|
||||
updateTrayMenu();
|
||||
|
||||
// Click on tray icon toggles tray panel
|
||||
tray.on("click", () => {
|
||||
toggleTrayPanel();
|
||||
});
|
||||
|
||||
console.log("[GlobalShortcut] System tray created");
|
||||
} catch (err) {
|
||||
console.error("[GlobalShortcut] Error creating tray:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the tray context menu with dynamic content
|
||||
*/
|
||||
function buildTrayMenuTemplate() {
|
||||
const { app } = electronModule;
|
||||
const menuTemplate = [];
|
||||
|
||||
// Open Main Window
|
||||
menuTemplate.push({
|
||||
label: "Open Main Window",
|
||||
click: () => {
|
||||
const win = getMainWindow();
|
||||
if (win) {
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.show();
|
||||
win.focus();
|
||||
try {
|
||||
app.focus({ steal: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
menuTemplate.push({ type: "separator" });
|
||||
|
||||
// Active Sessions
|
||||
if (trayMenuData.sessions && trayMenuData.sessions.length > 0) {
|
||||
menuTemplate.push({
|
||||
label: "Sessions",
|
||||
enabled: false,
|
||||
});
|
||||
for (const session of trayMenuData.sessions) {
|
||||
const statusText =
|
||||
session.status === "connected"
|
||||
? STATUS_TEXT.session.connected
|
||||
: session.status === "connecting"
|
||||
? STATUS_TEXT.session.connecting
|
||||
: STATUS_TEXT.session.disconnected;
|
||||
menuTemplate.push({
|
||||
label: ` ${session.hostLabel || session.label} (${statusText})`,
|
||||
click: () => {
|
||||
// Focus window and switch to this session
|
||||
const win = getMainWindow();
|
||||
if (win) {
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.show();
|
||||
win.focus();
|
||||
// Notify renderer to focus this session
|
||||
win.webContents?.send("netcatty:tray:focusSession", session.id);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
menuTemplate.push({ type: "separator" });
|
||||
}
|
||||
|
||||
// Port Forwarding Rules
|
||||
if (trayMenuData.portForwardRules && trayMenuData.portForwardRules.length > 0) {
|
||||
menuTemplate.push({
|
||||
label: "Port Forwarding",
|
||||
enabled: false,
|
||||
});
|
||||
for (const rule of trayMenuData.portForwardRules) {
|
||||
const isActive = rule.status === "active";
|
||||
const isConnecting = rule.status === "connecting";
|
||||
const statusText =
|
||||
rule.status === "active"
|
||||
? STATUS_TEXT.portForward.active
|
||||
: rule.status === "connecting"
|
||||
? STATUS_TEXT.portForward.connecting
|
||||
: rule.status === "error"
|
||||
? STATUS_TEXT.portForward.error
|
||||
: STATUS_TEXT.portForward.inactive;
|
||||
const typeLabel = rule.type === "local" ? "L" : rule.type === "remote" ? "R" : "D";
|
||||
const portInfo = rule.type === "dynamic"
|
||||
? `${rule.localPort}`
|
||||
: `${rule.localPort} → ${rule.remoteHost}:${rule.remotePort}`;
|
||||
|
||||
menuTemplate.push({
|
||||
label: ` [${typeLabel}] ${rule.label || portInfo} (${statusText})`,
|
||||
enabled: !isConnecting,
|
||||
click: () => {
|
||||
const win = getMainWindow();
|
||||
if (win) {
|
||||
win.webContents?.send("netcatty:tray:togglePortForward", rule.id, !isActive);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
menuTemplate.push({ type: "separator" });
|
||||
}
|
||||
|
||||
// Quit
|
||||
menuTemplate.push({
|
||||
label: "Quit",
|
||||
click: () => {
|
||||
closeToTray = false;
|
||||
app.quit();
|
||||
},
|
||||
});
|
||||
|
||||
return menuTemplate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the tray context menu
|
||||
*/
|
||||
function updateTrayMenu() {
|
||||
if (!tray) return;
|
||||
// Avoid showing a context menu on left-click; we toggle our custom panel instead.
|
||||
// On macOS, right-click may still show a menu if one is set, so we don't set any.
|
||||
try {
|
||||
tray.setContextMenu(null);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update tray menu data from renderer
|
||||
*/
|
||||
function setTrayMenuData(data) {
|
||||
if (data.sessions !== undefined) {
|
||||
trayMenuData.sessions = data.sessions;
|
||||
}
|
||||
if (data.portForwardRules !== undefined) {
|
||||
trayMenuData.portForwardRules = data.portForwardRules;
|
||||
}
|
||||
// Rebuild menu with new data
|
||||
updateTrayMenu();
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the system tray icon
|
||||
*/
|
||||
function destroyTray() {
|
||||
if (tray) {
|
||||
try {
|
||||
tray.destroy();
|
||||
tray = null;
|
||||
console.log("[GlobalShortcut] System tray destroyed");
|
||||
} catch (err) {
|
||||
console.warn("[GlobalShortcut] Error destroying tray:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set close-to-tray behavior
|
||||
*/
|
||||
function setCloseToTray(enabled) {
|
||||
closeToTray = !!enabled;
|
||||
|
||||
if (closeToTray) {
|
||||
// Create tray if it doesn't exist
|
||||
if (!tray) {
|
||||
createTray();
|
||||
}
|
||||
} else {
|
||||
// Destroy tray if it exists
|
||||
destroyTray();
|
||||
}
|
||||
|
||||
return { success: true, enabled: closeToTray };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if close-to-tray is enabled
|
||||
*/
|
||||
function isCloseToTrayEnabled() {
|
||||
return closeToTray;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current hotkey status
|
||||
*/
|
||||
function getHotkeyStatus() {
|
||||
return {
|
||||
enabled: hotkeyEnabled,
|
||||
hotkey: currentHotkey,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle window close event - hide to tray instead of closing
|
||||
*/
|
||||
function handleWindowClose(event, win) {
|
||||
if (closeToTray && tray) {
|
||||
event.preventDefault();
|
||||
win.hide();
|
||||
return true; // Prevented close
|
||||
}
|
||||
return false; // Allow close
|
||||
}
|
||||
|
||||
/**
|
||||
* Register IPC handlers
|
||||
*/
|
||||
function registerHandlers(ipcMain) {
|
||||
// Register global toggle hotkey
|
||||
ipcMain.handle("netcatty:globalHotkey:register", async (_event, { hotkey }) => {
|
||||
return registerGlobalHotkey(hotkey);
|
||||
});
|
||||
|
||||
// Unregister global toggle hotkey
|
||||
ipcMain.handle("netcatty:globalHotkey:unregister", async () => {
|
||||
unregisterGlobalHotkey();
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// Get current hotkey status
|
||||
ipcMain.handle("netcatty:globalHotkey:status", async () => {
|
||||
return getHotkeyStatus();
|
||||
});
|
||||
|
||||
// Set close-to-tray behavior
|
||||
ipcMain.handle("netcatty:tray:setCloseToTray", async (_event, { enabled }) => {
|
||||
return setCloseToTray(enabled);
|
||||
});
|
||||
|
||||
// Get close-to-tray status
|
||||
ipcMain.handle("netcatty:tray:isCloseToTray", async () => {
|
||||
return { enabled: closeToTray };
|
||||
});
|
||||
|
||||
// Update tray menu data
|
||||
ipcMain.handle("netcatty:tray:updateMenuData", async (_event, data) => {
|
||||
setTrayMenuData(data);
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:trayPanel:hide", async () => {
|
||||
hideTrayPanel();
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:trayPanel:openMainWindow", async () => {
|
||||
openMainWindow();
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:trayPanel:jumpToSession", async (_event, sessionId) => {
|
||||
openMainWindow();
|
||||
try {
|
||||
const win = getMainWindow();
|
||||
win?.webContents?.send("netcatty:trayPanel:jumpToSession", sessionId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:trayPanel:connectToHost", async (_event, hostId) => {
|
||||
openMainWindow();
|
||||
try {
|
||||
const win = getMainWindow();
|
||||
win?.webContents?.send("netcatty:trayPanel:connectToHost", hostId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
console.log("[GlobalShortcut] IPC handlers registered");
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup on app quit
|
||||
*/
|
||||
function cleanup() {
|
||||
unregisterGlobalHotkey();
|
||||
destroyTray();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init,
|
||||
registerHandlers,
|
||||
registerGlobalHotkey,
|
||||
unregisterGlobalHotkey,
|
||||
setCloseToTray,
|
||||
isCloseToTrayEnabled,
|
||||
handleWindowClose,
|
||||
toggleWindowVisibility,
|
||||
getHotkeyStatus,
|
||||
setTrayMenuData,
|
||||
updateTrayMenu,
|
||||
cleanup,
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Passphrase Handler - Handles passphrase requests for encrypted SSH keys
|
||||
* This module provides a mechanism to request passphrase input from the user
|
||||
* when encountering encrypted default SSH keys in ~/.ssh
|
||||
*/
|
||||
|
||||
// Passphrase request pending map
|
||||
// Map of requestId -> { resolveCallback, rejectCallback, webContentsId, keyPath, createdAt, timeoutId }
|
||||
const passphraseRequests = new Map();
|
||||
|
||||
// TTL for abandoned requests (2 minutes)
|
||||
const REQUEST_TTL_MS = 2 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Generate a unique request ID for passphrase requests
|
||||
*/
|
||||
function generateRequestId(prefix = 'pp') {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request passphrase from user via IPC
|
||||
* @param {Object} sender - Electron webContents sender
|
||||
* @param {string} keyPath - Path to the encrypted key
|
||||
* @param {string} keyName - Name of the key (e.g., id_rsa)
|
||||
* @param {string} [hostname] - Optional hostname for context
|
||||
* @returns {Promise<{ passphrase?: string, cancelled?: boolean, skipped?: boolean } | null>}
|
||||
*/
|
||||
function requestPassphrase(sender, keyPath, keyName, hostname) {
|
||||
return new Promise((resolve) => {
|
||||
if (!sender || sender.isDestroyed()) {
|
||||
console.warn('[Passphrase] Sender is destroyed, cannot request passphrase');
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = generateRequestId();
|
||||
|
||||
// Set up TTL timeout to clean up abandoned requests
|
||||
const timeoutId = setTimeout(() => {
|
||||
const pending = passphraseRequests.get(requestId);
|
||||
if (pending) {
|
||||
console.warn(`[Passphrase] Request ${requestId} timed out after ${REQUEST_TTL_MS / 1000}s`);
|
||||
passphraseRequests.delete(requestId);
|
||||
|
||||
// Notify renderer to close the modal
|
||||
try {
|
||||
if (!sender.isDestroyed()) {
|
||||
sender.send('netcatty:passphrase-timeout', { requestId });
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[Passphrase] Failed to send timeout notification:', err.message);
|
||||
}
|
||||
|
||||
resolve(null);
|
||||
}
|
||||
}, REQUEST_TTL_MS);
|
||||
|
||||
passphraseRequests.set(requestId, {
|
||||
resolveCallback: resolve,
|
||||
webContentsId: sender.id,
|
||||
keyPath,
|
||||
keyName,
|
||||
createdAt: Date.now(),
|
||||
timeoutId,
|
||||
});
|
||||
|
||||
console.log(`[Passphrase] Requesting passphrase for ${keyName} (${requestId})`);
|
||||
|
||||
try {
|
||||
sender.send('netcatty:passphrase-request', {
|
||||
requestId,
|
||||
keyPath,
|
||||
keyName,
|
||||
hostname,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Passphrase] Failed to send passphrase request:', err);
|
||||
passphraseRequests.delete(requestId);
|
||||
clearTimeout(timeoutId);
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle passphrase response from renderer
|
||||
*/
|
||||
function handleResponse(_event, payload) {
|
||||
const { requestId, passphrase, cancelled, skipped } = payload;
|
||||
const pending = passphraseRequests.get(requestId);
|
||||
|
||||
if (!pending) {
|
||||
console.warn(`[Passphrase] No pending request for ${requestId}`);
|
||||
return { success: false, error: 'Request not found' };
|
||||
}
|
||||
|
||||
// Clear the TTL timeout
|
||||
if (pending.timeoutId) {
|
||||
clearTimeout(pending.timeoutId);
|
||||
}
|
||||
|
||||
passphraseRequests.delete(requestId);
|
||||
|
||||
if (cancelled) {
|
||||
// User clicked Cancel - stop the entire passphrase flow
|
||||
console.log(`[Passphrase] Request ${requestId} cancelled by user`);
|
||||
pending.resolveCallback({ cancelled: true });
|
||||
} else if (skipped) {
|
||||
// User clicked Skip - skip this key but continue with others
|
||||
console.log(`[Passphrase] Request ${requestId} skipped by user`);
|
||||
pending.resolveCallback({ skipped: true });
|
||||
} else {
|
||||
console.log(`[Passphrase] Received passphrase for ${requestId}`);
|
||||
pending.resolveCallback({ passphrase: passphrase || null });
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Register IPC handler for passphrase responses
|
||||
*/
|
||||
function registerHandler(ipcMain) {
|
||||
ipcMain.handle('netcatty:passphrase:respond', handleResponse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending requests (for debugging)
|
||||
*/
|
||||
function getRequests() {
|
||||
return passphraseRequests;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateRequestId,
|
||||
requestPassphrase,
|
||||
handleResponse,
|
||||
registerHandler,
|
||||
getRequests,
|
||||
};
|
||||
@@ -6,6 +6,12 @@
|
||||
const net = require("node:net");
|
||||
const { Client: SSHClient } = require("ssh2");
|
||||
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
const {
|
||||
buildAuthHandler,
|
||||
createKeyboardInteractiveHandler,
|
||||
applyAuthToConnOpts,
|
||||
findAllDefaultPrivateKeys: findAllDefaultPrivateKeysFromHelper,
|
||||
} = require("./sshAuthHelper.cjs");
|
||||
|
||||
// Active port forwarding tunnels
|
||||
const portForwardingTunnels = new Map();
|
||||
@@ -38,85 +44,62 @@ async function startPortForward(event, payload) {
|
||||
username,
|
||||
password,
|
||||
privateKey,
|
||||
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 (password) {
|
||||
connectOpts.password = password;
|
||||
}
|
||||
|
||||
// Build auth handler with keyboard-interactive support
|
||||
const authMethods = [];
|
||||
if (privateKey) authMethods.push("publickey");
|
||||
if (password) authMethods.push("password");
|
||||
authMethods.push("keyboard-interactive");
|
||||
connectOpts.authHandler = authMethods;
|
||||
|
||||
// Handle keyboard-interactive authentication (2FA/MFA)
|
||||
conn.on("keyboard-interactive", (name, instructions, instructionsLang, prompts, finish) => {
|
||||
console.log(`[PortForward] ${hostname} keyboard-interactive auth requested`, {
|
||||
name,
|
||||
instructions,
|
||||
promptCount: prompts?.length || 0,
|
||||
prompts: prompts?.map(p => ({ prompt: p.prompt, echo: p.echo })),
|
||||
});
|
||||
|
||||
// If there are no prompts, just call finish with empty array
|
||||
if (!prompts || prompts.length === 0) {
|
||||
console.log(`[PortForward] No prompts, finishing keyboard-interactive`);
|
||||
finish([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward ALL prompts to user - no auto-fill to avoid semantic detection issues
|
||||
// (Prompt text is admin-customizable and may not contain expected keywords)
|
||||
const requestId = keyboardInteractiveHandler.generateRequestId('pf');
|
||||
|
||||
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
|
||||
console.log(`[PortForward] Received user responses, finishing keyboard-interactive`);
|
||||
finish(userResponses);
|
||||
}, sender.id, tunnelId);
|
||||
|
||||
const promptsData = prompts.map((p) => ({
|
||||
prompt: p.prompt,
|
||||
echo: p.echo,
|
||||
}));
|
||||
|
||||
console.log(`[PortForward] Showing modal for ${promptsData.length} prompts`);
|
||||
|
||||
safeSend(sender, "netcatty:keyboard-interactive", {
|
||||
requestId,
|
||||
sessionId: tunnelId,
|
||||
name: name || "",
|
||||
instructions: instructions || "",
|
||||
prompts: promptsData,
|
||||
hostname: hostname,
|
||||
savedPassword: password || null,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
conn.on('ready', () => {
|
||||
console.log(`[PortForward] SSH connection ready for tunnel ${tunnelId}`);
|
||||
|
||||
@@ -23,6 +23,13 @@ const { NetcattyAgent } = require("./netcattyAgent.cjs");
|
||||
const fileWatcherBridge = require("./fileWatcherBridge.cjs");
|
||||
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
const { createProxySocket } = require("./proxyUtils.cjs");
|
||||
const {
|
||||
buildAuthHandler,
|
||||
createKeyboardInteractiveHandler,
|
||||
applyAuthToConnOpts,
|
||||
safeSend: authSafeSend,
|
||||
findAllDefaultPrivateKeys: findAllDefaultPrivateKeysFromHelper,
|
||||
} = require("./sshAuthHelper.cjs");
|
||||
|
||||
// SFTP clients storage - shared reference passed from main
|
||||
let sftpClients = null;
|
||||
@@ -165,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 ? "/" : "";
|
||||
@@ -258,7 +277,8 @@ function init(deps) {
|
||||
/**
|
||||
* Connect through a chain of jump hosts for SFTP
|
||||
*/
|
||||
async function connectThroughChainForSftp(event, options, jumpHosts, targetHost, targetPort) {
|
||||
async function connectThroughChainForSftp(event, options, jumpHosts, targetHost, targetPort, connId) {
|
||||
const sender = event.sender;
|
||||
const connections = [];
|
||||
let currentSocket = null;
|
||||
|
||||
@@ -282,14 +302,25 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
host: jump.hostname,
|
||||
port: jump.port || 22,
|
||||
username: jump.username || 'root',
|
||||
readyTimeout: 20000,
|
||||
readyTimeout: 120000, // 2 minutes to allow for keyboard-interactive (2FA/MFA)
|
||||
keepaliveInterval: 10000,
|
||||
keepaliveCountMax: 3,
|
||||
// Enable keyboard-interactive authentication (required for 2FA/MFA)
|
||||
tryKeyboard: true,
|
||||
algorithms: {
|
||||
cipher: ['aes128-gcm@openssh.com', 'aes256-gcm@openssh.com', 'aes128-ctr', 'aes256-ctr'],
|
||||
kex: ['curve25519-sha256', 'curve25519-sha256@libssh.org', 'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'diffie-hellman-group14-sha256'],
|
||||
// Prioritize fastest ciphers (GCM modes are hardware-accelerated)
|
||||
cipher: [
|
||||
'aes128-gcm@openssh.com', 'aes256-gcm@openssh.com',
|
||||
'aes128-ctr', 'aes192-ctr', 'aes256-ctr',
|
||||
],
|
||||
// Prioritize modern key exchange algorithms for broad compatibility
|
||||
kex: [
|
||||
'curve25519-sha256', 'curve25519-sha256@libssh.org',
|
||||
'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521',
|
||||
'diffie-hellman-group14-sha256',
|
||||
'diffie-hellman-group16-sha512', 'diffie-hellman-group18-sha512',
|
||||
'diffie-hellman-group-exchange-sha256',
|
||||
],
|
||||
compress: ['none'],
|
||||
},
|
||||
};
|
||||
@@ -318,11 +349,22 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
|
||||
if (jump.password) connOpts.password = jump.password;
|
||||
|
||||
if (authAgent) {
|
||||
const order = ["agent"];
|
||||
if (connOpts.password) order.push("password");
|
||||
connOpts.authHandler = order;
|
||||
}
|
||||
// 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({
|
||||
privateKey: connOpts.privateKey,
|
||||
password: connOpts.password,
|
||||
passphrase: connOpts.passphrase,
|
||||
agent: connOpts.agent,
|
||||
username: connOpts.username,
|
||||
logPrefix: `[SFTP Chain] Hop ${i + 1}`,
|
||||
unlockedEncryptedKeys: options._unlockedEncryptedKeys || [],
|
||||
defaultKeys,
|
||||
});
|
||||
applyAuthToConnOpts(connOpts, authConfig);
|
||||
|
||||
// If first hop and proxy is configured, connect through proxy
|
||||
if (isFirst && options.proxy) {
|
||||
@@ -351,6 +393,14 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
console.error(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: ${hopLabel} timeout`);
|
||||
reject(new Error(`Connection timeout to ${hopLabel}`));
|
||||
});
|
||||
// Handle keyboard-interactive authentication for jump hosts (2FA/MFA)
|
||||
conn.on('keyboard-interactive', createKeyboardInteractiveHandler({
|
||||
sender,
|
||||
sessionId: connId,
|
||||
hostname: hopLabel,
|
||||
password: jump.password,
|
||||
logPrefix: `[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}`,
|
||||
}));
|
||||
conn.connect(connOpts);
|
||||
});
|
||||
|
||||
@@ -632,6 +682,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;
|
||||
@@ -643,12 +696,17 @@ 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,
|
||||
jumpHosts,
|
||||
options.hostname,
|
||||
options.port || 22
|
||||
options.port || 22,
|
||||
connId
|
||||
);
|
||||
connectionSocket = chainResult.socket;
|
||||
chainConnections = chainResult.connections;
|
||||
@@ -700,78 +758,30 @@ async function openSftp(event, options) {
|
||||
|
||||
if (options.password) connectOpts.password = options.password;
|
||||
|
||||
if (authAgent) {
|
||||
const order = ["agent"];
|
||||
if (connectOpts.password) order.push("password");
|
||||
connectOpts.authHandler = order;
|
||||
} else if (options.privateKey && connectOpts.password) {
|
||||
// Prefer key auth when both key and password are present (password still needed for sudo)
|
||||
connectOpts.authHandler = ["publickey", "password"];
|
||||
}
|
||||
|
||||
// Add keyboard-interactive authentication support
|
||||
// ssh2-sftp-client exposes the underlying ssh2 Client through its `on` method
|
||||
const kiHandler = (name, instructions, instructionsLang, prompts, finish) => {
|
||||
console.log(`[SFTP] ${options.hostname} keyboard-interactive auth requested`, {
|
||||
name,
|
||||
instructions,
|
||||
promptCount: prompts?.length || 0,
|
||||
prompts: prompts?.map(p => ({ prompt: p.prompt, echo: p.echo })),
|
||||
});
|
||||
|
||||
// If there are no prompts, just call finish with empty array
|
||||
if (!prompts || prompts.length === 0) {
|
||||
console.log(`[SFTP] No prompts, finishing keyboard-interactive`);
|
||||
finish([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward ALL prompts to user - no auto-fill to avoid semantic detection issues
|
||||
// (Prompt text is admin-customizable and may not contain expected keywords)
|
||||
const requestId = keyboardInteractiveHandler.generateRequestId('sftp');
|
||||
|
||||
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
|
||||
console.log(`[SFTP] Received user responses, finishing keyboard-interactive`);
|
||||
finish(userResponses);
|
||||
}, event.sender.id, connId);
|
||||
|
||||
const promptsData = prompts.map((p) => ({
|
||||
prompt: p.prompt,
|
||||
echo: p.echo,
|
||||
}));
|
||||
|
||||
console.log(`[SFTP] Showing modal for ${promptsData.length} prompts`);
|
||||
|
||||
safeSend(event.sender, "netcatty:keyboard-interactive", {
|
||||
requestId,
|
||||
sessionId: connId,
|
||||
name: name || "",
|
||||
instructions: instructions || "",
|
||||
prompts: promptsData,
|
||||
hostname: options.hostname,
|
||||
savedPassword: options.password || null,
|
||||
});
|
||||
};
|
||||
// Build auth handler using shared helper
|
||||
const authConfig = buildAuthHandler({
|
||||
privateKey: connectOpts.privateKey,
|
||||
password: connectOpts.password,
|
||||
passphrase: connectOpts.passphrase,
|
||||
agent: connectOpts.agent,
|
||||
username: connectOpts.username,
|
||||
logPrefix: "[SFTP]",
|
||||
defaultKeys,
|
||||
});
|
||||
applyAuthToConnOpts(connectOpts, authConfig);
|
||||
|
||||
// Create keyboard-interactive handler using shared helper
|
||||
const kiHandler = createKeyboardInteractiveHandler({
|
||||
sender: event.sender,
|
||||
sessionId: connId,
|
||||
hostname: options.hostname,
|
||||
password: options.password,
|
||||
logPrefix: "[SFTP]",
|
||||
});
|
||||
|
||||
// Add keyboard-interactive listener BEFORE connecting
|
||||
client.on("keyboard-interactive", kiHandler);
|
||||
|
||||
// Enable keyboard-interactive authentication in authHandler
|
||||
if (connectOpts.authHandler) {
|
||||
// Add keyboard-interactive after the existing methods
|
||||
if (!connectOpts.authHandler.includes("keyboard-interactive")) {
|
||||
connectOpts.authHandler.push("keyboard-interactive");
|
||||
}
|
||||
} else {
|
||||
// Create authHandler with keyboard-interactive support
|
||||
const authMethods = [];
|
||||
if (connectOpts.privateKey) authMethods.push("publickey");
|
||||
if (connectOpts.password) authMethods.push("password");
|
||||
authMethods.push("keyboard-interactive");
|
||||
connectOpts.authHandler = authMethods;
|
||||
}
|
||||
|
||||
// Increase timeout to allow for keyboard-interactive auth
|
||||
connectOpts.readyTimeout = 120000; // 2 minutes for 2FA input
|
||||
|
||||
@@ -1038,6 +1048,11 @@ async function writeSftpBinaryWithProgress(event, payload) {
|
||||
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
|
||||
const encodedPath = encodePath(remotePath, encoding);
|
||||
|
||||
// Extract callback functions from payload
|
||||
const onProgress = payload.onProgress;
|
||||
const onComplete = payload.onComplete;
|
||||
const onError = payload.onError;
|
||||
|
||||
// Optimize: Use Buffer.isBuffer to avoid unnecessary copy if already a Buffer
|
||||
// For ArrayBuffer from renderer, we still need to convert but use a more efficient method
|
||||
const buffer = Buffer.isBuffer(content) ? content : Buffer.from(content);
|
||||
@@ -1085,13 +1100,22 @@ async function writeSftpBinaryWithProgress(event, payload) {
|
||||
const isComplete = transferredBytes >= totalBytes;
|
||||
|
||||
if (isComplete || timeSinceLastProgress >= PROGRESS_THROTTLE_MS || bytesSinceLastProgress >= PROGRESS_THROTTLE_BYTES) {
|
||||
const contents = electronModule.webContents.fromId(event.sender.id);
|
||||
contents?.send("netcatty:upload:progress", {
|
||||
transferId,
|
||||
transferred: transferredBytes,
|
||||
totalBytes,
|
||||
speed,
|
||||
});
|
||||
// Call the progress callback if provided, otherwise send IPC event
|
||||
if (typeof onProgress === 'function') {
|
||||
try {
|
||||
onProgress(transferredBytes, totalBytes, speed);
|
||||
} catch (err) {
|
||||
console.warn('[SFTP] Progress callback error:', err);
|
||||
}
|
||||
} else {
|
||||
const contents = electronModule.webContents.fromId(event.sender.id);
|
||||
contents?.send("netcatty:upload:progress", {
|
||||
transferId,
|
||||
transferred: transferredBytes,
|
||||
totalBytes,
|
||||
speed,
|
||||
});
|
||||
}
|
||||
lastProgressSentTime = now;
|
||||
lastProgressSentBytes = transferredBytes;
|
||||
}
|
||||
@@ -1109,22 +1133,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
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
/**
|
||||
* SSH Authentication Helper - Shared authentication logic for SSH connections
|
||||
* Used by sshBridge, sftpBridge, and portForwardingBridge
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
const passphraseHandler = require("./passphraseHandler.cjs");
|
||||
|
||||
// Default SSH key names in priority order
|
||||
const DEFAULT_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
||||
|
||||
/**
|
||||
* Check if an SSH private key is encrypted (requires passphrase)
|
||||
* @param {string} keyContent - The content of the private key file
|
||||
* @returns {boolean} - True if the key is encrypted
|
||||
*/
|
||||
function isKeyEncrypted(keyContent) {
|
||||
if (!keyContent || typeof keyContent !== "string") return false;
|
||||
|
||||
// Check for PKCS#8 encrypted format (-----BEGIN ENCRYPTED PRIVATE KEY-----)
|
||||
if (keyContent.includes("-----BEGIN ENCRYPTED PRIVATE KEY-----")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for legacy PEM format encryption (e.g., RSA PRIVATE KEY with encryption)
|
||||
if (keyContent.includes("Proc-Type:") && keyContent.includes("ENCRYPTED")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for DEK-Info header (legacy PEM encryption indicator)
|
||||
if (keyContent.includes("DEK-Info:")) return true;
|
||||
|
||||
// Check for OpenSSH format keys
|
||||
if (keyContent.includes("-----BEGIN OPENSSH PRIVATE KEY-----")) {
|
||||
try {
|
||||
// Extract the base64 content between the markers
|
||||
const base64Match = keyContent.match(
|
||||
/-----BEGIN OPENSSH PRIVATE KEY-----\s*([\s\S]*?)\s*-----END OPENSSH PRIVATE KEY-----/
|
||||
);
|
||||
if (base64Match) {
|
||||
const base64Content = base64Match[1].replace(/\s/g, "");
|
||||
const keyBuffer = Buffer.from(base64Content, "base64");
|
||||
|
||||
// OpenSSH key format: "openssh-key-v1\0" followed by cipher name
|
||||
// If ciphername is "none", the key is not encrypted
|
||||
const authMagic = "openssh-key-v1\0";
|
||||
if (keyBuffer.toString("ascii", 0, authMagic.length) === authMagic) {
|
||||
// After magic, read ciphername (length-prefixed string)
|
||||
let offset = authMagic.length;
|
||||
const cipherNameLen = keyBuffer.readUInt32BE(offset);
|
||||
offset += 4;
|
||||
const cipherName = keyBuffer.toString("ascii", offset, offset + cipherNameLen);
|
||||
return cipherName !== "none";
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If parsing fails, assume it might be encrypted to be safe
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find default SSH private key from user's ~/.ssh directory
|
||||
* Skips encrypted keys that require a passphrase
|
||||
* @returns {Promise<{ privateKey: string, keyPath: string, keyName: string } | null>}
|
||||
*/
|
||||
async function findDefaultPrivateKey() {
|
||||
const sshDir = path.join(os.homedir(), ".ssh");
|
||||
for (const name of DEFAULT_KEY_NAMES) {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {Promise<Array<{ privateKey: string, keyPath: string, keyName: string, isEncrypted?: boolean }>>}
|
||||
*/
|
||||
async function findAllDefaultPrivateKeys(options = {}) {
|
||||
const { includeEncrypted = false } = options;
|
||||
const sshDir = path.join(os.homedir(), ".ssh");
|
||||
|
||||
const promises = DEFAULT_KEY_NAMES.map(async (name) => {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
return results.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ssh-agent socket path based on platform
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function getSshAgentSocket() {
|
||||
if (process.platform === "win32") {
|
||||
return "\\\\.\\pipe\\openssh-ssh-agent";
|
||||
}
|
||||
return process.env.SSH_AUTH_SOCK || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build authentication handler with default key fallback support
|
||||
* @param {Object} options
|
||||
* @param {string} [options.privateKey] - Explicitly configured private key
|
||||
* @param {string} [options.password] - Password for authentication
|
||||
* @param {string} [options.passphrase] - Passphrase for encrypted private key
|
||||
* @param {Object} [options.agent] - SSH agent (NetcattyAgent or socket path)
|
||||
* @param {string} options.username - SSH username
|
||||
* @param {string} [options.logPrefix] - Log prefix for debugging
|
||||
* @returns {{ authHandler: Function|Array, privateKey: string|null, agent: string|Object|null, usedDefaultKeys: boolean }}
|
||||
* @param {Array} [options.unlockedEncryptedKeys] - Array of unlocked encrypted keys with passphrases
|
||||
*/
|
||||
function buildAuthHandler(options) {
|
||||
const { privateKey, password, passphrase, agent, username, logPrefix = "[SSH]", unlockedEncryptedKeys = [], defaultKeys = [] } = options;
|
||||
|
||||
// Determine what type of explicit auth the user configured
|
||||
const hasExplicitKey = !!privateKey;
|
||||
const hasExplicitPassword = !!password;
|
||||
const hasExplicitAgent = !!agent;
|
||||
const hasExplicitAuth = hasExplicitKey || hasExplicitPassword || hasExplicitAgent;
|
||||
|
||||
// Determine if this is a password-only or key-only connection
|
||||
const isPasswordOnly = hasExplicitPassword && !hasExplicitKey && !hasExplicitAgent;
|
||||
const isKeyOnly = hasExplicitKey && !hasExplicitAgent;
|
||||
|
||||
const sshAgentSocket = getSshAgentSocket();
|
||||
|
||||
// Only use system ssh-agent BEFORE user's auth when:
|
||||
// - User explicitly configured agent, OR
|
||||
// - No explicit auth is configured (pure fallback mode)
|
||||
// When user configured key/password, system agent should only be used AFTER as fallback
|
||||
const useAgentFirst = hasExplicitAgent || !hasExplicitAuth;
|
||||
|
||||
// Determine effective agent
|
||||
const effectiveAgent = agent || (useAgentFirst ? sshAgentSocket : null);
|
||||
|
||||
// Determine effective privateKey (user-provided takes priority)
|
||||
const effectivePrivateKey = privateKey || (!hasExplicitAuth && defaultKeys.length > 0 ? defaultKeys[0].privateKey : null);
|
||||
|
||||
// Determine fallback keys (keys to try after user's primary auth fails)
|
||||
// - If user provided a key: all default keys are fallbacks
|
||||
// - If no explicit auth: first default key is primary, rest are fallbacks
|
||||
// - If password-only or agent-only: all default keys are fallbacks (tried after primary)
|
||||
const fallbackKeys = hasExplicitKey
|
||||
? defaultKeys
|
||||
: !hasExplicitAuth
|
||||
? defaultKeys.slice(1)
|
||||
: defaultKeys;
|
||||
|
||||
// Check if we need dynamic handler (have fallback options)
|
||||
const hasFallbackOptions = fallbackKeys.length > 0 ||
|
||||
(!hasExplicitAgent && sshAgentSocket) ||
|
||||
(isPasswordOnly && defaultKeys.length > 0);
|
||||
|
||||
// If only simple auth methods and no fallback keys needed, use array-based handler
|
||||
if (hasExplicitAuth && !hasFallbackOptions) {
|
||||
const authMethods = [];
|
||||
if (effectiveAgent) authMethods.push("agent");
|
||||
if (privateKey) authMethods.push("publickey");
|
||||
if (password) authMethods.push("password");
|
||||
authMethods.push("keyboard-interactive");
|
||||
|
||||
return {
|
||||
authHandler: authMethods,
|
||||
privateKey: effectivePrivateKey,
|
||||
agent: effectiveAgent,
|
||||
usedDefaultKeys: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Build comprehensive authMethods array with all auth options
|
||||
// Order depends on what user explicitly configured:
|
||||
// - Password-only: password -> agent -> default keys -> keyboard-interactive
|
||||
// - Key-only: user key -> password -> agent -> default keys -> keyboard-interactive
|
||||
// - Agent configured: agent -> user key -> password -> default keys -> keyboard-interactive
|
||||
// - No explicit auth: agent -> default keys -> keyboard-interactive
|
||||
const authMethods = [];
|
||||
|
||||
if (isPasswordOnly) {
|
||||
// Password-only: password first, then fallbacks
|
||||
authMethods.push({ type: "password", id: "password" });
|
||||
|
||||
// Add agent and default keys AFTER password as fallback
|
||||
if (sshAgentSocket) {
|
||||
authMethods.push({ type: "agent", id: "agent" });
|
||||
}
|
||||
for (const keyInfo of defaultKeys) {
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: keyInfo.privateKey,
|
||||
id: `publickey-default-${keyInfo.keyName}`
|
||||
});
|
||||
}
|
||||
} else if (isKeyOnly) {
|
||||
// Key-only: user key first, then password (if any), then agent/default keys as fallback
|
||||
|
||||
// 1. User-provided key first
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: privateKey,
|
||||
passphrase: passphrase,
|
||||
id: "publickey-user"
|
||||
});
|
||||
|
||||
// 2. Password (if configured alongside key)
|
||||
if (password) {
|
||||
authMethods.push({ type: "password", id: "password" });
|
||||
}
|
||||
|
||||
// 3. System agent as fallback (AFTER user's key)
|
||||
if (sshAgentSocket) {
|
||||
authMethods.push({ type: "agent", id: "agent" });
|
||||
}
|
||||
|
||||
// 4. Default keys as fallback
|
||||
for (const keyInfo of fallbackKeys) {
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: keyInfo.privateKey,
|
||||
id: `publickey-default-${keyInfo.keyName}`
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Agent configured or no explicit auth: agent -> user key -> password -> default keys
|
||||
|
||||
// 1. Agent (user-provided or system)
|
||||
if (effectiveAgent) {
|
||||
authMethods.push({ type: "agent", id: "agent" });
|
||||
}
|
||||
|
||||
// 2. User-provided key
|
||||
if (privateKey) {
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: privateKey,
|
||||
passphrase: passphrase,
|
||||
id: "publickey-user"
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Password (if configured)
|
||||
if (password) {
|
||||
authMethods.push({ type: "password", id: "password" });
|
||||
}
|
||||
|
||||
// 4. Default keys as fallback
|
||||
for (const keyInfo of fallbackKeys) {
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: keyInfo.privateKey,
|
||||
id: `publickey-default-${keyInfo.keyName}`
|
||||
});
|
||||
}
|
||||
|
||||
// 5. If no user key provided, add first default key at the beginning (after agent)
|
||||
if (!privateKey && defaultKeys.length > 0) {
|
||||
const insertIndex = effectiveAgent ? 1 : 0;
|
||||
authMethods.splice(insertIndex, 0, {
|
||||
type: "publickey",
|
||||
key: defaultKeys[0].privateKey,
|
||||
id: `publickey-default-${defaultKeys[0].keyName}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add unlocked encrypted default keys (user provided passphrases for these)
|
||||
for (const keyInfo of unlockedEncryptedKeys) {
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: keyInfo.privateKey,
|
||||
passphrase: keyInfo.passphrase,
|
||||
id: `publickey-encrypted-${keyInfo.keyName}`
|
||||
});
|
||||
}
|
||||
|
||||
// Keyboard-interactive as last resort
|
||||
authMethods.push({ type: "keyboard-interactive", id: "keyboard-interactive" });
|
||||
|
||||
console.log(`${logPrefix} Auth methods configured`, {
|
||||
isPasswordOnly,
|
||||
hasUserKey: !!privateKey,
|
||||
hasPassword: !!password,
|
||||
hasAgent: !!effectiveAgent,
|
||||
methodCount: authMethods.length,
|
||||
methods: authMethods.map(m => m.id),
|
||||
});
|
||||
|
||||
// Use dynamic authHandler to try all keys
|
||||
let authIndex = 0;
|
||||
const attemptedMethodIds = new Set();
|
||||
|
||||
const authHandler = (methodsLeft, partialSuccess, callback) => {
|
||||
const availableMethods = methodsLeft || ["publickey", "password", "keyboard-interactive", "agent"];
|
||||
|
||||
while (authIndex < authMethods.length) {
|
||||
const method = authMethods[authIndex];
|
||||
authIndex++;
|
||||
|
||||
if (attemptedMethodIds.has(method.id)) continue;
|
||||
attemptedMethodIds.add(method.id);
|
||||
|
||||
if (method.type === "agent" && (availableMethods.includes("publickey") || availableMethods.includes("agent"))) {
|
||||
console.log(`${logPrefix} Trying agent auth`);
|
||||
return callback("agent");
|
||||
} else if (method.type === "publickey" && availableMethods.includes("publickey")) {
|
||||
console.log(`${logPrefix} Trying publickey auth:`, method.id);
|
||||
const pubkeyAuth = {
|
||||
type: "publickey",
|
||||
username,
|
||||
key: method.key,
|
||||
};
|
||||
if (method.passphrase) {
|
||||
pubkeyAuth.passphrase = method.passphrase;
|
||||
}
|
||||
return callback(pubkeyAuth);
|
||||
} else if (method.type === "password" && availableMethods.includes("password")) {
|
||||
console.log(`${logPrefix} Trying password auth`);
|
||||
return callback({
|
||||
type: "password",
|
||||
username,
|
||||
password,
|
||||
});
|
||||
} else if (method.type === "keyboard-interactive" && availableMethods.includes("keyboard-interactive")) {
|
||||
return callback("keyboard-interactive");
|
||||
}
|
||||
}
|
||||
return callback(false);
|
||||
};
|
||||
|
||||
// Determine the agent to return - if authMethods includes agent, we need to provide the socket
|
||||
// even if effectiveAgent is null (for fallback scenarios)
|
||||
const hasAgentInMethods = authMethods.some(m => m.type === "agent");
|
||||
const returnAgent = effectiveAgent || (hasAgentInMethods ? sshAgentSocket : null);
|
||||
|
||||
return {
|
||||
authHandler,
|
||||
privateKey: effectivePrivateKey,
|
||||
agent: returnAgent,
|
||||
usedDefaultKeys: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a keyboard-interactive event handler
|
||||
* @param {Object} options
|
||||
* @param {Object} options.sender - Electron webContents sender
|
||||
* @param {string} options.sessionId - Session/connection ID
|
||||
* @param {string} options.hostname - Host being connected to
|
||||
* @param {string} [options.password] - Saved password for fill button
|
||||
* @param {string} [options.logPrefix] - Log prefix for debugging
|
||||
* @returns {Function} - Event handler for 'keyboard-interactive' event
|
||||
*/
|
||||
function createKeyboardInteractiveHandler(options) {
|
||||
const { sender, sessionId, hostname, password, logPrefix = "[SSH]" } = options;
|
||||
|
||||
return (name, instructions, instructionsLang, prompts, finish) => {
|
||||
console.log(`${logPrefix} ${hostname} keyboard-interactive auth requested`, {
|
||||
name,
|
||||
instructions,
|
||||
promptCount: prompts?.length || 0,
|
||||
});
|
||||
|
||||
// If there are no prompts, just call finish with empty array
|
||||
if (!prompts || prompts.length === 0) {
|
||||
console.log(`${logPrefix} No prompts, finishing keyboard-interactive`);
|
||||
finish([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward prompts to user via IPC
|
||||
const requestId = keyboardInteractiveHandler.generateRequestId('ssh');
|
||||
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
|
||||
console.log(`${logPrefix} Received user responses, finishing keyboard-interactive`);
|
||||
finish(userResponses);
|
||||
}, sender.id, sessionId);
|
||||
|
||||
const promptsData = prompts.map((p) => ({
|
||||
prompt: p.prompt,
|
||||
echo: p.echo,
|
||||
}));
|
||||
|
||||
console.log(`${logPrefix} Showing modal for ${promptsData.length} prompts`);
|
||||
|
||||
safeSend(sender, "netcatty:keyboard-interactive", {
|
||||
requestId,
|
||||
sessionId,
|
||||
name: name || hostname,
|
||||
instructions: instructions || "",
|
||||
prompts: promptsData,
|
||||
hostname: hostname,
|
||||
savedPassword: password || null,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to renderer safely
|
||||
*/
|
||||
function safeSend(sender, channel, payload) {
|
||||
try {
|
||||
if (!sender || sender.isDestroyed()) return;
|
||||
sender.send(channel, payload);
|
||||
} catch {
|
||||
// Ignore destroyed webContents during shutdown.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply auth configuration to connection options
|
||||
* Convenience function that combines buildAuthHandler results with connOpts
|
||||
* @param {Object} connOpts - SSH connection options to modify
|
||||
* @param {Object} authConfig - Auth configuration from buildAuthHandler
|
||||
*/
|
||||
function applyAuthToConnOpts(connOpts, authConfig) {
|
||||
connOpts.authHandler = authConfig.authHandler;
|
||||
if (authConfig.privateKey) {
|
||||
connOpts.privateKey = authConfig.privateKey;
|
||||
}
|
||||
if (authConfig.agent) {
|
||||
connOpts.agent = authConfig.agent;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request passphrases for encrypted default keys
|
||||
* Shows a modal for each encrypted key and collects passphrases
|
||||
* @param {Object} sender - Electron webContents sender
|
||||
* @param {string} [hostname] - Optional hostname for context
|
||||
* @returns {Promise<{ keys: Array<{ privateKey: string, keyPath: string, keyName: string, passphrase: string }>, cancelled: boolean }>}
|
||||
*/
|
||||
async function requestPassphrasesForEncryptedKeys(sender, hostname) {
|
||||
const allKeys = await findAllDefaultPrivateKeys({ includeEncrypted: true });
|
||||
const encryptedKeys = allKeys.filter(k => k.isEncrypted);
|
||||
|
||||
if (encryptedKeys.length === 0) {
|
||||
return { keys: [], cancelled: false };
|
||||
}
|
||||
|
||||
console.log(`[SSHAuth] Found ${encryptedKeys.length} encrypted default key(s), requesting passphrases`);
|
||||
|
||||
const unlockedKeys = [];
|
||||
let wasCancelled = false;
|
||||
|
||||
for (const keyInfo of encryptedKeys) {
|
||||
const result = await passphraseHandler.requestPassphrase(
|
||||
sender,
|
||||
keyInfo.keyPath,
|
||||
keyInfo.keyName,
|
||||
hostname
|
||||
);
|
||||
|
||||
// Handle different response types
|
||||
if (!result) {
|
||||
// Timeout or error - continue with next key
|
||||
console.log(`[SSHAuth] No response for ${keyInfo.keyName}, continuing...`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result.cancelled) {
|
||||
// User clicked Cancel - stop the entire flow
|
||||
console.log(`[SSHAuth] User cancelled passphrase flow at ${keyInfo.keyName}`);
|
||||
wasCancelled = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (result.skipped) {
|
||||
// User clicked Skip - continue with next key
|
||||
console.log(`[SSHAuth] User skipped passphrase for ${keyInfo.keyName}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result.passphrase) {
|
||||
// User provided passphrase
|
||||
unlockedKeys.push({
|
||||
privateKey: keyInfo.privateKey,
|
||||
keyPath: keyInfo.keyPath,
|
||||
keyName: keyInfo.keyName,
|
||||
passphrase: result.passphrase,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { keys: unlockedKeys, cancelled: wasCancelled };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_KEY_NAMES,
|
||||
isKeyEncrypted,
|
||||
findDefaultPrivateKey,
|
||||
findAllDefaultPrivateKeys,
|
||||
getSshAgentSocket,
|
||||
buildAuthHandler,
|
||||
createKeyboardInteractiveHandler,
|
||||
applyAuthToConnOpts,
|
||||
safeSend,
|
||||
requestPassphrasesForEncryptedKeys,
|
||||
};
|
||||
@@ -11,7 +11,16 @@ const { exec } = require("node:child_process");
|
||||
const { Client: SSHClient, utils: sshUtils } = require("ssh2");
|
||||
const { NetcattyAgent } = require("./netcattyAgent.cjs");
|
||||
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
const passphraseHandler = require("./passphraseHandler.cjs");
|
||||
const { createProxySocket } = require("./proxyUtils.cjs");
|
||||
const {
|
||||
buildAuthHandler,
|
||||
createKeyboardInteractiveHandler,
|
||||
applyAuthToConnOpts,
|
||||
safeSend: authSafeSend,
|
||||
requestPassphrasesForEncryptedKeys,
|
||||
findAllDefaultPrivateKeys: findAllDefaultPrivateKeysFromHelper,
|
||||
} = require("./sshAuthHelper.cjs");
|
||||
|
||||
// Default SSH key names in priority order
|
||||
const DEFAULT_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
||||
@@ -67,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");
|
||||
@@ -101,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;
|
||||
}
|
||||
@@ -229,7 +240,7 @@ function init(deps) {
|
||||
/**
|
||||
* Connect through a chain of jump hosts
|
||||
*/
|
||||
async function connectThroughChain(event, options, jumpHosts, targetHost, targetPort) {
|
||||
async function connectThroughChain(event, options, jumpHosts, targetHost, targetPort, sessionId) {
|
||||
const sender = event.sender;
|
||||
const connections = [];
|
||||
let currentSocket = null;
|
||||
@@ -259,7 +270,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
host: jump.hostname,
|
||||
port: jump.port || 22,
|
||||
username: jump.username || 'root',
|
||||
readyTimeout: 20000, // Reduced from 60s for faster failure detection
|
||||
readyTimeout: 120000, // 2 minutes to allow for keyboard-interactive (2FA/MFA)
|
||||
// Use user-configured keepalive interval from options (in seconds -> convert to ms)
|
||||
// If 0 or not provided, use 10000ms as default
|
||||
keepaliveInterval: options.keepaliveInterval && options.keepaliveInterval > 0 ? options.keepaliveInterval * 1000 : 10000,
|
||||
@@ -268,14 +279,23 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
tryKeyboard: true,
|
||||
algorithms: {
|
||||
// Prioritize fastest ciphers (GCM modes are hardware-accelerated)
|
||||
cipher: ['aes128-gcm@openssh.com', 'aes256-gcm@openssh.com', 'aes128-ctr', 'aes256-ctr'],
|
||||
// Prioritize faster key exchange
|
||||
kex: ['curve25519-sha256', 'curve25519-sha256@libssh.org', 'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'diffie-hellman-group14-sha256'],
|
||||
cipher: [
|
||||
'aes128-gcm@openssh.com', 'aes256-gcm@openssh.com',
|
||||
'aes128-ctr', 'aes192-ctr', 'aes256-ctr',
|
||||
],
|
||||
// Prioritize modern key exchange algorithms for broad compatibility
|
||||
kex: [
|
||||
'curve25519-sha256', 'curve25519-sha256@libssh.org',
|
||||
'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521',
|
||||
'diffie-hellman-group14-sha256',
|
||||
'diffie-hellman-group16-sha512', 'diffie-hellman-group18-sha512',
|
||||
'diffie-hellman-group-exchange-sha256',
|
||||
],
|
||||
compress: ['none'],
|
||||
},
|
||||
};
|
||||
|
||||
// Auth - support agent (certificate), key, and password fallback
|
||||
// Auth - support agent (certificate), key, password, and default key fallback
|
||||
const hasCertificate =
|
||||
typeof jump.certificate === "string" && jump.certificate.trim().length > 0;
|
||||
|
||||
@@ -299,11 +319,22 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
|
||||
if (jump.password) connOpts.password = jump.password;
|
||||
|
||||
if (authAgent) {
|
||||
const order = ["agent"];
|
||||
if (connOpts.password) order.push("password");
|
||||
connOpts.authHandler = order;
|
||||
}
|
||||
// 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({
|
||||
privateKey: connOpts.privateKey,
|
||||
password: connOpts.password,
|
||||
passphrase: connOpts.passphrase,
|
||||
agent: connOpts.agent,
|
||||
username: connOpts.username,
|
||||
logPrefix: `[Chain] Hop ${i + 1}`,
|
||||
unlockedEncryptedKeys: options._unlockedEncryptedKeys || [],
|
||||
defaultKeys,
|
||||
});
|
||||
applyAuthToConnOpts(connOpts, authConfig);
|
||||
|
||||
// If first hop and proxy is configured, connect through proxy
|
||||
if (isFirst && options.proxy) {
|
||||
@@ -334,6 +365,14 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
console.error(`[Chain] Hop ${i + 1}/${totalHops}: ${hopLabel} timeout`);
|
||||
reject(new Error(`Connection timeout to ${hopLabel}`));
|
||||
});
|
||||
// Handle keyboard-interactive authentication for jump hosts (2FA/MFA)
|
||||
conn.on('keyboard-interactive', createKeyboardInteractiveHandler({
|
||||
sender,
|
||||
sessionId,
|
||||
hostname: hopLabel,
|
||||
password: jump.password,
|
||||
logPrefix: `[Chain] Hop ${i + 1}/${totalHops}`,
|
||||
}));
|
||||
console.log(`[Chain] Hop ${i + 1}/${totalHops}: Connecting to ${hopLabel}...`);
|
||||
conn.connect(connOpts);
|
||||
});
|
||||
@@ -428,9 +467,18 @@ async function startSSHSession(event, options) {
|
||||
tryKeyboard: true,
|
||||
algorithms: {
|
||||
// Prioritize fastest ciphers (GCM modes are hardware-accelerated)
|
||||
cipher: ['aes128-gcm@openssh.com', 'aes256-gcm@openssh.com', 'aes128-ctr', 'aes256-ctr'],
|
||||
// Prioritize faster key exchange
|
||||
kex: ['curve25519-sha256', 'curve25519-sha256@libssh.org', 'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'diffie-hellman-group14-sha256'],
|
||||
cipher: [
|
||||
'aes128-gcm@openssh.com', 'aes256-gcm@openssh.com',
|
||||
'aes128-ctr', 'aes192-ctr', 'aes256-ctr',
|
||||
],
|
||||
// Prioritize modern key exchange algorithms for broad compatibility
|
||||
kex: [
|
||||
'curve25519-sha256', 'curve25519-sha256@libssh.org',
|
||||
'ecdh-sha2-nistp256', 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521',
|
||||
'diffie-hellman-group14-sha256',
|
||||
'diffie-hellman-group16-sha512', 'diffie-hellman-group18-sha512',
|
||||
'diffie-hellman-group-exchange-sha256',
|
||||
],
|
||||
compress: ['none'],
|
||||
},
|
||||
};
|
||||
@@ -484,26 +532,36 @@ 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
|
||||
const unlockedEncryptedKeys = options._unlockedEncryptedKeys || [];
|
||||
if (unlockedEncryptedKeys.length > 0) {
|
||||
log("Using unlocked encrypted keys from retry", {
|
||||
count: unlockedEncryptedKeys.length,
|
||||
keyNames: unlockedEncryptedKeys.map(k => k.keyName)
|
||||
});
|
||||
}
|
||||
|
||||
// If no primary auth method configured, try ssh-agent first, then ALL default keys
|
||||
if (!connectOpts.privateKey && !connectOpts.password && !connectOpts.agent) {
|
||||
// First, try to use ssh-agent if available (this is what regular SSH does)
|
||||
const sshAgentSocket = process.platform === "win32"
|
||||
? "\\\\.\\pipe\\openssh-ssh-agent"
|
||||
const sshAgentSocket = process.platform === "win32"
|
||||
? "\\\\.\\pipe\\openssh-ssh-agent"
|
||||
: process.env.SSH_AUTH_SOCK;
|
||||
|
||||
|
||||
if (sshAgentSocket) {
|
||||
log("No auth method configured, trying ssh-agent first", { agentSocket: sshAgentSocket });
|
||||
connectOpts.agent = sshAgentSocket;
|
||||
}
|
||||
|
||||
|
||||
// Mark that we need to try all default keys (handled in authMethods below)
|
||||
if (allDefaultKeys.length > 0) {
|
||||
log("Will try all default SSH keys as fallback", { count: allDefaultKeys.length, keyNames: allDefaultKeys.map(k => k.keyName) });
|
||||
@@ -578,11 +636,11 @@ async function startSSHSession(event, options) {
|
||||
// This is critical because different servers may have different keys in authorized_keys
|
||||
if (usedDefaultKeyAsPrimary && allDefaultKeys.length > 0) {
|
||||
for (const keyInfo of allDefaultKeys) {
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: keyInfo.privateKey,
|
||||
isDefault: true,
|
||||
id: `publickey-default-${keyInfo.keyName}`
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: keyInfo.privateKey,
|
||||
isDefault: true,
|
||||
id: `publickey-default-${keyInfo.keyName}`
|
||||
});
|
||||
}
|
||||
} else if (defaultKeyInfo && !options.privateKey && !usedDefaultKeyAsPrimary) {
|
||||
@@ -590,6 +648,17 @@ async function startSSHSession(event, options) {
|
||||
authMethods.push({ type: "publickey", key: defaultKeyInfo.privateKey, isDefault: true, id: "publickey-default" });
|
||||
}
|
||||
|
||||
// Add unlocked encrypted default keys (user provided passphrases for these)
|
||||
for (const keyInfo of unlockedEncryptedKeys) {
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: keyInfo.privateKey,
|
||||
passphrase: keyInfo.passphrase,
|
||||
isDefault: true,
|
||||
id: `publickey-encrypted-${keyInfo.keyName}`
|
||||
});
|
||||
}
|
||||
|
||||
// Finally try keyboard-interactive
|
||||
authMethods.push({ type: "keyboard-interactive", id: "keyboard-interactive" });
|
||||
|
||||
@@ -709,7 +778,7 @@ async function startSSHSession(event, options) {
|
||||
// Check if this method is still available on server
|
||||
// Note: "agent" uses "publickey" as the underlying method type
|
||||
const methodName = method.type === "password" ? "password" :
|
||||
method.type === "publickey" ? "publickey" :
|
||||
method.type === "publickey" ? "publickey" :
|
||||
method.type === "agent" ? "publickey" : "keyboard-interactive";
|
||||
if (!availableMethods.includes(methodName) && !availableMethods.includes(method.type)) {
|
||||
log("Auth method not available on server, skipping", { method: method.id });
|
||||
@@ -769,12 +838,16 @@ 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,
|
||||
jumpHosts,
|
||||
options.hostname,
|
||||
options.port || 22
|
||||
options.port || 22,
|
||||
sessionId
|
||||
);
|
||||
connectionSocket = chainResult.socket;
|
||||
chainConnections = chainResult.connections;
|
||||
@@ -1052,12 +1125,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;
|
||||
@@ -1113,7 +1192,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,
|
||||
};
|
||||
|
||||
@@ -1137,7 +1216,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;
|
||||
@@ -1208,6 +1309,57 @@ async function startSSHSessionWrapper(event, options) {
|
||||
err.level === 'client-authentication';
|
||||
|
||||
if (isAuthError) {
|
||||
// Check if there are encrypted default keys we haven't tried yet
|
||||
// Only offer retry if no unlocked keys were provided in this attempt
|
||||
if (!options._unlockedEncryptedKeys || options._unlockedEncryptedKeys.length === 0) {
|
||||
const allKeysWithEncrypted = await findAllDefaultPrivateKeysFromHelper({ includeEncrypted: true });
|
||||
const encryptedKeys = allKeysWithEncrypted.filter(k => k.isEncrypted);
|
||||
|
||||
if (encryptedKeys.length > 0) {
|
||||
console.log('[SSH] Auth failed, found encrypted default keys. Requesting passphrases for retry...');
|
||||
|
||||
// Request passphrases from user
|
||||
const passphraseResult = await requestPassphrasesForEncryptedKeys(
|
||||
event.sender,
|
||||
options.hostname
|
||||
);
|
||||
|
||||
// If user cancelled, don't retry even if some keys were unlocked
|
||||
if (passphraseResult.cancelled) {
|
||||
console.log('[SSH] User cancelled passphrase flow, not retrying');
|
||||
} else if (passphraseResult.keys.length > 0) {
|
||||
console.log('[SSH] User unlocked keys, retrying connection...', {
|
||||
count: passphraseResult.keys.length,
|
||||
keyNames: passphraseResult.keys.map(k => k.keyName)
|
||||
});
|
||||
|
||||
// Retry connection with unlocked keys
|
||||
// Wrap in try-catch to ensure consistent error handling for retry failures
|
||||
try {
|
||||
return await startSSHSession(event, {
|
||||
...options,
|
||||
_unlockedEncryptedKeys: passphraseResult.keys,
|
||||
});
|
||||
} catch (retryErr) {
|
||||
// Re-wrap retry errors the same way as initial errors
|
||||
const isRetryAuthError = retryErr.message?.toLowerCase().includes('authentication') ||
|
||||
retryErr.message?.toLowerCase().includes('auth') ||
|
||||
retryErr.level === 'client-authentication';
|
||||
|
||||
if (isRetryAuthError) {
|
||||
const authError = new Error(retryErr.message);
|
||||
authError.level = 'client-authentication';
|
||||
authError.isAuthError = true;
|
||||
throw authError;
|
||||
}
|
||||
throw retryErr;
|
||||
}
|
||||
} else {
|
||||
console.log('[SSH] User did not unlock any keys, not retrying');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-throw with a clean error to avoid Electron printing full stack trace
|
||||
// The frontend will handle this as a normal auth failure for fallback
|
||||
const authError = new Error(err.message);
|
||||
@@ -1623,14 +1775,19 @@ 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;
|
||||
});
|
||||
// Register the shared keyboard-interactive response handler
|
||||
keyboardInteractiveHandler.registerHandler(ipcMain);
|
||||
// Register the passphrase response handler
|
||||
passphraseHandler.registerHandler(ipcMain);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
@@ -1644,4 +1801,8 @@ module.exports = {
|
||||
generateKeyPair,
|
||||
checkWindowsSshAgent,
|
||||
findDefaultPrivateKey,
|
||||
findAllDefaultPrivateKeys,
|
||||
isKeyEncrypted,
|
||||
findAllDefaultPrivateKeys,
|
||||
isKeyEncrypted,
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const globalShortcutBridge = require("./globalShortcutBridge.cjs");
|
||||
|
||||
// Theme colors configuration
|
||||
const THEME_COLORS = {
|
||||
@@ -28,6 +29,7 @@ let currentLanguage = "en";
|
||||
let handlersRegistered = false; // Prevent duplicate IPC handler registration
|
||||
let menuDeps = null;
|
||||
let electronApp = null; // Reference to Electron app for userData path
|
||||
let isQuitting = false;
|
||||
const rendererReadyCallbacksByWebContentsId = new Map();
|
||||
const DEBUG_WINDOWS = process.env.NETCATTY_DEBUG_WINDOWS === "1";
|
||||
const OAUTH_DEFAULT_WIDTH = 600;
|
||||
@@ -47,6 +49,10 @@ function debugLog(...args) {
|
||||
}
|
||||
}
|
||||
|
||||
function setIsQuitting(nextValue) {
|
||||
isQuitting = Boolean(nextValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the window state file
|
||||
*/
|
||||
@@ -87,9 +93,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 +107,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 +636,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 +658,42 @@ async function createWindow(electronModule, options) {
|
||||
});
|
||||
|
||||
// Save state when window is about to close
|
||||
win.on("close", () => {
|
||||
win.on("close", (event) => {
|
||||
// Check if close-to-tray is enabled
|
||||
if (!isQuitting && globalShortcutBridge.handleWindowClose(event, win)) {
|
||||
// Window was hidden to tray - save state before returning
|
||||
if (saveStateTimer) clearTimeout(saveStateTimer);
|
||||
const state = getWindowBoundsState(win, lastNormalBounds);
|
||||
if (state) saveWindowStateSync(state);
|
||||
return;
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -1048,5 +1126,6 @@ module.exports = {
|
||||
buildAppMenu,
|
||||
getMainWindow,
|
||||
getSettingsWindow,
|
||||
setIsQuitting,
|
||||
THEME_COLORS,
|
||||
};
|
||||
@@ -79,6 +79,8 @@ 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 globalShortcutBridge = require("./bridges/globalShortcutBridge.cjs");
|
||||
const windowManager = require("./bridges/windowManager.cjs");
|
||||
|
||||
// GPU settings
|
||||
@@ -266,22 +268,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 +365,13 @@ const registerBridges = (win) => {
|
||||
transferBridge.init(deps);
|
||||
terminalBridge.init(deps);
|
||||
fileWatcherBridge.init(deps);
|
||||
globalShortcutBridge.init(deps);
|
||||
|
||||
// Initialize compress upload bridge with transferBridge dependency
|
||||
compressUploadBridge.init({
|
||||
...deps,
|
||||
transferBridge,
|
||||
});
|
||||
|
||||
// Initialize temp directory (synchronously)
|
||||
tempDirBridge.ensureTempDir();
|
||||
@@ -382,6 +391,8 @@ const registerBridges = (win) => {
|
||||
fileWatcherBridge.registerHandlers(ipcMain);
|
||||
tempDirBridge.registerHandlers(ipcMain, shell);
|
||||
sessionLogsBridge.registerHandlers(ipcMain);
|
||||
compressUploadBridge.registerHandlers(ipcMain);
|
||||
globalShortcutBridge.registerHandlers(ipcMain);
|
||||
|
||||
// Settings window handler
|
||||
ipcMain.handle("netcatty:settings:open", async () => {
|
||||
@@ -551,6 +562,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:`);
|
||||
@@ -721,6 +748,10 @@ app.on("window-all-closed", () => {
|
||||
}
|
||||
});
|
||||
|
||||
app.on("before-quit", () => {
|
||||
windowManager.setIsQuitting(true);
|
||||
});
|
||||
|
||||
// Cleanup all PTY sessions and port forwarding tunnels before quitting
|
||||
app.on("will-quit", () => {
|
||||
try {
|
||||
@@ -733,6 +764,11 @@ app.on("will-quit", () => {
|
||||
} catch (err) {
|
||||
console.warn("Error during port forwarding cleanup:", err);
|
||||
}
|
||||
try {
|
||||
globalShortcutBridge.cleanup();
|
||||
} catch (err) {
|
||||
console.warn("Error during global shortcut cleanup:", err);
|
||||
}
|
||||
});
|
||||
|
||||
// Export for testing
|
||||
|
||||
@@ -10,6 +10,8 @@ const authFailedListeners = new Map();
|
||||
const languageChangeListeners = new Set();
|
||||
const fullscreenChangeListeners = new Set();
|
||||
const keyboardInteractiveListeners = new Set();
|
||||
const passphraseListeners = new Set();
|
||||
const passphraseTimeoutListeners = new Set();
|
||||
|
||||
ipcRenderer.on("netcatty:data", (_event, payload) => {
|
||||
const set = dataListeners.get(payload.sessionId);
|
||||
@@ -98,6 +100,28 @@ ipcRenderer.on("netcatty:keyboard-interactive", (_event, payload) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Passphrase request events for encrypted SSH keys
|
||||
ipcRenderer.on("netcatty:passphrase-request", (_event, payload) => {
|
||||
passphraseListeners.forEach((cb) => {
|
||||
try {
|
||||
cb(payload);
|
||||
} catch (err) {
|
||||
console.error("Passphrase request callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Passphrase timeout events (request expired)
|
||||
ipcRenderer.on("netcatty:passphrase-timeout", (_event, payload) => {
|
||||
passphraseTimeoutListeners.forEach((cb) => {
|
||||
try {
|
||||
cb(payload);
|
||||
} catch (err) {
|
||||
console.error("Passphrase timeout callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Transfer progress events
|
||||
ipcRenderer.on("netcatty:transfer:progress", (_event, payload) => {
|
||||
const cb = transferProgressListeners.get(payload.transferId);
|
||||
@@ -152,6 +176,11 @@ const uploadProgressListeners = new Map();
|
||||
const uploadCompleteListeners = new Map();
|
||||
const uploadErrorListeners = new Map();
|
||||
|
||||
// Compress upload listeners
|
||||
const compressProgressListeners = new Map();
|
||||
const compressCompleteListeners = new Map();
|
||||
const compressErrorListeners = new Map();
|
||||
|
||||
ipcRenderer.on("netcatty:upload:progress", (_event, payload) => {
|
||||
const cb = uploadProgressListeners.get(payload.transferId);
|
||||
if (cb) {
|
||||
@@ -193,6 +222,55 @@ ipcRenderer.on("netcatty:upload:error", (_event, payload) => {
|
||||
uploadErrorListeners.delete(payload.transferId);
|
||||
});
|
||||
|
||||
// Compress upload events
|
||||
ipcRenderer.on("netcatty:compress:progress", (_event, payload) => {
|
||||
const cb = compressProgressListeners.get(payload.compressionId);
|
||||
if (cb) {
|
||||
try {
|
||||
cb(payload.phase, payload.transferred, payload.total);
|
||||
} catch (err) {
|
||||
console.error("Compress progress callback failed", err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:compress:complete", (_event, payload) => {
|
||||
const cb = compressCompleteListeners.get(payload.compressionId);
|
||||
if (cb) {
|
||||
try {
|
||||
cb();
|
||||
} catch (err) {
|
||||
console.error("Compress complete callback failed", err);
|
||||
}
|
||||
}
|
||||
// Cleanup listeners
|
||||
compressProgressListeners.delete(payload.compressionId);
|
||||
compressCompleteListeners.delete(payload.compressionId);
|
||||
compressErrorListeners.delete(payload.compressionId);
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:compress:error", (_event, payload) => {
|
||||
const cb = compressErrorListeners.get(payload.compressionId);
|
||||
if (cb) {
|
||||
try {
|
||||
cb(payload.error);
|
||||
} catch (err) {
|
||||
console.error("Compress error callback failed", err);
|
||||
}
|
||||
}
|
||||
// Cleanup listeners
|
||||
compressProgressListeners.delete(payload.compressionId);
|
||||
compressCompleteListeners.delete(payload.compressionId);
|
||||
compressErrorListeners.delete(payload.compressionId);
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:compress:cancelled", (_event, payload) => {
|
||||
// Just cleanup listeners, the UI already knows it's cancelled
|
||||
compressProgressListeners.delete(payload.compressionId);
|
||||
compressCompleteListeners.delete(payload.compressionId);
|
||||
compressErrorListeners.delete(payload.compressionId);
|
||||
});
|
||||
|
||||
// Port forwarding status listeners
|
||||
const portForwardStatusListeners = new Map();
|
||||
|
||||
@@ -318,6 +396,29 @@ const api = {
|
||||
cancelled,
|
||||
});
|
||||
},
|
||||
// Passphrase request for encrypted SSH keys
|
||||
onPassphraseRequest: (cb) => {
|
||||
passphraseListeners.add(cb);
|
||||
return () => passphraseListeners.delete(cb);
|
||||
},
|
||||
respondPassphrase: async (requestId, passphrase, cancelled = false) => {
|
||||
return ipcRenderer.invoke("netcatty:passphrase:respond", {
|
||||
requestId,
|
||||
passphrase,
|
||||
cancelled,
|
||||
});
|
||||
},
|
||||
respondPassphraseSkip: async (requestId) => {
|
||||
return ipcRenderer.invoke("netcatty:passphrase:respond", {
|
||||
requestId,
|
||||
passphrase: '',
|
||||
skipped: true,
|
||||
});
|
||||
},
|
||||
onPassphraseTimeout: (cb) => {
|
||||
passphraseTimeoutListeners.add(cb);
|
||||
return () => passphraseTimeoutListeners.delete(cb);
|
||||
},
|
||||
openSftp: async (options) => {
|
||||
const result = await ipcRenderer.invoke("netcatty:sftp:open", options);
|
||||
return result.sftpId;
|
||||
@@ -440,6 +541,26 @@ const api = {
|
||||
transferErrorListeners.delete(transferId);
|
||||
return ipcRenderer.invoke("netcatty:transfer:cancel", { transferId });
|
||||
},
|
||||
// Compressed folder upload
|
||||
startCompressedUpload: async (options, onProgress, onComplete, onError) => {
|
||||
const { compressionId } = options;
|
||||
// Register callbacks
|
||||
if (onProgress) compressProgressListeners.set(compressionId, onProgress);
|
||||
if (onComplete) compressCompleteListeners.set(compressionId, onComplete);
|
||||
if (onError) compressErrorListeners.set(compressionId, onError);
|
||||
|
||||
return ipcRenderer.invoke("netcatty:compress:start", options);
|
||||
},
|
||||
cancelCompressedUpload: async (compressionId) => {
|
||||
// Cleanup listeners
|
||||
compressProgressListeners.delete(compressionId);
|
||||
compressCompleteListeners.delete(compressionId);
|
||||
compressErrorListeners.delete(compressionId);
|
||||
return ipcRenderer.invoke("netcatty:compress:cancel", { compressionId });
|
||||
},
|
||||
checkCompressedUploadSupport: async (sftpId) => {
|
||||
return ipcRenderer.invoke("netcatty:compress:checkSupport", { sftpId });
|
||||
},
|
||||
// Window controls for custom title bar
|
||||
windowMinimize: () => ipcRenderer.invoke("netcatty:window:minimize"),
|
||||
windowMaximize: () => ipcRenderer.invoke("netcatty:window:maximize"),
|
||||
@@ -584,7 +705,11 @@ const api = {
|
||||
ipcRenderer.invoke("netcatty:openWithApplication", { filePath, appPath }),
|
||||
downloadSftpToTemp: (sftpId, remotePath, fileName, encoding) =>
|
||||
ipcRenderer.invoke("netcatty:sftp:downloadToTemp", { sftpId, remotePath, fileName, encoding }),
|
||||
|
||||
|
||||
// Save dialog for file downloads
|
||||
showSaveDialog: (defaultPath, filters) =>
|
||||
ipcRenderer.invoke("netcatty:showSaveDialog", { defaultPath, filters }),
|
||||
|
||||
// File watcher for auto-sync feature
|
||||
startFileWatch: (localPath, remotePath, sftpId, encoding) =>
|
||||
ipcRenderer.invoke("netcatty:filewatch:start", { localPath, remotePath, sftpId, encoding }),
|
||||
@@ -627,6 +752,70 @@ const api = {
|
||||
openSessionLogsDir: (directory) =>
|
||||
ipcRenderer.invoke("netcatty:sessionLogs:openDir", { directory }),
|
||||
|
||||
// Global Toggle Hotkey (Quake Mode)
|
||||
registerGlobalHotkey: (hotkey) =>
|
||||
ipcRenderer.invoke("netcatty:globalHotkey:register", { hotkey }),
|
||||
unregisterGlobalHotkey: () =>
|
||||
ipcRenderer.invoke("netcatty:globalHotkey:unregister"),
|
||||
getGlobalHotkeyStatus: () =>
|
||||
ipcRenderer.invoke("netcatty:globalHotkey:status"),
|
||||
|
||||
// System Tray / Close to Tray
|
||||
setCloseToTray: (enabled) =>
|
||||
ipcRenderer.invoke("netcatty:tray:setCloseToTray", { enabled }),
|
||||
isCloseToTray: () =>
|
||||
ipcRenderer.invoke("netcatty:tray:isCloseToTray"),
|
||||
updateTrayMenuData: (data) =>
|
||||
ipcRenderer.invoke("netcatty:tray:updateMenuData", data),
|
||||
// Listen for tray menu actions
|
||||
onTrayFocusSession: (callback) => {
|
||||
const handler = (_event, sessionId) => callback(sessionId);
|
||||
ipcRenderer.on("netcatty:tray:focusSession", handler);
|
||||
return () => ipcRenderer.removeListener("netcatty:tray:focusSession", handler);
|
||||
},
|
||||
onTrayTogglePortForward: (callback) => {
|
||||
const handler = (_event, ruleId, start) => callback(ruleId, start);
|
||||
ipcRenderer.on("netcatty:tray:togglePortForward", handler);
|
||||
return () => ipcRenderer.removeListener("netcatty:tray:togglePortForward", handler);
|
||||
},
|
||||
|
||||
// Tray panel actions forwarded to main window
|
||||
onTrayPanelJumpToSession: (callback) => {
|
||||
const handler = (_event, sessionId) => callback(sessionId);
|
||||
ipcRenderer.on("netcatty:trayPanel:jumpToSession", handler);
|
||||
return () => ipcRenderer.removeListener("netcatty:trayPanel:jumpToSession", handler);
|
||||
},
|
||||
onTrayPanelConnectToHost: (callback) => {
|
||||
const handler = (_event, hostId) => callback(hostId);
|
||||
ipcRenderer.on("netcatty:trayPanel:connectToHost", handler);
|
||||
return () => ipcRenderer.removeListener("netcatty:trayPanel:connectToHost", handler);
|
||||
},
|
||||
|
||||
// Tray panel window
|
||||
hideTrayPanel: () => ipcRenderer.invoke("netcatty:trayPanel:hide"),
|
||||
openMainWindow: () => ipcRenderer.invoke("netcatty:trayPanel:openMainWindow"),
|
||||
jumpToSessionFromTrayPanel: (sessionId) =>
|
||||
ipcRenderer.invoke("netcatty:trayPanel:jumpToSession", sessionId),
|
||||
connectToHostFromTrayPanel: (hostId) =>
|
||||
ipcRenderer.invoke("netcatty:trayPanel:connectToHost", hostId),
|
||||
onTrayPanelCloseRequest: (callback) => {
|
||||
const handler = () => callback();
|
||||
ipcRenderer.on("netcatty:trayPanel:closeRequest", handler);
|
||||
return () => ipcRenderer.removeListener("netcatty:trayPanel:closeRequest", handler);
|
||||
},
|
||||
|
||||
onTrayPanelRefresh: (callback) => {
|
||||
const handler = () => callback();
|
||||
ipcRenderer.on("netcatty:trayPanel:refresh", handler);
|
||||
return () => ipcRenderer.removeListener("netcatty:trayPanel:refresh", handler);
|
||||
},
|
||||
|
||||
onTrayPanelMenuData: (callback) => {
|
||||
const handler = (_event, data) => callback(data);
|
||||
ipcRenderer.on("netcatty:trayPanel:setMenuData", handler);
|
||||
return () => ipcRenderer.removeListener("netcatty:trayPanel:setMenuData", handler);
|
||||
},
|
||||
|
||||
// Get file path from File object (for drag-and-drop)
|
||||
getPathForFile: (file) => {
|
||||
try {
|
||||
|
||||
@@ -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 }>;
|
||||
@@ -245,6 +256,27 @@ declare global {
|
||||
cancelled?: boolean
|
||||
): Promise<{ success: boolean; error?: string }>;
|
||||
|
||||
// Passphrase request for encrypted SSH keys
|
||||
onPassphraseRequest?(
|
||||
cb: (request: {
|
||||
requestId: string;
|
||||
keyPath: string;
|
||||
keyName: string;
|
||||
hostname?: string;
|
||||
}) => void
|
||||
): () => void;
|
||||
respondPassphrase?(
|
||||
requestId: string,
|
||||
passphrase: string,
|
||||
cancelled?: boolean
|
||||
): Promise<{ success: boolean; error?: string }>;
|
||||
respondPassphraseSkip?(
|
||||
requestId: string
|
||||
): Promise<{ success: boolean; error?: string }>;
|
||||
onPassphraseTimeout?(
|
||||
cb: (event: { requestId: string }) => void
|
||||
): () => void;
|
||||
|
||||
// SFTP operations
|
||||
openSftp(options: NetcattySSHOptions): Promise<string>;
|
||||
listSftp(sftpId: string, path: string, encoding?: SftpFilenameEncoding): Promise<RemoteFile[]>;
|
||||
@@ -261,9 +293,9 @@ declare global {
|
||||
|
||||
// Write binary with real-time progress callback
|
||||
writeSftpBinaryWithProgress?(
|
||||
sftpId: string,
|
||||
path: string,
|
||||
content: ArrayBuffer,
|
||||
sftpId: string,
|
||||
path: string,
|
||||
content: ArrayBuffer,
|
||||
transferId: string,
|
||||
encoding?: SftpFilenameEncoding,
|
||||
onProgress?: (transferred: number, total: number, speed: number) => void,
|
||||
@@ -278,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
|
||||
@@ -484,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 }>;
|
||||
@@ -523,6 +580,52 @@ declare global {
|
||||
|
||||
// Get file path from File object (for drag-and-drop, uses Electron's webUtils)
|
||||
getPathForFile?(file: File): string | undefined;
|
||||
|
||||
// Global Toggle Hotkey (Quake Mode)
|
||||
registerGlobalHotkey?(hotkey: string): Promise<{ success: boolean; enabled?: boolean; error?: string; accelerator?: string }>;
|
||||
unregisterGlobalHotkey?(): Promise<{ success: boolean }>;
|
||||
getGlobalHotkeyStatus?(): Promise<{ enabled: boolean; hotkey: string | null }>;
|
||||
|
||||
// System Tray / Close to Tray
|
||||
setCloseToTray?(enabled: boolean): Promise<{ success: boolean; enabled: boolean }>;
|
||||
isCloseToTray?(): Promise<{ enabled: boolean }>;
|
||||
updateTrayMenuData?(data: {
|
||||
sessions?: Array<{ id: string; label: string; hostLabel: string; status: "connecting" | "connected" | "disconnected"; workspaceId?: string; workspaceTitle?: string }>;
|
||||
portForwardRules?: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
type: "local" | "remote" | "dynamic";
|
||||
localPort: number;
|
||||
remoteHost?: string;
|
||||
remotePort?: number;
|
||||
status: "inactive" | "connecting" | "active" | "error";
|
||||
}>;
|
||||
}): Promise<{ success: boolean }>;
|
||||
onTrayFocusSession?(callback: (sessionId: string) => void): () => void;
|
||||
onTrayTogglePortForward?(callback: (ruleId: string, start: boolean) => void): () => void;
|
||||
|
||||
onTrayPanelJumpToSession?(callback: (sessionId: string) => void): () => void;
|
||||
onTrayPanelConnectToHost?(callback: (hostId: string) => void): () => void;
|
||||
|
||||
hideTrayPanel?(): Promise<{ success: boolean }>;
|
||||
openMainWindow?(): Promise<{ success: boolean }>;
|
||||
jumpToSessionFromTrayPanel?(sessionId: string): Promise<{ success: boolean }>;
|
||||
connectToHostFromTrayPanel?(hostId: string): Promise<{ success: boolean }>;
|
||||
onTrayPanelCloseRequest?(callback: () => void): () => void;
|
||||
onTrayPanelRefresh?(callback: () => void): () => void;
|
||||
onTrayPanelMenuData?(callback: (data: {
|
||||
sessions?: Array<{ id: string; label: string; hostLabel: string; status: "connecting" | "connected" | "disconnected"; workspaceId?: string; workspaceTitle?: string }>;
|
||||
portForwardRules?: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
type: "local" | "remote" | "dynamic";
|
||||
localPort: number;
|
||||
remoteHost?: string;
|
||||
remotePort?: number;
|
||||
status: "inactive" | "connecting" | "active" | "error";
|
||||
hostId?: string;
|
||||
}>;
|
||||
}) => void): () => void;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
|
||||
@@ -11,6 +11,7 @@ import App from './App';
|
||||
import { ToastProvider } from './components/ui/toast';
|
||||
|
||||
const LazySettingsPage = lazy(() => import('./components/SettingsPage'));
|
||||
const LazyTrayPanel = lazy(() => import('./components/TrayPanel'));
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
@@ -23,6 +24,9 @@ const getRoute = () => {
|
||||
if (hash === '#/settings' || hash.startsWith('#/settings')) {
|
||||
return 'settings';
|
||||
}
|
||||
if (hash === '#/tray' || hash.startsWith('#/tray')) {
|
||||
return 'tray';
|
||||
}
|
||||
return 'main';
|
||||
};
|
||||
|
||||
@@ -38,6 +42,14 @@ const renderApp = () => {
|
||||
</Suspense>
|
||||
</ToastProvider>
|
||||
);
|
||||
} else if (route === 'tray') {
|
||||
root.render(
|
||||
<ToastProvider>
|
||||
<Suspense fallback={<div style={{ padding: 12, color: '#fff' }}>Loading tray panel…</div>}>
|
||||
<LazyTrayPanel />
|
||||
</Suspense>
|
||||
</ToastProvider>
|
||||
);
|
||||
} else {
|
||||
root.render(<App />);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ export const STORAGE_KEY_SHELL_HISTORY = 'netcatty_shell_history_v1';
|
||||
export const STORAGE_KEY_CONNECTION_LOGS = 'netcatty_connection_logs_v1';
|
||||
export const STORAGE_KEY_IDENTITIES = 'netcatty_identities_v1';
|
||||
export const STORAGE_KEY_VAULT_HOSTS_VIEW_MODE = 'netcatty_vault_hosts_view_mode_v1';
|
||||
export const STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED = 'netcatty_vault_hosts_tree_expanded_v1';
|
||||
export const STORAGE_KEY_VAULT_SIDEBAR_COLLAPSED = 'netcatty_vault_sidebar_collapsed_v1';
|
||||
export const STORAGE_KEY_VAULT_KEYS_VIEW_MODE = 'netcatty_vault_keys_view_mode_v1';
|
||||
export const STORAGE_KEY_VAULT_SNIPPETS_VIEW_MODE = 'netcatty_vault_snippets_view_mode_v1';
|
||||
export const STORAGE_KEY_VAULT_KNOWN_HOSTS_VIEW_MODE = 'netcatty_vault_known_hosts_view_mode_v1';
|
||||
@@ -43,6 +45,7 @@ export const STORAGE_KEY_SFTP_FILE_ASSOCIATIONS = 'netcatty_sftp_file_associatio
|
||||
export const STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR = 'netcatty_sftp_double_click_behavior_v1';
|
||||
export const STORAGE_KEY_SFTP_AUTO_SYNC = 'netcatty_sftp_auto_sync_v1';
|
||||
export const STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES = 'netcatty_sftp_show_hidden_files_v1';
|
||||
export const STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD = 'netcatty_sftp_use_compressed_upload_v1';
|
||||
|
||||
// Session Logs Settings
|
||||
export const STORAGE_KEY_SESSION_LOGS_ENABLED = 'netcatty_session_logs_enabled_v1';
|
||||
@@ -51,3 +54,10 @@ 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';
|
||||
|
||||
// Global Toggle Window Settings (Quake Mode)
|
||||
export const STORAGE_KEY_TOGGLE_WINDOW_HOTKEY = 'netcatty_toggle_window_hotkey_v1';
|
||||
export const STORAGE_KEY_CLOSE_TO_TRAY = 'netcatty_close_to_tray_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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -12,4 +12,15 @@ export function cn(...inputs: ClassValue[]) {
|
||||
*/
|
||||
export function normalizeLineEndings(text: string): string {
|
||||
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if the current platform is macOS.
|
||||
* Used for keyboard shortcut handling to differentiate between Mac and PC shortcuts.
|
||||
*/
|
||||
export function isMacPlatform(): boolean {
|
||||
if (typeof navigator !== 'undefined') {
|
||||
return /Mac|iPod|iPhone|iPad/.test(navigator.platform);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -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",
|
||||
@@ -69,7 +70,7 @@
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "^39.2.6",
|
||||
"electron": "^40.1.0",
|
||||
"electron-builder": "^26.0.12",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
@@ -79,5 +80,8 @@
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.2.7",
|
||||
"wait-on": "^9.0.3"
|
||||
},
|
||||
"overrides": {
|
||||
"cpu-features": "npm:empty-npm-package@1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 615 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 512 B |
|
After Width: | Height: | Size: 942 B |
|
Before Width: | Height: | Size: 825 KiB |
|
Before Width: | Height: | Size: 995 KiB |
|
Before Width: | Height: | Size: 888 KiB |
|
After Width: | Height: | Size: 4.8 MiB |
|
After Width: | Height: | Size: 4.2 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 2.7 MiB |