Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(npm run lint:*)",
|
||||
"Bash(npm run build:*)",
|
||||
"Bash(gh pr view:*)",
|
||||
"Bash(gh pr list:*)",
|
||||
"Bash(gh api:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(gh issue view:*)",
|
||||
"Bash(npm run dev:*)",
|
||||
"Bash(git checkout:*)",
|
||||
"Bash(git commit -m \"$\\(cat <<''EOF''\nfeat\\(sftp\\): bundle folder uploads and improve cancel/delete operations\n\n- Bundle folder uploads as single tasks showing aggregate progress\n- Add unique file transfer IDs for proper cancellation tracking\n- Fix cancel button to call cancelExternalUpload for external uploads\n- Improve backend cancel detection using cancelled flag instead of error message\n- Use SSH exec with rm -rf for fast folder deletion on remote servers\n- Add FolderUp icon for folder upload tasks in transfer queue\n- Add i18n key for upload cancelled message\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF\n\\)\")",
|
||||
"Bash(git push:*)",
|
||||
"Bash(gh pr create --title \"feat\\(sftp\\): bundle folder uploads and improve cancel/delete operations\" --body \"$\\(cat <<''EOF''\n## Summary\n\n- **Bundle folder uploads as single tasks** - When uploading a folder from computer, show it as one aggregated task with total progress instead of individual files\n- **Fix cancel upload** - Properly cancel external uploads by calling the correct cancel function and using unique file transfer IDs for backend tracking\n- **Fast folder deletion** - Use SSH exec with `rm -rf` command for remote folder deletion instead of slow recursive SFTP rmdir\n- **UI improvements** - Add FolderUp icon for folder upload tasks, add cancelled status toast message\n\n## Changes\n\n### Bundle folder uploads\n- Added `detectRootFolders` helper to group entries by root folder\n- Create single bundled task per folder with aggregate byte count\n- Track progress across all files in the bundle\n\n### Fix cancel upload\n- Each file now uses unique `fileTransferId` for backend cancellation tracking\n- Added `activeFileTransferIdsRef` to track all active uploads\n- Modified `cancelExternalUpload` to cancel all active file uploads\n- Backend now checks `uploadState.cancelled` flag instead of just error message\n- Frontend catch block checks `cancelUploadRef.current` to break out of loop\n\n### Fast folder deletion\n- Added `execSshCommand` helper function in sftpBridge.cjs\n- Uses `client.client` \\(underlying ssh2 Client\\) to execute `rm -rf` command\n- Falls back to SFTP rmdir if SSH exec fails\n\n## Test plan\n- [ ] Drag a folder from computer to SFTP pane - should show as single task with aggregate progress\n- [ ] Click cancel button during folder upload - should stop immediately without errors\n- [ ] Delete a large folder on remote server - should complete quickly using rm -rf\n\n🤖 Generated with [Claude Code]\\(https://claude.com/claude-code\\)\nEOF\n\\)\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -33,3 +33,6 @@ coverage
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Claude Code local settings
|
||||
/.claude/settings.local.json
|
||||
|
||||
41
App.tsx
41
App.tsx
@@ -3,6 +3,7 @@ import { activeTabStore, useActiveTabId, useIsSftpActive, useIsTerminalLayerVisi
|
||||
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';
|
||||
@@ -86,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 =
|
||||
@@ -150,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);
|
||||
@@ -232,6 +237,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
closeSession,
|
||||
closeWorkspace,
|
||||
updateSessionStatus,
|
||||
createWorkspaceWithHosts,
|
||||
createWorkspaceFromSessions,
|
||||
addSessionToWorkspace,
|
||||
updateSplitSizes,
|
||||
@@ -254,6 +260,20 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
// isMacClient is used for window controls styling
|
||||
const isMacClient = typeof navigator !== 'undefined' && /Mac|Macintosh/.test(navigator.userAgent);
|
||||
|
||||
// Get port forwarding rules and import function
|
||||
const { rules: portForwardingRules, importRules: importPortForwardingRules } = usePortForwardingState();
|
||||
|
||||
const portForwardingRulesForSync = useMemo(
|
||||
() =>
|
||||
portForwardingRules.map((rule) => ({
|
||||
...rule,
|
||||
status: "inactive",
|
||||
error: undefined,
|
||||
lastUsedAt: undefined,
|
||||
})),
|
||||
[portForwardingRules],
|
||||
);
|
||||
|
||||
// Auto-sync hook for cloud sync
|
||||
const { syncNow: handleSyncNow } = useAutoSync({
|
||||
hosts,
|
||||
@@ -261,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({
|
||||
@@ -271,6 +291,10 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
snippets: payload.snippets,
|
||||
customGroups: payload.customGroups,
|
||||
}));
|
||||
|
||||
if (payload.portForwardingRules) {
|
||||
importPortForwardingRules(payload.portForwardingRules);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -974,6 +998,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
connectionLogs={connectionLogs}
|
||||
managedSources={managedSources}
|
||||
sessions={sessions}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
onOpenSettings={handleOpenSettings}
|
||||
onOpenQuickSwitcher={handleOpenQuickSwitcher}
|
||||
onCreateLocalTerminal={handleCreateLocalTerminal}
|
||||
@@ -1081,8 +1107,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
setQuickSearch('');
|
||||
}}
|
||||
onCreateWorkspace={() => {
|
||||
// TODO: Implement workspace creation
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setIsCreateWorkspaceOpen(true);
|
||||
}}
|
||||
onClose={() => {
|
||||
setIsQuickSwitcherOpen(false);
|
||||
@@ -1147,6 +1173,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}>
|
||||
|
||||
@@ -48,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
|
||||
@@ -250,6 +253,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',
|
||||
@@ -460,6 +464,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',
|
||||
@@ -647,6 +658,10 @@ const en: Messages = {
|
||||
'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',
|
||||
@@ -856,6 +871,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',
|
||||
@@ -1233,6 +1257,15 @@ const en: Messages = {
|
||||
'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',
|
||||
|
||||
@@ -557,6 +557,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': '内存使用',
|
||||
@@ -807,6 +816,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': '关闭并删除',
|
||||
@@ -913,6 +929,10 @@ const zhCN: Messages = {
|
||||
'sftp.upload.cancelled': '上传已取消',
|
||||
'sftp.upload.cancel': '取消',
|
||||
|
||||
// SFTP Download
|
||||
'sftp.download.completed': '已下载',
|
||||
'sftp.download.cancelled': '下载已取消',
|
||||
|
||||
// SFTP Reconnecting
|
||||
'sftp.reconnecting.title': '正在重连...',
|
||||
'sftp.reconnecting.desc': '连接已断开,正在尝试重新连接',
|
||||
@@ -1037,6 +1057,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',
|
||||
@@ -1222,6 +1243,15 @@ const zhCN: Messages = {
|
||||
'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': '连接串口',
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -233,6 +233,31 @@ export const useManagedSourceSync = ({
|
||||
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;
|
||||
@@ -245,8 +270,8 @@ export const useManagedSourceSync = ({
|
||||
};
|
||||
|
||||
for (const source of managedSources) {
|
||||
const prevManaged = prevHosts.filter((h) => h.managedSourceId === source.id);
|
||||
const currManaged = hosts.filter((h) => h.managedSourceId === source.id);
|
||||
const prevManaged = prevHostsBySource.get(source.id) || [];
|
||||
const currManaged = currHostsBySource.get(source.id) || [];
|
||||
|
||||
console.log(`[ManagedSourceSync] Source ${source.groupName}: prev=${prevManaged.length}, curr=${currManaged.length}`);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Host, PortForwardingRule } from "../../domain/models";
|
||||
import {
|
||||
STORAGE_KEY_PF_PREFER_FORM_MODE,
|
||||
@@ -9,7 +9,6 @@ import { localStorageAdapter } from "../../infrastructure/persistence/localStora
|
||||
import {
|
||||
clearReconnectTimer,
|
||||
getActiveConnection,
|
||||
getActiveRuleIds,
|
||||
startPortForward,
|
||||
stopPortForward,
|
||||
syncWithBackend,
|
||||
@@ -40,6 +39,7 @@ export interface UsePortForwardingStateResult {
|
||||
updateRule: (id: string, updates: Partial<PortForwardingRule>) => void;
|
||||
deleteRule: (id: string) => void;
|
||||
duplicateRule: (id: string) => void;
|
||||
importRules: (rules: PortForwardingRule[]) => void;
|
||||
|
||||
setRuleStatus: (
|
||||
id: string,
|
||||
@@ -63,8 +63,58 @@ export interface UsePortForwardingStateResult {
|
||||
selectedRule: PortForwardingRule | undefined;
|
||||
}
|
||||
|
||||
// Global Store State
|
||||
let globalRules: PortForwardingRule[] = [];
|
||||
let isInitialized = false;
|
||||
const listeners = new Set<(rules: PortForwardingRule[]) => void>();
|
||||
|
||||
// Store Actions
|
||||
const notifyListeners = () => {
|
||||
listeners.forEach((listener) => listener(globalRules));
|
||||
};
|
||||
|
||||
const setGlobalRules = (newRules: PortForwardingRule[]) => {
|
||||
globalRules = newRules;
|
||||
notifyListeners();
|
||||
localStorageAdapter.write(STORAGE_KEY_PORT_FORWARDING, newRules);
|
||||
};
|
||||
|
||||
const normalizeRulesWithConnections = (rules: PortForwardingRule[]) => {
|
||||
return rules.map((rule) => {
|
||||
const connection = getActiveConnection(rule.id);
|
||||
if (connection) {
|
||||
return {
|
||||
...rule,
|
||||
status: connection.status,
|
||||
error: connection.error,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...rule,
|
||||
status: "inactive",
|
||||
error: undefined,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// Initialization Logic
|
||||
const initializeStore = async () => {
|
||||
if (isInitialized) return;
|
||||
isInitialized = true;
|
||||
|
||||
await syncWithBackend();
|
||||
|
||||
const saved = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
);
|
||||
if (saved && Array.isArray(saved)) {
|
||||
setGlobalRules(normalizeRulesWithConnections(saved));
|
||||
}
|
||||
};
|
||||
|
||||
export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
const [rules, setRules] = useState<PortForwardingRule[]>([]);
|
||||
const [rules, setRules] = useState<PortForwardingRule[]>(globalRules);
|
||||
const [selectedRuleId, setSelectedRuleId] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useStoredViewMode(
|
||||
STORAGE_KEY_PF_VIEW_MODE,
|
||||
@@ -76,49 +126,31 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
return localStorageAdapter.readBoolean(STORAGE_KEY_PF_PREFER_FORM_MODE) ?? false;
|
||||
});
|
||||
|
||||
// Track if sync has been executed for this component instance
|
||||
const syncExecutedRef = useRef(false);
|
||||
|
||||
const setPreferFormMode = useCallback((prefer: boolean) => {
|
||||
setPreferFormModeState(prefer);
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_PF_PREFER_FORM_MODE, prefer);
|
||||
}, []);
|
||||
|
||||
// Load rules from storage on mount and sync with backend
|
||||
// Initialize store on mount (only once globally)
|
||||
useEffect(() => {
|
||||
const loadAndSync = async () => {
|
||||
// Only sync once per component instance (prevents duplicate calls from React StrictMode)
|
||||
if (!syncExecutedRef.current) {
|
||||
syncExecutedRef.current = true;
|
||||
await syncWithBackend();
|
||||
}
|
||||
void initializeStore();
|
||||
}, []);
|
||||
|
||||
const saved = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
);
|
||||
if (saved && Array.isArray(saved)) {
|
||||
// Sync status with active connections in the service layer
|
||||
const _activeRuleIds = getActiveRuleIds();
|
||||
const withSyncedStatus = saved.map((r) => {
|
||||
const conn = getActiveConnection(r.id);
|
||||
if (conn) {
|
||||
// This rule has an active connection, preserve its status
|
||||
return { ...r, status: conn.status, error: conn.error };
|
||||
}
|
||||
// No active connection, reset to inactive
|
||||
return { ...r, status: "inactive" as const, error: undefined };
|
||||
});
|
||||
setRules(withSyncedStatus);
|
||||
}
|
||||
// Subscribe to global store
|
||||
useEffect(() => {
|
||||
// If global state was updated before we subscribed (e.g. init finished), update local state
|
||||
if (rules !== globalRules) {
|
||||
setRules(globalRules);
|
||||
}
|
||||
|
||||
const listener = (newRules: PortForwardingRule[]) => {
|
||||
setRules(newRules);
|
||||
};
|
||||
|
||||
void loadAndSync();
|
||||
}, []);
|
||||
|
||||
// Persist rules to storage whenever they change
|
||||
const persistRules = useCallback((updatedRules: PortForwardingRule[]) => {
|
||||
localStorageAdapter.write(STORAGE_KEY_PORT_FORWARDING, updatedRules);
|
||||
}, []);
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}, [rules]);
|
||||
|
||||
const addRule = useCallback(
|
||||
(
|
||||
@@ -130,47 +162,38 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
createdAt: Date.now(),
|
||||
status: "inactive",
|
||||
};
|
||||
setRules((prev) => {
|
||||
const updated = [...prev, newRule];
|
||||
persistRules(updated);
|
||||
return updated;
|
||||
});
|
||||
const updated = [...globalRules, newRule];
|
||||
setGlobalRules(updated);
|
||||
setSelectedRuleId(newRule.id);
|
||||
return newRule;
|
||||
},
|
||||
[persistRules],
|
||||
[],
|
||||
);
|
||||
|
||||
const updateRule = useCallback(
|
||||
(id: string, updates: Partial<PortForwardingRule>) => {
|
||||
setRules((prev) => {
|
||||
const updated = prev.map((r) =>
|
||||
r.id === id ? { ...r, ...updates } : r,
|
||||
);
|
||||
persistRules(updated);
|
||||
return updated;
|
||||
});
|
||||
const updated = globalRules.map((r) =>
|
||||
r.id === id ? { ...r, ...updates } : r,
|
||||
);
|
||||
setGlobalRules(updated);
|
||||
},
|
||||
[persistRules],
|
||||
[],
|
||||
);
|
||||
|
||||
const deleteRule = useCallback(
|
||||
(id: string) => {
|
||||
setRules((prev) => {
|
||||
const updated = prev.filter((r) => r.id !== id);
|
||||
persistRules(updated);
|
||||
return updated;
|
||||
});
|
||||
const updated = globalRules.filter((r) => r.id !== id);
|
||||
setGlobalRules(updated);
|
||||
if (selectedRuleId === id) {
|
||||
setSelectedRuleId(null);
|
||||
}
|
||||
},
|
||||
[selectedRuleId, persistRules],
|
||||
[selectedRuleId],
|
||||
);
|
||||
|
||||
const duplicateRule = useCallback(
|
||||
(id: string) => {
|
||||
const original = rules.find((r) => r.id === id);
|
||||
const original = globalRules.find((r) => r.id === id);
|
||||
if (!original) return;
|
||||
|
||||
const copy: PortForwardingRule = {
|
||||
@@ -182,33 +205,31 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
error: undefined,
|
||||
lastUsedAt: undefined,
|
||||
};
|
||||
setRules((prev) => {
|
||||
const updated = [...prev, copy];
|
||||
persistRules(updated);
|
||||
return updated;
|
||||
});
|
||||
const updated = [...globalRules, copy];
|
||||
setGlobalRules(updated);
|
||||
setSelectedRuleId(copy.id);
|
||||
},
|
||||
[rules, persistRules],
|
||||
[],
|
||||
);
|
||||
|
||||
const importRules = useCallback((newRules: PortForwardingRule[]) => {
|
||||
setGlobalRules(normalizeRulesWithConnections(newRules));
|
||||
}, []);
|
||||
|
||||
const setRuleStatus = useCallback(
|
||||
(id: string, status: PortForwardingRule["status"], error?: string) => {
|
||||
setRules((prev) => {
|
||||
const updated = prev.map((r) => {
|
||||
if (r.id !== id) return r;
|
||||
return {
|
||||
...r,
|
||||
status,
|
||||
error,
|
||||
lastUsedAt: status === "active" ? Date.now() : r.lastUsedAt,
|
||||
};
|
||||
});
|
||||
persistRules(updated);
|
||||
return updated;
|
||||
const updated = globalRules.map((r) => {
|
||||
if (r.id !== id) return r;
|
||||
return {
|
||||
...r,
|
||||
status,
|
||||
error,
|
||||
lastUsedAt: status === "active" ? Date.now() : r.lastUsedAt,
|
||||
};
|
||||
});
|
||||
setGlobalRules(updated);
|
||||
},
|
||||
[persistRules],
|
||||
[],
|
||||
);
|
||||
|
||||
const startTunnel = useCallback(
|
||||
@@ -301,6 +322,7 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
updateRule,
|
||||
deleteRule,
|
||||
duplicateRule,
|
||||
importRules,
|
||||
|
||||
setRuleStatus,
|
||||
startTunnel,
|
||||
|
||||
@@ -286,6 +286,69 @@ export const useSessionState = () => {
|
||||
setWorkspaceRenameValue('');
|
||||
}, []);
|
||||
|
||||
const createWorkspaceWithHosts = useCallback((name: string, hosts: Host[]) => {
|
||||
if (hosts.length === 0) return;
|
||||
|
||||
// Create sessions for each host
|
||||
const newSessions: TerminalSession[] = hosts.map(host => {
|
||||
// Handle serial hosts specially
|
||||
if (host.protocol === 'serial') {
|
||||
const serialConfig: SerialConfig = host.serialConfig || {
|
||||
path: host.hostname,
|
||||
baudRate: host.port || 115200,
|
||||
dataBits: 8,
|
||||
stopBits: 1,
|
||||
parity: 'none',
|
||||
flowControl: 'none',
|
||||
localEcho: false,
|
||||
lineMode: false,
|
||||
};
|
||||
|
||||
const portName = serialConfig.path.split('/').pop() || serialConfig.path;
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
hostId: host.id,
|
||||
hostLabel: host.label || `Serial: ${portName}`,
|
||||
hostname: serialConfig.path,
|
||||
username: '',
|
||||
status: 'connecting',
|
||||
protocol: 'serial',
|
||||
serialConfig: serialConfig,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
hostId: host.id,
|
||||
hostLabel: host.label,
|
||||
hostname: host.hostname,
|
||||
username: host.username,
|
||||
status: 'connecting',
|
||||
protocol: host.protocol,
|
||||
port: host.port,
|
||||
moshEnabled: host.moshEnabled,
|
||||
};
|
||||
});
|
||||
|
||||
const sessionIds = newSessions.map(s => s.id);
|
||||
|
||||
// Create workspace
|
||||
const workspace = createWorkspaceFromSessionIds(sessionIds, {
|
||||
title: name,
|
||||
viewMode: 'split',
|
||||
});
|
||||
|
||||
// Assign workspaceId to sessions
|
||||
const sessionsWithWorkspace = newSessions.map(s => ({
|
||||
...s,
|
||||
workspaceId: workspace.id
|
||||
}));
|
||||
|
||||
setSessions(prev => [...prev, ...sessionsWithWorkspace]);
|
||||
setWorkspaces(prev => [...prev, workspace]);
|
||||
setActiveTabId(workspace.id);
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const createWorkspaceFromSessions = useCallback((
|
||||
baseSessionId: string,
|
||||
joiningSessionId: string,
|
||||
@@ -669,6 +732,7 @@ export const useSessionState = () => {
|
||||
closeSession,
|
||||
closeWorkspace,
|
||||
updateSessionStatus,
|
||||
createWorkspaceWithHosts,
|
||||
createWorkspaceFromSessions,
|
||||
addSessionToWorkspace,
|
||||
updateSplitSizes,
|
||||
|
||||
@@ -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
|
||||
|
||||
143
components/CreateWorkspaceDialog.tsx
Normal file
143
components/CreateWorkspaceDialog.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { Search } from 'lucide-react';
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { Host } from '../types';
|
||||
import { DistroAvatar } from './DistroAvatar';
|
||||
import { Button } from './ui/button';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
|
||||
interface CreateWorkspaceDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
hosts: Host[];
|
||||
onCreate: (name: string, selectedHosts: Host[]) => void;
|
||||
}
|
||||
|
||||
export const CreateWorkspaceDialog: React.FC<CreateWorkspaceDialogProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
hosts,
|
||||
onCreate,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [name, setName] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedHostIds, setSelectedHostIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const filteredHosts = useMemo(() => {
|
||||
if (!search.trim()) return hosts;
|
||||
const term = search.toLowerCase();
|
||||
return hosts.filter(h =>
|
||||
h.label.toLowerCase().includes(term) ||
|
||||
h.hostname.toLowerCase().includes(term) ||
|
||||
(h.group || '').toLowerCase().includes(term)
|
||||
);
|
||||
}, [hosts, search]);
|
||||
|
||||
const toggleHost = (hostId: string) => {
|
||||
setSelectedHostIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(hostId)) {
|
||||
next.delete(hostId);
|
||||
} else {
|
||||
next.add(hostId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
const selected = hosts.filter(h => selectedHostIds.has(h.id));
|
||||
onCreate(name, selected);
|
||||
onClose();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setName('');
|
||||
setSearch('');
|
||||
setSelectedHostIds(new Set());
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-md flex flex-col max-h-[80vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('dialog.createWorkspace.title', 'Create Workspace')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2 flex-1 flex flex-col min-h-0">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="workspace-name">{t('field.name', 'Name')}</Label>
|
||||
<Input
|
||||
id="workspace-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t('placeholder.workspaceName', 'Workspace Name')}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 flex-1 flex flex-col min-h-0">
|
||||
<Label>{t('field.selectHosts', 'Select Hosts')}</Label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t('placeholder.searchHosts', 'Search hosts...')}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-md flex-1 min-h-[200px]">
|
||||
<ScrollArea className="h-full max-h-[300px]">
|
||||
<div className="p-2 space-y-1">
|
||||
{filteredHosts.length === 0 ? (
|
||||
<div className="text-center py-4 text-sm text-muted-foreground">
|
||||
{t('common.noResults', 'No hosts found')}
|
||||
</div>
|
||||
) : (
|
||||
filteredHosts.map(host => {
|
||||
const isSelected = selectedHostIds.has(host.id);
|
||||
return (
|
||||
<div
|
||||
key={host.id}
|
||||
className={`flex items-center gap-3 p-2 rounded-md cursor-pointer hover:bg-muted/50 ${isSelected ? 'bg-primary/10' : ''}`}
|
||||
onClick={() => toggleHost(host.id)}
|
||||
>
|
||||
<div className={`h-4 w-4 border rounded flex items-center justify-center ${isSelected ? 'bg-primary border-primary' : 'border-muted-foreground'}`}>
|
||||
{isSelected && <div className="h-2 w-2 bg-primary-foreground rounded-sm" />}
|
||||
</div>
|
||||
<DistroAvatar host={host} size="sm" fallback={host.label.slice(0, 2).toUpperCase()} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{host.label}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{host.hostname}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground text-right">
|
||||
{selectedHostIds.size} {t('common.selected', 'selected')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose}>{t('common.cancel', 'Cancel')}</Button>
|
||||
<Button onClick={handleCreate} disabled={!name.trim() || selectedHostIds.size === 0}>
|
||||
{t('common.create', 'Create')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -916,6 +916,8 @@ echo $3 >> "$FILE"`);
|
||||
<ImportKeyPanel
|
||||
draftKey={draftKey}
|
||||
setDraftKey={setDraftKey}
|
||||
showPassphrase={showPassphrase}
|
||||
setShowPassphrase={setShowPassphrase}
|
||||
onImport={handleImport}
|
||||
/>
|
||||
)}
|
||||
@@ -1114,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
|
||||
|
||||
@@ -701,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)}
|
||||
|
||||
@@ -94,7 +94,6 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
return IS_MAC ? binding.mac : binding.pc;
|
||||
}, [keyBindings]);
|
||||
const quickSwitchKey = getHotkeyLabel('quick-switch');
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -102,7 +101,6 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
// Reset state when opening
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setIsFocused(false);
|
||||
setSelectedIndex(0);
|
||||
// Auto focus the input after a short delay
|
||||
setTimeout(() => {
|
||||
@@ -134,7 +132,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
[sessions]
|
||||
);
|
||||
|
||||
const showCategorized = isFocused || query.trim().length > 0;
|
||||
const showCategorized = query.trim().length > 0;
|
||||
|
||||
// Memoize flat items list and index map
|
||||
const { flatItems, itemIndexMap } = useMemo(() => {
|
||||
@@ -232,7 +230,6 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
onQueryChange(e.target.value);
|
||||
setSelectedIndex(0);
|
||||
}}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t("qs.search.placeholder")}
|
||||
className="flex-1 h-8 border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 px-0 text-sm"
|
||||
|
||||
@@ -21,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";
|
||||
@@ -82,9 +83,10 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
cancelSftpUpload,
|
||||
startStreamTransfer,
|
||||
cancelTransfer,
|
||||
showSaveDialog,
|
||||
} = useSftpBackend();
|
||||
const { t } = useI18n();
|
||||
const { sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload } = useSettingsState();
|
||||
const { sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, hotkeyScheme, keyBindings } = useSettingsState();
|
||||
const isLocalSession = host.protocol === "local";
|
||||
const [filenameEncoding, setFilenameEncoding] = useState<SftpFilenameEncoding>(
|
||||
host.sftpEncoding ?? "auto"
|
||||
@@ -358,6 +360,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
handleDrag,
|
||||
handleDrop,
|
||||
cancelUpload,
|
||||
cancelTask,
|
||||
dismissTask,
|
||||
} = useSftpModalTransfers({
|
||||
currentPath,
|
||||
@@ -376,6 +379,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
cancelSftpUpload,
|
||||
startStreamTransfer,
|
||||
cancelTransfer,
|
||||
showSaveDialog,
|
||||
setLoading,
|
||||
t,
|
||||
useCompressedUpload: sftpUseCompressedUpload,
|
||||
@@ -505,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);
|
||||
@@ -625,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}
|
||||
|
||||
@@ -66,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("/");
|
||||
@@ -91,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(() => {
|
||||
@@ -105,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}/`)),
|
||||
@@ -119,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}/`)),
|
||||
@@ -130,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) {
|
||||
@@ -180,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(() => {
|
||||
@@ -359,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 && (
|
||||
@@ -390,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);
|
||||
}
|
||||
|
||||
@@ -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,13 @@ 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={cn(
|
||||
"relative border-r border-border/70 flex flex-col",
|
||||
focusedSide === "left" && "ring-1 ring-inset ring-primary/70"
|
||||
)}
|
||||
onClick={() => handlePaneFocus("left")}
|
||||
>
|
||||
{/* Left side tab bar - only show when there are tabs */}
|
||||
{leftTabsInfo.length > 0 && (
|
||||
<SftpTabBar
|
||||
@@ -233,7 +265,13 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex flex-col">
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col",
|
||||
focusedSide === "right" && "ring-1 ring-inset ring-primary/70"
|
||||
)}
|
||||
onClick={() => handlePaneFocus("right")}
|
||||
>
|
||||
{/* Right side tab bar - only show when there are tabs */}
|
||||
{rightTabsInfo.length > 0 && (
|
||||
<SftpTabBar
|
||||
|
||||
@@ -1,11 +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 { ManagedSource } from '../domain/models';
|
||||
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';
|
||||
@@ -25,6 +25,8 @@ interface SnippetsManagerProps {
|
||||
hosts: Host[];
|
||||
customGroups?: string[];
|
||||
shellHistory: ShellHistoryEntry[];
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
keyBindings: KeyBinding[];
|
||||
onSave: (snippet: Snippet) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onPackagesChange: (packages: string[]) => void;
|
||||
@@ -46,6 +48,8 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
hosts,
|
||||
customGroups = [],
|
||||
shellHistory,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
onSave,
|
||||
onDelete,
|
||||
onPackagesChange,
|
||||
@@ -89,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);
|
||||
@@ -114,6 +299,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
tags: editingSnippet.tags || [],
|
||||
package: editingSnippet.package || '',
|
||||
targets: targetSelection,
|
||||
shortkey: editingSnippet.shortkey,
|
||||
});
|
||||
setRightPanelMode('none');
|
||||
}
|
||||
@@ -606,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">
|
||||
@@ -895,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"
|
||||
|
||||
@@ -216,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);
|
||||
@@ -235,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;
|
||||
|
||||
@@ -425,6 +449,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onHotkeyActionRef,
|
||||
isBroadcastEnabledRef,
|
||||
onBroadcastInputRef,
|
||||
snippetsRef,
|
||||
sessionId,
|
||||
statusRef,
|
||||
onCommandExecuted,
|
||||
@@ -442,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") {
|
||||
@@ -991,7 +1030,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
try {
|
||||
const dropEntries = await extractDropEntries(e.dataTransfer);
|
||||
|
||||
|
||||
if (dropEntries.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -1082,7 +1121,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onSplitVertical={onSplitVertical}
|
||||
onClose={inWorkspace ? () => onCloseSession?.(sessionId) : undefined}
|
||||
>
|
||||
<div
|
||||
<div
|
||||
className="relative h-full w-full flex overflow-hidden bg-gradient-to-br from-[#050910] via-[#06101a] to-[#0b1220]"
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
@@ -1095,13 +1134,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
<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
|
||||
{isLocalConnection
|
||||
? t("terminal.dragDrop.localTitle")
|
||||
: t("terminal.dragDrop.remoteTitle")
|
||||
}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{isLocalConnection
|
||||
{isLocalConnection
|
||||
? t("terminal.dragDrop.localMessage")
|
||||
: t("terminal.dragDrop.remoteMessage")
|
||||
}
|
||||
@@ -1478,46 +1517,46 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
{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,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<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
|
||||
|
||||
@@ -85,6 +85,7 @@ import { SortDropdown, SortMode } from "./ui/sort-dropdown";
|
||||
import { TagFilterDropdown } from "./ui/tag-filter-dropdown";
|
||||
import { toast } from "./ui/toast";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { HotkeyScheme, KeyBinding } from "../domain/models";
|
||||
|
||||
const LazyProtocolSelectDialog = lazy(() => import("./ProtocolSelectDialog"));
|
||||
const LazyConnectionLogsManager = lazy(() => import("./ConnectionLogsManager"));
|
||||
@@ -104,6 +105,8 @@ interface VaultViewProps {
|
||||
connectionLogs: ConnectionLog[];
|
||||
managedSources: ManagedSource[];
|
||||
sessions: TerminalSession[];
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
keyBindings: KeyBinding[];
|
||||
onOpenSettings: () => void;
|
||||
onOpenQuickSwitcher: () => void;
|
||||
onCreateLocalTerminal: () => void;
|
||||
@@ -144,6 +147,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
connectionLogs,
|
||||
managedSources,
|
||||
sessions,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
onOpenSettings,
|
||||
onOpenQuickSwitcher,
|
||||
onCreateLocalTerminal,
|
||||
@@ -2075,6 +2080,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
hosts={hosts}
|
||||
customGroups={customGroups}
|
||||
shellHistory={shellHistory}
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
onPackagesChange={onUpdateSnippetPackages}
|
||||
onSave={(s) =>
|
||||
onUpdateSnippets(
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,31 +1,33 @@
|
||||
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: UploadTask) => {
|
||||
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('|')) {
|
||||
@@ -96,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" />
|
||||
@@ -117,18 +123,18 @@ export const SftpModalUploadTasks: React.FC<SftpModalUploadTasksProps> = ({ task
|
||||
<span className="text-xs font-medium truncate">
|
||||
{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
|
||||
@@ -140,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">
|
||||
{t("sftp.upload.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">
|
||||
{t("sftp.upload.cancelled")}
|
||||
{t(task.direction === "download" ? "sftp.download.cancelled" : "sftp.upload.cancelled")}
|
||||
</div>
|
||||
)}
|
||||
{task.status === "failed" && task.error && (
|
||||
@@ -178,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} />
|
||||
|
||||
155
components/sftp-modal/hooks/useSftpModalKeyboardShortcuts.ts
Normal file
155
components/sftp-modal/hooks/useSftpModalKeyboardShortcuts.ts
Normal file
@@ -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]);
|
||||
};
|
||||
@@ -13,10 +13,10 @@ import {
|
||||
} 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;
|
||||
@@ -26,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;
|
||||
@@ -67,6 +71,7 @@ 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
|
||||
@@ -85,6 +90,7 @@ interface UseSftpModalTransfersResult {
|
||||
handleDrag: (e: React.DragEvent) => void;
|
||||
handleDrop: (e: React.DragEvent) => void;
|
||||
cancelUpload: () => Promise<void>;
|
||||
cancelTask: (taskId: string) => Promise<void>;
|
||||
dismissTask: (taskId: string) => void;
|
||||
}
|
||||
|
||||
@@ -95,7 +101,6 @@ export const useSftpModalTransfers = ({
|
||||
ensureSftp,
|
||||
loadFiles,
|
||||
readLocalFile,
|
||||
readSftp,
|
||||
writeLocalFile,
|
||||
writeSftpBinaryWithProgress,
|
||||
writeSftpBinary,
|
||||
@@ -104,6 +109,7 @@ export const useSftpModalTransfers = ({
|
||||
cancelSftpUpload,
|
||||
startStreamTransfer,
|
||||
cancelTransfer,
|
||||
showSaveDialog,
|
||||
setLoading,
|
||||
t,
|
||||
useCompressedUpload = false,
|
||||
@@ -214,6 +220,7 @@ export const useSftpModalTransfers = ({
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
isDirectory: true,
|
||||
direction: "upload",
|
||||
};
|
||||
setUploadTasks(prev => [...prev, scanningTask]);
|
||||
},
|
||||
@@ -231,6 +238,7 @@ export const useSftpModalTransfers = ({
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
isDirectory: task.isDirectory,
|
||||
direction: "upload",
|
||||
};
|
||||
setUploadTasks(prev => [...prev, uploadTask]);
|
||||
},
|
||||
@@ -376,19 +384,159 @@ export const useSftpModalTransfers = ({
|
||||
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);
|
||||
|
||||
// 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"),
|
||||
@@ -398,7 +546,7 @@ export const useSftpModalTransfers = ({
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[currentPath, ensureSftp, isLocalSession, joinPath, readLocalFile, readSftp, setLoading, t],
|
||||
[currentPath, ensureSftp, isLocalSession, joinPath, readLocalFile, setLoading, showSaveDialog, startStreamTransfer, t],
|
||||
);
|
||||
|
||||
|
||||
@@ -608,6 +756,56 @@ export const useSftpModalTransfers = ({
|
||||
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));
|
||||
}, []);
|
||||
@@ -625,6 +823,7 @@ export const useSftpModalTransfers = ({
|
||||
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));
|
||||
};
|
||||
|
||||
124
components/sftp/hooks/useSftpClipboard.ts
Normal file
124
components/sftp/hooks/useSftpClipboard.ts
Normal file
@@ -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
|
||||
);
|
||||
};
|
||||
120
components/sftp/hooks/useSftpDialogAction.ts
Normal file
120
components/sftp/hooks/useSftpDialogAction.ts
Normal file
@@ -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]);
|
||||
};
|
||||
54
components/sftp/hooks/useSftpFocusedPane.ts
Normal file
54
components/sftp/hooks/useSftpFocusedPane.ts
Normal file
@@ -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
|
||||
);
|
||||
};
|
||||
290
components/sftp/hooks/useSftpKeyboardShortcuts.ts
Normal file
290
components/sftp/hooks/useSftpKeyboardShortcuts.ts
Normal file
@@ -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 */
|
||||
|
||||
295
components/terminal/HostKeywordHighlightPopover.tsx
Normal file
295
components/terminal/HostKeywordHighlightPopover.tsx
Normal file
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -96,6 +96,9 @@ export interface Host {
|
||||
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';
|
||||
@@ -136,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 {
|
||||
@@ -173,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
|
||||
@@ -261,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) {
|
||||
@@ -285,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[] = [
|
||||
@@ -328,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
|
||||
@@ -553,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;
|
||||
|
||||
@@ -10,6 +10,7 @@ const {
|
||||
buildAuthHandler,
|
||||
createKeyboardInteractiveHandler,
|
||||
applyAuthToConnOpts,
|
||||
findAllDefaultPrivateKeys: findAllDefaultPrivateKeysFromHelper,
|
||||
} = require("./sshAuthHelper.cjs");
|
||||
|
||||
// Active port forwarding tunnels
|
||||
@@ -46,55 +47,59 @@ async function startPortForward(event, payload) {
|
||||
passphrase,
|
||||
} = payload;
|
||||
|
||||
const conn = new SSHClient();
|
||||
const sender = event.sender;
|
||||
|
||||
const sendStatus = (status, error = null) => {
|
||||
if (!sender.isDestroyed()) {
|
||||
sender.send("netcatty:portforward:status", { tunnelId, status, error });
|
||||
}
|
||||
};
|
||||
|
||||
const connectOpts = {
|
||||
host: hostname,
|
||||
port: port,
|
||||
username: username || 'root',
|
||||
readyTimeout: 120000, // 2 minutes for 2FA input
|
||||
keepaliveInterval: 10000,
|
||||
// Enable keyboard-interactive authentication (required for 2FA/MFA)
|
||||
tryKeyboard: true,
|
||||
};
|
||||
|
||||
if (privateKey) {
|
||||
connectOpts.privateKey = privateKey;
|
||||
}
|
||||
if (passphrase) {
|
||||
connectOpts.passphrase = passphrase;
|
||||
}
|
||||
if (password) {
|
||||
connectOpts.password = password;
|
||||
}
|
||||
|
||||
// Get default keys
|
||||
const defaultKeys = await findAllDefaultPrivateKeysFromHelper();
|
||||
|
||||
// Build auth handler using shared helper
|
||||
const authConfig = buildAuthHandler({
|
||||
privateKey,
|
||||
password,
|
||||
passphrase,
|
||||
username: connectOpts.username,
|
||||
logPrefix: "[PortForward]",
|
||||
defaultKeys,
|
||||
});
|
||||
applyAuthToConnOpts(connectOpts, authConfig);
|
||||
|
||||
// Handle keyboard-interactive authentication (2FA/MFA)
|
||||
conn.on("keyboard-interactive", createKeyboardInteractiveHandler({
|
||||
sender,
|
||||
sessionId: tunnelId,
|
||||
hostname,
|
||||
password,
|
||||
logPrefix: "[PortForward]",
|
||||
}));
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const conn = new SSHClient();
|
||||
const sender = event.sender;
|
||||
|
||||
const sendStatus = (status, error = null) => {
|
||||
if (!sender.isDestroyed()) {
|
||||
sender.send("netcatty:portforward:status", { tunnelId, status, error });
|
||||
}
|
||||
};
|
||||
|
||||
const connectOpts = {
|
||||
host: hostname,
|
||||
port: port,
|
||||
username: username || 'root',
|
||||
readyTimeout: 120000, // 2 minutes for 2FA input
|
||||
keepaliveInterval: 10000,
|
||||
// Enable keyboard-interactive authentication (required for 2FA/MFA)
|
||||
tryKeyboard: true,
|
||||
};
|
||||
|
||||
if (privateKey) {
|
||||
connectOpts.privateKey = privateKey;
|
||||
}
|
||||
if (passphrase) {
|
||||
connectOpts.passphrase = passphrase;
|
||||
}
|
||||
if (password) {
|
||||
connectOpts.password = password;
|
||||
}
|
||||
|
||||
// Build auth handler using shared helper
|
||||
const authConfig = buildAuthHandler({
|
||||
privateKey,
|
||||
password,
|
||||
passphrase,
|
||||
username: connectOpts.username,
|
||||
logPrefix: "[PortForward]",
|
||||
});
|
||||
applyAuthToConnOpts(connectOpts, authConfig);
|
||||
|
||||
// Handle keyboard-interactive authentication (2FA/MFA)
|
||||
conn.on("keyboard-interactive", createKeyboardInteractiveHandler({
|
||||
sender,
|
||||
sessionId: tunnelId,
|
||||
hostname,
|
||||
password,
|
||||
logPrefix: "[PortForward]",
|
||||
}));
|
||||
|
||||
|
||||
conn.on('ready', () => {
|
||||
console.log(`[PortForward] SSH connection ready for tunnel ${tunnelId}`);
|
||||
|
||||
@@ -28,6 +28,7 @@ const {
|
||||
createKeyboardInteractiveHandler,
|
||||
applyAuthToConnOpts,
|
||||
safeSend: authSafeSend,
|
||||
findAllDefaultPrivateKeys: findAllDefaultPrivateKeysFromHelper,
|
||||
} = require("./sshAuthHelper.cjs");
|
||||
|
||||
// SFTP clients storage - shared reference passed from main
|
||||
@@ -171,6 +172,18 @@ const ensureRemoteDirInternal = async (sftp, dirPath, encoding) => {
|
||||
const normalized = path.posix.normalize(dirPath);
|
||||
if (!normalized || normalized === ".") return;
|
||||
|
||||
// Optimization: Check if the full path already exists to avoid O(N) round trips
|
||||
// This is the common case (e.g. uploading multiple files to the same directory)
|
||||
const encodedFull = encodePath(normalized, encoding);
|
||||
try {
|
||||
const stats = await statAsync(sftp, encodedFull);
|
||||
if (stats.isDirectory()) {
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
// If path doesn't exist or other error, proceed to recursive check
|
||||
}
|
||||
|
||||
const isAbsolute = normalized.startsWith("/");
|
||||
const parts = normalized.split("/").filter(Boolean);
|
||||
let current = isAbsolute ? "/" : "";
|
||||
@@ -295,8 +308,19 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
// 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'],
|
||||
},
|
||||
};
|
||||
@@ -325,6 +349,9 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
|
||||
if (jump.password) connOpts.password = jump.password;
|
||||
|
||||
// Get default keys (either from options if pre-fetched, or fetch them now)
|
||||
const defaultKeys = options._defaultKeys || await findAllDefaultPrivateKeysFromHelper();
|
||||
|
||||
// Build auth handler using shared helper
|
||||
// Pass unlocked encrypted keys from options so jump hosts can use them for retry
|
||||
const authConfig = buildAuthHandler({
|
||||
@@ -335,6 +362,7 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
username: connOpts.username,
|
||||
logPrefix: `[SFTP Chain] Hop ${i + 1}`,
|
||||
unlockedEncryptedKeys: options._unlockedEncryptedKeys || [],
|
||||
defaultKeys,
|
||||
});
|
||||
applyAuthToConnOpts(connOpts, authConfig);
|
||||
|
||||
@@ -654,6 +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;
|
||||
@@ -665,6 +696,10 @@ async function openSftp(event, options) {
|
||||
// Handle chain/proxy connections
|
||||
if (hasJumpHosts) {
|
||||
console.log(`[SFTP] Opening connection through ${jumpHosts.length} jump host(s) to ${options.hostname}:${options.port || 22}`);
|
||||
|
||||
// Pass default keys to chain connection
|
||||
options._defaultKeys = defaultKeys;
|
||||
|
||||
const chainResult = await connectThroughChainForSftp(
|
||||
event,
|
||||
options,
|
||||
@@ -731,6 +766,7 @@ async function openSftp(event, options) {
|
||||
agent: connectOpts.agent,
|
||||
username: connectOpts.username,
|
||||
logPrefix: "[SFTP]",
|
||||
defaultKeys,
|
||||
});
|
||||
applyAuthToConnOpts(connectOpts, authConfig);
|
||||
|
||||
|
||||
@@ -68,22 +68,21 @@ const passphraseHandler = require("./passphraseHandler.cjs");
|
||||
/**
|
||||
* Find default SSH private key from user's ~/.ssh directory
|
||||
* Skips encrypted keys that require a passphrase
|
||||
* @returns {{ privateKey: string, keyPath: string, keyName: string } | null}
|
||||
* @returns {Promise<{ privateKey: string, keyPath: string, keyName: string } | null>}
|
||||
*/
|
||||
function findDefaultPrivateKey() {
|
||||
async function findDefaultPrivateKey() {
|
||||
const sshDir = path.join(os.homedir(), ".ssh");
|
||||
for (const name of DEFAULT_KEY_NAMES) {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
if (fs.existsSync(keyPath)) {
|
||||
try {
|
||||
const privateKey = fs.readFileSync(keyPath, "utf8");
|
||||
if (isKeyEncrypted(privateKey)) {
|
||||
continue;
|
||||
}
|
||||
return { privateKey, keyPath, keyName: name };
|
||||
} catch {
|
||||
try {
|
||||
await fs.promises.access(keyPath, fs.constants.F_OK);
|
||||
const privateKey = await fs.promises.readFile(keyPath, "utf8");
|
||||
if (isKeyEncrypted(privateKey)) {
|
||||
continue;
|
||||
}
|
||||
return { privateKey, keyPath, keyName: name };
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -93,33 +92,34 @@ const passphraseHandler = require("./passphraseHandler.cjs");
|
||||
* Find ALL default SSH private keys from user's ~/.ssh directory
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.includeEncrypted=false] - If true, include encrypted keys with isEncrypted flag
|
||||
* @returns {Array<{ privateKey: string, keyPath: string, keyName: string, isEncrypted?: boolean }>}
|
||||
* @returns {Promise<Array<{ privateKey: string, keyPath: string, keyName: string, isEncrypted?: boolean }>>}
|
||||
*/
|
||||
function findAllDefaultPrivateKeys(options = {}) {
|
||||
async function findAllDefaultPrivateKeys(options = {}) {
|
||||
const { includeEncrypted = false } = options;
|
||||
const sshDir = path.join(os.homedir(), ".ssh");
|
||||
const keys = [];
|
||||
for (const name of DEFAULT_KEY_NAMES) {
|
||||
|
||||
const promises = DEFAULT_KEY_NAMES.map(async (name) => {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
if (fs.existsSync(keyPath)) {
|
||||
try {
|
||||
const privateKey = fs.readFileSync(keyPath, "utf8");
|
||||
const encrypted = isKeyEncrypted(privateKey);
|
||||
if (encrypted && !includeEncrypted) {
|
||||
continue; // Skip encrypted keys when not including them
|
||||
}
|
||||
keys.push({
|
||||
privateKey,
|
||||
keyPath,
|
||||
keyName: name,
|
||||
...(includeEncrypted ? { isEncrypted: encrypted } : {})
|
||||
});
|
||||
} catch {
|
||||
continue;
|
||||
try {
|
||||
await fs.promises.access(keyPath, fs.constants.F_OK);
|
||||
const privateKey = await fs.promises.readFile(keyPath, "utf8");
|
||||
const encrypted = isKeyEncrypted(privateKey);
|
||||
if (encrypted && !includeEncrypted) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
privateKey,
|
||||
keyPath,
|
||||
keyName: name,
|
||||
...(includeEncrypted ? { isEncrypted: encrypted } : {})
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
});
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
return results.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,7 +146,7 @@ function findAllDefaultPrivateKeys(options = {}) {
|
||||
* @param {Array} [options.unlockedEncryptedKeys] - Array of unlocked encrypted keys with passphrases
|
||||
*/
|
||||
function buildAuthHandler(options) {
|
||||
const { privateKey, password, passphrase, agent, username, logPrefix = "[SSH]", unlockedEncryptedKeys = [] } = options;
|
||||
const { privateKey, password, passphrase, agent, username, logPrefix = "[SSH]", unlockedEncryptedKeys = [], defaultKeys = [] } = options;
|
||||
|
||||
// Determine what type of explicit auth the user configured
|
||||
const hasExplicitKey = !!privateKey;
|
||||
@@ -159,7 +159,6 @@ function findAllDefaultPrivateKeys(options = {}) {
|
||||
const isKeyOnly = hasExplicitKey && !hasExplicitAgent;
|
||||
|
||||
const sshAgentSocket = getSshAgentSocket();
|
||||
const defaultKeys = findAllDefaultPrivateKeys();
|
||||
|
||||
// Only use system ssh-agent BEFORE user's auth when:
|
||||
// - User explicitly configured agent, OR
|
||||
@@ -465,7 +464,7 @@ function findAllDefaultPrivateKeys(options = {}) {
|
||||
* @returns {Promise<{ keys: Array<{ privateKey: string, keyPath: string, keyName: string, passphrase: string }>, cancelled: boolean }>}
|
||||
*/
|
||||
async function requestPassphrasesForEncryptedKeys(sender, hostname) {
|
||||
const allKeys = findAllDefaultPrivateKeys({ includeEncrypted: true });
|
||||
const allKeys = await findAllDefaultPrivateKeys({ includeEncrypted: true });
|
||||
const encryptedKeys = allKeys.filter(k => k.isEncrypted);
|
||||
|
||||
if (encryptedKeys.length === 0) {
|
||||
|
||||
@@ -13,9 +13,9 @@ const { NetcattyAgent } = require("./netcattyAgent.cjs");
|
||||
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
const passphraseHandler = require("./passphraseHandler.cjs");
|
||||
const { createProxySocket } = require("./proxyUtils.cjs");
|
||||
const {
|
||||
buildAuthHandler,
|
||||
createKeyboardInteractiveHandler,
|
||||
const {
|
||||
buildAuthHandler,
|
||||
createKeyboardInteractiveHandler,
|
||||
applyAuthToConnOpts,
|
||||
safeSend: authSafeSend,
|
||||
requestPassphrasesForEncryptedKeys,
|
||||
@@ -76,31 +76,29 @@ function isKeyEncrypted(keyContent) {
|
||||
/**
|
||||
* Find default SSH private key from user's ~/.ssh directory
|
||||
* Skips encrypted keys that require a passphrase to allow password/keyboard-interactive auth
|
||||
* @returns {{ privateKey: string, keyPath: string, keyName: string } | null}
|
||||
* @returns {Promise<{ privateKey: string, keyPath: string, keyName: string } | null>}
|
||||
*/
|
||||
function findDefaultPrivateKey() {
|
||||
async function findDefaultPrivateKey() {
|
||||
const sshDir = path.join(os.homedir(), ".ssh");
|
||||
log("Searching for default SSH keys", { sshDir, keyNames: DEFAULT_KEY_NAMES });
|
||||
for (const name of DEFAULT_KEY_NAMES) {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
log("Checking key file", { keyPath, exists: fs.existsSync(keyPath) });
|
||||
if (fs.existsSync(keyPath)) {
|
||||
try {
|
||||
const privateKey = fs.readFileSync(keyPath, "utf8");
|
||||
// Skip encrypted keys - they require a passphrase and would abort
|
||||
// authentication before password/keyboard-interactive can be tried
|
||||
const encrypted = isKeyEncrypted(privateKey);
|
||||
log("Key file read", { keyPath, keyName: name, encrypted, keyLength: privateKey.length });
|
||||
if (encrypted) {
|
||||
log("Skipping encrypted default key", { keyPath, keyName: name });
|
||||
continue;
|
||||
}
|
||||
log("Found default key", { keyPath, keyName: name });
|
||||
return { privateKey, keyPath, keyName: name };
|
||||
} catch (e) {
|
||||
log("Failed to read default key", { keyPath, error: e.message });
|
||||
try {
|
||||
await fs.promises.access(keyPath, fs.constants.F_OK);
|
||||
const privateKey = await fs.promises.readFile(keyPath, "utf8");
|
||||
// Skip encrypted keys - they require a passphrase and would abort
|
||||
// authentication before password/keyboard-interactive can be tried
|
||||
const encrypted = isKeyEncrypted(privateKey);
|
||||
log("Key file read", { keyPath, keyName: name, encrypted, keyLength: privateKey.length });
|
||||
if (encrypted) {
|
||||
log("Skipping encrypted default key", { keyPath, keyName: name });
|
||||
continue;
|
||||
}
|
||||
log("Found default key", { keyPath, keyName: name });
|
||||
return { privateKey, keyPath, keyName: name };
|
||||
} catch (e) {
|
||||
log("Failed to read default key", { keyPath, error: e.message });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
log("No suitable default SSH key found");
|
||||
@@ -110,29 +108,33 @@ function findDefaultPrivateKey() {
|
||||
/**
|
||||
* Find ALL default SSH private keys from user's ~/.ssh directory
|
||||
* Returns all non-encrypted keys for fallback authentication
|
||||
* @returns {Array<{ privateKey: string, keyPath: string, keyName: string }>}
|
||||
* @returns {Promise<Array<{ privateKey: string, keyPath: string, keyName: string }>>}
|
||||
*/
|
||||
function findAllDefaultPrivateKeys() {
|
||||
async function findAllDefaultPrivateKeys() {
|
||||
const sshDir = path.join(os.homedir(), ".ssh");
|
||||
const keys = [];
|
||||
log("Searching for ALL default SSH keys", { sshDir, keyNames: DEFAULT_KEY_NAMES });
|
||||
for (const name of DEFAULT_KEY_NAMES) {
|
||||
|
||||
const promises = DEFAULT_KEY_NAMES.map(async (name) => {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
if (fs.existsSync(keyPath)) {
|
||||
try {
|
||||
const privateKey = fs.readFileSync(keyPath, "utf8");
|
||||
const encrypted = isKeyEncrypted(privateKey);
|
||||
if (!encrypted) {
|
||||
keys.push({ privateKey, keyPath, keyName: name });
|
||||
log("Found default key for fallback", { keyPath, keyName: name });
|
||||
} else {
|
||||
log("Skipping encrypted key", { keyPath, keyName: name });
|
||||
}
|
||||
} catch (e) {
|
||||
log("Failed to read key", { keyPath, error: e.message });
|
||||
try {
|
||||
await fs.promises.access(keyPath, fs.constants.F_OK);
|
||||
const privateKey = await fs.promises.readFile(keyPath, "utf8");
|
||||
const encrypted = isKeyEncrypted(privateKey);
|
||||
if (!encrypted) {
|
||||
log("Found default key for fallback", { keyPath, keyName: name });
|
||||
return { privateKey, keyPath, keyName: name };
|
||||
} else {
|
||||
log("Skipping encrypted key", { keyPath, keyName: name });
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
log("Failed to read key", { keyPath, error: e.message });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
const keys = results.filter(Boolean);
|
||||
log("Found default SSH keys", { count: keys.length, keyNames: keys.map(k => k.keyName) });
|
||||
return keys;
|
||||
}
|
||||
@@ -277,9 +279,18 @@ 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'],
|
||||
},
|
||||
};
|
||||
@@ -308,6 +319,9 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
|
||||
if (jump.password) connOpts.password = jump.password;
|
||||
|
||||
// Get default keys (either from options if pre-fetched, or fetch them now)
|
||||
const defaultKeys = options._defaultKeys || await findAllDefaultPrivateKeys();
|
||||
|
||||
// Build auth handler using shared helper
|
||||
// Pass unlocked encrypted keys from options so jump hosts can use them for retry
|
||||
const authConfig = buildAuthHandler({
|
||||
@@ -318,6 +332,7 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
username: connOpts.username,
|
||||
logPrefix: `[Chain] Hop ${i + 1}`,
|
||||
unlockedEncryptedKeys: options._unlockedEncryptedKeys || [],
|
||||
defaultKeys,
|
||||
});
|
||||
applyAuthToConnOpts(connOpts, authConfig);
|
||||
|
||||
@@ -452,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'],
|
||||
},
|
||||
};
|
||||
@@ -508,19 +532,19 @@ 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", {
|
||||
log("Using unlocked encrypted keys from retry", {
|
||||
count: unlockedEncryptedKeys.length,
|
||||
keyNames: unlockedEncryptedKeys.map(k => k.keyName)
|
||||
});
|
||||
@@ -529,15 +553,15 @@ async function startSSHSession(event, options) {
|
||||
// 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) });
|
||||
@@ -612,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) {
|
||||
@@ -626,12 +650,12 @@ async function startSSHSession(event, options) {
|
||||
|
||||
// Add unlocked encrypted default keys (user provided passphrases for these)
|
||||
for (const keyInfo of unlockedEncryptedKeys) {
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: keyInfo.privateKey,
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: keyInfo.privateKey,
|
||||
passphrase: keyInfo.passphrase,
|
||||
isDefault: true,
|
||||
id: `publickey-encrypted-${keyInfo.keyName}`
|
||||
isDefault: true,
|
||||
id: `publickey-encrypted-${keyInfo.keyName}`
|
||||
});
|
||||
}
|
||||
|
||||
@@ -754,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 });
|
||||
@@ -814,6 +838,9 @@ async function startSSHSession(event, options) {
|
||||
|
||||
// Handle chain/proxy connections
|
||||
if (hasJumpHosts) {
|
||||
// Pass fetched keys to chain connection to avoid re-reading files
|
||||
options._defaultKeys = allDefaultKeys;
|
||||
|
||||
const chainResult = await connectThroughChain(
|
||||
event,
|
||||
options,
|
||||
@@ -1098,12 +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;
|
||||
@@ -1159,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,
|
||||
};
|
||||
|
||||
@@ -1183,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;
|
||||
@@ -1257,18 +1312,18 @@ async function startSSHSessionWrapper(event, options) {
|
||||
// Check if there are encrypted default keys we haven't tried yet
|
||||
// Only offer retry if no unlocked keys were provided in this attempt
|
||||
if (!options._unlockedEncryptedKeys || options._unlockedEncryptedKeys.length === 0) {
|
||||
const allKeysWithEncrypted = findAllDefaultPrivateKeysFromHelper({ includeEncrypted: true });
|
||||
const allKeysWithEncrypted = await findAllDefaultPrivateKeysFromHelper({ includeEncrypted: true });
|
||||
const encryptedKeys = allKeysWithEncrypted.filter(k => k.isEncrypted);
|
||||
|
||||
|
||||
if (encryptedKeys.length > 0) {
|
||||
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');
|
||||
@@ -1277,7 +1332,7 @@ async function startSSHSessionWrapper(event, options) {
|
||||
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 {
|
||||
@@ -1290,7 +1345,7 @@ async function startSSHSessionWrapper(event, options) {
|
||||
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';
|
||||
@@ -1304,7 +1359,7 @@ async function startSSHSessionWrapper(event, options) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 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);
|
||||
@@ -1720,8 +1775,11 @@ function registerHandlers(ipcMain) {
|
||||
const keys = [];
|
||||
for (const name of DEFAULT_KEY_NAMES) {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
if (fs.existsSync(keyPath)) {
|
||||
try {
|
||||
await fs.promises.access(keyPath, fs.constants.F_OK);
|
||||
keys.push({ name, path: keyPath });
|
||||
} catch {
|
||||
// ignore missing keys
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
|
||||
@@ -87,9 +87,9 @@ function loadWindowState() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Save window state to disk
|
||||
* Save window state to disk (synchronous)
|
||||
*/
|
||||
function saveWindowState(state) {
|
||||
function saveWindowStateSync(state) {
|
||||
try {
|
||||
const statePath = getWindowStatePath();
|
||||
if (!statePath) return false;
|
||||
@@ -101,6 +101,47 @@ function saveWindowState(state) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save window state to disk (asynchronous)
|
||||
*/
|
||||
async function saveWindowState(state) {
|
||||
try {
|
||||
const statePath = getWindowStatePath();
|
||||
if (!statePath) return false;
|
||||
await fs.promises.writeFile(statePath, JSON.stringify(state, null, 2), { mode: 0o600 });
|
||||
return true;
|
||||
} catch (err) {
|
||||
debugLog("Failed to save window state:", err?.message || err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let pendingWindowStateWrite = null;
|
||||
let queuedWindowState = null;
|
||||
let windowStateCloseRequested = false;
|
||||
|
||||
async function queueWindowStateSave(state) {
|
||||
if (!state) return false;
|
||||
if (windowStateCloseRequested) {
|
||||
return pendingWindowStateWrite || false;
|
||||
}
|
||||
queuedWindowState = state;
|
||||
if (pendingWindowStateWrite) {
|
||||
return pendingWindowStateWrite;
|
||||
}
|
||||
pendingWindowStateWrite = (async () => {
|
||||
let lastResult = true;
|
||||
while (queuedWindowState) {
|
||||
const nextState = queuedWindowState;
|
||||
queuedWindowState = null;
|
||||
lastResult = await saveWindowState(nextState);
|
||||
}
|
||||
pendingWindowStateWrite = null;
|
||||
return lastResult;
|
||||
})();
|
||||
return pendingWindowStateWrite;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current window bounds state for saving
|
||||
* @param {BrowserWindow} win - The window to get bounds from
|
||||
@@ -589,7 +630,7 @@ async function createWindow(electronModule, options) {
|
||||
if (saveStateTimer) clearTimeout(saveStateTimer);
|
||||
saveStateTimer = setTimeout(() => {
|
||||
const state = getWindowBoundsState(win, lastNormalBounds);
|
||||
if (state) saveWindowState(state);
|
||||
if (state) queueWindowStateSave(state);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
@@ -611,11 +652,33 @@ async function createWindow(electronModule, options) {
|
||||
});
|
||||
|
||||
// Save state when window is about to close
|
||||
win.on("close", () => {
|
||||
win.on("close", (event) => {
|
||||
if (windowStateCloseRequested) {
|
||||
return;
|
||||
}
|
||||
windowStateCloseRequested = true;
|
||||
if (saveStateTimer) clearTimeout(saveStateTimer);
|
||||
const state = getWindowBoundsState(win, lastNormalBounds);
|
||||
if (state) saveWindowState(state);
|
||||
// Close settings window when main window closes
|
||||
if (pendingWindowStateWrite) {
|
||||
event.preventDefault();
|
||||
if (state) queuedWindowState = state;
|
||||
pendingWindowStateWrite
|
||||
.catch(() => {
|
||||
// ignore async write errors before closing
|
||||
})
|
||||
.finally(() => {
|
||||
const finalState = getWindowBoundsState(win, lastNormalBounds);
|
||||
if (finalState) saveWindowStateSync(finalState);
|
||||
closeSettingsWindow();
|
||||
try {
|
||||
win.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (state) saveWindowStateSync(state);
|
||||
closeSettingsWindow();
|
||||
});
|
||||
|
||||
|
||||
@@ -267,22 +267,22 @@ let cloudSyncSessionPassword = null;
|
||||
const CLOUD_SYNC_PASSWORD_FILE = "netcatty_cloud_sync_master_password_v1";
|
||||
|
||||
// Key management helpers
|
||||
const ensureKeyDir = () => {
|
||||
const ensureKeyDir = async () => {
|
||||
try {
|
||||
fs.mkdirSync(keyRoot, { recursive: true, mode: 0o700 });
|
||||
await fs.promises.mkdir(keyRoot, { recursive: true, mode: 0o700 });
|
||||
} catch (err) {
|
||||
console.warn("Unable to ensure key cache dir", err);
|
||||
}
|
||||
};
|
||||
|
||||
const writeKeyToDisk = (keyId, privateKey) => {
|
||||
const writeKeyToDisk = async (keyId, privateKey) => {
|
||||
if (!privateKey) return null;
|
||||
ensureKeyDir();
|
||||
await ensureKeyDir();
|
||||
const filename = `${keyId || "temp"}.pem`;
|
||||
const target = path.join(keyRoot, filename);
|
||||
const normalized = privateKey.endsWith("\n") ? privateKey : `${privateKey}\n`;
|
||||
try {
|
||||
fs.writeFileSync(target, normalized, { mode: 0o600 });
|
||||
await fs.promises.writeFile(target, normalized, { mode: 0o600 });
|
||||
return target;
|
||||
} catch (err) {
|
||||
console.error("Failed to persist private key", err);
|
||||
@@ -559,6 +559,22 @@ const registerBridges = (win) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Show save file dialog and return selected path
|
||||
ipcMain.handle("netcatty:showSaveDialog", async (_event, { defaultPath, filters }) => {
|
||||
const { dialog } = electronModule;
|
||||
|
||||
const result = await dialog.showSaveDialog({
|
||||
defaultPath,
|
||||
filters: filters || [{ name: "All Files", extensions: ["*"] }],
|
||||
});
|
||||
|
||||
if (result.canceled || !result.filePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.filePath;
|
||||
});
|
||||
|
||||
// Download SFTP file to temp and return local path
|
||||
ipcMain.handle("netcatty:sftp:downloadToTemp", async (_event, { sftpId, remotePath, fileName, encoding }) => {
|
||||
console.log(`[Main] Downloading SFTP file to temp:`);
|
||||
|
||||
@@ -705,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 }),
|
||||
|
||||
5
global.d.ts
vendored
5
global.d.ts
vendored
@@ -182,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 }>;
|
||||
@@ -536,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 }>;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
11
lib/utils.ts
11
lib/utils.ts
@@ -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;
|
||||
}
|
||||
2269
package-lock.json
generated
2269
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -70,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",
|
||||
@@ -80,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"types": [
|
||||
"node",
|
||||
"vite/client"
|
||||
|
||||
Reference in New Issue
Block a user