Compare commits

...

11 Commits

Author SHA1 Message Date
bincxz
2b067a9aae fix: apply host-level keyword highlight rules immediately after runtime creation
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
This fixes a timing issue where the useEffect for keyword highlighting
runs before the runtime is created, causing host-level rules to be missed
for fresh sessions. Now merged global and host rules are applied right
after the XTermRuntime is created in the boot() function.
2026-02-03 14:04:00 +08:00
bincxz
2d4a3a5602 Cleans up SFTP module imports and catch blocks
Removes unused React hook imports (`useState`, `useCallback`) from SFTP components to improve code clarity.

Simplifies a `catch` block in SFTP keyboard shortcuts by removing the unused `error` variable, making the code more concise.
2026-02-03 13:50:00 +08:00
Copilot
6c57ce7b28 Feature: Host-level keyword highlighting with toolbar popover (#185)
* Initial plan

* Add host-level keyword highlighting feature with popover UI

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

* Add accessibility labels to color inputs in HostKeywordHighlightPopover

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

* Disable host highlight popover for serial sessions

* Fix keyword highlight rule merging

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-02-03 13:45:48 +08:00
Copilot
6a2bd0a6a1 Fix SFTP jump connection unsupported algorithm chacha20-poly1305 error (#184)
* Initial plan

* Fix SFTP jump connection unsupported algorithm chacha20-poly1305 error

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-02-03 11:53:49 +08:00
bincxz
0c4900c73d fix: exclude cpu-features to fix native module build failure
Some checks failed
build-packages / build-macos-latest (push) Has been cancelled
build-packages / build-ubuntu-latest (push) Has been cancelled
build-packages / build-windows-latest (push) Has been cancelled
build-packages / release (push) Has been cancelled
The cpu-features package fails to compile with newer Node.js/Electron
due to deprecated V8 APIs. Since it's an optional dependency of ssh2,
replace it with an empty package via npm overrides.
2026-02-03 02:35:41 +08:00
陈大猫
3174e9ad27 feat(sftp): add visual focus indicator for pane selection (#181)
- Add inset ring border to focused SFTP pane for clear visual distinction
- Fix useSftpKeyboardShortcuts context error by passing showHiddenFiles as parameter
- Use sftpFocusStore to track which pane is currently focused
2026-02-03 02:17:11 +08:00
Copilot
f517c85d07 feat: Add SFTP keyboard shortcuts for copy, paste, cut, select all, rename, delete (#180)
* Initial plan

* feat: Add SFTP keyboard shortcuts support for copy, paste, cut, select all, rename, delete, refresh, and new folder operations

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

* feat: Add keyboard shortcuts support to SFTPModal for select all, rename, delete, refresh, and new folder

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

* fix: Address code review feedback - optimize useMemo deps and add same-pane paste notification

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

* Fix SFTP paste source path

* fix: delete sources after SFTP cut paste

* Fix SFTP delete key matching

* Fix cut delete after successful SFTP transfers

* fix: finalize cut-paste deletes after conflicts

* fix: track original names for cut transfers

* Fix delete key matching for shortcuts

* fix: respect visible files for sftp select all

* Fix modal selection and cut cleanup

* Throw on missing SFTP delete prerequisites

* Fix dialog action handler memo deps

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-02-03 01:45:53 +08:00
陈大猫
0b9e3c430d fix: remove chacha20-poly1305 cipher and upgrade Electron to 40.1.0 (#179)
- Remove chacha20-poly1305@openssh.com from SSH cipher list as Electron's
  BoringSSL (from Chromium) does not support standalone chacha20 cipher
- Upgrade Electron from 39.2.6 to 40.1.0 (Node.js 24.11.1)
- Keep AES-GCM and AES-CTR ciphers which are fully supported

The chacha20-poly1305 algorithm requires OpenSSL's chacha20 cipher which
is not available in Electron's bundled BoringSSL. This caused connection
failures with 'Unsupported algorithm' error when connecting to SSH servers.
2026-02-02 21:43:24 +08:00
Copilot
1c526e6965 Add keyboard shortcuts for snippets (#174)
* Initial plan

* Add snippet shortkey feature for sending commands via keyboard shortcuts

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

* Address code review feedback: extract isMacPlatform utility and improve comments

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

* Fix snippet shortcut validation

* Improve snippet shortcut conflict checks

* Append newline for snippet shortcuts

* Allow snippet shortcuts to fall through when disconnected

* Broadcast snippet shortcuts

* Fix snippet shortcut validation when hotkeys disabled

* Prevent cross-platform snippet shortcut matches

* Record snippet shortcuts in command history

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-02-02 21:12:00 +08:00
Copilot
70ff5299b6 Expand SSH algorithm support for modern servers (#178)
* Initial plan

* feat: expand SSH algorithm support for modern servers

Add additional cipher and key exchange algorithms to improve
compatibility with modern SSH servers:

Ciphers:
- chacha20-poly1305@openssh.com
- aes192-ctr

Key Exchange:
- ecdh-sha2-nistp521
- diffie-hellman-group16-sha512
- diffie-hellman-group18-sha512
- diffie-hellman-group-exchange-sha256

Fixes issue with "no matching key exchange algorithm" error.

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-02-02 20:24:34 +08:00
Copilot
3ef53faef5 Add tooltip to port forwarding rules showing relay host details (#175)
* Initial plan

* Add tooltip to port forwarding rule card showing relay host info

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-02-02 20:10:04 +08:00
30 changed files with 3037 additions and 1281 deletions

View File

@@ -998,6 +998,8 @@ function App({ settings }: { settings: SettingsState }) {
connectionLogs={connectionLogs}
managedSources={managedSources}
sessions={sessions}
hotkeyScheme={hotkeyScheme}
keyBindings={keyBindings}
onOpenSettings={handleOpenSettings}
onOpenQuickSwitcher={handleOpenQuickSwitcher}
onCreateLocalTerminal={handleCreateLocalTerminal}

View File

@@ -253,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',
@@ -463,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',
@@ -863,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',
@@ -1240,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',

View File

@@ -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': '关闭并删除',
@@ -1041,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',
@@ -1226,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': '连接串口',

View File

@@ -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,
};

View File

@@ -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;
}

View File

@@ -160,6 +160,7 @@ export const useSftpState = (
createDirectory,
createFile,
deleteFiles,
deleteFilesAtPath,
renameFile,
changePermissions,
} = useSftpPaneActions({
@@ -269,6 +270,7 @@ export const useSftpState = (
createDirectory,
createFile,
deleteFiles,
deleteFilesAtPath,
renameFile,
changePermissions,
readTextFile,
@@ -313,6 +315,7 @@ export const useSftpState = (
createDirectory,
createFile,
deleteFiles,
deleteFilesAtPath,
renameFile,
changePermissions,
readTextFile,
@@ -361,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),

View File

@@ -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)}

View File

@@ -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";
@@ -85,7 +86,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
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"
@@ -508,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);

View File

@@ -14,7 +14,7 @@
* - 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";
@@ -37,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
@@ -51,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(() => ({
@@ -84,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(() => {
@@ -200,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
@@ -240,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

View File

@@ -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"

View File

@@ -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

View File

@@ -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(

View File

@@ -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">

View File

@@ -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">

View 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]);
};

View File

@@ -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));
};

View 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
);
};

View 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]);
};

View 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
);
};

View 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]);
};

View 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;

View File

@@ -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"

View File

@@ -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';

View File

@@ -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;

View File

@@ -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;

View File

@@ -308,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'],
},
};

View File

@@ -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,
@@ -279,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'],
},
};
@@ -458,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'],
},
};
@@ -526,7 +544,7 @@ async function startSSHSession(event, options) {
// 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)
});
@@ -535,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) });
@@ -618,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) {
@@ -632,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}`
});
}
@@ -760,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 });
@@ -1296,16 +1314,16 @@ async function startSSHSessionWrapper(event, options) {
if (!options._unlockedEncryptedKeys || options._unlockedEncryptedKeys.length === 0) {
const allKeysWithEncrypted = await findAllDefaultPrivateKeysFromHelper({ includeEncrypted: true });
const encryptedKeys = allKeysWithEncrypted.filter(k => k.isEncrypted);
if (encryptedKeys.length > 0) {
console.log('[SSH] Auth failed, found encrypted default keys. Requesting passphrases for retry...');
// Request passphrases from user
const passphraseResult = await requestPassphrasesForEncryptedKeys(
event.sender,
options.hostname
);
// If user cancelled, don't retry even if some keys were unlocked
if (passphraseResult.cancelled) {
console.log('[SSH] User cancelled passphrase flow, not retrying');
@@ -1314,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 {
@@ -1327,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';
@@ -1341,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);

View File

@@ -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;
}

2239
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}
}